From a8c8539e7a5ba59030d6aa9f40c1cb3231e2c54a Mon Sep 17 00:00:00 2001 From: Caleb John Date: Sat, 6 Jun 2020 09:00:20 -0600 Subject: [PATCH] Desktop: Add option to choose Code Mirror as code editor (#3284) --- .eslintignore | 10 + .gitignore | 10 + ElectronClient/app.js | 4 +- ElectronClient/gui/MainScreen.jsx | 3 +- .../NoteBody/AceEditor/styles/index.ts | 2 +- .../NoteBody/CodeMirror/CodeMirror.tsx | 426 ++++++++++++++++++ .../NoteEditor/NoteBody/CodeMirror/Editor.tsx | 182 ++++++++ .../NoteBody/CodeMirror/Toolbar.tsx | 169 +++++++ .../NoteBody/CodeMirror/styles/index.ts | 60 +++ .../NoteBody/CodeMirror/utils/index.ts | 84 ++++ .../NoteBody/CodeMirror/utils/types.ts | 11 + .../CodeMirror/utils/useCursorUtils.ts | 104 +++++ .../CodeMirror/utils/useLineSorting.ts | 29 ++ .../NoteBody/CodeMirror/utils/useListIdent.ts | 133 ++++++ .../CodeMirror/utils/useScrollUtils.ts | 19 + ElectronClient/gui/NoteEditor/NoteEditor.tsx | 3 + ElectronClient/gui/style/theme/aritimDark.js | 3 +- ElectronClient/gui/style/theme/dark.js | 3 +- ElectronClient/gui/style/theme/dracula.js | 3 +- ElectronClient/gui/style/theme/light.js | 3 +- ElectronClient/gui/style/theme/nord.js | 3 +- .../gui/style/theme/solarizedDark.js | 3 +- .../gui/style/theme/solarizedLight.js | 3 +- ElectronClient/index.html | 8 +- ElectronClient/package.json | 1 + ElectronClient/style.css | 32 +- ReactNativeClient/lib/markdownUtils.js | 24 +- ReactNativeClient/lib/models/Setting.js | 10 +- 28 files changed, 1330 insertions(+), 15 deletions(-) create mode 100644 ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/CodeMirror.tsx create mode 100644 ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/Editor.tsx create mode 100644 ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/Toolbar.tsx create mode 100644 ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/styles/index.ts create mode 100644 ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/utils/index.ts create mode 100644 ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/utils/types.ts create mode 100644 ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/utils/useCursorUtils.ts create mode 100644 ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/utils/useLineSorting.ts create mode 100644 ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/utils/useListIdent.ts create mode 100644 ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollUtils.ts 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 @@ + + + + + + - \ No newline at end of file + diff --git a/ElectronClient/package.json b/ElectronClient/package.json index 85d597a30..9454bd548 100644 --- a/ElectronClient/package.json +++ b/ElectronClient/package.json @@ -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", diff --git a/ElectronClient/style.css b/ElectronClient/style.css index c409bcc44..9d478179e 100644 --- a/ElectronClient/style.css +++ b/ElectronClient/style.css @@ -169,4 +169,34 @@ a { @keyframes rotate { from {transform: rotate(0deg);} to {transform: rotate(360deg);} -} \ No newline at end of file +} + +/* 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; +} diff --git a/ReactNativeClient/lib/markdownUtils.js b/ReactNativeClient/lib/markdownUtils.js index 36c0b4aab..82df8dfe0 100644 --- a/ReactNativeClient/lib/markdownUtils.js +++ b/ReactNativeClient/lib/markdownUtils.js @@ -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) { diff --git a/ReactNativeClient/lib/models/Setting.js b/ReactNativeClient/lib/models/Setting.js index f509bda6f..a5e294eed 100644 --- a/ReactNativeClient/lib/models/Setting.js +++ b/ReactNativeClient/lib/models/Setting.js @@ -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'] },