mirror of
https://github.com/laurent22/joplin.git
synced 2024-12-24 10:27:10 +02:00
Desktop: Add option to choose Code Mirror as code editor (#3284)
This commit is contained in:
parent
a3153f1c9f
commit
a8c8539e7a
@ -69,6 +69,16 @@ ElectronClient/gui/NoteEditor/NoteBody/AceEditor/Toolbar.js
|
||||
ElectronClient/gui/NoteEditor/NoteBody/AceEditor/utils/index.js
|
||||
ElectronClient/gui/NoteEditor/NoteBody/AceEditor/utils/types.js
|
||||
ElectronClient/gui/NoteEditor/NoteBody/AceEditor/utils/useListIdent.js
|
||||
ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/CodeMirror.js
|
||||
ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/Editor.js
|
||||
ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/styles/index.js
|
||||
ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/Toolbar.js
|
||||
ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/utils/index.js
|
||||
ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/utils/types.js
|
||||
ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/utils/useCursorUtils.js
|
||||
ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/utils/useLineSorting.js
|
||||
ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/utils/useListIdent.js
|
||||
ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollUtils.js
|
||||
ElectronClient/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.js
|
||||
ElectronClient/gui/NoteEditor/NoteBody/TinyMCE/utils/useScroll.js
|
||||
ElectronClient/gui/NoteEditor/NoteEditor.js
|
||||
|
10
.gitignore
vendored
10
.gitignore
vendored
@ -59,6 +59,16 @@ ElectronClient/gui/NoteEditor/NoteBody/AceEditor/Toolbar.js
|
||||
ElectronClient/gui/NoteEditor/NoteBody/AceEditor/utils/index.js
|
||||
ElectronClient/gui/NoteEditor/NoteBody/AceEditor/utils/types.js
|
||||
ElectronClient/gui/NoteEditor/NoteBody/AceEditor/utils/useListIdent.js
|
||||
ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/CodeMirror.js
|
||||
ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/Editor.js
|
||||
ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/styles/index.js
|
||||
ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/Toolbar.js
|
||||
ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/utils/index.js
|
||||
ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/utils/types.js
|
||||
ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/utils/useCursorUtils.js
|
||||
ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/utils/useLineSorting.js
|
||||
ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/utils/useListIdent.js
|
||||
ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollUtils.js
|
||||
ElectronClient/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.js
|
||||
ElectronClient/gui/NoteEditor/NoteBody/TinyMCE/utils/useScroll.js
|
||||
ElectronClient/gui/NoteEditor/NoteEditor.js
|
||||
|
@ -1314,10 +1314,12 @@ class Application extends BaseApplication {
|
||||
// The '*' and '!important' parts are necessary to make sure Russian text is displayed properly
|
||||
// https://github.com/laurent22/joplin/issues/155
|
||||
|
||||
const css = `.ace_editor * { font-family: ${fontFamilies.join(', ')} !important; }`;
|
||||
const css = `.CodeMirror * { font-family: ${fontFamilies.join(', ')} !important; }`;
|
||||
const ace_css = `.ace_editor * { font-family: ${fontFamilies.join(', ')} !important; }`;
|
||||
const styleTag = document.createElement('style');
|
||||
styleTag.type = 'text/css';
|
||||
styleTag.appendChild(document.createTextNode(css));
|
||||
styleTag.appendChild(document.createTextNode(ace_css));
|
||||
document.head.appendChild(styleTag);
|
||||
}
|
||||
|
||||
|
@ -866,7 +866,8 @@ class MainScreenComponent extends React.Component {
|
||||
const noteContentPropertiesDialogOptions = this.state.noteContentPropertiesDialogOptions;
|
||||
const shareNoteDialogOptions = this.state.shareNoteDialogOptions;
|
||||
|
||||
const bodyEditor = this.props.settingEditorCodeView ? 'AceEditor' : 'TinyMCE';
|
||||
const codeEditor = Setting.value('editor.betaCodeMirror') ? 'CodeMirror' : 'AceEditor';
|
||||
const bodyEditor = this.props.settingEditorCodeView ? codeEditor : 'TinyMCE';
|
||||
|
||||
return (
|
||||
<div style={style}>
|
||||
|
@ -53,7 +53,7 @@ export default function styles(props: NoteBodyEditorProps) {
|
||||
fontSize: `${theme.editorFontSize}px`,
|
||||
color: theme.color,
|
||||
backgroundColor: theme.backgroundColor,
|
||||
editorTheme: theme.editorTheme, // Defined in theme.js
|
||||
aceEditorTheme: theme.aceEditorTheme, // Defined in theme.js
|
||||
},
|
||||
};
|
||||
});
|
||||
|
426
ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/CodeMirror.tsx
Normal file
426
ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/CodeMirror.tsx
Normal file
@ -0,0 +1,426 @@
|
||||
import * as React from 'react';
|
||||
import { useState, useEffect, useRef, forwardRef, useCallback, useImperativeHandle, useMemo } from 'react';
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
import { EditorCommand, NoteBodyEditorProps } from '../../utils/types';
|
||||
import { commandAttachFileToBody, handlePasteEvent } from '../../utils/resourceHandling';
|
||||
import { ScrollOptions, ScrollOptionTypes } from '../../utils/types';
|
||||
import { useScrollHandler, usePrevious, cursorPositionToTextOffset } from './utils';
|
||||
import Toolbar from './Toolbar';
|
||||
import styles_ from './styles';
|
||||
import { RenderedBody, defaultRenderedBody } from './utils/types';
|
||||
import Editor from './Editor';
|
||||
|
||||
// @ts-ignore
|
||||
const { bridge } = require('electron').remote.require('./bridge');
|
||||
// @ts-ignore
|
||||
const Note = require('lib/models/Note.js');
|
||||
const { clipboard } = require('electron');
|
||||
const Setting = require('lib/models/Setting.js');
|
||||
const NoteTextViewer = require('../../../NoteTextViewer.min');
|
||||
const shared = require('lib/components/shared/note-screen-shared.js');
|
||||
const Menu = bridge().Menu;
|
||||
const MenuItem = bridge().MenuItem;
|
||||
const markdownUtils = require('lib/markdownUtils');
|
||||
const { _ } = require('lib/locale');
|
||||
const { reg } = require('lib/registry.js');
|
||||
const dialogs = require('../../../dialogs');
|
||||
|
||||
function markupRenderOptions(override: any = null) {
|
||||
return { ...override };
|
||||
}
|
||||
|
||||
function CodeMirror(props: NoteBodyEditorProps, ref: any) {
|
||||
const styles = styles_(props);
|
||||
|
||||
const [renderedBody, setRenderedBody] = useState<RenderedBody>(defaultRenderedBody()); // Viewer content
|
||||
const [webviewReady, setWebviewReady] = useState(false);
|
||||
|
||||
const previousRenderedBody = usePrevious(renderedBody);
|
||||
const previousSearchMarkers = usePrevious(props.searchMarkers);
|
||||
const previousContentKey = usePrevious(props.contentKey);
|
||||
|
||||
const editorRef = useRef(null);
|
||||
const rootRef = useRef(null);
|
||||
const webviewRef = useRef(null);
|
||||
const props_onChangeRef = useRef<Function>(null);
|
||||
props_onChangeRef.current = props.onChange;
|
||||
const contentKeyHasChangedRef = useRef(false);
|
||||
contentKeyHasChangedRef.current = previousContentKey !== props.contentKey;
|
||||
|
||||
const { resetScroll, editor_scroll, setEditorPercentScroll, setViewerPercentScroll } = useScrollHandler(editorRef, webviewRef, props.onScroll);
|
||||
|
||||
const cancelledKeys: {mac: string[], default: string[]} = { mac: [], default: [] };
|
||||
// Remove Joplin reserved key bindings from the editor
|
||||
const letters = ['F', 'T', 'P', 'Q', 'L', ',', 'G', 'K'];
|
||||
for (let i = 0; i < letters.length; i++) {
|
||||
const l = letters[i];
|
||||
cancelledKeys.default.push(`Ctrl-${l}`);
|
||||
cancelledKeys.mac.push(`Cmd-${l}`);
|
||||
}
|
||||
cancelledKeys.default.push('Alt-E');
|
||||
cancelledKeys.mac.push('Alt-E');
|
||||
|
||||
const codeMirror_change = useCallback((newBody: string) => {
|
||||
props_onChangeRef.current({ changeId: null, content: newBody });
|
||||
}, []);
|
||||
|
||||
const wrapSelectionWithStrings = useCallback((string1: string, string2 = '', defaultText = '') => {
|
||||
if (!editorRef.current) return;
|
||||
|
||||
if (editorRef.current.somethingSelected()) {
|
||||
editorRef.current.wrapSelections(string1, string2);
|
||||
} else {
|
||||
editorRef.current.wrapSelections(string1 + defaultText, string2);
|
||||
|
||||
// Now select the default text so the user can replace it
|
||||
const selections = editorRef.current.listSelections();
|
||||
const newSelections = [];
|
||||
for (let i = 0; i < selections.length; i++) {
|
||||
const s = selections[i];
|
||||
const anchor = { line: s.anchor.line, ch: s.anchor.ch + string1.length };
|
||||
const head = { line: s.head.line, ch: s.head.ch - string2.length };
|
||||
newSelections.push({ anchor: anchor, head: head });
|
||||
}
|
||||
editorRef.current.setSelections(newSelections);
|
||||
}
|
||||
editorRef.current.focus();
|
||||
}, []);
|
||||
|
||||
const addListItem = useCallback((string1, defaultText = '') => {
|
||||
if (editorRef.current) {
|
||||
if (editorRef.current.somethingSelected()) {
|
||||
editorRef.current.wrapSelectionsByLine(string1);
|
||||
} else if (editorRef.current.getCursor('anchor').ch !== 0) {
|
||||
editorRef.current.insertAtCursor(`\n${string1}`);
|
||||
} else {
|
||||
wrapSelectionWithStrings(string1, '', defaultText);
|
||||
}
|
||||
editorRef.current.focus();
|
||||
}
|
||||
}, [wrapSelectionWithStrings]);
|
||||
|
||||
useImperativeHandle(ref, () => {
|
||||
return {
|
||||
content: () => props.content,
|
||||
resetScroll: () => {
|
||||
resetScroll();
|
||||
},
|
||||
scrollTo: (options:ScrollOptions) => {
|
||||
if (options.type === ScrollOptionTypes.Hash) {
|
||||
if (!webviewRef.current) return;
|
||||
webviewRef.current.wrappedInstance.send('scrollToHash', options.value as string);
|
||||
} else if (options.type === ScrollOptionTypes.Percent) {
|
||||
const p = options.value as number;
|
||||
setEditorPercentScroll(p);
|
||||
setViewerPercentScroll(p);
|
||||
} else {
|
||||
throw new Error(`Unsupported scroll options: ${options.type}`);
|
||||
}
|
||||
},
|
||||
supportsCommand: (/* name:string*/) => {
|
||||
// TODO: not implemented, currently only used for "search" command
|
||||
// which is not directly supported by Ace Editor.
|
||||
return false;
|
||||
},
|
||||
execCommand: async (cmd: EditorCommand) => {
|
||||
if (!editorRef.current) return false;
|
||||
|
||||
reg.logger().debug('CodeMirror: execCommand', cmd);
|
||||
|
||||
let commandProcessed = true;
|
||||
|
||||
if (cmd.name === 'dropItems') {
|
||||
if (cmd.value.type === 'notes') {
|
||||
editorRef.current.insertAtCursor(cmd.value.markdownTags.join('\n'));
|
||||
} else if (cmd.value.type === 'files') {
|
||||
const pos = cursorPositionToTextOffset(editorRef.current.getCursor(), props.content);
|
||||
const newBody = await commandAttachFileToBody(props.content, cmd.value.paths, { createFileURL: !!cmd.value.createFileURL, position: pos });
|
||||
editorRef.current.updateBody(newBody);
|
||||
} else {
|
||||
reg.logger().warn('CodeMirror: unsupported drop item: ', cmd);
|
||||
}
|
||||
} else if (cmd.name === 'focus') {
|
||||
editorRef.current.focus();
|
||||
} else {
|
||||
commandProcessed = false;
|
||||
}
|
||||
|
||||
if (!commandProcessed) {
|
||||
const commands: any = {
|
||||
textBold: () => wrapSelectionWithStrings('**', '**', _('strong text')),
|
||||
textItalic: () => wrapSelectionWithStrings('*', '*', _('emphasized text')),
|
||||
textLink: async () => {
|
||||
const url = await dialogs.prompt(_('Insert Hyperlink'));
|
||||
if (url) wrapSelectionWithStrings('[', `](${url})`);
|
||||
},
|
||||
textCode: () => {
|
||||
const selections = editorRef.current.getSelections();
|
||||
|
||||
// This bases the selection wrapping only around the first element
|
||||
if (selections.length > 0) {
|
||||
const string = selections[0];
|
||||
|
||||
// Look for newlines
|
||||
const match = string.match(/\r?\n/);
|
||||
|
||||
if (match && match.length > 0) {
|
||||
wrapSelectionWithStrings(`\`\`\`${match[0]}`, `${match[0]}\`\`\``);
|
||||
} else {
|
||||
wrapSelectionWithStrings('`', '`', '');
|
||||
}
|
||||
}
|
||||
},
|
||||
insertText: (value: any) => editorRef.current.insertAtCursor(value),
|
||||
attachFile: async () => {
|
||||
const cursor = editorRef.current.getCursor();
|
||||
const pos = cursorPositionToTextOffset(cursor, props.content);
|
||||
|
||||
const newBody = await commandAttachFileToBody(props.content, null, { position: pos });
|
||||
if (newBody) editorRef.current.updateBody(newBody);
|
||||
},
|
||||
textNumberedList: () => {
|
||||
let bulletNumber = markdownUtils.olLineNumber(editorRef.current.getCurrentLine());
|
||||
if (!bulletNumber) bulletNumber = markdownUtils.olLineNumber(editorRef.current.getPreviousLine());
|
||||
if (!bulletNumber) bulletNumber = 0;
|
||||
addListItem(`${bulletNumber + 1}. `, _('List item'));
|
||||
},
|
||||
textBulletedList: () => addListItem('- ', _('List item')),
|
||||
textCheckbox: () => addListItem('- [ ] ', _('List item')),
|
||||
textHeading: () => addListItem('## ', ''),
|
||||
textHorizontalRule: () => addListItem('* * *'),
|
||||
};
|
||||
|
||||
if (commands[cmd.name]) {
|
||||
commands[cmd.name](cmd.value);
|
||||
} else {
|
||||
reg.logger().warn('CodeMirror: unsupported Joplin command: ', cmd);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}, [props.content, addListItem, wrapSelectionWithStrings, setEditorPercentScroll, setViewerPercentScroll, resetScroll, renderedBody]);
|
||||
|
||||
const onEditorPaste = useCallback(async (event: any = null) => {
|
||||
const resourceMds = await handlePasteEvent(event);
|
||||
if (!resourceMds.length) return;
|
||||
if (editorRef.current) {
|
||||
editorRef.current.replaceSelection(resourceMds.join('\n'));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const editorCutText = useCallback(() => {
|
||||
if (editorRef.current) {
|
||||
const selections = editorRef.current.getSelections();
|
||||
if (selections.length > 0) {
|
||||
clipboard.writeText(selections[0]);
|
||||
// Easy way to wipe out just the first selection
|
||||
selections[0] = '';
|
||||
editorRef.current.replaceSelections(selections);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const editorCopyText = useCallback(() => {
|
||||
if (editorRef.current) {
|
||||
const selections = editorRef.current.getSelections();
|
||||
if (selections.length > 0) {
|
||||
clipboard.writeText(selections[0]);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const editorPasteText = useCallback(() => {
|
||||
if (editorRef.current) {
|
||||
editorRef.current.replaceSelection(clipboard.readText());
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onEditorContextMenu = useCallback(() => {
|
||||
const menu = new Menu();
|
||||
|
||||
const hasSelectedText = editorRef.current && !!editorRef.current.getSelection() ;
|
||||
const clipboardText = clipboard.readText();
|
||||
|
||||
menu.append(
|
||||
new MenuItem({
|
||||
label: _('Cut'),
|
||||
enabled: hasSelectedText,
|
||||
click: async () => {
|
||||
editorCutText();
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
menu.append(
|
||||
new MenuItem({
|
||||
label: _('Copy'),
|
||||
enabled: hasSelectedText,
|
||||
click: async () => {
|
||||
editorCopyText();
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
menu.append(
|
||||
new MenuItem({
|
||||
label: _('Paste'),
|
||||
enabled: true,
|
||||
click: async () => {
|
||||
if (clipboardText) {
|
||||
editorPasteText();
|
||||
} else {
|
||||
// To handle pasting images
|
||||
onEditorPaste();
|
||||
}
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
menu.popup(bridge().window());
|
||||
}, [props.content, editorCutText, editorPasteText, editorCopyText, onEditorPaste]);
|
||||
|
||||
const webview_domReady = useCallback(() => {
|
||||
setWebviewReady(true);
|
||||
}, []);
|
||||
|
||||
const webview_ipcMessage = useCallback((event: any) => {
|
||||
const msg = event.channel ? event.channel : '';
|
||||
const args = event.args;
|
||||
const arg0 = args && args.length >= 1 ? args[0] : null;
|
||||
|
||||
if (msg.indexOf('checkboxclick:') === 0) {
|
||||
const newBody = shared.toggleCheckbox(msg, props.content);
|
||||
if (editorRef.current) {
|
||||
editorRef.current.updateBody(newBody);
|
||||
}
|
||||
} else if (msg === 'percentScroll') {
|
||||
setEditorPercentScroll(arg0);
|
||||
} else {
|
||||
props.onMessage(event);
|
||||
}
|
||||
}, [props.onMessage, props.content, setEditorPercentScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const interval = contentKeyHasChangedRef.current ? 0 : 500;
|
||||
|
||||
const timeoutId = setTimeout(async () => {
|
||||
let bodyToRender = props.content;
|
||||
|
||||
if (!bodyToRender.trim() && props.visiblePanes.indexOf('viewer') >= 0 && props.visiblePanes.indexOf('editor') < 0) {
|
||||
// Fixes https://github.com/laurent22/joplin/issues/217
|
||||
bodyToRender = `<i>${_('This note has no content. Click on "%s" to toggle the editor and edit the note.', _('Layout'))}</i>`;
|
||||
}
|
||||
|
||||
const result = await props.markupToHtml(props.contentMarkupLanguage, bodyToRender, markupRenderOptions({ resourceInfos: props.resourceInfos }));
|
||||
if (cancelled) return;
|
||||
setRenderedBody(result);
|
||||
}, interval);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
}, [props.content, props.contentMarkupLanguage, props.visiblePanes, props.resourceInfos, props.markupToHtml]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!webviewReady) return;
|
||||
|
||||
const options: any = {
|
||||
pluginAssets: renderedBody.pluginAssets,
|
||||
downloadResources: Setting.value('sync.resourceDownloadMode'),
|
||||
};
|
||||
webviewRef.current.wrappedInstance.send('setHtml', renderedBody.html, options);
|
||||
}, [renderedBody, webviewReady]);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.searchMarkers !== previousSearchMarkers || renderedBody !== previousRenderedBody) {
|
||||
webviewRef.current.wrappedInstance.send('setMarkers', props.searchMarkers.keywords, props.searchMarkers.options);
|
||||
}
|
||||
}, [props.searchMarkers, renderedBody]);
|
||||
|
||||
const cellEditorStyle = useMemo(() => {
|
||||
const output = { ...styles.cellEditor };
|
||||
if (!props.visiblePanes.includes('editor')) {
|
||||
output.display = 'none'; // Seems to work fine since the refactoring
|
||||
}
|
||||
|
||||
return output;
|
||||
}, [styles.cellEditor, props.visiblePanes]);
|
||||
|
||||
const cellViewerStyle = useMemo(() => {
|
||||
const output = { ...styles.cellViewer };
|
||||
if (!props.visiblePanes.includes('viewer')) {
|
||||
// Note: setting webview.display to "none" is currently not supported due
|
||||
// to this bug: https://github.com/electron/electron/issues/8277
|
||||
// So instead setting the width 0.
|
||||
output.width = 1;
|
||||
output.maxWidth = 1;
|
||||
} else if (!props.visiblePanes.includes('editor')) {
|
||||
output.borderLeftStyle = 'none';
|
||||
}
|
||||
return output;
|
||||
}, [styles.cellViewer, props.visiblePanes]);
|
||||
|
||||
const editorReadOnly = props.visiblePanes.indexOf('editor') < 0;
|
||||
|
||||
function renderEditor() {
|
||||
return (
|
||||
<div style={cellEditorStyle}>
|
||||
<Editor
|
||||
value={props.content}
|
||||
ref={editorRef}
|
||||
mode={props.contentMarkupLanguage === Note.MARKUP_LANGUAGE_HTML ? 'xml' : 'gfm'}
|
||||
theme={styles.editor.codeMirrorTheme}
|
||||
style={styles.editor}
|
||||
readOnly={props.visiblePanes.indexOf('editor') < 0}
|
||||
autoMatchBraces={Setting.value('editor.autoMatchingBraces')}
|
||||
keyMap={props.keyboardMode}
|
||||
cancelledKeys={cancelledKeys}
|
||||
onChange={codeMirror_change}
|
||||
onScroll={editor_scroll}
|
||||
onEditorContextMenu={onEditorContextMenu}
|
||||
onEditorPaste={onEditorPaste}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderViewer() {
|
||||
return (
|
||||
<div style={cellViewerStyle}>
|
||||
<NoteTextViewer
|
||||
ref={webviewRef}
|
||||
viewerStyle={styles.viewer}
|
||||
onIpcMessage={webview_ipcMessage}
|
||||
onDomReady={webview_domReady}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={styles.root} ref={rootRef}>
|
||||
<div style={styles.rowToolbar}>
|
||||
<Toolbar
|
||||
theme={props.theme}
|
||||
dispatch={props.dispatch}
|
||||
disabled={editorReadOnly}
|
||||
/>
|
||||
{props.noteToolbar}
|
||||
</div>
|
||||
<div style={styles.rowEditorViewer}>
|
||||
{renderEditor()}
|
||||
{renderViewer()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default forwardRef(CodeMirror);
|
||||
|
182
ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/Editor.tsx
Normal file
182
ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/Editor.tsx
Normal file
@ -0,0 +1,182 @@
|
||||
import * as React from 'react';
|
||||
import { useEffect, useImperativeHandle, useState, useRef, useCallback, forwardRef } from 'react';
|
||||
|
||||
const CodeMirror = require('codemirror');
|
||||
import 'codemirror/addon/comment/comment';
|
||||
import 'codemirror/addon/dialog/dialog';
|
||||
import 'codemirror/addon/edit/closebrackets';
|
||||
import 'codemirror/addon/edit/continuelist';
|
||||
import 'codemirror/addon/scroll/scrollpastend';
|
||||
|
||||
import useListIdent from './utils/useListIdent';
|
||||
import useScrollUtils from './utils/useScrollUtils';
|
||||
import useCursorUtils from './utils/useCursorUtils';
|
||||
import useLineSorting from './utils/useLineSorting';
|
||||
|
||||
import 'codemirror/keymap/emacs';
|
||||
import 'codemirror/keymap/vim';
|
||||
|
||||
import 'codemirror/mode/gfm/gfm';
|
||||
import 'codemirror/mode/xml/xml';
|
||||
// Modes for syntax highlighting inside of code blocks
|
||||
import 'codemirror/mode/python/python';
|
||||
import 'codemirror/mode/javascript/javascript';
|
||||
import 'codemirror/mode/markdown/markdown';
|
||||
import 'codemirror/mode/clike/clike';
|
||||
import 'codemirror/mode/diff/diff';
|
||||
import 'codemirror/mode/sql/sql';
|
||||
|
||||
export interface CancelledKeys {
|
||||
mac: string[],
|
||||
default: string[],
|
||||
}
|
||||
|
||||
export interface EditorProps {
|
||||
value: string,
|
||||
mode: string,
|
||||
style: any,
|
||||
theme: any,
|
||||
readOnly: boolean,
|
||||
autoMatchBraces: boolean,
|
||||
keyMap: string,
|
||||
cancelledKeys: CancelledKeys,
|
||||
onChange: any,
|
||||
onScroll: any,
|
||||
onEditorContextMenu: any,
|
||||
onEditorPaste: any,
|
||||
}
|
||||
|
||||
function Editor(props: EditorProps, ref: any) {
|
||||
const [editor, setEditor] = useState(null);
|
||||
const editorParent = useRef(null);
|
||||
|
||||
// Codemirror plugins add new commands to codemirror (or change it's behavior)
|
||||
// This command adds the smartListIndent function which will be bound to tab
|
||||
useListIdent(CodeMirror);
|
||||
useScrollUtils(CodeMirror);
|
||||
useCursorUtils(CodeMirror);
|
||||
useLineSorting(CodeMirror);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.cancelledKeys) {
|
||||
for (let i = 0; i < props.cancelledKeys.mac.length; i++) {
|
||||
const k = props.cancelledKeys.mac[i];
|
||||
CodeMirror.keyMap.macDefault[k] = null;
|
||||
}
|
||||
for (let i = 0; i < props.cancelledKeys.default.length; i++) {
|
||||
const k = props.cancelledKeys.default[i];
|
||||
CodeMirror.keyMap.default[k] = null;
|
||||
}
|
||||
}
|
||||
}, [props.cancelledKeys]);
|
||||
|
||||
useImperativeHandle(ref, () => {
|
||||
return editor;
|
||||
});
|
||||
|
||||
const editor_change = useCallback((cm: any, change: any) => {
|
||||
if (props.onChange && change.origin !== 'setValue') {
|
||||
props.onChange(cm.getValue());
|
||||
}
|
||||
}, [props.onChange]);
|
||||
|
||||
// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
|
||||
const editor_scroll = useCallback((_cm: any) => {
|
||||
props.onScroll();
|
||||
}, [props.onScroll]);
|
||||
|
||||
// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
|
||||
const editor_mousedown = useCallback((_cm: any, event: any) => {
|
||||
if (event && event.button === 2) {
|
||||
props.onEditorContextMenu();
|
||||
}
|
||||
}, [props.onEditorContextMenu]);
|
||||
|
||||
// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
|
||||
const editor_paste = useCallback((_cm: any, _event: any) => {
|
||||
props.onEditorPaste();
|
||||
}, [props.onEditorPaste]);
|
||||
|
||||
// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
|
||||
const editor_drop = useCallback((cm: any, _event: any) => {
|
||||
cm.focus();
|
||||
}, []);
|
||||
|
||||
const editor_drag = useCallback((cm: any, event: any) => {
|
||||
// This is the type for all drag and drops that are external to codemirror
|
||||
// setting the cursor allows us to drop them in the right place
|
||||
if (event.dataTransfer.effectAllowed === 'all') {
|
||||
const coords = cm.coordsChar({ left: event.x, top: event.y });
|
||||
cm.setCursor(coords);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// const divRef = useCallback(node => {
|
||||
useEffect(() => {
|
||||
if (!editorParent.current) return () => {};
|
||||
|
||||
const cmOptions = {
|
||||
value: props.value,
|
||||
screenReaderLabel: props.value,
|
||||
theme: props.theme,
|
||||
mode: props.mode,
|
||||
readOnly: props.readOnly,
|
||||
autoCloseBrackets: props.autoMatchBraces,
|
||||
inputStyle: 'textarea', // contenteditable loses cursor position on focus change, use textarea instead
|
||||
lineWrapping: true,
|
||||
lineNumbers: false,
|
||||
scrollPastEnd: true,
|
||||
indentWithTabs: true,
|
||||
indentUnit: 4,
|
||||
spellcheck: true,
|
||||
allowDropFileTypes: [''], // disable codemirror drop handling
|
||||
keyMap: props.keyMap ? props.keyMap : 'default',
|
||||
extraKeys: { 'Enter': 'insertListElement',
|
||||
'Ctrl-/': 'toggleComment',
|
||||
'Ctrl-Alt-S': 'sortSelectedLines',
|
||||
'Tab': 'smartListIndent',
|
||||
'Shift-Tab': 'smartListUnindent' },
|
||||
};
|
||||
const cm = CodeMirror(editorParent.current, cmOptions);
|
||||
setEditor(cm);
|
||||
cm.on('change', editor_change);
|
||||
cm.on('scroll', editor_scroll);
|
||||
cm.on('mousedown', editor_mousedown);
|
||||
cm.on('paste', editor_paste);
|
||||
cm.on('drop', editor_drop);
|
||||
cm.on('dragover', editor_drag);
|
||||
|
||||
return () => {
|
||||
// Clean up codemirror
|
||||
cm.off('change', editor_change);
|
||||
cm.off('scroll', editor_scroll);
|
||||
cm.off('mousedown', editor_mousedown);
|
||||
cm.off('paste', editor_paste);
|
||||
cm.off('drop', editor_drop);
|
||||
cm.off('dragover', editor_drag);
|
||||
editorParent.current.removeChild(cm.getWrapperElement());
|
||||
setEditor(null);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (editor) {
|
||||
// Value can also be changed by the editor itself so we need this guard
|
||||
// to prevent loops
|
||||
if (props.value !== editor.getValue()) {
|
||||
editor.setValue(props.value);
|
||||
editor.clearHistory();
|
||||
}
|
||||
editor.setOption('screenReaderLabel', props.value);
|
||||
editor.setOption('theme', props.theme);
|
||||
editor.setOption('mode', props.mode);
|
||||
editor.setOption('readOnly', props.readOnly);
|
||||
editor.setOption('autoCloseBrackets', props.autoMatchBraces);
|
||||
editor.setOption('keyMap', props.keyMap ? props.keyMap : 'default');
|
||||
}
|
||||
}, [props.value, props.theme, props.mode, props.readOnly, props.autoMatchBraces, props.keyMap]);
|
||||
|
||||
return <div style={props.style} ref={editorParent} />;
|
||||
}
|
||||
|
||||
export default forwardRef(Editor);
|
169
ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/Toolbar.tsx
Normal file
169
ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/Toolbar.tsx
Normal file
@ -0,0 +1,169 @@
|
||||
import * as React from 'react';
|
||||
|
||||
const ToolbarBase = require('../../../Toolbar.min.js');
|
||||
const { _ } = require('lib/locale');
|
||||
const { buildStyle, themeStyle } = require('../../../../theme.js');
|
||||
|
||||
interface ToolbarProps {
|
||||
theme: number,
|
||||
dispatch: Function,
|
||||
disabled: boolean,
|
||||
}
|
||||
|
||||
function styles_(props:ToolbarProps) {
|
||||
return buildStyle('AceEditorToolbar', props.theme, (/* theme:any*/) => {
|
||||
const theme = themeStyle(props.theme);
|
||||
return {
|
||||
root: {
|
||||
flex: 1,
|
||||
marginBottom: 0,
|
||||
borderTop: `1px solid ${theme.dividerColor}`,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export default function Toolbar(props:ToolbarProps) {
|
||||
const styles = styles_(props);
|
||||
|
||||
function createToolbarItems() {
|
||||
const toolbarItems = [];
|
||||
|
||||
toolbarItems.push({
|
||||
tooltip: _('Bold'),
|
||||
iconName: 'fa-bold',
|
||||
onClick: () => {
|
||||
props.dispatch({
|
||||
type: 'WINDOW_COMMAND',
|
||||
name: 'textBold',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
toolbarItems.push({
|
||||
tooltip: _('Italic'),
|
||||
iconName: 'fa-italic',
|
||||
onClick: () => {
|
||||
props.dispatch({
|
||||
type: 'WINDOW_COMMAND',
|
||||
name: 'textItalic',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
toolbarItems.push({
|
||||
type: 'separator',
|
||||
});
|
||||
|
||||
toolbarItems.push({
|
||||
tooltip: _('Hyperlink'),
|
||||
iconName: 'fa-link',
|
||||
onClick: () => {
|
||||
props.dispatch({
|
||||
type: 'WINDOW_COMMAND',
|
||||
name: 'textLink',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
toolbarItems.push({
|
||||
tooltip: _('Code'),
|
||||
iconName: 'fa-code',
|
||||
onClick: () => {
|
||||
props.dispatch({
|
||||
type: 'WINDOW_COMMAND',
|
||||
name: 'textCode',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
toolbarItems.push({
|
||||
tooltip: _('Attach file'),
|
||||
iconName: 'fa-paperclip',
|
||||
onClick: () => {
|
||||
props.dispatch({
|
||||
type: 'WINDOW_COMMAND',
|
||||
name: 'attachFile',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
toolbarItems.push({
|
||||
type: 'separator',
|
||||
});
|
||||
|
||||
toolbarItems.push({
|
||||
tooltip: _('Numbered List'),
|
||||
iconName: 'fa-list-ol',
|
||||
onClick: () => {
|
||||
props.dispatch({
|
||||
type: 'WINDOW_COMMAND',
|
||||
name: 'textNumberedList',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
toolbarItems.push({
|
||||
tooltip: _('Bulleted List'),
|
||||
iconName: 'fa-list-ul',
|
||||
onClick: () => {
|
||||
props.dispatch({
|
||||
type: 'WINDOW_COMMAND',
|
||||
name: 'textBulletedList',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
toolbarItems.push({
|
||||
tooltip: _('Checkbox'),
|
||||
iconName: 'fa-check-square',
|
||||
onClick: () => {
|
||||
props.dispatch({
|
||||
type: 'WINDOW_COMMAND',
|
||||
name: 'textCheckbox',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
toolbarItems.push({
|
||||
tooltip: _('Heading'),
|
||||
iconName: 'fa-heading',
|
||||
onClick: () => {
|
||||
props.dispatch({
|
||||
type: 'WINDOW_COMMAND',
|
||||
name: 'textHeading',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
toolbarItems.push({
|
||||
tooltip: _('Horizontal Rule'),
|
||||
iconName: 'fa-ellipsis-h',
|
||||
onClick: () => {
|
||||
props.dispatch({
|
||||
type: 'WINDOW_COMMAND',
|
||||
name: 'textHorizontalRule',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
toolbarItems.push({
|
||||
tooltip: _('Insert Date Time'),
|
||||
iconName: 'fa-calendar-plus',
|
||||
onClick: () => {
|
||||
props.dispatch({
|
||||
type: 'WINDOW_COMMAND',
|
||||
name: 'insertDateTime',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
toolbarItems.push({
|
||||
type: 'separator',
|
||||
});
|
||||
|
||||
return toolbarItems;
|
||||
}
|
||||
|
||||
return <ToolbarBase disabled={props.disabled} style={styles.root} items={createToolbarItems()} />;
|
||||
}
|
@ -0,0 +1,60 @@
|
||||
import { NoteBodyEditorProps } from '../../../utils/types';
|
||||
const { buildStyle } = require('../../../../../theme.js');
|
||||
|
||||
export default function styles(props: NoteBodyEditorProps) {
|
||||
return buildStyle('AceEditor', props.theme, (theme: any) => {
|
||||
return {
|
||||
root: {
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
...props.style,
|
||||
},
|
||||
rowToolbar: {
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
},
|
||||
rowEditorViewer: {
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
flex: 1,
|
||||
paddingTop: 10,
|
||||
},
|
||||
cellEditor: {
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
},
|
||||
cellViewer: {
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
borderLeftWidth: 1,
|
||||
borderLeftColor: theme.dividerColor,
|
||||
borderLeftStyle: 'solid',
|
||||
},
|
||||
viewer: {
|
||||
display: 'flex',
|
||||
overflow: 'hidden',
|
||||
verticalAlign: 'top',
|
||||
boxSizing: 'border-box',
|
||||
width: '100%',
|
||||
},
|
||||
editor: {
|
||||
display: 'flex',
|
||||
width: 'auto',
|
||||
height: 'auto',
|
||||
flex: 1,
|
||||
overflowY: 'hidden',
|
||||
paddingTop: 0,
|
||||
lineHeight: `${theme.textAreaLineHeight}px`,
|
||||
fontSize: `${theme.editorFontSize}px`,
|
||||
color: theme.color,
|
||||
backgroundColor: theme.backgroundColor,
|
||||
codeMirrorTheme: theme.codeMirrorTheme, // Defined in theme.js
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
@ -0,0 +1,84 @@
|
||||
import { useEffect, useCallback, useRef } from 'react';
|
||||
|
||||
export function cursorPositionToTextOffset(cursorPos: any, body: string) {
|
||||
if (!body) return 0;
|
||||
|
||||
const noteLines = body.split('\n');
|
||||
|
||||
let pos = 0;
|
||||
for (let i = 0; i < noteLines.length; i++) {
|
||||
if (i > 0) pos++; // Need to add the newline that's been removed in the split() call above
|
||||
|
||||
if (i === cursorPos.line) {
|
||||
pos += cursorPos.ch;
|
||||
break;
|
||||
} else {
|
||||
pos += noteLines[i].length;
|
||||
}
|
||||
}
|
||||
|
||||
return pos;
|
||||
}
|
||||
|
||||
export function usePrevious(value: any): any {
|
||||
const ref = useRef();
|
||||
useEffect(() => {
|
||||
ref.current = value;
|
||||
});
|
||||
return ref.current;
|
||||
}
|
||||
|
||||
export function useScrollHandler(editorRef: any, webviewRef: any, onScroll: Function) {
|
||||
const ignoreNextEditorScrollEvent_ = useRef(false);
|
||||
const scrollTimeoutId_ = useRef<any>(null);
|
||||
|
||||
const scheduleOnScroll = useCallback((event: any) => {
|
||||
if (scrollTimeoutId_.current) {
|
||||
clearTimeout(scrollTimeoutId_.current);
|
||||
scrollTimeoutId_.current = null;
|
||||
}
|
||||
|
||||
scrollTimeoutId_.current = setTimeout(() => {
|
||||
scrollTimeoutId_.current = null;
|
||||
onScroll(event);
|
||||
}, 10);
|
||||
}, [onScroll]);
|
||||
|
||||
const setEditorPercentScroll = useCallback((p: number) => {
|
||||
ignoreNextEditorScrollEvent_.current = true;
|
||||
|
||||
if (editorRef.current) {
|
||||
editorRef.current.setScrollPercent(p);
|
||||
|
||||
scheduleOnScroll({ percent: p });
|
||||
}
|
||||
}, [scheduleOnScroll]);
|
||||
|
||||
const setViewerPercentScroll = useCallback((p: number) => {
|
||||
if (webviewRef.current) {
|
||||
webviewRef.current.wrappedInstance.send('setPercentScroll', p);
|
||||
scheduleOnScroll({ percent: p });
|
||||
}
|
||||
}, [scheduleOnScroll]);
|
||||
|
||||
const editor_scroll = useCallback(() => {
|
||||
if (ignoreNextEditorScrollEvent_.current) {
|
||||
ignoreNextEditorScrollEvent_.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (editorRef.current) {
|
||||
const percent = editorRef.current.getScrollPercent();
|
||||
|
||||
setViewerPercentScroll(percent);
|
||||
}
|
||||
}, [setViewerPercentScroll]);
|
||||
|
||||
const resetScroll = useCallback(() => {
|
||||
if (editorRef.current) {
|
||||
editorRef.current.setScrollPercent(0);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { resetScroll, setEditorPercentScroll, setViewerPercentScroll, editor_scroll };
|
||||
}
|
@ -0,0 +1,11 @@
|
||||
export interface RenderedBody {
|
||||
html: string;
|
||||
pluginAssets: any[];
|
||||
}
|
||||
|
||||
export function defaultRenderedBody(): RenderedBody {
|
||||
return {
|
||||
html: '',
|
||||
pluginAssets: [],
|
||||
};
|
||||
}
|
@ -0,0 +1,104 @@
|
||||
// Helper functions that use the cursor
|
||||
export default function useCursorUtils(CodeMirror: any) {
|
||||
|
||||
CodeMirror.defineExtension('insertAtCursor', function(text: string) {
|
||||
// This is also the method to get all cursors
|
||||
const ranges = this.listSelections();
|
||||
// Batches the insert operations, if this wasn't done the inserts
|
||||
// could potentially overwrite one another
|
||||
this.operation(() => {
|
||||
for (let i = 0; i < ranges.length; i++) {
|
||||
// anchor is where the selection starts, and head is where it ends
|
||||
// this changes based on how the uses makes a selection
|
||||
const { anchor, head } = ranges[i];
|
||||
// We want the selection that comes first in the document
|
||||
let range = anchor;
|
||||
if (head.line < anchor.line || (head.line === anchor.line && head.ch < anchor.ch)) {
|
||||
range = head;
|
||||
}
|
||||
this.replaceRange(text, range);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
CodeMirror.defineExtension('getCurrentLine', function() {
|
||||
const curs = this.getCursor('anchor');
|
||||
|
||||
return this.getLine(curs.line);
|
||||
});
|
||||
|
||||
CodeMirror.defineExtension('getPreviousLine', function() {
|
||||
const curs = this.getCursor('anchor');
|
||||
|
||||
if (curs.line > 0) { return this.getLine(curs.line - 1); }
|
||||
return '';
|
||||
});
|
||||
|
||||
// this updates the body in a way that registers with the undo/redo
|
||||
CodeMirror.defineExtension('updateBody', function(newBody: string) {
|
||||
const start = { line: this.firstLine(), ch: 0 };
|
||||
const last = this.getLine(this.lastLine());
|
||||
const end = { line: this.lastLine(), ch: last ? last.length : 0 };
|
||||
|
||||
this.replaceRange(newBody, start, end);
|
||||
});
|
||||
|
||||
CodeMirror.defineExtension('wrapSelections', function(string1: string, string2: string) {
|
||||
const selectedStrings = this.getSelections();
|
||||
|
||||
// Batches the insert operations, if this wasn't done the inserts
|
||||
// could potentially overwrite one another
|
||||
this.operation(() => {
|
||||
for (let i = 0; i < selectedStrings.length; i++) {
|
||||
const selected = selectedStrings[i];
|
||||
|
||||
// Remove white space on either side of selection
|
||||
const start = selected.search(/[^\s]/);
|
||||
const end = selected.search(/[^\s](?=[\s]*$)/);
|
||||
const core = selected.substr(start, end - start + 1);
|
||||
|
||||
// If selection can be toggled do that
|
||||
if (core.startsWith(string1) && core.endsWith(string2)) {
|
||||
const inside = core.substr(string1.length, core.length - string1.length - string2.length);
|
||||
selectedStrings[i] = selected.substr(0, start) + inside + selected.substr(end + 1);
|
||||
} else {
|
||||
selectedStrings[i] = selected.substr(0, start) + string1 + core + string2 + selected.substr(end + 1);
|
||||
}
|
||||
}
|
||||
this.replaceSelections(selectedStrings, 'around');
|
||||
});
|
||||
});
|
||||
|
||||
CodeMirror.defineExtension('wrapSelectionsByLine', function(string1: string) {
|
||||
const selectedStrings = this.getSelections();
|
||||
|
||||
// Batches the insert operations, if this wasn't done the inserts
|
||||
// could potentially overwrite one another
|
||||
this.operation(() => {
|
||||
for (let i = 0; i < selectedStrings.length; i++) {
|
||||
const selected = selectedStrings[i];
|
||||
|
||||
const lines = selected.split(/\r?\n/);
|
||||
// Save the newline character to restore it later
|
||||
const newLines = selected.match(/\r?\n/);
|
||||
|
||||
for (let j = 0; j < lines.length; j++) {
|
||||
const line = lines[j];
|
||||
// Only add the list token if it's not already there
|
||||
// if it is, remove it
|
||||
if (!line.startsWith(string1)) {
|
||||
lines[j] = string1 + line;
|
||||
} else {
|
||||
lines[j] = line.substr(string1.length, line.length - string1.length);
|
||||
}
|
||||
}
|
||||
|
||||
const newLine = newLines !== null ? newLines[0] : '\n';
|
||||
selectedStrings[i] = lines.join(newLine);
|
||||
}
|
||||
this.replaceSelections(selectedStrings, 'around');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
}
|
@ -0,0 +1,29 @@
|
||||
// Duplicates AceEditors line sorting function
|
||||
// https://discourse.joplinapp.org/t/sort-lines/8874/2
|
||||
export default function useLineSorting(CodeMirror: any) {
|
||||
CodeMirror.commands.sortSelectedLines = function(cm: any) {
|
||||
const ranges = cm.listSelections();
|
||||
// Batches the insert operations, if this wasn't done the inserts
|
||||
// could potentially overwrite one another
|
||||
cm.operation(() => {
|
||||
for (let i = 0; i < ranges.length; i++) {
|
||||
// anchor is where the selection starts, and head is where it ends
|
||||
// this changes based on how the uses makes a selection
|
||||
const { anchor, head } = ranges[i];
|
||||
const start = Math.min(anchor.line, head.line);
|
||||
const end = Math.max(anchor.line, head.line);
|
||||
|
||||
const lines = [];
|
||||
for (let j = start; j <= end; j++) {
|
||||
lines.push(cm.getLine(j));
|
||||
}
|
||||
|
||||
const text = lines.sort().join('\n');
|
||||
// Get the end of the last line
|
||||
const ch = lines[lines.length - 1].length;
|
||||
|
||||
cm.replaceRange(text, { line: start, ch: 0 }, { line: end, ch: ch });
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
@ -0,0 +1,133 @@
|
||||
const { isListItem, isEmptyListItem, extractListToken, olLineNumber } = require('lib/markdownUtils');
|
||||
|
||||
// Markdown list indentation.
|
||||
// If the current line starts with `markup.list` token,
|
||||
// hitting `Tab` key indents the line instead of inserting tab at cursor.
|
||||
// hitting enter will insert a new list element, and unindent/delete an empty element
|
||||
export default function useListIdent(CodeMirror: any) {
|
||||
|
||||
function isSelection(anchor: any, head: any) {
|
||||
return anchor.line !== head.line || anchor.ch !== head.ch;
|
||||
}
|
||||
|
||||
function getIndentLevel(cm: any, line: number) {
|
||||
const tokens = cm.getLineTokens(line);
|
||||
let indentLevel = 0;
|
||||
if (tokens.length > 0 && tokens[0].string.match(/\s/)) {
|
||||
indentLevel = tokens[0].string.length;
|
||||
}
|
||||
|
||||
return indentLevel;
|
||||
}
|
||||
|
||||
function newListToken(cm: any, line: number) {
|
||||
const currentToken = extractListToken(cm.getLine(line));
|
||||
const indentLevel = getIndentLevel(cm, line);
|
||||
|
||||
while (--line > 0) {
|
||||
const currentLine = cm.getLine(line);
|
||||
if (!isListItem(currentLine)) return currentToken;
|
||||
|
||||
const indent = getIndentLevel(cm, line);
|
||||
|
||||
if (indent < indentLevel - 1) return currentToken;
|
||||
|
||||
if (indent === indentLevel - 1) {
|
||||
if (olLineNumber(currentLine)) {
|
||||
return `${olLineNumber(currentLine) + 1}. `;
|
||||
}
|
||||
const token = extractListToken(currentLine);
|
||||
if (token.match(/x/)) {
|
||||
return '- [ ] ';
|
||||
}
|
||||
return token;
|
||||
}
|
||||
}
|
||||
|
||||
return currentToken;
|
||||
}
|
||||
|
||||
// Gets the first non-whitespace token locationof a list
|
||||
function getListSpan(listTokens: any) {
|
||||
let start = listTokens[0].start;
|
||||
const end = listTokens[listTokens.length - 1].end;
|
||||
|
||||
if (listTokens.length > 1 && listTokens[0].string.match(/\s/)) {
|
||||
start = listTokens[1].start;
|
||||
}
|
||||
|
||||
return { start: start, end: end };
|
||||
}
|
||||
|
||||
CodeMirror.commands.smartListIndent = function(cm: any) {
|
||||
if (cm.getOption('disableInput')) return CodeMirror.Pass;
|
||||
|
||||
const ranges = cm.listSelections();
|
||||
for (let i = 0; i < ranges.length; i++) {
|
||||
const { anchor, head } = ranges[i];
|
||||
|
||||
const line = cm.getLine(anchor.line);
|
||||
|
||||
// This is an actual selection and we should indent
|
||||
if (isSelection(anchor, head) || !isListItem(line)) {
|
||||
cm.execCommand('defaultTab');
|
||||
} else {
|
||||
if (olLineNumber(line)) {
|
||||
const tokens = cm.getLineTokens(anchor.line);
|
||||
const { start, end } = getListSpan(tokens);
|
||||
// Resets numbered list to 1.
|
||||
cm.replaceRange('1. ', { line: anchor.line, ch: start }, { line: anchor.line, ch: end });
|
||||
}
|
||||
|
||||
cm.indentLine(anchor.line, 'add');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
CodeMirror.commands.smartListUnindent = function(cm: any) {
|
||||
if (cm.getOption('disableInput')) return CodeMirror.Pass;
|
||||
|
||||
const ranges = cm.listSelections();
|
||||
for (let i = 0; i < ranges.length; i++) {
|
||||
const { anchor, head } = ranges[i];
|
||||
|
||||
const line = cm.getLine(anchor.line);
|
||||
|
||||
// This is an actual selection and we should unindent
|
||||
if (isSelection(anchor, head) || !isListItem(line)) {
|
||||
cm.execCommand('indentLess');
|
||||
} else {
|
||||
const newToken = newListToken(cm, anchor.line);
|
||||
const tokens = cm.getLineTokens(anchor.line);
|
||||
const { start, end } = getListSpan(tokens);
|
||||
|
||||
cm.replaceRange(newToken, { line: anchor.line, ch: start }, { line: anchor.line, ch: end });
|
||||
|
||||
cm.indentLine(anchor.line, 'subtract');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
CodeMirror.commands.insertListElement = function(cm: any) {
|
||||
if (cm.getOption('disableInput')) return CodeMirror.Pass;
|
||||
|
||||
const ranges = cm.listSelections();
|
||||
for (let i = 0; i < ranges.length; i++) {
|
||||
const { anchor } = ranges[i];
|
||||
|
||||
const line = cm.getLine(anchor.line);
|
||||
|
||||
if (isEmptyListItem(line)) {
|
||||
const tokens = cm.getLineTokens(anchor.line);
|
||||
// A empty list item with an indent will have whitespace as the first token
|
||||
if (tokens.length > 1 && tokens[0].string.match(/\s/)) {
|
||||
cm.execCommand('smartListUnindent');
|
||||
} else {
|
||||
cm.replaceRange('', { line: anchor.line, ch: 0 }, anchor);
|
||||
}
|
||||
} else {
|
||||
cm.execCommand('newlineAndIndentContinueMarkdownList');
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
@ -0,0 +1,19 @@
|
||||
// Helper functions to sync up scrolling
|
||||
export default function useScrollUtils(CodeMirror: any) {
|
||||
function getScrollHeight(cm: any) {
|
||||
const info = cm.getScrollInfo();
|
||||
const overdraw = cm.state.scrollPastEndPadding ? cm.state.scrollPastEndPadding : '0px';
|
||||
return info.height - info.clientHeight - parseInt(overdraw);
|
||||
}
|
||||
|
||||
CodeMirror.defineExtension('getScrollPercent', function() {
|
||||
const info = this.getScrollInfo();
|
||||
|
||||
return info.top / getScrollHeight(this);
|
||||
});
|
||||
|
||||
CodeMirror.defineExtension('setScrollPercent', function(p: number) {
|
||||
this.scrollTo(null, p * getScrollHeight(this));
|
||||
});
|
||||
|
||||
}
|
@ -3,6 +3,7 @@ import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
import TinyMCE from './NoteBody/TinyMCE/TinyMCE';
|
||||
import AceEditor from './NoteBody/AceEditor/AceEditor';
|
||||
import CodeMirror from './NoteBody/CodeMirror/CodeMirror';
|
||||
import { connect } from 'react-redux';
|
||||
import MultiNoteActions from '../MultiNoteActions';
|
||||
import NoteToolbar from '../NoteToolbar/NoteToolbar';
|
||||
@ -448,6 +449,8 @@ function NoteEditor(props: NoteEditorProps) {
|
||||
editor = <TinyMCE {...editorProps}/>;
|
||||
} else if (props.bodyEditor === 'AceEditor') {
|
||||
editor = <AceEditor {...editorProps}/>;
|
||||
} else if (props.bodyEditor === 'CodeMirror') {
|
||||
editor = <CodeMirror {...editorProps}/>;
|
||||
} else {
|
||||
throw new Error(`Invalid editor: ${props.bodyEditor}`);
|
||||
}
|
||||
|
@ -34,7 +34,8 @@ const aritimStyle = {
|
||||
htmlCodeBorderColor: '#141a21', // Single line code border, and tables
|
||||
htmlCodeColor: '#005b47', // Single line code text
|
||||
|
||||
editorTheme: 'chaos',
|
||||
aceEditorTheme: 'chaos',
|
||||
codeMirrorTheme: 'monokai',
|
||||
codeThemeCss: 'atom-one-dark-reasonable.css',
|
||||
|
||||
highlightedColor: '#d3dae3',
|
||||
|
@ -33,7 +33,8 @@ const darkStyle = {
|
||||
htmlCodeBackgroundColor: 'rgb(47, 48, 49)',
|
||||
htmlCodeBorderColor: 'rgb(70, 70, 70)',
|
||||
|
||||
editorTheme: 'twilight',
|
||||
aceEditorTheme: 'twilight',
|
||||
codeMirrorTheme: 'material-darker',
|
||||
codeThemeCss: 'atom-one-dark-reasonable.css',
|
||||
|
||||
highlightedColor: '#0066C7',
|
||||
|
@ -33,7 +33,8 @@ const draculaStyle = {
|
||||
htmlCodeBorderColor: '#f8f8f2',
|
||||
htmlCodeColor: '#50fa7b',
|
||||
|
||||
editorTheme: 'dracula',
|
||||
aceEditorTheme: 'dracula',
|
||||
codeMirrorTheme: 'dracula',
|
||||
codeThemeCss: 'atom-one-dark-reasonable.css',
|
||||
};
|
||||
|
||||
|
@ -32,7 +32,8 @@ const lightStyle = {
|
||||
htmlCodeBorderColor: 'rgb(220, 220, 220)',
|
||||
htmlCodeColor: 'rgb(0,0,0)',
|
||||
|
||||
editorTheme: 'chrome',
|
||||
aceEditorTheme: 'chrome',
|
||||
codeMirrorTheme: 'default',
|
||||
codeThemeCss: 'atom-one-light.css',
|
||||
};
|
||||
|
||||
|
@ -79,7 +79,8 @@ const nordStyle = {
|
||||
htmlCodeBorderColor: nord[2],
|
||||
htmlCodeColor: nord[13],
|
||||
|
||||
editorTheme: 'terminal',
|
||||
aceEditorTheme: 'terminal',
|
||||
codeMirrorTheme: 'nord',
|
||||
codeThemeCss: 'atom-one-dark-reasonable.css',
|
||||
};
|
||||
|
||||
|
@ -33,7 +33,8 @@ const solarizedDarkStyle = {
|
||||
htmlCodeBorderColor: '#696969',
|
||||
htmlCodeColor: '#fdf6e3',
|
||||
|
||||
editorTheme: 'twilight',
|
||||
aceEditorTheme: 'twilight',
|
||||
codeMirrorTheme: 'solarized dark',
|
||||
codeThemeCss: 'atom-one-dark-reasonable.css',
|
||||
};
|
||||
|
||||
|
@ -31,7 +31,8 @@ const solarizedLightStyle = {
|
||||
htmlCodeBorderColor: '#eee8d5',
|
||||
htmlCodeColor: '#002b36',
|
||||
|
||||
editorTheme: 'tomorrow',
|
||||
aceEditorTheme: 'tomorrow',
|
||||
codeMirrorTheme: 'solarized light',
|
||||
codeThemeCss: 'atom-one-light.css',
|
||||
};
|
||||
|
||||
|
@ -12,6 +12,12 @@
|
||||
<link rel="stylesheet" href="node_modules/@fortawesome/fontawesome-free/css/all.min.css">
|
||||
<link rel="stylesheet" href="node_modules/react-datetime/css/react-datetime.css">
|
||||
<link rel="stylesheet" href="node_modules/smalltalk/css/smalltalk.css">
|
||||
<link rel="stylesheet" href="node_modules/codemirror/lib/codemirror.css">
|
||||
<link rel="stylesheet" href="node_modules/codemirror/theme/dracula.css">
|
||||
<link rel="stylesheet" href="node_modules/codemirror/theme/monokai.css">
|
||||
<link rel="stylesheet" href="node_modules/codemirror/theme/solarized.css">
|
||||
<link rel="stylesheet" href="node_modules/codemirror/theme/nord.css">
|
||||
<link rel="stylesheet" href="node_modules/codemirror/theme/material-darker.css">
|
||||
|
||||
<style>
|
||||
.smalltalk {
|
||||
|
@ -102,6 +102,7 @@
|
||||
"base64-stream": "^1.0.0",
|
||||
"chokidar": "^3.0.0",
|
||||
"clean-html": "^1.5.0",
|
||||
"codemirror": "^5.54.0",
|
||||
"color": "^3.1.2",
|
||||
"compare-versions": "^3.2.1",
|
||||
"countable": "^3.0.1",
|
||||
|
@ -170,3 +170,33 @@ a {
|
||||
from {transform: rotate(0deg);}
|
||||
to {transform: rotate(360deg);}
|
||||
}
|
||||
|
||||
/* These must be important to prevent the codemirror defaults from taking over*/
|
||||
.CodeMirror {
|
||||
font-family: monospace;
|
||||
height: 100% !important;
|
||||
width: 100% !important;
|
||||
color: inherit !important;
|
||||
background-color: inherit !important;
|
||||
position: absolute !important;
|
||||
}
|
||||
|
||||
.cm-header-1 {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.cm-header-2 {
|
||||
font-size: 1.3em;
|
||||
}
|
||||
|
||||
.cm-header-3 {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.cm-header-4, .cm-header-5, .cm-header-6 {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.cm-header-1, .cm-header-2, .cm-header-3, .cm-header-4, .cm-header-5, .cm-header-6 {
|
||||
line-height: 1.5em;
|
||||
}
|
||||
|
@ -4,6 +4,10 @@ const MarkdownIt = require('markdown-it');
|
||||
const { setupLinkify } = require('lib/joplin-renderer');
|
||||
const removeMarkdown = require('remove-markdown');
|
||||
|
||||
// Taken from codemirror/addon/edit/continuelist.js
|
||||
const listRegex = /^(\s*)([*+-] \[[x ]\]\s|[*+-]\s|(\d+)([.)]))(\s*)/;
|
||||
const emptyListRegex = /^(\s*)([*+-] \[[x ]\]|[*+-]|(\d+)[.)])(\s*)$/;
|
||||
|
||||
const markdownUtils = {
|
||||
// Not really escaping because that's not supported by marked.js
|
||||
escapeLinkText(text) {
|
||||
@ -61,9 +65,25 @@ const markdownUtils = {
|
||||
return output;
|
||||
},
|
||||
|
||||
// The match results has 5 items
|
||||
// Full match array is
|
||||
// [Full match, whitespace, list token, ol line number, whitespace following token]
|
||||
olLineNumber(line) {
|
||||
const match = line.match(/^(\d+)\.(\s.*|)$/);
|
||||
return match ? Number(match[1]) : 0;
|
||||
const match = line.match(listRegex);
|
||||
return match ? Number(match[3]) : 0;
|
||||
},
|
||||
|
||||
extractListToken(line) {
|
||||
const match = line.match(listRegex);
|
||||
return match ? match[2] : '';
|
||||
},
|
||||
|
||||
isListItem(line) {
|
||||
return listRegex.test(line);
|
||||
},
|
||||
|
||||
isEmptyListItem(line) {
|
||||
return emptyListRegex.test(line);
|
||||
},
|
||||
|
||||
createMarkdownTable(headers, rows) {
|
||||
|
@ -347,6 +347,14 @@ class Setting extends BaseModel {
|
||||
appTypes: ['desktop'],
|
||||
label: () => _('Auto-pair braces, parenthesis, quotations, etc.'),
|
||||
},
|
||||
'editor.betaCodeMirror': {
|
||||
value: false,
|
||||
type: Setting.TYPE_BOOL,
|
||||
public: true,
|
||||
section: 'note',
|
||||
appTypes: ['desktop'],
|
||||
label: () => _('Use CodeMirror as the code editor (WARNING: BETA).'),
|
||||
},
|
||||
'notes.sortOrder.reverse': { value: true, type: Setting.TYPE_BOOL, section: 'note', public: true, label: () => _('Reverse sort order'), appTypes: ['cli'] },
|
||||
'folders.sortOrder.field': {
|
||||
value: 'title',
|
||||
@ -499,7 +507,7 @@ class Setting extends BaseModel {
|
||||
section: 'appearance',
|
||||
label: () => _('Editor font family'),
|
||||
description: () =>
|
||||
_('This must be *monospace* font or it will not work properly. If the font ' +
|
||||
_('This should be a *monospace* font or some elements will render incorrectly. If the font ' +
|
||||
'is incorrect or empty, it will default to a generic monospace font.'),
|
||||
},
|
||||
'style.sidebar.width': { value: 150, minimum: 80, maximum: 400, type: Setting.TYPE_INT, public: false, appTypes: ['desktop'] },
|
||||
|
Loading…
Reference in New Issue
Block a user