diff --git a/.eslintignore b/.eslintignore
index a22458399..88ed59d2e 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -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
diff --git a/.gitignore b/.gitignore
index 65f5387bf..04d7fd640 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
diff --git a/ElectronClient/app.js b/ElectronClient/app.js
index c0a8fd5ad..95f09482a 100644
--- a/ElectronClient/app.js
+++ b/ElectronClient/app.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);
}
diff --git a/ElectronClient/gui/MainScreen.jsx b/ElectronClient/gui/MainScreen.jsx
index 0524e09dd..c0bc89e60 100644
--- a/ElectronClient/gui/MainScreen.jsx
+++ b/ElectronClient/gui/MainScreen.jsx
@@ -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 (
diff --git a/ElectronClient/gui/NoteEditor/NoteBody/AceEditor/styles/index.ts b/ElectronClient/gui/NoteEditor/NoteBody/AceEditor/styles/index.ts
index b5e421027..0798130c9 100644
--- a/ElectronClient/gui/NoteEditor/NoteBody/AceEditor/styles/index.ts
+++ b/ElectronClient/gui/NoteEditor/NoteBody/AceEditor/styles/index.ts
@@ -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
},
};
});
diff --git a/ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/CodeMirror.tsx b/ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/CodeMirror.tsx
new file mode 100644
index 000000000..523c01e7c
--- /dev/null
+++ b/ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/CodeMirror.tsx
@@ -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
(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(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 = `${_('This note has no content. Click on "%s" to toggle the editor and edit the note.', _('Layout'))}`;
+ }
+
+ 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 (
+
+
+
+ );
+ }
+
+ function renderViewer() {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ {props.noteToolbar}
+
+
+ {renderEditor()}
+ {renderViewer()}
+
+
+ );
+}
+
+export default forwardRef(CodeMirror);
+
diff --git a/ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/Editor.tsx b/ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/Editor.tsx
new file mode 100644
index 000000000..5c7168347
--- /dev/null
+++ b/ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/Editor.tsx
@@ -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 ;
+}
+
+export default forwardRef(Editor);
diff --git a/ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/Toolbar.tsx b/ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/Toolbar.tsx
new file mode 100644
index 000000000..e6eaa7dfb
--- /dev/null
+++ b/ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/Toolbar.tsx
@@ -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 ;
+}
diff --git a/ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/styles/index.ts b/ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/styles/index.ts
new file mode 100644
index 000000000..c74ca2318
--- /dev/null
+++ b/ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/styles/index.ts
@@ -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
+ },
+ };
+ });
+}
diff --git a/ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/utils/index.ts b/ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/utils/index.ts
new file mode 100644
index 000000000..f9896229e
--- /dev/null
+++ b/ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/utils/index.ts
@@ -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(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 };
+}
diff --git a/ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/utils/types.ts b/ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/utils/types.ts
new file mode 100644
index 000000000..79c6bb8e3
--- /dev/null
+++ b/ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/utils/types.ts
@@ -0,0 +1,11 @@
+export interface RenderedBody {
+ html: string;
+ pluginAssets: any[];
+}
+
+export function defaultRenderedBody(): RenderedBody {
+ return {
+ html: '',
+ pluginAssets: [],
+ };
+}
diff --git a/ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/utils/useCursorUtils.ts b/ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/utils/useCursorUtils.ts
new file mode 100644
index 000000000..2a5dca652
--- /dev/null
+++ b/ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/utils/useCursorUtils.ts
@@ -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');
+ });
+ });
+
+
+}
diff --git a/ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/utils/useLineSorting.ts b/ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/utils/useLineSorting.ts
new file mode 100644
index 000000000..c85ac581e
--- /dev/null
+++ b/ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/utils/useLineSorting.ts
@@ -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 });
+ }
+ });
+ };
+}
diff --git a/ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/utils/useListIdent.ts b/ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/utils/useListIdent.ts
new file mode 100644
index 000000000..8714d1d31
--- /dev/null
+++ b/ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/utils/useListIdent.ts
@@ -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');
+ }
+ }
+ };
+}
diff --git a/ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollUtils.ts b/ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollUtils.ts
new file mode 100644
index 000000000..b1f9b6700
--- /dev/null
+++ b/ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollUtils.ts
@@ -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));
+ });
+
+}
diff --git a/ElectronClient/gui/NoteEditor/NoteEditor.tsx b/ElectronClient/gui/NoteEditor/NoteEditor.tsx
index 4d31e61c4..0960981c0 100644
--- a/ElectronClient/gui/NoteEditor/NoteEditor.tsx
+++ b/ElectronClient/gui/NoteEditor/NoteEditor.tsx
@@ -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 = ;
} else if (props.bodyEditor === 'AceEditor') {
editor = ;
+ } else if (props.bodyEditor === 'CodeMirror') {
+ editor = ;
} else {
throw new Error(`Invalid editor: ${props.bodyEditor}`);
}
diff --git a/ElectronClient/gui/style/theme/aritimDark.js b/ElectronClient/gui/style/theme/aritimDark.js
index eecaa85cf..1d63e7890 100644
--- a/ElectronClient/gui/style/theme/aritimDark.js
+++ b/ElectronClient/gui/style/theme/aritimDark.js
@@ -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',
diff --git a/ElectronClient/gui/style/theme/dark.js b/ElectronClient/gui/style/theme/dark.js
index ecc2567bc..3842fe56b 100644
--- a/ElectronClient/gui/style/theme/dark.js
+++ b/ElectronClient/gui/style/theme/dark.js
@@ -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',
diff --git a/ElectronClient/gui/style/theme/dracula.js b/ElectronClient/gui/style/theme/dracula.js
index 8cd3046d6..60924cb25 100644
--- a/ElectronClient/gui/style/theme/dracula.js
+++ b/ElectronClient/gui/style/theme/dracula.js
@@ -33,7 +33,8 @@ const draculaStyle = {
htmlCodeBorderColor: '#f8f8f2',
htmlCodeColor: '#50fa7b',
- editorTheme: 'dracula',
+ aceEditorTheme: 'dracula',
+ codeMirrorTheme: 'dracula',
codeThemeCss: 'atom-one-dark-reasonable.css',
};
diff --git a/ElectronClient/gui/style/theme/light.js b/ElectronClient/gui/style/theme/light.js
index 29c43c0fc..ea3e78fcd 100644
--- a/ElectronClient/gui/style/theme/light.js
+++ b/ElectronClient/gui/style/theme/light.js
@@ -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',
};
diff --git a/ElectronClient/gui/style/theme/nord.js b/ElectronClient/gui/style/theme/nord.js
index 2189bfb13..970ecd713 100644
--- a/ElectronClient/gui/style/theme/nord.js
+++ b/ElectronClient/gui/style/theme/nord.js
@@ -79,7 +79,8 @@ const nordStyle = {
htmlCodeBorderColor: nord[2],
htmlCodeColor: nord[13],
- editorTheme: 'terminal',
+ aceEditorTheme: 'terminal',
+ codeMirrorTheme: 'nord',
codeThemeCss: 'atom-one-dark-reasonable.css',
};
diff --git a/ElectronClient/gui/style/theme/solarizedDark.js b/ElectronClient/gui/style/theme/solarizedDark.js
index afe7106cf..a9e5758bb 100644
--- a/ElectronClient/gui/style/theme/solarizedDark.js
+++ b/ElectronClient/gui/style/theme/solarizedDark.js
@@ -33,7 +33,8 @@ const solarizedDarkStyle = {
htmlCodeBorderColor: '#696969',
htmlCodeColor: '#fdf6e3',
- editorTheme: 'twilight',
+ aceEditorTheme: 'twilight',
+ codeMirrorTheme: 'solarized dark',
codeThemeCss: 'atom-one-dark-reasonable.css',
};
diff --git a/ElectronClient/gui/style/theme/solarizedLight.js b/ElectronClient/gui/style/theme/solarizedLight.js
index 042ad3b05..70a81009d 100644
--- a/ElectronClient/gui/style/theme/solarizedLight.js
+++ b/ElectronClient/gui/style/theme/solarizedLight.js
@@ -31,7 +31,8 @@ const solarizedLightStyle = {
htmlCodeBorderColor: '#eee8d5',
htmlCodeColor: '#002b36',
- editorTheme: 'tomorrow',
+ aceEditorTheme: 'tomorrow',
+ codeMirrorTheme: 'solarized light',
codeThemeCss: 'atom-one-light.css',
};
diff --git a/ElectronClient/index.html b/ElectronClient/index.html
index 5a867092a..3529b6882 100644
--- a/ElectronClient/index.html
+++ b/ElectronClient/index.html
@@ -12,6 +12,12 @@
+
+
+
+
+
+