From 84c6de9b56be60628d1079bc59378ddabd9acf5e Mon Sep 17 00:00:00 2001 From: Henry Heino <46334387+personalizedrefrigerator@users.noreply.github.com> Date: Thu, 21 Sep 2023 01:12:40 -0700 Subject: [PATCH] Desktop: Add new beta Markdown editor based on CodeMirror 6 (#8793) --- .eslintignore | 61 ++- .gitignore | 61 ++- .npmpackagejsonlintrc.json | 1 + packages/app-desktop/app.ts | 2 +- .../app-desktop/gui/MainScreen/MainScreen.tsx | 8 +- .../NoteBody/CodeMirror/styles/index.ts | 60 --- .../NoteBody/CodeMirror/utils/setupVim.ts | 12 + .../CodeMirror/utils/useContextMenu.ts | 175 +++++++ .../NoteBody/CodeMirror/utils/useKeymap.ts | 11 +- .../NoteBody/CodeMirror/utils/useStyles.ts | 82 ++++ .../CodeMirror/utils/useWebviewIpcMessage.ts | 46 ++ .../CodeMirror/{ => v5}/CodeMirror.tsx | 224 ++------- .../NoteBody/CodeMirror/{ => v5}/Editor.tsx | 18 +- .../NoteBody/CodeMirror/v6/CodeMirror.tsx | 428 +++++++++++++++++ .../NoteBody/CodeMirror/v6/Editor.tsx | 103 ++++ .../CodeMirror/v6/useEditorCommands.ts | 136 ++++++ .../app-desktop/gui/NoteEditor/NoteEditor.tsx | 11 +- .../app-desktop/gui/NoteEditor/utils/types.ts | 11 +- packages/app-desktop/package.json | 1 + .../NoteEditor/CodeMirror/CodeMirror.test.ts | 59 --- .../NoteEditor/CodeMirror/CodeMirror.ts | 452 +----------------- .../components/NoteEditor/CodeMirror/types.ts | 27 -- .../components/NoteEditor/EditLinkDialog.tsx | 4 +- .../MarkdownToolbar/MarkdownToolbar.tsx | 17 +- .../components/NoteEditor/NoteEditor.tsx | 203 +++++--- .../components/NoteEditor/SearchPanel.tsx | 6 +- .../NoteEditor/SelectionFormatting.ts | 98 ---- .../app-mobile/components/NoteEditor/types.ts | 92 ++-- .../app-mobile/components/screens/Note.tsx | 4 +- packages/app-mobile/jest.setup.js | 16 - packages/app-mobile/metro.config.js | 1 + packages/app-mobile/package.json | 16 +- .../CodeMirror5Emulation.test.ts | 111 +++++ .../CodeMirror5Emulation.ts | 410 ++++++++++++++++ .../CodeMirror5Emulation/Decorator.ts | 385 +++++++++++++++ .../CodeMirror/CodeMirrorControl.test.ts | 46 ++ .../editor/CodeMirror/CodeMirrorControl.ts | 137 ++++++ packages/editor/CodeMirror/PluginLoader.ts | 156 ++++++ .../editor/CodeMirror/configFromSettings.ts | 70 +++ .../editor/CodeMirror/createEditor.test.ts | 122 +++++ packages/editor/CodeMirror/createEditor.ts | 303 ++++++++++++ .../editorCommands/editorCommands.ts | 69 +++ .../editorCommands/supportsCommand.ts | 11 + .../CodeMirror/editorCommands/swapLine.ts | 49 ++ .../editor/CodeMirror/getScrollFraction.ts | 11 + .../markdown/computeSelectionFormatting.ts | 123 +++++ .../markdown}/decoratorExtension.ts | 43 ++ ...rkdownCommands.bulletedVsChecklist.test.ts | 10 +- .../markdown}/markdownCommands.test.ts | 28 +- .../markdownCommands.toggleList.test.ts | 16 +- .../CodeMirror/markdown}/markdownCommands.ts | 2 +- .../markdown}/markdownMathParser.test.ts | 4 +- .../markdown}/markdownMathParser.ts | 0 .../markdown}/markdownReformatter.test.ts | 0 .../markdown}/markdownReformatter.ts | 0 .../markdown}/syntaxHighlightingLanguages.ts | 0 .../testUtil/createEditorSettings.ts | 24 + .../CodeMirror/testUtil/createTestEditor.ts} | 6 +- .../CodeMirror/testUtil/forceFullParse.ts | 0 .../CodeMirror/testUtil/loadLanguages.ts | 2 +- .../NoteEditor => editor}/CodeMirror/theme.ts | 72 ++- packages/editor/SelectionFormatting.ts | 73 +++ packages/editor/events.ts | 65 +++ packages/editor/jest.config.js | 19 + packages/editor/jest.setup.js | 16 + packages/editor/package.json | 48 ++ packages/editor/tsconfig.json | 11 + packages/editor/types.ts | 163 +++++++ packages/lib/models/Setting.ts | 31 +- yarn.lock | 425 ++++++++++------ 70 files changed, 4201 insertions(+), 1306 deletions(-) delete mode 100644 packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/styles/index.ts create mode 100644 packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/setupVim.ts create mode 100644 packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useContextMenu.ts create mode 100644 packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useStyles.ts create mode 100644 packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useWebviewIpcMessage.ts rename packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/{ => v5}/CodeMirror.tsx (78%) rename packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/{ => v5}/Editor.tsx (94%) create mode 100644 packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/CodeMirror.tsx create mode 100644 packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/Editor.tsx create mode 100644 packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/useEditorCommands.ts delete mode 100644 packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.test.ts delete mode 100644 packages/app-mobile/components/NoteEditor/CodeMirror/types.ts delete mode 100644 packages/app-mobile/components/NoteEditor/SelectionFormatting.ts create mode 100644 packages/editor/CodeMirror/CodeMirror5Emulation/CodeMirror5Emulation.test.ts create mode 100644 packages/editor/CodeMirror/CodeMirror5Emulation/CodeMirror5Emulation.ts create mode 100644 packages/editor/CodeMirror/CodeMirror5Emulation/Decorator.ts create mode 100644 packages/editor/CodeMirror/CodeMirrorControl.test.ts create mode 100644 packages/editor/CodeMirror/CodeMirrorControl.ts create mode 100644 packages/editor/CodeMirror/PluginLoader.ts create mode 100644 packages/editor/CodeMirror/configFromSettings.ts create mode 100644 packages/editor/CodeMirror/createEditor.test.ts create mode 100644 packages/editor/CodeMirror/createEditor.ts create mode 100644 packages/editor/CodeMirror/editorCommands/editorCommands.ts create mode 100644 packages/editor/CodeMirror/editorCommands/supportsCommand.ts create mode 100644 packages/editor/CodeMirror/editorCommands/swapLine.ts create mode 100644 packages/editor/CodeMirror/getScrollFraction.ts create mode 100644 packages/editor/CodeMirror/markdown/computeSelectionFormatting.ts rename packages/{app-mobile/components/NoteEditor/CodeMirror => editor/CodeMirror/markdown}/decoratorExtension.ts (79%) rename packages/{app-mobile/components/NoteEditor/CodeMirror => editor/CodeMirror/markdown}/markdownCommands.bulletedVsChecklist.test.ts (87%) rename packages/{app-mobile/components/NoteEditor/CodeMirror => editor/CodeMirror/markdown}/markdownCommands.test.ts (87%) rename packages/{app-mobile/components/NoteEditor/CodeMirror => editor/CodeMirror/markdown}/markdownCommands.toggleList.test.ts (95%) rename packages/{app-mobile/components/NoteEditor/CodeMirror => editor/CodeMirror/markdown}/markdownCommands.ts (99%) rename packages/{app-mobile/components/NoteEditor/CodeMirror => editor/CodeMirror/markdown}/markdownMathParser.test.ts (97%) rename packages/{app-mobile/components/NoteEditor/CodeMirror => editor/CodeMirror/markdown}/markdownMathParser.ts (100%) rename packages/{app-mobile/components/NoteEditor/CodeMirror => editor/CodeMirror/markdown}/markdownReformatter.test.ts (100%) rename packages/{app-mobile/components/NoteEditor/CodeMirror => editor/CodeMirror/markdown}/markdownReformatter.ts (100%) rename packages/{app-mobile/components/NoteEditor/CodeMirror => editor/CodeMirror/markdown}/syntaxHighlightingLanguages.ts (100%) create mode 100644 packages/editor/CodeMirror/testUtil/createEditorSettings.ts rename packages/{app-mobile/components/NoteEditor/CodeMirror/testUtil/createEditor.ts => editor/CodeMirror/testUtil/createTestEditor.ts} (93%) rename packages/{app-mobile/components/NoteEditor => editor}/CodeMirror/testUtil/forceFullParse.ts (100%) rename packages/{app-mobile/components/NoteEditor => editor}/CodeMirror/testUtil/loadLanguages.ts (79%) rename packages/{app-mobile/components/NoteEditor => editor}/CodeMirror/theme.ts (72%) create mode 100644 packages/editor/SelectionFormatting.ts create mode 100644 packages/editor/events.ts create mode 100644 packages/editor/jest.config.js create mode 100644 packages/editor/jest.setup.js create mode 100644 packages/editor/package.json create mode 100644 packages/editor/tsconfig.json create mode 100644 packages/editor/types.ts diff --git a/.eslintignore b/.eslintignore index 30ba7625c..811541fc4 100644 --- a/.eslintignore +++ b/.eslintignore @@ -215,12 +215,11 @@ packages/app-desktop/gui/MenuBar.js packages/app-desktop/gui/MultiNoteActions.js packages/app-desktop/gui/Navigator.js packages/app-desktop/gui/NoteContentPropertiesDialog.js -packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/CodeMirror.js -packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/Editor.js packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/Toolbar.js -packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/styles/index.js packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/index.js +packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/setupVim.js packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/types.js +packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useContextMenu.js packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useCursorUtils.test.js packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useCursorUtils.js packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useEditorSearch.js @@ -232,6 +231,13 @@ packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useLineSorting.js packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useListIdent.js packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollHandler.js packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollUtils.js +packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useStyles.js +packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useWebviewIpcMessage.js +packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v5/CodeMirror.js +packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v5/Editor.js +packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/CodeMirror.js +packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/Editor.js +packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/useEditorCommands.js packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.js packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/styles/index.js packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/joplinCommandToTinyMceCommands.js @@ -410,23 +416,7 @@ packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.js packages/app-mobile/components/NoteBodyViewer/hooks/useOnMessage.js packages/app-mobile/components/NoteBodyViewer/hooks/useOnResourceLongPress.js packages/app-mobile/components/NoteBodyViewer/hooks/useSource.js -packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.test.js packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.js -packages/app-mobile/components/NoteEditor/CodeMirror/decoratorExtension.js -packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.bulletedVsChecklist.test.js -packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.test.js -packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.toggleList.test.js -packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.js -packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.test.js -packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.js -packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.test.js -packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.js -packages/app-mobile/components/NoteEditor/CodeMirror/syntaxHighlightingLanguages.js -packages/app-mobile/components/NoteEditor/CodeMirror/testUtil/createEditor.js -packages/app-mobile/components/NoteEditor/CodeMirror/testUtil/forceFullParse.js -packages/app-mobile/components/NoteEditor/CodeMirror/testUtil/loadLanguages.js -packages/app-mobile/components/NoteEditor/CodeMirror/theme.js -packages/app-mobile/components/NoteEditor/CodeMirror/types.js packages/app-mobile/components/NoteEditor/CodeMirror/webviewLogger.js packages/app-mobile/components/NoteEditor/EditLinkDialog.js packages/app-mobile/components/NoteEditor/MarkdownToolbar/MarkdownToolbar.js @@ -439,7 +429,6 @@ packages/app-mobile/components/NoteEditor/MarkdownToolbar/types.js packages/app-mobile/components/NoteEditor/NoteEditor.test.js packages/app-mobile/components/NoteEditor/NoteEditor.js packages/app-mobile/components/NoteEditor/SearchPanel.js -packages/app-mobile/components/NoteEditor/SelectionFormatting.js packages/app-mobile/components/NoteEditor/types.js packages/app-mobile/components/NoteList.js packages/app-mobile/components/ProfileSwitcher/ProfileEditor.js @@ -489,6 +478,38 @@ packages/app-mobile/utils/fs-driver-rn.js packages/app-mobile/utils/setupNotifications.js packages/app-mobile/utils/shareHandler.js packages/app-mobile/utils/types.js +packages/editor/CodeMirror/CodeMirror5Emulation/CodeMirror5Emulation.test.js +packages/editor/CodeMirror/CodeMirror5Emulation/CodeMirror5Emulation.js +packages/editor/CodeMirror/CodeMirror5Emulation/Decorator.js +packages/editor/CodeMirror/CodeMirrorControl.test.js +packages/editor/CodeMirror/CodeMirrorControl.js +packages/editor/CodeMirror/PluginLoader.js +packages/editor/CodeMirror/configFromSettings.js +packages/editor/CodeMirror/createEditor.test.js +packages/editor/CodeMirror/createEditor.js +packages/editor/CodeMirror/editorCommands/editorCommands.js +packages/editor/CodeMirror/editorCommands/supportsCommand.js +packages/editor/CodeMirror/editorCommands/swapLine.js +packages/editor/CodeMirror/getScrollFraction.js +packages/editor/CodeMirror/markdown/computeSelectionFormatting.js +packages/editor/CodeMirror/markdown/decoratorExtension.js +packages/editor/CodeMirror/markdown/markdownCommands.bulletedVsChecklist.test.js +packages/editor/CodeMirror/markdown/markdownCommands.test.js +packages/editor/CodeMirror/markdown/markdownCommands.toggleList.test.js +packages/editor/CodeMirror/markdown/markdownCommands.js +packages/editor/CodeMirror/markdown/markdownMathParser.test.js +packages/editor/CodeMirror/markdown/markdownMathParser.js +packages/editor/CodeMirror/markdown/markdownReformatter.test.js +packages/editor/CodeMirror/markdown/markdownReformatter.js +packages/editor/CodeMirror/markdown/syntaxHighlightingLanguages.js +packages/editor/CodeMirror/testUtil/createEditorSettings.js +packages/editor/CodeMirror/testUtil/createTestEditor.js +packages/editor/CodeMirror/testUtil/forceFullParse.js +packages/editor/CodeMirror/testUtil/loadLanguages.js +packages/editor/CodeMirror/theme.js +packages/editor/SelectionFormatting.js +packages/editor/events.js +packages/editor/types.js packages/fork-htmlparser2/src/CollectingHandler.js packages/fork-htmlparser2/src/FeedHandler.spec.js packages/fork-htmlparser2/src/FeedHandler.js diff --git a/.gitignore b/.gitignore index 93929cb98..40c2c66ae 100644 --- a/.gitignore +++ b/.gitignore @@ -201,12 +201,11 @@ packages/app-desktop/gui/MenuBar.js packages/app-desktop/gui/MultiNoteActions.js packages/app-desktop/gui/Navigator.js packages/app-desktop/gui/NoteContentPropertiesDialog.js -packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/CodeMirror.js -packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/Editor.js packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/Toolbar.js -packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/styles/index.js packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/index.js +packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/setupVim.js packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/types.js +packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useContextMenu.js packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useCursorUtils.test.js packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useCursorUtils.js packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useEditorSearch.js @@ -218,6 +217,13 @@ packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useLineSorting.js packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useListIdent.js packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollHandler.js packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollUtils.js +packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useStyles.js +packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useWebviewIpcMessage.js +packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v5/CodeMirror.js +packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v5/Editor.js +packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/CodeMirror.js +packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/Editor.js +packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/useEditorCommands.js packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.js packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/styles/index.js packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/joplinCommandToTinyMceCommands.js @@ -396,23 +402,7 @@ packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.js packages/app-mobile/components/NoteBodyViewer/hooks/useOnMessage.js packages/app-mobile/components/NoteBodyViewer/hooks/useOnResourceLongPress.js packages/app-mobile/components/NoteBodyViewer/hooks/useSource.js -packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.test.js packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.js -packages/app-mobile/components/NoteEditor/CodeMirror/decoratorExtension.js -packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.bulletedVsChecklist.test.js -packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.test.js -packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.toggleList.test.js -packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.js -packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.test.js -packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.js -packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.test.js -packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.js -packages/app-mobile/components/NoteEditor/CodeMirror/syntaxHighlightingLanguages.js -packages/app-mobile/components/NoteEditor/CodeMirror/testUtil/createEditor.js -packages/app-mobile/components/NoteEditor/CodeMirror/testUtil/forceFullParse.js -packages/app-mobile/components/NoteEditor/CodeMirror/testUtil/loadLanguages.js -packages/app-mobile/components/NoteEditor/CodeMirror/theme.js -packages/app-mobile/components/NoteEditor/CodeMirror/types.js packages/app-mobile/components/NoteEditor/CodeMirror/webviewLogger.js packages/app-mobile/components/NoteEditor/EditLinkDialog.js packages/app-mobile/components/NoteEditor/MarkdownToolbar/MarkdownToolbar.js @@ -425,7 +415,6 @@ packages/app-mobile/components/NoteEditor/MarkdownToolbar/types.js packages/app-mobile/components/NoteEditor/NoteEditor.test.js packages/app-mobile/components/NoteEditor/NoteEditor.js packages/app-mobile/components/NoteEditor/SearchPanel.js -packages/app-mobile/components/NoteEditor/SelectionFormatting.js packages/app-mobile/components/NoteEditor/types.js packages/app-mobile/components/NoteList.js packages/app-mobile/components/ProfileSwitcher/ProfileEditor.js @@ -475,6 +464,38 @@ packages/app-mobile/utils/fs-driver-rn.js packages/app-mobile/utils/setupNotifications.js packages/app-mobile/utils/shareHandler.js packages/app-mobile/utils/types.js +packages/editor/CodeMirror/CodeMirror5Emulation/CodeMirror5Emulation.test.js +packages/editor/CodeMirror/CodeMirror5Emulation/CodeMirror5Emulation.js +packages/editor/CodeMirror/CodeMirror5Emulation/Decorator.js +packages/editor/CodeMirror/CodeMirrorControl.test.js +packages/editor/CodeMirror/CodeMirrorControl.js +packages/editor/CodeMirror/PluginLoader.js +packages/editor/CodeMirror/configFromSettings.js +packages/editor/CodeMirror/createEditor.test.js +packages/editor/CodeMirror/createEditor.js +packages/editor/CodeMirror/editorCommands/editorCommands.js +packages/editor/CodeMirror/editorCommands/supportsCommand.js +packages/editor/CodeMirror/editorCommands/swapLine.js +packages/editor/CodeMirror/getScrollFraction.js +packages/editor/CodeMirror/markdown/computeSelectionFormatting.js +packages/editor/CodeMirror/markdown/decoratorExtension.js +packages/editor/CodeMirror/markdown/markdownCommands.bulletedVsChecklist.test.js +packages/editor/CodeMirror/markdown/markdownCommands.test.js +packages/editor/CodeMirror/markdown/markdownCommands.toggleList.test.js +packages/editor/CodeMirror/markdown/markdownCommands.js +packages/editor/CodeMirror/markdown/markdownMathParser.test.js +packages/editor/CodeMirror/markdown/markdownMathParser.js +packages/editor/CodeMirror/markdown/markdownReformatter.test.js +packages/editor/CodeMirror/markdown/markdownReformatter.js +packages/editor/CodeMirror/markdown/syntaxHighlightingLanguages.js +packages/editor/CodeMirror/testUtil/createEditorSettings.js +packages/editor/CodeMirror/testUtil/createTestEditor.js +packages/editor/CodeMirror/testUtil/forceFullParse.js +packages/editor/CodeMirror/testUtil/loadLanguages.js +packages/editor/CodeMirror/theme.js +packages/editor/SelectionFormatting.js +packages/editor/events.js +packages/editor/types.js packages/fork-htmlparser2/src/CollectingHandler.js packages/fork-htmlparser2/src/FeedHandler.spec.js packages/fork-htmlparser2/src/FeedHandler.js diff --git a/.npmpackagejsonlintrc.json b/.npmpackagejsonlintrc.json index 4d6e95e20..fcc54cf86 100644 --- a/.npmpackagejsonlintrc.json +++ b/.npmpackagejsonlintrc.json @@ -5,6 +5,7 @@ "exceptions": [ "@joplin/lib", "@joplin/renderer", + "@joplin/editor", "@joplin/pdf-viewer", "@joplin/fork-htmlparser2", "@joplin/fork-sax", diff --git a/packages/app-desktop/app.ts b/packages/app-desktop/app.ts index 3c4b6b125..873087718 100644 --- a/packages/app-desktop/app.ts +++ b/packages/app-desktop/app.ts @@ -192,7 +192,7 @@ 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 = `.CodeMirror * { font-family: ${fontFamilies.join(', ')} !important; }`; + const css = `.CodeMirror *, .cm-editor .cm-content { font-family: ${fontFamilies.join(', ')} !important; }`; const styleTag = document.createElement('style'); styleTag.type = 'text/css'; styleTag.appendChild(document.createTextNode(css)); diff --git a/packages/app-desktop/gui/MainScreen/MainScreen.tsx b/packages/app-desktop/gui/MainScreen/MainScreen.tsx index e5a879dc4..ad4b98ede 100644 --- a/packages/app-desktop/gui/MainScreen/MainScreen.tsx +++ b/packages/app-desktop/gui/MainScreen/MainScreen.tsx @@ -79,6 +79,7 @@ interface Props { startupPluginsLoaded: boolean; shareInvitations: ShareInvitation[]; isSafeMode: boolean; + enableBetaMarkdownEditor: boolean; needApiAuth: boolean; processingShareInvitationResponse: boolean; isResettingLayout: boolean; @@ -737,7 +738,11 @@ class MainScreenComponent extends React.Component { }, editor: () => { - const bodyEditor = this.props.settingEditorCodeView ? 'CodeMirror' : 'TinyMCE'; + let bodyEditor = this.props.settingEditorCodeView ? 'CodeMirror' : 'TinyMCE'; + + if (this.props.settingEditorCodeView && this.props.enableBetaMarkdownEditor) { + bodyEditor = 'CodeMirror6'; + } return ; }, }; @@ -909,6 +914,7 @@ const mapStateToProps = (state: AppState) => { shareInvitations: state.shareService.shareInvitations, processingShareInvitationResponse: state.shareService.processingShareInvitationResponse, isSafeMode: state.settings.isSafeMode, + enableBetaMarkdownEditor: state.settings['editor.beta'], needApiAuth: state.needApiAuth, showInstallTemplatesPlugin: state.hasLegacyTemplates && !state.pluginService.plugins['joplin.plugin.templates'], isResettingLayout: state.isResettingLayout, diff --git a/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/styles/index.ts b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/styles/index.ts deleted file mode 100644 index 990f37b62..000000000 --- a/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/styles/index.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { NoteBodyEditorProps } from '../../../utils/types'; -const { buildStyle } = require('@joplin/lib/theme'); - -export default function styles(props: NoteBodyEditorProps) { - return buildStyle(['CodeMirror', props.fontSize], props.themeId, (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: `${Math.round(17 * props.fontSize / 12)}px`, - fontSize: `${props.fontSize}px`, - color: theme.color, - backgroundColor: theme.backgroundColor, - codeMirrorTheme: theme.codeMirrorTheme, // Defined in theme.js - }, - }; - }); -} diff --git a/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/setupVim.ts b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/setupVim.ts new file mode 100644 index 000000000..d2b4fe038 --- /dev/null +++ b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/setupVim.ts @@ -0,0 +1,12 @@ +import CodeMirrorControl from '@joplin/editor/CodeMirror/CodeMirrorControl'; + +const setupVim = (CodeMirror: CodeMirrorControl) => { + CodeMirror.Vim.defineAction('swapLineDown', CodeMirror.commands.swapLineDown); + CodeMirror.Vim.mapCommand('', 'action', 'swapLineDown', {}, { context: 'normal', isEdit: true }); + CodeMirror.Vim.defineAction('swapLineUp', CodeMirror.commands.swapLineUp); + CodeMirror.Vim.mapCommand('', 'action', 'swapLineUp', {}, { context: 'normal', isEdit: true }); + CodeMirror.Vim.defineAction('insertListElement', CodeMirror.commands.vimInsertListElement); + CodeMirror.Vim.mapCommand('o', 'action', 'insertListElement', { after: true }, { context: 'normal', isEdit: true, interlaceInsertRepeat: true }); +}; + +export default setupVim; diff --git a/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useContextMenu.ts b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useContextMenu.ts new file mode 100644 index 000000000..2fa111213 --- /dev/null +++ b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useContextMenu.ts @@ -0,0 +1,175 @@ + +import { ContextMenuEvent, ContextMenuParams } from 'electron'; +import { useEffect, RefObject } from 'react'; +import { _ } from '@joplin/lib/locale'; +import Setting from '@joplin/lib/models/Setting'; +import { PluginStates } from '@joplin/lib/services/plugins/reducer'; +import { MenuItemLocation } from '@joplin/lib/services/plugins/api/types'; +import MenuUtils from '@joplin/lib/services/commands/MenuUtils'; +import CommandService from '@joplin/lib/services/CommandService'; +import convertToScreenCoordinates from '../../../../utils/convertToScreenCoordinates'; +import SpellCheckerService from '@joplin/lib/services/spellChecker/SpellCheckerService'; +import { EditContextMenuFilterObject } from '@joplin/lib/services/plugins/api/JoplinWorkspace'; +import type CodeMirrorControl from '@joplin/editor/CodeMirror/CodeMirrorControl'; +import eventManager from '@joplin/lib/eventManager'; +import bridge from '../../../../../services/bridge'; + +const Menu = bridge().Menu; +const MenuItem = bridge().MenuItem; +const menuUtils = new MenuUtils(CommandService.instance()); + + +interface ContextMenuProps { + plugins: PluginStates; + editorCutText: ()=> void; + editorCopyText: ()=> void; + editorPaste: ()=> void; + editorRef: RefObject; + editorClassName: string; +} + +const useContextMenu = (props: ContextMenuProps) => { + const editorRef = props.editorRef; + + // The below code adds support for spellchecking when it is enabled + // It might be buggy, refer to the below issue + // https://github.com/laurent22/joplin/pull/3974#issuecomment-718936703 + useEffect(() => { + const isAncestorOfCodeMirrorEditor = (elem: HTMLElement) => { + for (; elem.parentElement; elem = elem.parentElement) { + if (elem.classList.contains(props.editorClassName)) { + return true; + } + } + + return false; + }; + + let lastInCodeMirrorContextMenuTimestamp = 0; + + // The browser's contextmenu event provides additional information about the + // target of the event, not provided by the Electron context-menu event. + const onBrowserContextMenu = (event: Event) => { + if (isAncestorOfCodeMirrorEditor(event.target as HTMLElement)) { + lastInCodeMirrorContextMenuTimestamp = Date.now(); + } + }; + + function pointerInsideEditor(params: ContextMenuParams) { + const x = params.x, y = params.y, isEditable = params.isEditable; + const elements = document.getElementsByClassName(props.editorClassName); + + // Note: We can't check inputFieldType here. When spellcheck is enabled, + // params.inputFieldType is "none". When spellcheck is disabled, + // params.inputFieldType is "plainText". Thus, such a check would be inconsistent. + if (!elements.length || !isEditable) return false; + + const maximumMsSinceBrowserEvent = 100; + if (Date.now() - lastInCodeMirrorContextMenuTimestamp > maximumMsSinceBrowserEvent) { + return false; + } + + const rect = convertToScreenCoordinates(Setting.value('windowContentZoomFactor'), elements[0].getBoundingClientRect()); + return rect.x < x && rect.y < y && rect.right > x && rect.bottom > y; + } + + async function onContextMenu(event: ContextMenuEvent, params: ContextMenuParams) { + if (!pointerInsideEditor(params)) return; + + // Don't show the default menu. + event.preventDefault(); + + const menu = new Menu(); + + const hasSelectedText = editorRef.current && !!editorRef.current.getSelection() ; + + menu.append( + new MenuItem({ + label: _('Cut'), + enabled: hasSelectedText, + click: async () => { + props.editorCutText(); + }, + }), + ); + + menu.append( + new MenuItem({ + label: _('Copy'), + enabled: hasSelectedText, + click: async () => { + props.editorCopyText(); + }, + }), + ); + + menu.append( + new MenuItem({ + label: _('Paste'), + enabled: true, + click: async () => { + props.editorPaste(); + }, + }), + ); + + const spellCheckerMenuItems = SpellCheckerService.instance().contextMenuItems(params.misspelledWord, params.dictionarySuggestions); + + for (const item of spellCheckerMenuItems) { + menu.append(new MenuItem(item)); + } + + // CodeMirror 5 only: + // Typically CodeMirror handles all interactions itself (highlighting etc.) + // But in the case of clicking a mispelled word, we need electron to handle the click + // The result is that CodeMirror doesn't know what's been selected and doesn't + // move the cursor into the correct location. + // and when the user selects a new spelling it will be inserted in the wrong location + // So in this situation, we use must manually align the internal codemirror selection + // to the contextmenu selection + if (editorRef.current && !editorRef.current.cm6 && spellCheckerMenuItems.length > 0) { + (editorRef.current as any).alignSelection(params); + } + + let filterObject: EditContextMenuFilterObject = { + items: [], + }; + + filterObject = await eventManager.filterEmit('editorContextMenu', filterObject); + + for (const item of filterObject.items) { + menu.append(new MenuItem({ + label: item.label, + click: async () => { + const args = item.commandArgs || []; + void CommandService.instance().execute(item.commandName, ...args); + }, + type: item.type, + })); + } + + // eslint-disable-next-line github/array-foreach -- Old code before rule was applied + menuUtils.pluginContextMenuItems(props.plugins, MenuItemLocation.EditorContextMenu).forEach((item: any) => { + menu.append(new MenuItem(item)); + }); + + menu.popup(); + } + + // Prepend the event listener so that it gets called before + // the listener that shows the default menu. + bridge().window().webContents.prependListener('context-menu', onContextMenu); + + window.addEventListener('contextmenu', onBrowserContextMenu); + + return () => { + bridge().window().webContents.off('context-menu', onContextMenu); + window.removeEventListener('contextmenu', onBrowserContextMenu); + }; + }, [ + props.plugins, props.editorClassName, editorRef, + props.editorCutText, props.editorCopyText, props.editorPaste, + ]); +}; + +export default useContextMenu; diff --git a/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useKeymap.ts b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useKeymap.ts index ccdb4498a..09869a012 100644 --- a/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useKeymap.ts +++ b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useKeymap.ts @@ -4,6 +4,7 @@ import KeymapService, { KeymapItem } from '@joplin/lib/services/KeymapService'; import { EditorCommand } from '../../../utils/types'; import shim from '@joplin/lib/shim'; import { reg } from '@joplin/lib/registry'; +import setupVim from './setupVim'; export default function useKeymap(CodeMirror: any) { @@ -17,14 +18,6 @@ export default function useKeymap(CodeMirror: any) { CodeMirror.keyMap.emacs['Shift-Tab'] = 'smartListUnindent'; } - function setupVim() { - CodeMirror.Vim.defineAction('swapLineDown', CodeMirror.commands.swapLineDown); - CodeMirror.Vim.mapCommand('', 'action', 'swapLineDown', {}, { context: 'normal', isEdit: true }); - CodeMirror.Vim.defineAction('swapLineUp', CodeMirror.commands.swapLineUp); - CodeMirror.Vim.mapCommand('', 'action', 'swapLineUp', {}, { context: 'normal', isEdit: true }); - CodeMirror.Vim.defineAction('insertListElement', CodeMirror.commands.vimInsertListElement); - CodeMirror.Vim.mapCommand('o', 'action', 'insertListElement', { after: true }, { context: 'normal', isEdit: true, interlaceInsertRepeat: true }); - } function isEditorCommand(command: string) { return command.startsWith('editor.'); } @@ -184,7 +177,7 @@ export default function useKeymap(CodeMirror: any) { keymapService.on('keymapChange', registerKeymap); setupEmacs(); - setupVim(); + setupVim(CodeMirror); // eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied }, []); } diff --git a/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useStyles.ts b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useStyles.ts new file mode 100644 index 000000000..bc6c6bfad --- /dev/null +++ b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useStyles.ts @@ -0,0 +1,82 @@ +import { Theme } from '@joplin/lib/themes/type'; +import { NoteBodyEditorProps } from '../../../utils/types'; +import { buildStyle } from '@joplin/lib/theme'; +import { useMemo } from 'react'; + + + +const useStyles = (props: NoteBodyEditorProps) => { + return useMemo(() => { + return buildStyle(['CodeMirror', props.fontSize], props.themeId, (theme: Theme) => { + return { + root: { + position: 'relative', + display: 'flex', + flexDirection: 'column', + minHeight: 0, + ...props.style, + }, + rowToolbar: { + position: 'relative', + display: 'flex', + flexDirection: 'row', + }, + rowEditorViewer: { + position: 'relative', + display: 'flex', + flexDirection: 'row', + flex: 1, + paddingTop: 10, + + // Allow the editor container to shrink (allowing the editor to scroll) + minHeight: 0, + }, + 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: `${Math.round(17 * props.fontSize / 12)}px`, + fontSize: `${props.fontSize}px`, + color: theme.color, + backgroundColor: theme.backgroundColor, + + // CM5 only + codeMirrorTheme: theme.codeMirrorTheme, // Defined in theme.js + }, + + // CM6 only + globalTheme: { + ...theme, + fontFamily: 'inherit', + fontSize: props.fontSize, + fontSizeUnits: 'px', + isDesktop: true, + }, + }; + }); + }, [props.style, props.themeId, props.fontSize]); +}; +export default useStyles; diff --git a/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useWebviewIpcMessage.ts b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useWebviewIpcMessage.ts new file mode 100644 index 000000000..736e66551 --- /dev/null +++ b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useWebviewIpcMessage.ts @@ -0,0 +1,46 @@ +import type CodeMirror5Emulation from '@joplin/editor/CodeMirror/CodeMirror5Emulation/CodeMirror5Emulation'; +import shared from '@joplin/lib/components/shared/note-screen-shared'; +import { useCallback, RefObject } from 'react'; + +interface Props { + onMessage(event: any): void; + getLineScrollPercent(): number; + setEditorPercentScroll(fraction: number): void; + editorRef: RefObject; + content: string; +} + +const useWebviewIpcMessage = (props: Props) => { + const editorRef = props.editorRef; + + return 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 { line, from, to } = shared.toggleCheckboxRange(msg, props.content); + if (editorRef.current) { + // To cancel CodeMirror's layout drift, the scroll position + // is recorded before updated, and then it is restored. + // Ref. https://github.com/laurent22/joplin/issues/5890 + const percent = props.getLineScrollPercent(); + editorRef.current.replaceRange(line, from, to); + props.setEditorPercentScroll(percent); + } + } else if (msg === 'percentScroll') { + const percent = arg0; + props.setEditorPercentScroll(percent); + } else { + props.onMessage(event); + } + }, [ + props.onMessage, + props.content, + editorRef, + props.getLineScrollPercent, + props.setEditorPercentScroll, + ]); +}; + +export default useWebviewIpcMessage; diff --git a/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/CodeMirror.tsx b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v5/CodeMirror.tsx similarity index 78% rename from packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/CodeMirror.tsx rename to packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v5/CodeMirror.tsx index 5a6f4711e..f43bd1a1c 100644 --- a/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/CodeMirror.tsx +++ b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v5/CodeMirror.tsx @@ -1,55 +1,45 @@ import * as React from 'react'; -import { useState, useEffect, useRef, forwardRef, useCallback, useImperativeHandle, useMemo } from 'react'; +import { useState, useEffect, useRef, forwardRef, useCallback, useImperativeHandle, useMemo, ForwardedRef } from 'react'; // eslint-disable-next-line no-unused-vars -import { EditorCommand, NoteBodyEditorProps } from '../../utils/types'; -import { commandAttachFileToBody, getResourcesFromPasteEvent } from '../../utils/resourceHandling'; -import { ScrollOptions, ScrollOptionTypes } from '../../utils/types'; -import { CommandValue } from '../../utils/types'; -import { usePrevious, cursorPositionToTextOffset } from './utils'; -import useScrollHandler from './utils/useScrollHandler'; +import { EditorCommand, NoteBodyEditorProps, NoteBodyEditorRef } from '../../../utils/types'; +import { commandAttachFileToBody, getResourcesFromPasteEvent } from '../../../utils/resourceHandling'; +import { ScrollOptions, ScrollOptionTypes } from '../../../utils/types'; +import { CommandValue } from '../../../utils/types'; +import { usePrevious, cursorPositionToTextOffset } from '../utils'; +import useScrollHandler from '../utils/useScrollHandler'; import useElementSize from '@joplin/lib/hooks/useElementSize'; -import Toolbar from './Toolbar'; -import styles_ from './styles'; -import { RenderedBody, defaultRenderedBody } from './utils/types'; -import NoteTextViewer from '../../../NoteTextViewer'; +import Toolbar from '../Toolbar'; +import { RenderedBody, defaultRenderedBody } from '../utils/types'; +import NoteTextViewer from '../../../../NoteTextViewer'; import Editor from './Editor'; -import usePluginServiceRegistration from '../../utils/usePluginServiceRegistration'; +import usePluginServiceRegistration from '../../../utils/usePluginServiceRegistration'; import Setting from '@joplin/lib/models/Setting'; import Note from '@joplin/lib/models/Note'; import { _ } from '@joplin/lib/locale'; -import bridge from '../../../../services/bridge'; +import bridge from '../../../../../services/bridge'; import markdownUtils from '@joplin/lib/markdownUtils'; import shim from '@joplin/lib/shim'; -import { MenuItemLocation } from '@joplin/lib/services/plugins/api/types'; -import MenuUtils from '@joplin/lib/services/commands/MenuUtils'; -import CommandService from '@joplin/lib/services/CommandService'; import { themeStyle } from '@joplin/lib/theme'; import { ThemeAppearance } from '@joplin/lib/themes/type'; -import SpellCheckerService from '@joplin/lib/services/spellChecker/SpellCheckerService'; -import dialogs from '../../../dialogs'; -import convertToScreenCoordinates from '../../../utils/convertToScreenCoordinates'; +import dialogs from '../../../../dialogs'; import { MarkupToHtml } from '@joplin/renderer'; const { clipboard } = require('electron'); const debounce = require('debounce'); -import shared from '@joplin/lib/components/shared/note-screen-shared'; -const Menu = bridge().Menu; -const MenuItem = bridge().MenuItem; -import { reg } from '@joplin/lib/registry'; -import ErrorBoundary from '../../../ErrorBoundary'; -import { MarkupToHtmlOptions } from '../../utils/useMarkupToHtml'; -import eventManager from '@joplin/lib/eventManager'; -import { EditContextMenuFilterObject } from '@joplin/lib/services/plugins/api/JoplinWorkspace'; -import type { ContextMenuEvent, ContextMenuParams } from 'electron'; -const menuUtils = new MenuUtils(CommandService.instance()); +import { reg } from '@joplin/lib/registry'; +import ErrorBoundary from '../../../../ErrorBoundary'; +import { MarkupToHtmlOptions } from '../../../utils/useMarkupToHtml'; +import useStyles from '../utils/useStyles'; +import useContextMenu from '../utils/useContextMenu'; +import useWebviewIpcMessage from '../utils/useWebviewIpcMessage'; function markupRenderOptions(override: MarkupToHtmlOptions = null): MarkupToHtmlOptions { return { ...override }; } -function CodeMirror(props: NoteBodyEditorProps, ref: any) { - const styles = styles_(props); +function CodeMirror(props: NoteBodyEditorProps, ref: ForwardedRef) { + const styles = useStyles(props); const [renderedBody, setRenderedBody] = useState(defaultRenderedBody()); // Viewer content const [renderedBodyContentKey, setRenderedBodyContentKey] = useState(null); @@ -599,29 +589,13 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) { 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 { line, from, to } = shared.toggleCheckboxRange(msg, props.content); - if (editorRef.current) { - // To cancel CodeMirror's layout drift, the scroll position - // is recorded before updated, and then it is restored. - // Ref. https://github.com/laurent22/joplin/issues/5890 - const percent = getLineScrollPercent(); - editorRef.current.replaceRange(line, from, to); - setEditorPercentScroll(percent); - } - } else if (msg === 'percentScroll') { - const percent = arg0; - setEditorPercentScroll(percent); - } else { - props.onMessage(event); - } - // eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied - }, [props.onMessage, props.content, setEditorPercentScroll]); + const webview_ipcMessage = useWebviewIpcMessage({ + editorRef, + setEditorPercentScroll, + getLineScrollPercent, + content: props.content, + onMessage: props.onMessage, + }); useEffect(() => { let cancelled = false; @@ -779,142 +753,12 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) { editorRef.current.refresh(); }, [rootSize, styles.editor, props.visiblePanes]); - // The below code adds support for spellchecking when it is enabled - // It might be buggy, refer to the below issue - // https://github.com/laurent22/joplin/pull/3974#issuecomment-718936703 - useEffect(() => { - const isAncestorOfCodeMirrorEditor = (elem: HTMLElement) => { - for (; elem.parentElement; elem = elem.parentElement) { - if (elem.classList.contains('codeMirrorEditor')) { - return true; - } - } - - return false; - }; - - let lastInCodeMirrorContextMenuTimestamp = 0; - - // The browser's contextmenu event provides additional information about the - // target of the event, not provided by the Electron context-menu event. - const onBrowserContextMenu = (event: Event) => { - if (isAncestorOfCodeMirrorEditor(event.target as HTMLElement)) { - lastInCodeMirrorContextMenuTimestamp = Date.now(); - } - }; - - function pointerInsideEditor(params: ContextMenuParams) { - const x = params.x, y = params.y, isEditable = params.isEditable; - const elements = document.getElementsByClassName('codeMirrorEditor'); - - // Note: We can't check inputFieldType here. When spellcheck is enabled, - // params.inputFieldType is "none". When spellcheck is disabled, - // params.inputFieldType is "plainText". Thus, such a check would be inconsistent. - if (!elements.length || !isEditable) return false; - - const maximumMsSinceBrowserEvent = 100; - if (Date.now() - lastInCodeMirrorContextMenuTimestamp > maximumMsSinceBrowserEvent) { - return false; - } - - const rect = convertToScreenCoordinates(Setting.value('windowContentZoomFactor'), elements[0].getBoundingClientRect()); - return rect.x < x && rect.y < y && rect.right > x && rect.bottom > y; - } - - async function onContextMenu(event: ContextMenuEvent, params: ContextMenuParams) { - if (!pointerInsideEditor(params)) return; - - // Don't show the default menu. - event.preventDefault(); - - const menu = new Menu(); - - const hasSelectedText = editorRef.current && !!editorRef.current.getSelection() ; - - 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 () => { - editorPaste(); - }, - }), - ); - - const spellCheckerMenuItems = SpellCheckerService.instance().contextMenuItems(params.misspelledWord, params.dictionarySuggestions); - - for (const item of spellCheckerMenuItems) { - menu.append(new MenuItem(item)); - } - - // Typically CodeMirror handles all interactions itself (highlighting etc.) - // But in the case of clicking a mispelled word, we need electron to handle the click - // The result is that CodeMirror doesn't know what's been selected and doesn't - // move the cursor into the correct location. - // and when the user selects a new spelling it will be inserted in the wrong location - // So in this situation, we use must manually align the internal codemirror selection - // to the contextmenu selection - if (editorRef.current && spellCheckerMenuItems.length > 0) { - editorRef.current.alignSelection(params); - } - - let filterObject: EditContextMenuFilterObject = { - items: [], - }; - - filterObject = await eventManager.filterEmit('editorContextMenu', filterObject); - - for (const item of filterObject.items) { - menu.append(new MenuItem({ - label: item.label, - click: async () => { - const args = item.commandArgs || []; - void CommandService.instance().execute(item.commandName, ...args); - }, - type: item.type, - })); - } - - // eslint-disable-next-line github/array-foreach -- Old code before rule was applied - menuUtils.pluginContextMenuItems(props.plugins, MenuItemLocation.EditorContextMenu).forEach((item: any) => { - menu.append(new MenuItem(item)); - }); - - menu.popup(); - } - - // Prepend the event listener so that it gets called before - // the listener that shows the default menu. - bridge().window().webContents.prependListener('context-menu', onContextMenu); - - window.addEventListener('contextmenu', onBrowserContextMenu); - - return () => { - bridge().window().webContents.off('context-menu', onContextMenu); - window.removeEventListener('contextmenu', onBrowserContextMenu); - }; - // eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied - }, [props.plugins]); + useContextMenu({ + plugins: props.plugins, + editorCutText, editorCopyText, editorPaste, + editorRef, + editorClassName: 'codeMirrorEditor', + }); function renderEditor() { diff --git a/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/Editor.tsx b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v5/Editor.tsx similarity index 94% rename from packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/Editor.tsx rename to packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v5/Editor.tsx index 08a0885d6..49ca42e9f 100644 --- a/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/Editor.tsx +++ b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v5/Editor.tsx @@ -12,15 +12,15 @@ import 'codemirror/addon/scroll/annotatescrollbar'; import 'codemirror/addon/search/matchesonscrollbar'; import 'codemirror/addon/search/searchcursor'; -import useListIdent from './utils/useListIdent'; -import useScrollUtils from './utils/useScrollUtils'; -import useCursorUtils from './utils/useCursorUtils'; -import useLineSorting from './utils/useLineSorting'; -import useEditorSearch from './utils/useEditorSearch'; -import useJoplinMode from './utils/useJoplinMode'; -import useKeymap from './utils/useKeymap'; -import useExternalPlugins from './utils/useExternalPlugins'; -import useJoplinCommands from './utils/useJoplinCommands'; +import useListIdent from '../utils/useListIdent'; +import useScrollUtils from '../utils/useScrollUtils'; +import useCursorUtils from '../utils/useCursorUtils'; +import useLineSorting from '../utils/useLineSorting'; +import useEditorSearch from '../utils/useEditorSearch'; +import useJoplinMode from '../utils/useJoplinMode'; +import useKeymap from '../utils/useKeymap'; +import useExternalPlugins from '../utils/useExternalPlugins'; +import useJoplinCommands from '../utils/useJoplinCommands'; import 'codemirror/keymap/emacs'; import 'codemirror/keymap/vim'; diff --git a/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/CodeMirror.tsx b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/CodeMirror.tsx new file mode 100644 index 000000000..06f75b52b --- /dev/null +++ b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/CodeMirror.tsx @@ -0,0 +1,428 @@ +import * as React from 'react'; +import { useState, useEffect, useRef, forwardRef, useCallback, useImperativeHandle, useMemo, ForwardedRef } from 'react'; + +import { EditorCommand, NoteBodyEditorProps, NoteBodyEditorRef, OnChangeEvent } from '../../../utils/types'; +import { getResourcesFromPasteEvent } from '../../../utils/resourceHandling'; +import { ScrollOptions, ScrollOptionTypes } from '../../../utils/types'; +import NoteTextViewer from '../../../../NoteTextViewer'; +import Editor from './Editor'; +import usePluginServiceRegistration from '../../../utils/usePluginServiceRegistration'; +import Setting from '@joplin/lib/models/Setting'; +import Note from '@joplin/lib/models/Note'; +import { _ } from '@joplin/lib/locale'; +import bridge from '../../../../../services/bridge'; +import shim from '@joplin/lib/shim'; +import { MarkupToHtml } from '@joplin/renderer'; +const { clipboard } = require('electron'); +import { reg } from '@joplin/lib/registry'; +import ErrorBoundary from '../../../../ErrorBoundary'; +import { MarkupToHtmlOptions } from '../../../utils/useMarkupToHtml'; +import { EditorKeymap, EditorLanguageType, EditorSettings } from '@joplin/editor/types'; +import useStyles from '../utils/useStyles'; +import { EditorEvent, EditorEventType } from '@joplin/editor/events'; +import useScrollHandler from '../utils/useScrollHandler'; +import Logger from '@joplin/utils/Logger'; +import useEditorCommands from './useEditorCommands'; +import CodeMirrorControl from '@joplin/editor/CodeMirror/CodeMirrorControl'; +import useContextMenu from '../utils/useContextMenu'; +import useWebviewIpcMessage from '../utils/useWebviewIpcMessage'; +import Toolbar from '../Toolbar'; + +const logger = Logger.create('CodeMirror6'); +const logDebug = (message: string) => logger.debug(message); + +interface RenderedBody { + html: string; + pluginAssets: any[]; +} + +function defaultRenderedBody(): RenderedBody { + return { + html: '', + pluginAssets: [], + }; +} + +function markupRenderOptions(override: MarkupToHtmlOptions = null): MarkupToHtmlOptions { + return { ...override }; +} + +const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef) => { + const styles = useStyles(props); + + const [renderedBody, setRenderedBody] = useState(defaultRenderedBody()); // Viewer content + const [renderedBodyContentKey, setRenderedBodyContentKey] = useState(null); + + const [webviewReady, setWebviewReady] = useState(false); + + const editorRef = useRef(null); + const rootRef = useRef(null); + const webviewRef = useRef(null); + + type OnChangeCallback = (event: OnChangeEvent)=> void; + const props_onChangeRef = useRef(null); + props_onChangeRef.current = props.onChange; + + const [selectionRange, setSelectionRange] = useState({ from: 0, to: 0 }); + + const { + resetScroll, editor_scroll, setEditorPercentScroll, setViewerPercentScroll, getLineScrollPercent, + } = useScrollHandler(editorRef, webviewRef, props.onScroll); + + usePluginServiceRegistration(ref); + + const codeMirror_change = useCallback((newBody: string) => { + if (newBody !== props.content) { + props_onChangeRef.current({ changeId: null, content: newBody }); + } + }, [props.content]); + + const onEditorPaste = useCallback(async (event: any = null) => { + const resourceMds = await getResourcesFromPasteEvent(event); + if (!resourceMds.length) return; + if (editorRef.current) { + editorRef.current.insertText(resourceMds.join('\n')); + } + }, []); + + const editorCutText = useCallback(() => { + if (editorRef.current) { + const selections = editorRef.current.getSelections(); + if (selections.length > 0 && selections[0]) { + clipboard.writeText(selections[0]); + // Easy way to wipe out just the first selection + selections[0] = ''; + editorRef.current.replaceSelections(selections); + } else { + const cursor = editorRef.current.getCursor(); + const line = editorRef.current.getLine(cursor.line); + clipboard.writeText(`${line}\n`); + const startLine = editorRef.current.getCursor('head'); + startLine.ch = 0; + const endLine = { + line: startLine.line + 1, + ch: 0, + }; + editorRef.current.replaceRange('', startLine, endLine); + } + } + }, []); + + const editorCopyText = useCallback(() => { + if (editorRef.current) { + const selections = editorRef.current.getSelections(); + + // Handle the case when there is a selection - copy the selection to the clipboard + // When there is no selection, the selection array contains an empty string. + if (selections.length > 0 && selections[0]) { + clipboard.writeText(selections[0]); + } else { + // This is the case when there is no selection - copy the current line to the clipboard + const cursor = editorRef.current.getCursor(); + const line = editorRef.current.getLine(cursor.line); + clipboard.writeText(line); + } + } + }, []); + + const editorPasteText = useCallback(async () => { + if (editorRef.current) { + const modifiedMd = await Note.replaceResourceExternalToInternalLinks(clipboard.readText(), { useAbsolutePaths: true }); + editorRef.current.insertText(modifiedMd); + } + }, []); + + const editorPaste = useCallback(() => { + const clipboardText = clipboard.readText(); + + if (clipboardText) { + void editorPasteText(); + } else { + // To handle pasting images + void onEditorPaste(); + } + }, [editorPasteText, onEditorPaste]); + + const commands = useEditorCommands({ + webviewRef, + editorRef, + selectionRange, + + editorCopyText, editorCutText, editorPaste, + editorContent: props.content, + visiblePanes: props.visiblePanes, + }); + + useImperativeHandle(ref, () => { + return { + content: () => props.content, + resetScroll: () => { + resetScroll(); + }, + scrollTo: (options: ScrollOptions) => { + if (options.type === ScrollOptionTypes.Hash) { + if (!webviewRef.current) return; + webviewRef.current.send('scrollToHash', options.value as string); + } else if (options.type === ScrollOptionTypes.Percent) { + const percent = options.value as number; + setEditorPercentScroll(percent); + setViewerPercentScroll(percent); + + } else { + throw new Error(`Unsupported scroll options: ${options.type}`); + } + }, + supportsCommand: (name: string) => { + return name in commands || editorRef.current.supportsCommand(name); + }, + execCommand: async (cmd: EditorCommand) => { + if (!editorRef.current) return false; + + logger.debug('execCommand', cmd); + + let commandOutput = null; + if (cmd.name in commands) { + commandOutput = (commands as any)[cmd.name](cmd.value); + } else if (editorRef.current.supportsCommand(cmd.name)) { + commandOutput = editorRef.current.execCommand(cmd.name); + } else if (editorRef.current.supportsJoplinCommand(cmd.name)) { + commandOutput = editorRef.current.execJoplinCommand(cmd.name); + } else { + reg.logger().warn('CodeMirror: unsupported Joplin command: ', cmd); + } + + return commandOutput; + }, + }; + }, [props.content, commands, resetScroll, setEditorPercentScroll, setViewerPercentScroll]); + + const webview_domReady = useCallback(() => { + setWebviewReady(true); + }, []); + + const webview_ipcMessage = useWebviewIpcMessage({ + editorRef, + setEditorPercentScroll, + getLineScrollPercent, + content: props.content, + onMessage: props.onMessage, + }); + + useEffect(() => { + let cancelled = false; + + // When a new note is loaded (contentKey is different), we want the note to be displayed + // right away. However once that's done, we put a small delay so that the view is not + // being constantly updated while the user changes the note. + const interval = renderedBodyContentKey !== props.contentKey ? 0 : 500; + + const timeoutId = shim.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, + contentMaxWidth: props.contentMaxWidth, + mapsToLine: true, + // Always using useCustomPdfViewer for now, we can add a new setting for it in future if we need to. + useCustomPdfViewer: props.useCustomPdfViewer, + noteId: props.noteId, + vendorDir: bridge().vendorDir(), + })); + + if (cancelled) return; + + setRenderedBody(result); + + // Since we set `renderedBodyContentKey` here, it means this effect is going to + // be triggered again, but that's hard to avoid and the second call would be cheap + // anyway since the renderered markdown is cached by MdToHtml. We could use a ref + // to avoid this, but a second rendering might still happens anyway to render images, + // resources, or for other reasons. So it's best to focus on making any second call + // to this effect as cheap as possible with caching, etc. + setRenderedBodyContentKey(props.contentKey); + }, interval); + + return () => { + cancelled = true; + shim.clearTimeout(timeoutId); + }; + }, [ + props.content, props.contentKey, renderedBodyContentKey, props.contentMarkupLanguage, + props.visiblePanes, props.resourceInfos, props.markupToHtml, props.contentMaxWidth, + props.noteId, props.useCustomPdfViewer, + ]); + + useEffect(() => { + if (!webviewReady) return; + + let lineCount = 0; + if (editorRef.current) { + lineCount = editorRef.current.editor.state.doc.lines; + } + + const options: any = { + pluginAssets: renderedBody.pluginAssets, + downloadResources: Setting.value('sync.resourceDownloadMode'), + markupLineCount: lineCount, + }; + + // It seems when there's an error immediately when the component is + // mounted, webviewReady might be true, but webviewRef.current will be + // undefined. Maybe due to the error boundary that unmount components. + // Since we can't do much about it we just print an error. + if (webviewRef.current) { + // To keep consistency among CodeMirror's editing and scroll percents + // of Editor and Viewer. + const percent = getLineScrollPercent(); + setEditorPercentScroll(percent); + options.percent = percent; + webviewRef.current.send('setHtml', renderedBody.html, options); + } else { + console.error('Trying to set HTML on an undefined webview ref'); + } + }, [renderedBody, webviewReady, getLineScrollPercent, setEditorPercentScroll]); + + 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 editorPaneVisible = props.visiblePanes.indexOf('editor') >= 0; + + useEffect(() => { + if (!editorRef.current) return; + + // Anytime the user toggles the visible panes AND the editor is visible as a result + // we should focus the editor + // The intuition is that a panel toggle (with editor in view) is the equivalent of + // an editor interaction so users should expect the editor to be focused + if (editorPaneVisible) { + editorRef.current.focus(); + } + }, [editorPaneVisible]); + + useContextMenu({ + plugins: props.plugins, + editorCutText, editorCopyText, editorPaste, + editorRef, + editorClassName: 'cm-editor', + }); + + const onEditorEvent = useCallback((event: EditorEvent) => { + if (event.kind === EditorEventType.Scroll) { + editor_scroll(); + } else if (event.kind === EditorEventType.Change) { + codeMirror_change(event.value); + } else if (event.kind === EditorEventType.SelectionRangeChange) { + setSelectionRange({ from: event.from, to: event.to }); + } + }, [editor_scroll, codeMirror_change]); + + const editorSettings = useMemo((): EditorSettings => { + const isHTMLNote = props.contentMarkupLanguage === MarkupToHtml.MARKUP_LANGUAGE_HTML; + + let keyboardMode = EditorKeymap.Default; + if (props.keyboardMode === 'vim') { + keyboardMode = EditorKeymap.Vim; + } else if (props.keyboardMode === 'emacs') { + keyboardMode = EditorKeymap.Emacs; + } + + return { + language: isHTMLNote ? EditorLanguageType.Html : EditorLanguageType.Markdown, + readOnly: props.disabled || props.visiblePanes.indexOf('editor') < 0, + katexEnabled: Setting.value('markdown.plugin.katex'), + themeData: { + ...styles.globalTheme, + monospaceFont: Setting.value('style.editor.monospaceFontFamily'), + }, + automatchBraces: Setting.value('editor.autoMatchingBraces'), + useExternalSearch: false, + ignoreModifiers: true, + spellcheckEnabled: Setting.value('editor.spellcheckBeta'), + keymap: keyboardMode, + indentWithTabs: true, + }; + }, [ + props.contentMarkupLanguage, props.disabled, props.visiblePanes, + props.keyboardMode, styles.globalTheme, + ]); + + // Update the editor's value + useEffect(() => { + if (editorRef.current?.updateBody(props.content)) { + editorRef.current?.clearHistory(); + } + }, [props.content]); + + const renderEditor = () => { + return ( +
+ +
+ ); + }; + + const renderViewer = () => { + return ( +
+ +
+ ); + }; + + return ( + +
+
+ + {props.noteToolbar} +
+
+ {renderEditor()} + {renderViewer()} +
+
+
+ ); +}; + +export default forwardRef(CodeMirror); diff --git a/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/Editor.tsx b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/Editor.tsx new file mode 100644 index 000000000..595248a54 --- /dev/null +++ b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/Editor.tsx @@ -0,0 +1,103 @@ +import * as React from 'react'; +import { ForwardedRef } from 'react'; +import { useEffect, useState, useRef, forwardRef, useImperativeHandle } from 'react'; +import { EditorProps, LogMessageCallback, OnEventCallback, PluginData } from '@joplin/editor/types'; +import createEditor from '@joplin/editor/CodeMirror/createEditor'; +import CodeMirrorControl from '@joplin/editor/CodeMirror/CodeMirrorControl'; +import { PluginStates } from '@joplin/lib/services/plugins/reducer'; +import { ContentScriptType } from '@joplin/lib/services/plugins/api/types'; +import shim from '@joplin/lib/shim'; +import PluginService from '@joplin/lib/services/plugins/PluginService'; +import setupVim from '../utils/setupVim'; + +interface Props extends EditorProps { + style: React.CSSProperties; + pluginStates: PluginStates; +} + +const Editor = (props: Props, ref: ForwardedRef) => { + const editorContainerRef = useRef(); + const [editor, setEditor] = useState(null); + + // The editor will only be created once, so callbacks that could + // change need to be stored as references. + const onEventRef = useRef(props.onEvent); + const onLogMessageRef = useRef(props.onLogMessage); + + useEffect(() => { + onEventRef.current = props.onEvent; + onLogMessageRef.current = props.onLogMessage; + }, [props.onEvent, props.onLogMessage]); + + useImperativeHandle(ref, () => { + return editor; + }, [editor]); + + useEffect(() => { + if (!editor) { + return; + } + + const plugins: PluginData[] = []; + for (const pluginId in props.pluginStates) { + const pluginState = props.pluginStates[pluginId]; + const codeMirrorContentScripts = pluginState.contentScripts[ContentScriptType.CodeMirrorPlugin] ?? []; + + for (const contentScript of codeMirrorContentScripts) { + plugins.push({ + pluginId, + contentScriptId: contentScript.id, + contentScriptJs: () => shim.fsDriver().readFile(contentScript.path), + postMessageHandler: (message: any) => { + const plugin = PluginService.instance().pluginById(pluginId); + return plugin.emitContentScriptMessage(contentScript.id, message); + }, + }); + } + } + + void editor.setPlugins(plugins); + }, [editor, props.pluginStates]); + + useEffect(() => { + if (!editorContainerRef.current) return () => {}; + + const editorProps: EditorProps = { + ...props, + onEvent: event => onEventRef.current(event), + onLogMessage: message => onLogMessageRef.current(message), + }; + + const editor = createEditor(editorContainerRef.current, editorProps); + editor.addStyles({ + '.cm-scroller': { overflow: 'auto' }, + }); + setEditor(editor); + + return () => { + editor.remove(); + }; + // eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Should run just once + }, []); + + useEffect(() => { + editor?.updateSettings(props.settings); + }, [props.settings, editor]); + + useEffect(() => { + if (!editor) { + return; + } + + setupVim(editor); + }, [editor]); + + return ( +
+ ); +}; + +export default forwardRef(Editor); diff --git a/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/useEditorCommands.ts b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/useEditorCommands.ts new file mode 100644 index 000000000..e415449e1 --- /dev/null +++ b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/useEditorCommands.ts @@ -0,0 +1,136 @@ + +import { RefObject, useMemo } from 'react'; +import { CommandValue } from '../../../utils/types'; +import { commandAttachFileToBody } from '../../../utils/resourceHandling'; +import { _ } from '@joplin/lib/locale'; +import dialogs from '../../../../dialogs'; +import { EditorCommandType } from '@joplin/editor/types'; +import Logger from '@joplin/utils/Logger'; +import CodeMirrorControl from '@joplin/editor/CodeMirror/CodeMirrorControl'; + +const logger = Logger.create('CodeMirror 6 commands'); + +const wrapSelectionWithStrings = (editor: CodeMirrorControl, string1: string, string2 = '', defaultText = '') => { + if (editor.somethingSelected()) { + editor.wrapSelections(string1, string2); + } else { + editor.wrapSelections(string1 + defaultText, string2); + + // Now select the default text so the user can replace it + const selections = editor.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 }); + } + editor.setSelections(newSelections); + } +}; + +interface Props { + webviewRef: RefObject; + editorRef: RefObject; + editorContent: string; + + editorCutText(): void; + editorCopyText(): void; + editorPaste(): void; + selectionRange: { from: number; to: number }; + + visiblePanes: string[]; +} + +const useEditorCommands = (props: Props) => { + const editorRef = props.editorRef; + + return useMemo(() => { + const selectedText = () => { + if (!editorRef.current) return ''; + return editorRef.current.getSelection(); + }; + + return { + dropItems: async (cmd: any) => { + if (cmd.type === 'notes') { + editorRef.current.insertText(cmd.markdownTags.join('\n')); + } else if (cmd.type === 'files') { + const pos = props.selectionRange.from; + const newBody = await commandAttachFileToBody(props.editorContent, cmd.paths, { createFileURL: !!cmd.createFileURL, position: pos }); + editorRef.current.updateBody(newBody); + } else { + logger.warn('CodeMirror: unsupported drop item: ', cmd); + } + }, + selectedText: () => { + return selectedText(); + }, + selectedHtml: () => { + return selectedText(); + }, + replaceSelection: (value: string) => { + return editorRef.current.insertText(value); + }, + textCopy: () => { + props.editorCopyText(); + }, + textCut: () => { + props.editorCutText(); + }, + textPaste: () => { + props.editorPaste(); + }, + textSelectAll: () => { + return editorRef.current.execCommand(EditorCommandType.SelectAll); + }, + textLink: async () => { + const url = await dialogs.prompt(_('Insert Hyperlink')); + editorRef.current.focus(); + if (url) wrapSelectionWithStrings(editorRef.current, '[', `](${url})`); + }, + insertText: (value: any) => editorRef.current.insertText(value), + attachFile: async () => { + const newBody = await commandAttachFileToBody( + props.editorContent, null, { position: props.selectionRange.from }, + ); + if (newBody) { + editorRef.current.updateBody(newBody); + } + }, + textHorizontalRule: () => editorRef.current.insertText('* * *'), + 'editor.execCommand': (value: CommandValue) => { + if (!('args' in value)) value.args = []; + + if ((editorRef.current as any)[value.name]) { + const result = (editorRef.current as any)[value.name](...value.args); + return result; + } else if (editorRef.current.commandExists(value.name)) { + const result = editorRef.current.execCommand(value.name); + return result; + } else { + logger.warn('CodeMirror execCommand: unsupported command: ', value.name); + } + }, + 'editor.focus': () => { + if (props.visiblePanes.indexOf('editor') >= 0) { + editorRef.current.editor.focus(); + } else { + // If we just call focus() then the iframe is focused, + // but not its content, such that scrolling up / down + // with arrow keys fails + props.webviewRef.current.send('focus'); + } + }, + search: () => { + editorRef.current.execCommand(EditorCommandType.ShowSearch); + }, + }; + }, [ + props.visiblePanes, props.editorContent, props.editorCopyText, props.editorCutText, props.editorPaste, + props.selectionRange, + + props.webviewRef, editorRef, + ]); +}; +export default useEditorCommands; diff --git a/packages/app-desktop/gui/NoteEditor/NoteEditor.tsx b/packages/app-desktop/gui/NoteEditor/NoteEditor.tsx index 890f1865a..f94c3daea 100644 --- a/packages/app-desktop/gui/NoteEditor/NoteEditor.tsx +++ b/packages/app-desktop/gui/NoteEditor/NoteEditor.tsx @@ -1,7 +1,6 @@ import * as React from 'react'; import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import TinyMCE from './NoteBody/TinyMCE/TinyMCE'; -import CodeMirror from './NoteBody/CodeMirror/CodeMirror'; import { connect } from 'react-redux'; import MultiNoteActions from '../MultiNoteActions'; import { htmlToMarkdown, formNoteToNote } from './utils'; @@ -46,6 +45,8 @@ import { ModelType } from '@joplin/lib/BaseModel'; import BaseItem from '@joplin/lib/models/BaseItem'; import { ErrorCode } from '@joplin/lib/errors'; import ItemChange from '@joplin/lib/models/ItemChange'; +import CodeMirror6 from './NoteBody/CodeMirror/v6/CodeMirror'; +import CodeMirror5 from './NoteBody/CodeMirror/v5/CodeMirror'; const commands = [ require('./commands/showRevisions'), @@ -380,7 +381,7 @@ function NoteEditor(props: NoteEditorProps) { }; }, [setShowRevisions]); - const onScroll = useCallback((event: any) => { + const onScroll = useCallback((event: { percent: number }) => { props.dispatch({ type: 'EDITOR_SCROLL_PERCENT_SET', // In callbacks of setTimeout()/setInterval(), props/state cannot be used @@ -462,7 +463,9 @@ function NoteEditor(props: NoteEditorProps) { if (props.bodyEditor === 'TinyMCE') { editor = ; } else if (props.bodyEditor === 'CodeMirror') { - editor = ; + editor = ; + } else if (props.bodyEditor === 'CodeMirror6') { + editor = ; } else { throw new Error(`Invalid editor: ${props.bodyEditor}`); } @@ -602,7 +605,7 @@ function NoteEditor(props: NoteEditorProps) { disabled={isReadOnly} /> {renderSearchInfo()} -
+
{editor}
diff --git a/packages/app-desktop/gui/NoteEditor/utils/types.ts b/packages/app-desktop/gui/NoteEditor/utils/types.ts index 079ae2d8a..0c0c6a60c 100644 --- a/packages/app-desktop/gui/NoteEditor/utils/types.ts +++ b/packages/app-desktop/gui/NoteEditor/utils/types.ts @@ -48,6 +48,15 @@ export interface NoteEditorProps { syncUserId: string; } +export interface NoteBodyEditorRef { + content(): string|Promise; + resetScroll(): void; + scrollTo(options: ScrollOptions): void; + + supportsCommand(name: string): boolean; + execCommand(command: CommandValue): Promise; +} + export interface NoteBodyEditorProps { style: any; ref: any; @@ -59,7 +68,7 @@ export interface NoteBodyEditorProps { onChange(event: OnChangeEvent): void; onWillChange(event: any): void; onMessage(event: any): void; - onScroll(event: any): void; + onScroll(event: { percent: number }): void; markupToHtml: (markupLanguage: MarkupLanguage, markup: string, options: MarkupToHtmlOptions)=> Promise; // eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied htmlToMarkdown: Function; diff --git a/packages/app-desktop/package.json b/packages/app-desktop/package.json index 027a9fcdf..035c06f5e 100644 --- a/packages/app-desktop/package.json +++ b/packages/app-desktop/package.json @@ -143,6 +143,7 @@ "@electron/remote": "2.0.11", "@fortawesome/fontawesome-free": "5.15.4", "@joeattardi/emoji-button": "4.6.4", + "@joplin/editor": "~2.13", "@joplin/lib": "~2.13", "@joplin/renderer": "~2.13", "@joplin/utils": "~2.13", diff --git a/packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.test.ts b/packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.test.ts deleted file mode 100644 index d81291765..000000000 --- a/packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * @jest-environment jsdom - */ - -import { EditorSettings } from '../types'; -import { initCodeMirror } from './CodeMirror'; -import { themeStyle } from '@joplin/lib/theme'; -import Setting from '@joplin/lib/models/Setting'; -import { forceParsing } from '@codemirror/language'; -import loadLangauges from './testUtil/loadLanguages'; - -import { expect, describe, it } from '@jest/globals'; - - -const createEditorSettings = (themeId: number) => { - const themeData = themeStyle(themeId); - const editorSettings: EditorSettings = { - katexEnabled: true, - spellcheckEnabled: true, - readOnly: false, - themeId, - themeData, - }; - - return editorSettings; -}; - -describe('CodeMirror', () => { - // This checks for a regression -- occasionally, when updating packages, - // syntax highlighting in the CodeMirror editor stops working. This is usually - // fixed by - // 1. removing all `@codemirror/` and `@lezer/` dependencies from yarn.lock, - // 2. upgrading all CodeMirror packages to the latest versions in package.json, and - // 3. re-running `yarn install`. - // - // See https://github.com/laurent22/joplin/issues/7253 - it('should give headings a different style', async () => { - const headerLineText = '# Testing...'; - const initialText = `${headerLineText}\nThis is a test.`; - const editorSettings = createEditorSettings(Setting.THEME_LIGHT); - - await loadLangauges(); - const editor = initCodeMirror(document.body, initialText, editorSettings); - - // Force the generation of the syntax tree now. - forceParsing(editor.editor); - - // CodeMirror nests the tag that styles the header within .cm-headerLine: - //
Testing...
- const headerLineContent = document.body.querySelector('.cm-headerLine > span')!; - - - expect(headerLineContent.textContent).toBe(headerLineText); - - const style = getComputedStyle(headerLineContent); - expect(style.borderBottom).not.toBe(''); - expect(style.fontSize).toBe('1.6em'); - }); -}); diff --git a/packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.ts b/packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.ts index 658089471..95ea80a5b 100644 --- a/packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.ts +++ b/packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.ts @@ -9,448 +9,24 @@ // wrapper to access CodeMirror functionalities. Anything else should be done // from NoteEditor.tsx. -import { MarkdownMathExtension } from './markdownMathParser'; -import createTheme from './theme'; -import decoratorExtension from './decoratorExtension'; - -import syntaxHighlightingLanguages from './syntaxHighlightingLanguages'; - -import { EditorState } from '@codemirror/state'; -import { markdown } from '@codemirror/lang-markdown'; -import { GFM as GitHubFlavoredMarkdownExtension } from '@lezer/markdown'; -import { indentOnInput, indentUnit, syntaxTree } from '@codemirror/language'; -import { - openSearchPanel, closeSearchPanel, SearchQuery, setSearchQuery, getSearchQuery, - /* highlightSelectionMatches, */ search, findNext, findPrevious, replaceAll, replaceNext, -} from '@codemirror/search'; - -import { - EditorView, drawSelection, highlightSpecialChars, ViewUpdate, Command, -} from '@codemirror/view'; -import { undo, redo, history, undoDepth, redoDepth, indentWithTab } from '@codemirror/commands'; - -import { keymap, KeyBinding } from '@codemirror/view'; -import { searchKeymap } from '@codemirror/search'; -import { historyKeymap, defaultKeymap } from '@codemirror/commands'; - -import { CodeMirrorControl } from './types'; -import { EditorSettings, ListType, SearchState } from '../types'; -import { ChangeEvent, SelectionChangeEvent, Selection } from '../types'; -import SelectionFormatting from '../SelectionFormatting'; +import { EditorSettings } from '@joplin/editor/types'; +import createEditor from '@joplin/editor/CodeMirror/createEditor'; import { logMessage, postMessage } from './webviewLogger'; -import { - decreaseIndent, increaseIndent, - toggleBolded, toggleCode, - toggleHeaderLevel, toggleItalicized, - toggleList, toggleMath, updateLink, -} from './markdownCommands'; - - -interface CodeMirrorResult extends CodeMirrorControl { - editor: EditorView; -} +import CodeMirrorControl from '@joplin/editor/CodeMirror/CodeMirrorControl'; export function initCodeMirror( - parentElement: any, initialText: string, settings: EditorSettings, -): CodeMirrorResult { - logMessage('Initializing CodeMirror...'); - const theme = settings.themeData; + parentElement: HTMLElement, initialText: string, settings: EditorSettings, +): CodeMirrorControl { + return createEditor(parentElement, { + initialText, + settings, - let searchVisible = false; - - let schedulePostUndoRedoDepthChangeId_: any = 0; - const schedulePostUndoRedoDepthChange = (editor: EditorView, doItNow = false) => { - if (schedulePostUndoRedoDepthChangeId_) { - if (doItNow) { - clearTimeout(schedulePostUndoRedoDepthChangeId_); - } else { - return; - } - } - - schedulePostUndoRedoDepthChangeId_ = setTimeout(() => { - schedulePostUndoRedoDepthChangeId_ = null; - postMessage('onUndoRedoDepthChange', { - undoDepth: undoDepth(editor.state), - redoDepth: redoDepth(editor.state), - }); - }, doItNow ? 0 : 1000); - }; - - const notifyDocChanged = (viewUpdate: ViewUpdate) => { - if (viewUpdate.docChanged) { - const event: ChangeEvent = { - value: editor.state.doc.toString(), - }; - - postMessage('onChange', event); - schedulePostUndoRedoDepthChange(editor); - } - }; - - const notifyLinkEditRequest = () => { - postMessage('onRequestLinkEdit', null); - }; - - const showSearchDialog = () => { - const query = getSearchQuery(editor.state); - const searchState: SearchState = { - searchText: query.search, - replaceText: query.replace, - useRegex: query.regexp, - caseSensitive: query.caseSensitive, - dialogVisible: true, - }; - - postMessage('onRequestShowSearch', searchState); - searchVisible = true; - }; - - const hideSearchDialog = () => { - postMessage('onRequestHideSearch', null); - searchVisible = false; - }; - - const notifySelectionChange = (viewUpdate: ViewUpdate) => { - if (!viewUpdate.state.selection.eq(viewUpdate.startState.selection)) { - const mainRange = viewUpdate.state.selection.main; - const selection: Selection = { - start: mainRange.from, - end: mainRange.to, - }; - const event: SelectionChangeEvent = { - selection, - }; - postMessage('onSelectionChange', event); - } - }; - - const notifySelectionFormattingChange = (viewUpdate?: ViewUpdate) => { - // If we can't determine the previous formatting, post the update regardless - if (!viewUpdate) { - const formatting = computeSelectionFormatting(editor.state); - postMessage('onSelectionFormattingChange', formatting.toJSON()); - } else if (viewUpdate.docChanged || !viewUpdate.state.selection.eq(viewUpdate.startState.selection)) { - // Only post the update if something changed - const oldFormatting = computeSelectionFormatting(viewUpdate.startState); - const newFormatting = computeSelectionFormatting(viewUpdate.state); - - if (!oldFormatting.eq(newFormatting)) { - postMessage('onSelectionFormattingChange', newFormatting.toJSON()); - } - } - }; - - const computeSelectionFormatting = (state: EditorState): SelectionFormatting => { - const range = state.selection.main; - const formatting: SelectionFormatting = new SelectionFormatting(); - formatting.selectedText = state.doc.sliceString(range.from, range.to); - formatting.spellChecking = editor.contentDOM.spellcheck; - - const parseLinkData = (nodeText: string) => { - const linkMatch = nodeText.match(/\[([^\]]*)\]\(([^)]*)\)/); - - if (linkMatch) { - return { - linkText: linkMatch[1], - linkURL: linkMatch[2], - }; - } - - return null; - }; - - // Find nodes that overlap/are within the selected region - syntaxTree(state).iterate({ - from: range.from, to: range.to, - enter: node => { - // Checklists don't have a specific containing node. As such, - // we're in a checklist if we've selected a 'Task' node. - if (node.name === 'Task') { - formatting.inChecklist = true; - } - - // Only handle notes that contain the entire range. - if (node.from > range.from || node.to < range.to) { - return; - } - // Lazily compute the node's text - const nodeText = () => state.doc.sliceString(node.from, node.to); - - switch (node.name) { - case 'StrongEmphasis': - formatting.bolded = true; - break; - case 'Emphasis': - formatting.italicized = true; - break; - case 'ListItem': - formatting.listLevel += 1; - break; - case 'BulletList': - formatting.inUnorderedList = true; - break; - case 'OrderedList': - formatting.inOrderedList = true; - break; - case 'TaskList': - formatting.inChecklist = true; - break; - case 'InlineCode': - case 'FencedCode': - formatting.inCode = true; - formatting.unspellCheckableRegion = true; - break; - case 'InlineMath': - case 'BlockMath': - formatting.inMath = true; - formatting.unspellCheckableRegion = true; - break; - case 'ATXHeading1': - formatting.headerLevel = 1; - break; - case 'ATXHeading2': - formatting.headerLevel = 2; - break; - case 'ATXHeading3': - formatting.headerLevel = 3; - break; - case 'ATXHeading4': - formatting.headerLevel = 4; - break; - case 'ATXHeading5': - formatting.headerLevel = 5; - break; - case 'URL': - formatting.inLink = true; - formatting.linkData.linkURL = nodeText(); - formatting.unspellCheckableRegion = true; - break; - case 'Link': - formatting.inLink = true; - formatting.linkData = parseLinkData(nodeText()); - break; - } - }, - }); - - // The markdown parser marks checklists as unordered lists. Ensure - // that they aren't marked as such. - if (formatting.inChecklist) { - if (!formatting.inUnorderedList) { - // Even if the selection contains a Task, because an unordered list node - // must contain a valid Task node, we're only in a checklist if we're also in - // an unordered list. - formatting.inChecklist = false; - } else { - formatting.inUnorderedList = false; - } - } - - if (formatting.unspellCheckableRegion) { - formatting.spellChecking = false; - } - - return formatting; - }; - - // Returns a keyboard command that returns true (so accepts the keybind) - const keyCommand = (key: string, run: Command): KeyBinding => { - return { - key, - run, - preventDefault: true, - }; - }; - - const editor = new EditorView({ - state: EditorState.create({ - // See https://github.com/codemirror/basic-setup/blob/main/src/codemirror.ts - // for a sample configuration. - extensions: [ - markdown({ - extensions: [ - GitHubFlavoredMarkdownExtension, - - // Don't highlight KaTeX if the user disabled it - settings.katexEnabled ? MarkdownMathExtension : [], - ], - codeLanguages: syntaxHighlightingLanguages, - }), - ...createTheme(theme), - history(), - search({ - createPanel(_: EditorView) { - return { - // The actual search dialog is implemented with react native, - // use a dummy element. - dom: document.createElement('div'), - mount() { - showSearchDialog(); - }, - destroy() { - hideSearchDialog(); - }, - }; - }, - }), - drawSelection(), - highlightSpecialChars(), - // highlightSelectionMatches(), - indentOnInput(), - - // By default, indent with four spaces - indentUnit.of(' '), - EditorState.tabSize.of(4), - - // Apply styles to entire lines (block-display decorations) - decoratorExtension, - - EditorView.lineWrapping, - EditorView.contentAttributes.of({ - autocapitalize: 'sentence', - autocorrect: settings.spellcheckEnabled ? 'true' : 'false', - spellcheck: settings.spellcheckEnabled ? 'true' : 'false', - }), - EditorView.updateListener.of((viewUpdate: ViewUpdate) => { - notifyDocChanged(viewUpdate); - notifySelectionChange(viewUpdate); - notifySelectionFormattingChange(viewUpdate); - }), - keymap.of([ - // Custom mod-f binding: Toggle the external dialog implementation - // (don't show/hide the Panel dialog). - keyCommand('Mod-f', (_: EditorView) => { - if (searchVisible) { - hideSearchDialog(); - } else { - showSearchDialog(); - } - return true; - }), - // Markdown formatting keyboard shortcuts - keyCommand('Mod-b', toggleBolded), - keyCommand('Mod-i', toggleItalicized), - keyCommand('Mod-$', toggleMath), - keyCommand('Mod-`', toggleCode), - keyCommand('Mod-[', decreaseIndent), - keyCommand('Mod-]', increaseIndent), - keyCommand('Mod-k', (_: EditorView) => { - notifyLinkEditRequest(); - return true; - }), - - ...defaultKeymap, ...historyKeymap, indentWithTab, ...searchKeymap, - ]), - - EditorState.readOnly.of(settings.readOnly), - ], - doc: initialText, - }), - parent: parentElement, + onLogMessage: message => { + logMessage(message); + }, + onEvent: (event): void => { + postMessage('onEditorEvent', event); + }, }); - - // HACK: 09/02/22: Work around https://github.com/laurent22/joplin/issues/6802 by creating a copy mousedown - // event to prevent the Editor's .preventDefault from making the context menu not appear. - // TODO: Track the upstream issue at https://github.com/codemirror/dev/issues/935 and remove this workaround - // when the upstream bug is fixed. - document.body.addEventListener('mousedown', (evt) => { - if (!evt.isTrusted) { - return; - } - - // Walk up the tree -- is evt.target or any of its parent nodes the editor's input region? - for (let current: Record = evt.target; current; current = current.parentElement) { - if (current === editor.contentDOM) { - evt.stopPropagation(); - - const copyEvent = new Event('mousedown', evt); - editor.contentDOM.dispatchEvent(copyEvent); - return; - } - } - }, true); - - const updateSearchQuery = (newState: SearchState) => { - const query = new SearchQuery({ - search: newState.searchText, - caseSensitive: newState.caseSensitive, - regexp: newState.useRegex, - replace: newState.replaceText, - }); - editor.dispatch({ - effects: setSearchQuery.of(query), - }); - }; - - const editorControls = { - editor, - undo: () => { - undo(editor); - schedulePostUndoRedoDepthChange(editor, true); - }, - redo: () => { - redo(editor); - schedulePostUndoRedoDepthChange(editor, true); - }, - select: (anchor: number, head: number) => { - editor.dispatch(editor.state.update({ - selection: { anchor, head }, - scrollIntoView: true, - })); - }, - scrollSelectionIntoView: () => { - editor.dispatch(editor.state.update({ - scrollIntoView: true, - })); - }, - insertText: (text: string) => { - editor.dispatch(editor.state.replaceSelection(text)); - }, - toggleFindDialog: () => { - const opened = openSearchPanel(editor); - if (!opened) { - closeSearchPanel(editor); - } - }, - - // Formatting - toggleBolded: () => { toggleBolded(editor); }, - toggleItalicized: () => { toggleItalicized(editor); }, - toggleCode: () => { toggleCode(editor); }, - toggleMath: () => { toggleMath(editor); }, - increaseIndent: () => { increaseIndent(editor); }, - decreaseIndent: () => { decreaseIndent(editor); }, - toggleList: (kind: ListType) => { toggleList(kind)(editor); }, - toggleHeaderLevel: (level: number) => { toggleHeaderLevel(level)(editor); }, - updateLink: (label: string, url: string) => { updateLink(label, url)(editor); }, - - // Search - searchControl: { - findNext: () => { - findNext(editor); - }, - findPrevious: () => { - findPrevious(editor); - }, - replaceCurrent: () => { - replaceNext(editor); - }, - replaceAll: () => { - replaceAll(editor); - }, - setSearchState: (state: SearchState) => { - updateSearchQuery(state); - }, - showSearch: () => { - showSearchDialog(); - }, - hideSearch: () => { - hideSearchDialog(); - }, - }, - }; - - return editorControls; } diff --git a/packages/app-mobile/components/NoteEditor/CodeMirror/types.ts b/packages/app-mobile/components/NoteEditor/CodeMirror/types.ts deleted file mode 100644 index 5a91a6015..000000000 --- a/packages/app-mobile/components/NoteEditor/CodeMirror/types.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { ListType, SearchControl } from '../types'; - -// Controls for the CodeMirror portion of the editor -export interface CodeMirrorControl { - undo(): void; - redo(): void; - select(anchor: number, head: number): void; - insertText(text: string): void; - - // Toggle whether we're in a type of region. - toggleBolded(): void; - toggleItalicized(): void; - toggleList(kind: ListType): void; - toggleCode(): void; - toggleMath(): void; - toggleHeaderLevel(level: number): void; - - // Create a new link or update the currently selected link with - // the given [label] and [url]. - updateLink(label: string, url: string): void; - - increaseIndent(): void; - decreaseIndent(): void; - scrollSelectionIntoView(): void; - - searchControl: SearchControl; -} diff --git a/packages/app-mobile/components/NoteEditor/EditLinkDialog.tsx b/packages/app-mobile/components/NoteEditor/EditLinkDialog.tsx index 36a4d29f4..2e01a3718 100644 --- a/packages/app-mobile/components/NoteEditor/EditLinkDialog.tsx +++ b/packages/app-mobile/components/NoteEditor/EditLinkDialog.tsx @@ -9,8 +9,8 @@ import Modal from '../Modal'; import { themeStyle } from '@joplin/lib/theme'; import { _ } from '@joplin/lib/locale'; import { EditorControl } from './types'; -import SelectionFormatting from './SelectionFormatting'; import { useCallback } from 'react'; +import SelectionFormatting from '@joplin/editor/SelectionFormatting'; interface LinkDialogProps { editorControl: EditorControl; @@ -21,7 +21,7 @@ interface LinkDialogProps { const EditLinkDialog = (props: LinkDialogProps) => { // The content of the link selected in the editor (if any) - const editorLinkData = props.selectionState.linkData ?? {}; + const editorLinkData = props.selectionState.linkData; const [linkLabel, setLinkLabel] = useState(''); const [linkURL, setLinkURL] = useState(''); diff --git a/packages/app-mobile/components/NoteEditor/MarkdownToolbar/MarkdownToolbar.tsx b/packages/app-mobile/components/NoteEditor/MarkdownToolbar/MarkdownToolbar.tsx index 942f211c8..ed8e38f8b 100644 --- a/packages/app-mobile/components/NoteEditor/MarkdownToolbar/MarkdownToolbar.tsx +++ b/packages/app-mobile/components/NoteEditor/MarkdownToolbar/MarkdownToolbar.tsx @@ -14,13 +14,14 @@ import { _ } from '@joplin/lib/locale'; import time from '@joplin/lib/time'; import { useEffect } from 'react'; import { Keyboard, ViewStyle } from 'react-native'; -import { EditorControl, EditorSettings, ListType, SearchState } from '../types'; -import SelectionFormatting from '../SelectionFormatting'; +import { EditorControl, EditorSettings } from '../types'; import { ButtonSpec, StyleSheetData } from './types'; import Toolbar from './Toolbar'; import { buttonSize } from './ToolbarButton'; import { Theme } from '@joplin/lib/themes/type'; import ToggleSpaceButton from './ToggleSpaceButton'; +import { SearchState } from '@joplin/editor/types'; +import SelectionFormatting from '@joplin/editor/SelectionFormatting'; type OnAttachCallback = ()=> void; @@ -71,9 +72,7 @@ const MarkdownToolbar = (props: MarkdownToolbarProps) => { ), description: _('Unordered list'), active: selState.inUnorderedList, - onPress: useCallback(() => { - editorControl.toggleList(ListType.UnorderedList); - }, [editorControl]), + onPress: editorControl.toggleUnorderedList, priority: -2, disabled: readOnly, @@ -85,9 +84,7 @@ const MarkdownToolbar = (props: MarkdownToolbarProps) => { ), description: _('Ordered list'), active: selState.inOrderedList, - onPress: useCallback(() => { - editorControl.toggleList(ListType.OrderedList); - }, [editorControl]), + onPress: editorControl.toggleOrderedList, priority: -2, disabled: readOnly, @@ -99,9 +96,7 @@ const MarkdownToolbar = (props: MarkdownToolbarProps) => { ), description: _('Task list'), active: selState.inChecklist, - onPress: useCallback(() => { - editorControl.toggleList(ListType.CheckList); - }, [editorControl]), + onPress: editorControl.toggleTaskList, priority: -2, disabled: readOnly, diff --git a/packages/app-mobile/components/NoteEditor/NoteEditor.tsx b/packages/app-mobile/components/NoteEditor/NoteEditor.tsx index af247598e..a4a05b21d 100644 --- a/packages/app-mobile/components/NoteEditor/NoteEditor.tsx +++ b/packages/app-mobile/components/NoteEditor/NoteEditor.tsx @@ -5,29 +5,29 @@ import EditLinkDialog from './EditLinkDialog'; import { defaultSearchState, SearchPanel } from './SearchPanel'; import ExtendedWebView from '../ExtendedWebView'; -const React = require('react'); +import * as React from 'react'; import { forwardRef, RefObject, useImperativeHandle } from 'react'; import { useEffect, useMemo, useState, useCallback, useRef } from 'react'; import { LayoutChangeEvent, View, ViewStyle } from 'react-native'; const { editorFont } = require('../global-style'); -import SelectionFormatting from './SelectionFormatting'; -import { - EditorSettings, EditorControl, - ChangeEvent, UndoRedoDepthChangeEvent, Selection, SelectionChangeEvent, ListType, SearchState, -} from './types'; +import { EditorControl, EditorSettings, SelectionRange } from './types'; import { _ } from '@joplin/lib/locale'; import MarkdownToolbar from './MarkdownToolbar/MarkdownToolbar'; +import { ChangeEvent, EditorEvent, EditorEventType, SelectionRangeChangeEvent, UndoRedoDepthChangeEvent } from '@joplin/editor/events'; +import { EditorCommandType, EditorKeymap, EditorLanguageType, PluginData, SearchState } from '@joplin/editor/types'; +import supportsCommand from '@joplin/editor/CodeMirror/editorCommands/supportsCommand'; +import SelectionFormatting, { defaultSelectionFormatting } from '@joplin/editor/SelectionFormatting'; type ChangeEventHandler = (event: ChangeEvent)=> void; type UndoRedoDepthChangeHandler = (event: UndoRedoDepthChangeEvent)=> void; -type SelectionChangeEventHandler = (event: SelectionChangeEvent)=> void; +type SelectionChangeEventHandler = (event: SelectionRangeChangeEvent)=> void; type OnAttachCallback = ()=> void; interface Props { themeId: number; initialText: string; - initialSelection?: Selection; + initialSelection?: SelectionRange; style: ViewStyle; contentStyle?: ViewStyle; toolbarEnabled: boolean; @@ -110,6 +110,11 @@ function editorTheme(themeId: number) { return { ...themeStyle(themeId), + + // To allow accessibility font scaling, we also need to set the + // fontSize to a value in `em`s (relative scaling relative to + // parent font size). + fontSizeUnits: 'em', fontSize: estimatedFontSizeInEm, fontFamily: fontFamilyFromSettings(), }; @@ -120,48 +125,92 @@ type OnSetVisibleCallback = (visible: boolean)=> void; type OnSearchStateChangeCallback = (state: SearchState)=> void; const useEditorControl = ( injectJS: OnInjectJSCallback, setLinkDialogVisible: OnSetVisibleCallback, - setSearchState: OnSearchStateChangeCallback, searchStateRef: RefObject, + setSearchState: OnSearchStateChangeCallback, + searchStateRef: RefObject, ): EditorControl => { return useMemo(() => { - return { + const execCommand = (command: EditorCommandType) => { + injectJS(`cm.execCommand(${JSON.stringify(command)})`); + }; + + const setSearchStateCallback = (state: SearchState) => { + injectJS(`cm.setSearchState(${JSON.stringify(state)})`); + setSearchState(state); + }; + + const control: EditorControl = { + supportsCommand(command: EditorCommandType) { + return supportsCommand(command); + }, + execCommand, + undo() { - injectJS('cm.undo();'); + injectJS('cm.undo()'); }, redo() { - injectJS('cm.redo();'); + injectJS('cm.redo()'); }, select(anchor: number, head: number) { injectJS( `cm.select(${JSON.stringify(anchor)}, ${JSON.stringify(head)});`, ); }, + setScrollPercent(fraction: number) { + injectJS(`cm.setScrollFraction(${JSON.stringify(fraction)})`); + }, insertText(text: string) { injectJS(`cm.insertText(${JSON.stringify(text)});`); }, + updateBody(newBody: string) { + injectJS(`cm.updateBody(${JSON.stringify(newBody)});`); + }, + updateSettings(newSettings: EditorSettings) { + injectJS(`cm.updateSettings(${JSON.stringify(newSettings)})`); + }, toggleBolded() { - injectJS('cm.toggleBolded();'); + execCommand(EditorCommandType.ToggleBolded); }, toggleItalicized() { - injectJS('cm.toggleItalicized();'); + execCommand(EditorCommandType.ToggleItalicized); }, - toggleList(listType: ListType) { - injectJS(`cm.toggleList(${JSON.stringify(listType)});`); + toggleOrderedList() { + execCommand(EditorCommandType.ToggleNumberedList); + }, + toggleUnorderedList() { + execCommand(EditorCommandType.ToggleCheckList); + }, + toggleTaskList() { + execCommand(EditorCommandType.ToggleCheckList); }, toggleCode() { - injectJS('cm.toggleCode();'); + execCommand(EditorCommandType.ToggleCode); }, toggleMath() { - injectJS('cm.toggleMath();'); + execCommand(EditorCommandType.ToggleMath); }, toggleHeaderLevel(level: number) { - injectJS(`cm.toggleHeaderLevel(${level});`); + const levelToCommand = [ + EditorCommandType.ToggleHeading1, + EditorCommandType.ToggleHeading2, + EditorCommandType.ToggleHeading3, + EditorCommandType.ToggleHeading4, + EditorCommandType.ToggleHeading5, + ]; + + const index = level - 1; + + if (index < 0 || index >= levelToCommand.length) { + throw new Error(`Unsupported header level ${level}`); + } + + execCommand(levelToCommand[index]); }, increaseIndent() { - injectJS('cm.increaseIndent();'); + execCommand(EditorCommandType.IndentMore); }, decreaseIndent() { - injectJS('cm.decreaseIndent();'); + execCommand(EditorCommandType.IndentLess); }, updateLink(label: string, url: string) { injectJS(`cm.updateLink( @@ -170,7 +219,7 @@ const useEditorControl = ( );`); }, scrollSelectionIntoView() { - injectJS('cm.scrollSelectionIntoView();'); + execCommand(EditorCommandType.ScrollSelectionIntoView); }, showLinkDialog() { setLinkDialogVisible(true); @@ -181,23 +230,27 @@ const useEditorControl = ( hideKeyboard() { injectJS('document.activeElement?.blur();'); }, + + setPlugins: async (plugins: PluginData[]) => { + injectJS(`cm.setPlugins(${JSON.stringify(plugins)});`); + }, + + setSearchState: setSearchStateCallback, + searchControl: { findNext() { - injectJS('cm.searchControl.findNext();'); + execCommand(EditorCommandType.FindNext); }, findPrevious() { - injectJS('cm.searchControl.findPrevious();'); + execCommand(EditorCommandType.FindPrevious); }, - replaceCurrent() { - injectJS('cm.searchControl.replaceCurrent();'); + replaceNext() { + execCommand(EditorCommandType.ReplaceNext); }, replaceAll() { - injectJS('cm.searchControl.replaceAll();'); - }, - setSearchState(state: SearchState) { - injectJS(`cm.searchControl.setSearchState(${JSON.stringify(state)})`); - setSearchState(state); + execCommand(EditorCommandType.ReplaceAll); }, + showSearch() { setSearchState({ ...searchStateRef.current, @@ -210,8 +263,12 @@ const useEditorControl = ( dialogVisible: false, }); }, + + setSearchState: setSearchStateCallback, }, }; + + return control; }, [injectJS, searchStateRef, setLinkDialogVisible, setSearchState]); }; @@ -227,7 +284,16 @@ function NoteEditor(props: Props, ref: any) { themeData: editorTheme(props.themeId), katexEnabled: Setting.value('markdown.plugin.katex'), spellcheckEnabled: Setting.value('editor.mobile.spellcheckEnabled'), + language: EditorLanguageType.Markdown, + useExternalSearch: true, readOnly: props.readOnly, + + keymap: EditorKeymap.Default, + + automatchBraces: false, + ignoreModifiers: false, + + indentWithTabs: false, }; const injectedJavaScript = ` @@ -252,6 +318,12 @@ function NoteEditor(props: Props, ref: any) { ); }; + window.onunhandledrejection = (event) => { + window.ReactNativeWebView.postMessage( + "error: Unhandled promise rejection: " + event + ); + }; + if (!window.cm) { // This variable is not used within this script // but is called using "injectJavaScript" from @@ -269,7 +341,7 @@ function NoteEditor(props: Props, ref: any) { ${setInitialSelectionJS} window.onresize = () => { - cm.scrollSelectionIntoView(); + cm.execCommand('scrollSelectionIntoView'); }; } catch (e) { window.ReactNativeWebView.postMessage("error:" + e.message + ": " + JSON.stringify(e)) @@ -280,7 +352,7 @@ function NoteEditor(props: Props, ref: any) { const css = useCss(props.themeId); const html = useHtml(css); - const [selectionState, setSelectionState] = useState(new SelectionFormatting()); + const [selectionState, setSelectionState] = useState(defaultSelectionFormatting); const [linkDialogVisible, setLinkDialogVisible] = useState(false); const [searchState, setSearchState] = useState(defaultSearchState); @@ -293,7 +365,7 @@ function NoteEditor(props: Props, ref: any) { searchStateRef.current = searchState; }, [searchState]); - // / Runs [js] in the context of the CodeMirror frame. + // Runs [js] in the context of the CodeMirror frame. const injectJS = (js: string) => { webviewRef.current.injectJS(js); }; @@ -323,36 +395,41 @@ function NoteEditor(props: Props, ref: any) { console.info('CodeMirror:', ...event.value); }, - onChange: (event: ChangeEvent) => { - props.onChange(event); - }, + onEditorEvent: (event: EditorEvent) => { + let exhaustivenessCheck: never; + switch (event.kind) { + case EditorEventType.Change: + props.onChange(event); + break; + case EditorEventType.UndoRedoDepthChange: + props.onUndoRedoDepthChange(event); + break; + case EditorEventType.SelectionRangeChange: + props.onSelectionChange(event); + break; + case EditorEventType.SelectionFormattingChange: + setSelectionState(event.formatting); + break; + case EditorEventType.EditLink: + editorControl.showLinkDialog(); + break; + case EditorEventType.UpdateSearchDialog: + setSearchState(event.searchState); - onUndoRedoDepthChange: (event: UndoRedoDepthChangeEvent) => { - props.onUndoRedoDepthChange(event); - }, - - onSelectionChange: (event: SelectionChangeEvent) => { - props.onSelectionChange(event); - }, - - onSelectionFormattingChange(data: string) { - // We want a SelectionFormatting object, so are - // instantiating it from JSON. - const formatting = SelectionFormatting.fromJSON(data); - setSelectionState(formatting); - }, - - onRequestLinkEdit() { - editorControl.showLinkDialog(); - }, - - onRequestShowSearch(data: SearchState) { - setSearchState(data); - editorControl.searchControl.showSearch(); - }, - - onRequestHideSearch() { - editorControl.searchControl.hideSearch(); + if (event.searchState.dialogVisible) { + editorControl.searchControl.showSearch(); + } else { + editorControl.searchControl.hideSearch(); + } + break; + case EditorEventType.Scroll: + // Not handled + break; + default: + exhaustivenessCheck = event; + return exhaustivenessCheck; + } + return; }, }; diff --git a/packages/app-mobile/components/NoteEditor/SearchPanel.tsx b/packages/app-mobile/components/NoteEditor/SearchPanel.tsx index bbea39bf4..e912da924 100644 --- a/packages/app-mobile/components/NoteEditor/SearchPanel.tsx +++ b/packages/app-mobile/components/NoteEditor/SearchPanel.tsx @@ -4,11 +4,13 @@ const React = require('react'); const { useMemo, useState, useEffect } = require('react'); const MaterialCommunityIcon = require('react-native-vector-icons/MaterialCommunityIcons').default; -import { SearchControl, SearchState, EditorSettings } from './types'; +import { EditorSettings } from './types'; import { _ } from '@joplin/lib/locale'; import { BackHandler, TextInput, View, Text, StyleSheet, ViewStyle } from 'react-native'; import { Theme } from '@joplin/lib/themes/type'; import CustomButton from '../CustomButton'; +import { SearchState } from '@joplin/editor/types'; +import { SearchControl } from './types'; const buttonSize = 48; @@ -284,7 +286,7 @@ export const SearchPanel = (props: SearchPanelProps) => { themeId={themeId} styles={styles} iconName="swap-horizontal" - onPress={control.replaceCurrent} + onPress={control.replaceNext} title={_('Replace')} /> ); diff --git a/packages/app-mobile/components/NoteEditor/SelectionFormatting.ts b/packages/app-mobile/components/NoteEditor/SelectionFormatting.ts deleted file mode 100644 index e53b5ab53..000000000 --- a/packages/app-mobile/components/NoteEditor/SelectionFormatting.ts +++ /dev/null @@ -1,98 +0,0 @@ -// Stores information about the current content of the user's selection - -export default class SelectionFormatting { - public bolded = false; - public italicized = false; - public inChecklist = false; - public inCode = false; - public inUnorderedList = false; - public inOrderedList = false; - public inMath = false; - public inLink = false; - public spellChecking = false; - public unspellCheckableRegion = false; - - // Link data, both fields are null if not in a link. - public linkData: { linkText?: string; linkURL?: string } = { - linkText: null, - linkURL: null, - }; - - // If [headerLevel], [listLevel], etc. are zero, then the - // selection isn't in a header/list - public headerLevel = 0; - public listLevel = 0; - - // Content of the selection - public selectedText = ''; - - // List of data properties (for serializing/deseralizing) - private static propNames: string[] = [ - 'bolded', 'italicized', 'inChecklist', 'inCode', - 'inUnorderedList', 'inOrderedList', 'inMath', - 'inLink', 'linkData', - - 'headerLevel', 'listLevel', - - 'selectedText', - - 'spellChecking', - 'unspellCheckableRegion', - ]; - - // Returns true iff [this] is equivalent to [other] - public eq(other: SelectionFormatting): boolean { - // Cast to Records to allow usage of the indexing ([]) - // operator. - const selfAsRec = this as Record; - const otherAsRec = other as Record; - - for (const prop of SelectionFormatting.propNames) { - if (selfAsRec[prop] !== otherAsRec[prop]) { - return false; - } - } - - return true; - } - - public static fromJSON(json: string): SelectionFormatting { - const result = new SelectionFormatting(); - - // Casting result to a Record lets us use - // the indexing [] operator. - const resultRecord = result as Record; - const obj = JSON.parse(json) as Record; - - for (const prop of SelectionFormatting.propNames) { - if (obj[prop] !== undefined) { - // Type checking! - if (typeof obj[prop] !== typeof resultRecord[prop]) { - throw new Error([ - 'Deserialization Error:', - `${obj[prop]} and ${resultRecord[prop]}`, - 'have different types.', - ].join(' ')); - } - - resultRecord[prop] = obj[prop]; - } - } - - return result; - } - - public toJSON(): string { - const resultObj: Record = {}; - - // Cast this to a dictionary. This allows us to use - // the indexing [] operator. - const selfAsRecord = this as Record; - - for (const prop of SelectionFormatting.propNames) { - resultObj[prop] = selfAsRecord[prop]; - } - - return JSON.stringify(resultObj); - } -} diff --git a/packages/app-mobile/components/NoteEditor/types.ts b/packages/app-mobile/components/NoteEditor/types.ts index 80d90114b..312b74246 100644 --- a/packages/app-mobile/components/NoteEditor/types.ts +++ b/packages/app-mobile/components/NoteEditor/types.ts @@ -1,69 +1,53 @@ // Types related to the NoteEditor -import { Theme } from '@joplin/lib/themes/type'; -import { CodeMirrorControl } from './CodeMirror/types'; - -// Controls for the entire editor (including dialogs) -export interface EditorControl extends CodeMirrorControl { - showLinkDialog(): void; - hideLinkDialog(): void; - hideKeyboard(): void; -} - -export interface EditorSettings { - // EditorSettings objects are deserialized within WebViews, where - // [themeStyle(themeId: number)] doesn't work. As such, we need both - // the [themeId] and [themeData]. - themeId: number; - themeData: Theme; - - katexEnabled: boolean; - spellcheckEnabled: boolean; - readOnly: boolean; -} - -export interface ChangeEvent { - // New editor content - value: string; -} - -export interface UndoRedoDepthChangeEvent { - undoDepth: number; - redoDepth: number; -} - -export interface Selection { - start: number; - end: number; -} - -export interface SelectionChangeEvent { - selection: Selection; -} +import { EditorControl as EditorBodyControl, EditorSettings as EditorBodySettings, SearchState } from '@joplin/editor/types'; export interface SearchControl { findNext(): void; findPrevious(): void; - replaceCurrent(): void; + replaceNext(): void; replaceAll(): void; - setSearchState(state: SearchState): void; - showSearch(): void; hideSearch(): void; + setSearchState(state: SearchState): void; } -export interface SearchState { - useRegex: boolean; - caseSensitive: boolean; +// Controls for the entire editor (including dialogs) +export interface EditorControl extends EditorBodyControl { + showLinkDialog(): void; + hideLinkDialog(): void; + hideKeyboard(): void; - searchText: string; - replaceText: string; - dialogVisible: boolean; + // Additional shortcut commands (equivalent to .execCommand + // with the corresponding type). + // This reduces the need for useCallbacks in many cases. + undo(): void; + redo(): void; + + increaseIndent(): void; + decreaseIndent(): void; + toggleBolded(): void; + toggleItalicized(): void; + toggleCode(): void; + toggleMath(): void; + toggleOrderedList(): void; + toggleUnorderedList(): void; + toggleTaskList(): void; + toggleHeaderLevel(level: number): void; + + scrollSelectionIntoView(): void; + showLinkDialog(): void; + hideLinkDialog(): void; + hideKeyboard(): void; + + searchControl: SearchControl; } -// Possible types of lists in the editor -export enum ListType { - CheckList, - OrderedList, - UnorderedList, +export interface EditorSettings extends EditorBodySettings { + themeId: number; +} + +export interface SelectionRange { + start: number; + end: number; } diff --git a/packages/app-mobile/components/screens/Note.tsx b/packages/app-mobile/components/screens/Note.tsx index 4e5a6ce1f..cfa3d335a 100644 --- a/packages/app-mobile/components/screens/Note.tsx +++ b/packages/app-mobile/components/screens/Note.tsx @@ -6,7 +6,6 @@ import UndoRedoService from '@joplin/lib/services/UndoRedoService'; import NoteBodyViewer from '../NoteBodyViewer/NoteBodyViewer'; import checkPermissions from '../../utils/checkPermissions'; import NoteEditor from '../NoteEditor/NoteEditor'; -import { ChangeEvent, UndoRedoDepthChangeEvent } from '../NoteEditor/types'; const FileViewer = require('react-native-file-viewer').default; const React = require('react'); @@ -48,6 +47,7 @@ import Logger from '@joplin/utils/Logger'; import VoiceTypingDialog from '../voiceTyping/VoiceTypingDialog'; import { voskEnabled } from '../../services/voiceTyping/vosk'; import { isSupportedLanguage } from '../../services/voiceTyping/vosk.android'; +import { ChangeEvent as EditorChangeEvent, UndoRedoDepthChangeEvent } from '@joplin/editor/events'; const urlUtils = require('@joplin/lib/urlUtils'); // import Vosk from 'react-native-vosk'; @@ -255,7 +255,7 @@ class NoteScreenComponent extends BaseScreenComponent { return this.props.useEditorBeta; } - private onBodyChange(event: ChangeEvent) { + private onBodyChange(event: EditorChangeEvent) { shared.noteComponent_change(this, 'body', event.value); this.scheduleSave(); } diff --git a/packages/app-mobile/jest.setup.js b/packages/app-mobile/jest.setup.js index 152096cb3..d2b50f4b7 100644 --- a/packages/app-mobile/jest.setup.js +++ b/packages/app-mobile/jest.setup.js @@ -14,22 +14,6 @@ import { setImmediate } from 'timers'; // so is removed by jsdom). window.setImmediate = setImmediate; -// Prevents the CodeMirror error "getClientRects is undefined". -// See https://github.com/jsdom/jsdom/issues/3002#issue-652790925 -document.createRange = () => { - const range = new Range(); - range.getBoundingClientRect = jest.fn(); - range.getClientRects = () => { - return { - length: 0, - item: () => null, - [Symbol.iterator]: jest.fn(), - }; - }; - - return range; -}; - shimInit({ nodeSqlite: sqlite3 }); diff --git a/packages/app-mobile/metro.config.js b/packages/app-mobile/metro.config.js index 19c72bc81..3eaff4c12 100644 --- a/packages/app-mobile/metro.config.js +++ b/packages/app-mobile/metro.config.js @@ -15,6 +15,7 @@ const path = require('path'); const localPackages = { '@joplin/lib': path.resolve(__dirname, '../lib/'), '@joplin/renderer': path.resolve(__dirname, '../renderer/'), + '@joplin/editor': path.resolve(__dirname, '../editor/'), '@joplin/tools': path.resolve(__dirname, '../tools/'), '@joplin/utils': path.resolve(__dirname, '../utils/'), '@joplin/fork-htmlparser2': path.resolve(__dirname, '../fork-htmlparser2/'), diff --git a/packages/app-mobile/package.json b/packages/app-mobile/package.json index ae294b7f5..f8b2a27b8 100644 --- a/packages/app-mobile/package.json +++ b/packages/app-mobile/package.json @@ -18,7 +18,8 @@ "postinstall": "jetify && yarn run build" }, "dependencies": { - "@bam.tech/react-native-image-resizer": "3.0.7", + "@bam.tech/react-native-image-resizer": "3.0.5", + "@joplin/editor": "~2.13", "@joplin/lib": "~2.13", "@joplin/react-native-alarm-notification": "~2.13", "@joplin/react-native-saf-x": "~2.13", @@ -86,19 +87,6 @@ "@babel/core": "7.20.2", "@babel/preset-env": "7.20.2", "@babel/runtime": "7.20.0", - "@codemirror/commands": "6.2.2", - "@codemirror/lang-cpp": "6.0.2", - "@codemirror/lang-html": "6.4.3", - "@codemirror/lang-java": "6.0.1", - "@codemirror/lang-javascript": "6.1.5", - "@codemirror/lang-markdown": "6.1.0", - "@codemirror/lang-php": "6.0.1", - "@codemirror/lang-rust": "6.0.1", - "@codemirror/language": "6.6.0", - "@codemirror/legacy-modes": "6.3.2", - "@codemirror/search": "6.3.0", - "@codemirror/state": "6.2.0", - "@codemirror/view": "6.9.3", "@joplin/tools": "~2.13", "@lezer/highlight": "1.1.4", "@testing-library/jest-native": "5.4.3", diff --git a/packages/editor/CodeMirror/CodeMirror5Emulation/CodeMirror5Emulation.test.ts b/packages/editor/CodeMirror/CodeMirror5Emulation/CodeMirror5Emulation.test.ts new file mode 100644 index 000000000..c5ab4609a --- /dev/null +++ b/packages/editor/CodeMirror/CodeMirror5Emulation/CodeMirror5Emulation.test.ts @@ -0,0 +1,111 @@ +import CodeMirror5Emulation from './CodeMirror5Emulation'; +import { EditorView } from '@codemirror/view'; + +const makeCodeMirrorEmulation = (initialDocText: string) => { + const editorView = new EditorView({ + doc: initialDocText, + }); + return new CodeMirror5Emulation(editorView, ()=>{}); +}; + +describe('CodeMirror5Emulation', () => { + it('getSearchCursor should support searching for strings', () => { + const codeMirror = makeCodeMirrorEmulation('testing --- this is a test.'); + + // Should find two matches for "test" + // Note that the CodeMirror documentation specifies that a search cursor + // should return a boolean when calling findNext/findPrevious. However, + // the codemirror-vim adapter returns just a truthy/falsy value. + const testCursor = codeMirror.getSearchCursor('test'); + expect(testCursor.findNext()).toBeTruthy(); + expect(testCursor.findNext()).toBeTruthy(); + + // Replace the second match + testCursor.replace('passing test'); + expect(codeMirror.getValue()).toBe('testing --- this is a passing test.'); + + // Should also be able to find previous matches + expect(testCursor.findPrevious()).toBeTruthy(); + + // Should return a falsy value when attempting to search past the end of + // the document. + expect(testCursor.findPrevious()).toBeFalsy(); + }); + + it('should fire update/change events on change', async () => { + const codeMirror = makeCodeMirrorEmulation('testing --- this is a test.'); + + const updateCallback = jest.fn(); + const changeCallback = jest.fn(); + codeMirror.on('update', updateCallback); + codeMirror.on('change', changeCallback); + + expect(updateCallback).not.toHaveBeenCalled(); + expect(changeCallback).not.toHaveBeenCalled(); + + jest.useFakeTimers(); + + // Inserting text should trigger the update and change events + codeMirror.editor.dispatch({ + changes: { from: 0, to: 1, insert: 'Test: ' }, + }); + + // Advance timers -- there may be a delay between the CM 6 event + // and the dispatched CM 5 event. + await jest.advanceTimersByTimeAsync(100); + + expect(updateCallback).toHaveBeenCalled(); + expect(changeCallback).toHaveBeenCalled(); + + // The change callback should be given two arguments: + // - the CodeMirror emulation object + // - a description of the changes + expect(changeCallback.mock.lastCall[0]).toBe(codeMirror); + expect(changeCallback.mock.lastCall[1]).toMatchObject({ + from: { line: 0, ch: 0 }, + to: { line: 0, ch: 1 }, + + // Arrays of lines + text: ['Test: '], + removed: ['t'], + }); + }); + + it('defineOption should fire the option\'s update callback on change', () => { + const codeMirror = makeCodeMirrorEmulation('Test 1\nTest 2'); + + const onOptionUpdate = jest.fn(); + codeMirror.defineOption('an-option!', 'test', onOptionUpdate); + + const onOtherOptionUpdate = jest.fn(); + codeMirror.defineOption('an-option 2', 1, onOtherOptionUpdate); + + + // onUpdate should be called once initially + expect(onOtherOptionUpdate).toHaveBeenCalledTimes(1); + expect(onOptionUpdate).toHaveBeenCalledTimes(1); + expect(onOptionUpdate).toHaveBeenLastCalledWith( + codeMirror, + + // default value -- the new value + 'test', + + // the original value (none, so given CodeMirror.Init) + codeMirror.Init, + ); + + // onUpdate should be called each time the option changes + codeMirror.setOption('an-option!', 'test 2'); + expect(onOptionUpdate).toHaveBeenCalledTimes(2); + expect(onOptionUpdate).toHaveBeenLastCalledWith( + codeMirror, 'test 2', 'test', + ); + + codeMirror.setOption('an-option!', 'test...'); + expect(onOptionUpdate).toHaveBeenCalledTimes(3); + + // The other update callback should not have been triggered + // additional times if its option hasn't updated. + expect(onOtherOptionUpdate).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/editor/CodeMirror/CodeMirror5Emulation/CodeMirror5Emulation.ts b/packages/editor/CodeMirror/CodeMirror5Emulation/CodeMirror5Emulation.ts new file mode 100644 index 000000000..c130eab2b --- /dev/null +++ b/packages/editor/CodeMirror/CodeMirror5Emulation/CodeMirror5Emulation.ts @@ -0,0 +1,410 @@ +import { EditorView, ViewPlugin, ViewUpdate, showPanel } from '@codemirror/view'; +import { Extension, Text, Transaction } from '@codemirror/state'; +import getScrollFraction from '../getScrollFraction'; +import { CodeMirror as BaseCodeMirror5Emulation, Vim } from '@replit/codemirror-vim'; +import { LogMessageCallback } from '../../types'; +import editorCommands from '../editorCommands/editorCommands'; +import { StateEffect } from '@codemirror/state'; +import { StreamParser } from '@codemirror/language'; +import Decorator, { LineWidgetOptions } from './Decorator'; +const { pregQuote } = require('@joplin/lib/string-utils-common'); + + +type CodeMirror5Command = (codeMirror: CodeMirror5Emulation)=> void; + +type EditorEventCallback = (editor: CodeMirror5Emulation, ...args: any[])=> void; +type OptionUpdateCallback = (editor: CodeMirror5Emulation, newVal: any, oldVal: any)=> void; + +interface CodeMirror5OptionRecord { + onUpdate: OptionUpdateCallback; + value: any; +} + +interface DocumentPosition { + line: number; + ch: number; +} + +const documentPositionFromPos = (doc: Text, pos: number): DocumentPosition => { + const line = doc.lineAt(pos); + return { + // CM 5 uses 0-based line numbering + line: line.number - 1, + ch: pos - line.from, + }; +}; + +export default class CodeMirror5Emulation extends BaseCodeMirror5Emulation { + private _events: Record = {}; + private _options: Record = Object.create(null); + private _decorator: Decorator; + private _decoratorExtension: Extension; + + // Used by some plugins to store state. + public state: Record = Object.create(null); + + public Vim = Vim; + + // Passed as initial state to plugins + public Init = { toString: () => 'CodeMirror.Init' }; + + public constructor( + public editor: EditorView, + private logMessage: LogMessageCallback, + ) { + super(editor); + + const { decorator, extension: decoratorExtension } = Decorator.create(editor); + this._decorator = decorator; + this._decoratorExtension = decoratorExtension; + + editor.dispatch({ + effects: StateEffect.appendConfig.of(this.makeCM6Extensions()), + }); + } + + private makeCM6Extensions() { + const cm5 = this; + const editor = this.editor; + + return [ + // Fires events + EditorView.domEventHandlers({ + scroll: () => CodeMirror5Emulation.signal(this, 'scroll'), + focus: () => CodeMirror5Emulation.signal(this, 'focus'), + blur: () => CodeMirror5Emulation.signal(this, 'blur'), + mousedown: event => CodeMirror5Emulation.signal(this, 'mousedown', event), + }), + ViewPlugin.fromClass(class { + public update(update: ViewUpdate) { + try { + if (update.viewportChanged) { + CodeMirror5Emulation.signal( + cm5, + 'viewportChange', + editor.viewport.from, + editor.viewport.to, + ); + } + + if (update.docChanged) { + cm5.fireChangeEvents(update); + cm5.onChange(update); + } + + if (update.selectionSet) { + cm5.onSelectionChange(); + } + + CodeMirror5Emulation.signal(cm5, 'update'); + // Catch the error -- otherwise, CodeMirror will de-register the update listener. + } catch (error) { + cm5.logMessage(`Error dispatching update: ${error}`); + } + } + }), + + // Decorations + this._decoratorExtension, + + // Some plugins rely on a CodeMirror-measure element + // to store temporary content. + showPanel.of(() => { + const dom = document.createElement('div'); + dom.classList.add('CodeMirror-measure'); + return { dom }; + }), + + // Note: We can allow legacy CM5 CSS to apply to the editor + // with a line similar to the following: + // EditorView.editorAttributes.of({ class: 'CodeMirror' }), + // Many of these styles, however, don't work well with CodeMirror 6. + ]; + } + + private isEventHandledBySuperclass(eventName: string) { + return ['beforeSelectionChange'].includes(eventName); + } + + public on(eventName: string, callback: EditorEventCallback) { + if (this.isEventHandledBySuperclass(eventName)) { + return super.on(eventName, callback); + } + this._events[eventName] ??= []; + this._events[eventName].push(callback); + } + + public off(eventName: string, callback: EditorEventCallback) { + if (!(eventName in this._events)) { + return; + } + + this._events[eventName] = this._events[eventName].filter( + otherCallback => otherCallback !== callback, + ); + } + + public static signal(target: CodeMirror5Emulation, eventName: string, ...args: any[]) { + const listeners = target._events[eventName] ?? []; + + for (const listener of listeners) { + listener(target, ...args); + } + + super.signal(target, eventName, ...args); + } + + private fireChangeEvents(update: ViewUpdate) { + type ChangeRecord = { + from: DocumentPosition; + to: DocumentPosition; + text: string[]; + removed: string[]; + transaction: Transaction; + }; + const changes: ChangeRecord[] = []; + const origDoc = update.startState.doc; + + for (const transaction of update.transactions) { + transaction.changes.iterChanges((fromA, toA, _fromB, _toB, inserted: Text) => { + changes.push({ + from: documentPositionFromPos(origDoc, fromA), + to: documentPositionFromPos(origDoc, toA), + text: inserted.sliceString(0).split('\n'), + removed: origDoc.sliceString(fromA, toA).split('\n'), + transaction, + }); + }); + } + + // Delay firing events -- event listeners may try to create transactions. + // (this is done by the rich markdown plugin). + setTimeout(() => { + for (const change of changes) { + CodeMirror5Emulation.signal(this, 'change', change); + + // If triggered by a user, also send the inputRead event + if (change.transaction.isUserEvent('input')) { + CodeMirror5Emulation.signal(this, 'inputRead', change); + } + } + + CodeMirror5Emulation.signal(this, 'changes', changes); + }, 0); + + } + + // codemirror-vim's adapter doesn't match the CM5 docs -- wrap it. + public getCursor(mode?: 'head' | 'anchor' | 'from' | 'to'| 'start' | 'end') { + if (mode === 'from') { + mode = 'start'; + } + if (mode === 'to') { + mode = 'end'; + } + + return super.getCursor(mode); + } + + public override getSearchCursor(query: RegExp|string, pos?: DocumentPosition|null|0) { + // The superclass CodeMirror adapter only supports regular expression + // arguments. + if (typeof query === 'string') { + query = new RegExp(pregQuote(query)); + } + return super.getSearchCursor(query, pos || { line: 0, ch: 0 }); + } + + public lineAtHeight(height: number, _mode?: 'local') { + const lineInfo = this.editor.lineBlockAtHeight(height); + + // - 1: Convert to zero-based. + const lineNumber = this.editor.state.doc.lineAt(lineInfo.to).number - 1; + return lineNumber; + } + + public heightAtLine(lineNumber: number, mode?: 'local') { + // CodeMirror 5 uses 0-based line numbers. CM6 uses 1-based + // line numbers. + const doc = this.editor.state.doc; + const lineInfo = doc.line(Math.min(lineNumber + 1, doc.lines)); + const lineBlock = this.editor.lineBlockAt(lineInfo.from); + + const height = lineBlock.top; + if (mode === 'local') { + const editorTop = this.editor.lineBlockAt(0).top; + return height - editorTop; + } else { + return height; + } + } + + public lineInfo(lineNumber: number) { + const line = this.editor.state.doc.line(lineNumber + 1); + + const result = { + line: lineNumber, + + // Note: In CM5, a line handle is not just a line number + handle: lineNumber, + + text: line.text, + gutterMarkers: [] as any[], + textClass: ['cm-line', ...this._decorator.getLineClasses(lineNumber)], + bgClass: '', + wrapClass: '', + widgets: this._decorator.getLineWidgets(lineNumber), + }; + + return result; + } + + public getStateAfter(_line: number) { + // TODO: Should return parser state. Returning an empty object + // allows some plugins to run without crashing, however. + return {}; + } + + public getScrollPercent() { + return getScrollFraction(this.editor); + } + + public defineExtension(name: string, value: any) { + (CodeMirror5Emulation.prototype as any)[name] ??= value; + } + + public defineOption(name: string, defaultValue: any, onUpdate: OptionUpdateCallback) { + this._options[name] = { + value: defaultValue, + onUpdate, + }; + onUpdate(this, defaultValue, this.Init); + } + + // Override codemirror-vim's setOption to allow user-defined options + public override setOption(name: string, value: any) { + if (name in this._options) { + const oldValue = this._options[name].value; + this._options[name].value = value; + this._options[name].onUpdate(this, value, oldValue); + } else { + super.setOption(name, value); + } + } + + public override getOption(name: string): any { + if (name in this._options) { + return this._options[name].value; + } else { + return super.getOption(name); + } + } + + // codemirror-vim's API doesn't match the API docs here -- it expects addOverlay + // to return a SearchQuery. As such, this override returns "any". + public override addOverlay(modeObject: StreamParser|{ query: RegExp }): any { + if ('query' in modeObject) { + return super.addOverlay(modeObject); + } + + this._decorator.addOverlay(modeObject); + } + + public addLineClass(lineNumber: number, where: string, className: string) { + this._decorator.addLineClass(lineNumber, where, className); + } + + public removeLineClass(lineNumber: number, where: string, className: string) { + this._decorator.removeLineClass(lineNumber, where, className); + } + + public addLineWidget(lineNumber: number, node: HTMLElement, options: LineWidgetOptions) { + this._decorator.addLineWidget(lineNumber, node, options); + } + + // TODO: Currently copied from useCursorUtils.ts. + // TODO: Remove the duplicate code when CodeMirror 5 is eventually removed. + public wrapSelections(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.substring(start, end - start + 1); + + // If selection can be toggled do that + if (core.startsWith(string1) && core.endsWith(string2)) { + const inside = core.substring(string1.length, core.length - string1.length - string2.length); + selectedStrings[i] = selected.substring(0, start) + inside + selected.substring(end + 1); + } else { + selectedStrings[i] = selected.substring(0, start) + string1 + core + string2 + selected.substring(end + 1); + } + } + this.replaceSelections(selectedStrings); + }); + } + + public static commands = (() => { + const commands: Record = { + ...BaseCodeMirror5Emulation.commands, + }; + + for (const commandName in editorCommands) { + const command = editorCommands[commandName as keyof typeof editorCommands]; + + commands[commandName] = (codeMirror: CodeMirror5Emulation) => command(codeMirror.editor); + } + + // as any: Required to properly extend the base class -- without this, + // the commands dictionary isn't known (by TypeScript) to have the same + // properties as the commands dictionary in the parent class. + return commands as any; + })(); + + public commands = CodeMirror5Emulation.commands; + + private joplinCommandToCodeMirrorCommand(commandName: string): string|null { + const match = /^editor\.(.*)$/g.exec(commandName); + + if (!match || !(match[1] in CodeMirror5Emulation.commands)) { + return null; + } + + return match[1] as string; + } + + public supportsJoplinCommand(commandName: string): boolean { + return this.joplinCommandToCodeMirrorCommand(commandName) in CodeMirror5Emulation.commands; + } + + public execJoplinCommand(joplinCommandName: string) { + const commandName = this.joplinCommandToCodeMirrorCommand(joplinCommandName); + + if (commandName === null) { + this.logMessage(`Unsupported Joplin command, ${joplinCommandName}`); + return; + } + + this.execCommand(commandName); + } + + public commandExists(commandName: string) { + return commandName in CodeMirror5Emulation.commands; + } + + public execCommand(name: string) { + if (!this.commandExists(name)) { + this.logMessage(`Unsupported CodeMirror command, ${name}`); + return; + } + + CodeMirror5Emulation.commands[name as (keyof typeof CodeMirror5Emulation.commands)](this); + } +} + diff --git a/packages/editor/CodeMirror/CodeMirror5Emulation/Decorator.ts b/packages/editor/CodeMirror/CodeMirror5Emulation/Decorator.ts new file mode 100644 index 000000000..6b9f93ca6 --- /dev/null +++ b/packages/editor/CodeMirror/CodeMirror5Emulation/Decorator.ts @@ -0,0 +1,385 @@ + +// Handles adding decorations to the CodeMirror editor -- converts CodeMirror5-style calls +// to input accepted by CodeMirror 6 + +import { Decoration, DecorationSet, EditorView, ViewPlugin, ViewUpdate, WidgetType } from '@codemirror/view'; +import { ChangeDesc, Extension, Range, RangeSetBuilder, StateEffect, StateField, Transaction } from '@codemirror/state'; +import { StreamParser, StringStream, indentUnit } from '@codemirror/language'; + +interface DecorationRange { + from: number; + to: number; +} + +const mapRangeConfig = { + // Updates a range based on some change to the document + map: (range: T, change: ChangeDesc): T => { + const from = change.mapPos(range.from); + const to = change.mapPos(range.to); + return { + ...range, + from: Math.min(from, to), + to: Math.max(from, to), + }; + }, +}; + +interface LineCssDecorationSpec extends DecorationRange { + cssClass: string; +} + +const addLineDecorationEffect = StateEffect.define(mapRangeConfig); +const removeLineDecorationEffect = StateEffect.define(mapRangeConfig); +const addMarkDecorationEffect = StateEffect.define(mapRangeConfig); +// TODO: Support removing mark decorations +// const removeMarkDecorationEffect = StateEffect.define(mapRangeConfig); + +export interface LineWidgetOptions { + className?: string; + above?: boolean; +} + +interface LineWidgetDecorationSpec extends DecorationRange { + element: HTMLElement; + options: LineWidgetOptions; +} +const addLineWidgetEffect = StateEffect.define(mapRangeConfig); +const removeLineWidgetEffect = StateEffect.define<{ element: HTMLElement }>(); + + +class WidgetDecorationWrapper extends WidgetType { + public constructor( + public readonly element: HTMLElement, + public readonly options: LineWidgetOptions, + ) { + super(); + } + + public override toDOM() { + const container = document.createElement('div'); + this.element.remove(); + container.appendChild(this.element); + + if (this.options.className) { + container.classList.add(this.options.className); + } + + return container; + } +} + +interface LineWidgetControl { + node: HTMLElement; + clear(): void; + changed(): void; + className?: string; +} + +export default class Decorator { + private _extension: Extension; + private _effectDecorations: DecorationSet = Decoration.none; + + private constructor(private editor: EditorView) { + const decorator = this; + this._extension = [ + // Overlay decorations -- recreate all decorations when the editor changes + // (overlay decorations require parsing the document and may change output + // when the editor/view changes.) + ViewPlugin.fromClass(class { + public decorations: DecorationSet; + + public constructor(view: EditorView) { + this.decorations = decorator.createOverlayDecorations(view); + } + + public update(update: ViewUpdate) { + if (update.viewportChanged || update.docChanged) { + this.decorations = decorator.createOverlayDecorations(update.view); + } + } + }, { + decorations: v => v.decorations, + }), + + // Other decorations based on effects. See the decoration examples: https://codemirror.net/examples/decoration/ + // Note that EditorView.decorations.from is required for block widgets. + StateField.define({ + create: () => Decoration.none, + update: (_, viewUpdate) => decorator.updateEffectDecorations([viewUpdate]), + provide: field => EditorView.decorations.from(field), + }), + ]; + } + + public static create(editor: EditorView) { + const decorator = new Decorator(editor); + + return { decorator, extension: decorator._extension }; + } + + private _decorationCache: Record = Object.create(null); + private _overlays: (StreamParser)[] = []; + + private classNameToCssDecoration(className: string, isLineDecoration: boolean) { + let decoration; + + if (className in this._decorationCache) { + decoration = this._decorationCache[className]; + } else { + const attributes = { class: className }; + + if (isLineDecoration) { + decoration = Decoration.line({ attributes }); + } else { + decoration = Decoration.mark({ attributes }); + } + + this._decorationCache[className] = decoration; + } + + return decoration; + } + + private updateEffectDecorations(transactions: Transaction[]) { + let decorations = this._effectDecorations; + + // Update decoration positions + for (const transaction of transactions) { + decorations = decorations.map(transaction.changes); + + // Add or remove decorations + for (const effect of transaction.effects) { + const isMarkDecoration = effect.is(addMarkDecorationEffect); + const isLineDecoration = effect.is(addLineDecorationEffect); + if (isMarkDecoration || isLineDecoration) { + const decoration = this.classNameToCssDecoration( + effect.value.cssClass, isLineDecoration, + ); + + const value = effect.value; + const from = effect.value.from; + + // Line decorations are specified to have a size-zero range. + const to = isLineDecoration ? from : value.to; + + decorations = decorations.update({ + add: [decoration.range(from, to)], + }); + } else if (effect.is(removeLineDecorationEffect)) { + const doc = transaction.state.doc; + const targetFrom = doc.lineAt(effect.value.from).from; + const targetTo = doc.lineAt(effect.value.to).to; + + const targetDecoration = this.classNameToCssDecoration(effect.value.cssClass, true); + + decorations = decorations.update({ + // Returns true only for decorations that should be kept. + filter: (from, to, value) => { + if (from >= targetFrom && to <= targetTo && value.eq(targetDecoration)) { + return false; + } + + return true; + }, + }); + } else if (effect.is(addLineWidgetEffect)) { + const options = effect.value.options; + const decoration = Decoration.widget({ + widget: new WidgetDecorationWrapper(effect.value.element, options), + side: options.above ? -1 : 1, + block: true, + }); + + decorations = decorations.update({ + add: [decoration.range(options.above ? effect.value.from : effect.value.to)], + }); + } else if (effect.is(removeLineWidgetEffect)) { + decorations = decorations.update({ + // Returns true only for decorations that should be kept. + filter: (_from, _to, value) => { + return value.spec.widget?.element !== effect.value.element; + }, + }); + } + } + } + + this._effectDecorations = decorations; + return decorations; + } + + private createOverlayDecorations(view: EditorView): DecorationSet { + const makeDecoration = ( + tokenName: string, start: number, stop: number, + ) => { + const isLineDecoration = tokenName.startsWith('line-'); + + // CM5 prefixes class names with cm- + tokenName = `cm-${tokenName}`; + + const decoration = this.classNameToCssDecoration(tokenName, isLineDecoration); + return decoration.range(start, stop); + }; + + const indentSize = view.state.facet(indentUnit).length; + const newDecorations: Range[] = []; + + for (const overlay of this._overlays) { + const state = overlay.startState?.(indentSize) ?? {}; + + for (const { from, to } of view.visibleRanges) { + const fromLine = view.state.doc.lineAt(from); + const toLine = view.state.doc.lineAt(to); + + const fromLineNumber = fromLine.number; + const toLineNumber = toLine.number; + + for (let i = fromLineNumber; i <= toLineNumber; i++) { + const line = view.state.doc.line(i); + + const reader = new StringStream( + line.text, + view.state.tabSize, + indentSize, + ); + let lastPos = 0; + + (reader as any).baseToken ??= (): null => null; + + while (!reader.eol()) { + const token = overlay.token(reader, state); + + if (token) { + for (const className of token.split(/\s+/)) { + if (className.startsWith('line-')) { + newDecorations.push(makeDecoration(className, line.from, line.from)); + } else { + const from = lastPos + line.from; + const to = reader.pos + line.from; + newDecorations.push(makeDecoration(className, from, to)); + } + } + } + + if (reader.pos === lastPos) { + throw new Error( + 'Mark decoration position did not increase -- overlays must advance with each call to .token()', + ); + } + + lastPos = reader.pos; + } + } + } + } + + // Required by CodeMirror: + // Should be sorted by from position, then by length. + newDecorations.sort((a, b) => { + if (a.from !== b.from) { + return a.from - b.from; + } + + return a.to - b.to; + }); + + // Per the documentation, new tokens should be added in + // increasing order. + const decorations = new RangeSetBuilder(); + + for (const decoration of newDecorations) { + decorations.add(decoration.from, decoration.to, decoration.value); + } + + return decorations.finish(); + } + + public addOverlay(modeObject: StreamParser) { + this._overlays.push(modeObject); + } + + private addRemoveLineClass(lineNumber: number, className: string, add: boolean) { + // + 1: Convert from zero-indexed to one-indexed + const line = this.editor.state.doc.line(lineNumber + 1); + + const effect = add ? addLineDecorationEffect : removeLineDecorationEffect; + this.editor.dispatch({ + effects: effect.of({ + cssClass: className, + from: line.from, + to: line.to, + }), + }); + } + + public addLineClass(lineNumber: number, _where: string, className: string) { + this.addRemoveLineClass(lineNumber, className, true); + } + + public removeLineClass(lineNumber: number, _where: string, className: string) { + this.addRemoveLineClass(lineNumber, className, false); + } + + public getLineClasses(lineNumber: number) { + const line = this.editor.state.doc.line(lineNumber + 1); + const lineClasses: string[] = []; + + this._effectDecorations.between(line.from, line.to, (from, to, decoration) => { + if (from === line.from && to === line.to) { + const className = decoration.spec?.class; + if (typeof className === 'string') { + lineClasses.push(className); + } + } + }); + + return lineClasses; + } + + private createLineWidgetControl(node: HTMLElement, options: LineWidgetOptions): LineWidgetControl { + return { + node, + clear: () => { + this.editor.dispatch({ + effects: removeLineWidgetEffect.of({ element: node }), + }); + }, + changed: () => { + this.editor.requestMeasure(); + }, + className: options.className, + }; + } + + public getLineWidgets(lineNumber: number): LineWidgetControl[] { + const line = this.editor.state.doc.line(lineNumber + 1); + const lineWidgets: LineWidgetControl[] = []; + + this._effectDecorations.between(line.from, line.to, (from, to, decoration) => { + if (from >= line.from && from <= line.to && from === to) { + const widget = decoration.spec?.widget; + if (widget && widget instanceof WidgetDecorationWrapper) { + lineWidgets.push(this.createLineWidgetControl(widget.element, widget.options)); + } + } + }); + + return lineWidgets; + } + + public addLineWidget(lineNumber: number, node: HTMLElement, options: LineWidgetOptions): LineWidgetControl { + const line = this.editor.state.doc.line(lineNumber + 1); + + const lineWidgetOptions = { + from: line.from, + to: line.to, + element: node, + options, + }; + this.editor.dispatch({ + effects: addLineWidgetEffect.of(lineWidgetOptions), + }); + + return this.createLineWidgetControl(node, options); + } +} diff --git a/packages/editor/CodeMirror/CodeMirrorControl.test.ts b/packages/editor/CodeMirror/CodeMirrorControl.test.ts new file mode 100644 index 000000000..0e087958b --- /dev/null +++ b/packages/editor/CodeMirror/CodeMirrorControl.test.ts @@ -0,0 +1,46 @@ +import createEditor from './createEditor'; +import createEditorSettings from './testUtil/createEditorSettings'; +import Setting from '@joplin/lib/models/Setting'; + +const createEditorControls = (initialText: string) => { + const editorSettings = createEditorSettings(Setting.THEME_LIGHT); + + return createEditor(document.body, { + initialText, + settings: editorSettings, + onEvent: _event => {}, + onLogMessage: _message => {}, + }); +}; + +describe('CodeMirrorControl', () => { + it('clearHistory should clear the undo/redo history', () => { + const controls = createEditorControls(''); + + const insertedText = 'Testing... This is a test...'; + controls.insertText(insertedText); + + const fullInsertedText = insertedText; + expect(controls.getValue()).toBe(fullInsertedText); + + // Undo should work before clearing history + controls.undo(); + expect(controls.getValue()).toBe(''); + + controls.redo(); + controls.clearHistory(); + + expect(controls.getValue()).toBe(fullInsertedText); + + // Should not be able to undo cleared changes + controls.undo(); + expect(controls.getValue()).toBe(fullInsertedText); + + // Should be able to undo new changes + controls.insertText('!!!'); + expect(controls.getValue()).toBe(`${fullInsertedText}!!!`); + + controls.undo(); + expect(controls.getValue()).toBe(fullInsertedText); + }); +}); diff --git a/packages/editor/CodeMirror/CodeMirrorControl.ts b/packages/editor/CodeMirror/CodeMirrorControl.ts new file mode 100644 index 000000000..688190d55 --- /dev/null +++ b/packages/editor/CodeMirror/CodeMirrorControl.ts @@ -0,0 +1,137 @@ +import { EditorView } from '@codemirror/view'; +import { EditorCommandType, EditorControl, EditorSettings, LogMessageCallback, PluginData, SearchState } from '../types'; +import CodeMirror5Emulation from './CodeMirror5Emulation/CodeMirror5Emulation'; +import editorCommands from './editorCommands/editorCommands'; +import { EditorSelection, StateEffect } from '@codemirror/state'; +import { updateLink } from './markdown/markdownCommands'; +import { SearchQuery, setSearchQuery } from '@codemirror/search'; +import PluginLoader from './PluginLoader'; + +interface Callbacks { + onUndoRedo(): void; + onSettingsChange(newSettings: EditorSettings): void; + onClearHistory(): void; + onRemove(): void; + onLogMessage: LogMessageCallback; +} + +export default class CodeMirrorControl extends CodeMirror5Emulation implements EditorControl { + private _pluginControl: PluginLoader; + + public constructor( + editor: EditorView, + private _callbacks: Callbacks, + ) { + super(editor, _callbacks.onLogMessage); + + this._pluginControl = new PluginLoader(this, _callbacks.onLogMessage); + } + + public supportsCommand(name: string) { + return name in editorCommands || super.commandExists(name); + } + + public override execCommand(name: string) { + if (name in editorCommands) { + editorCommands[name as EditorCommandType](this.editor); + } else if (super.commandExists(name)) { + super.execCommand(name); + } + + if (name === EditorCommandType.Undo || name === EditorCommandType.Redo) { + this._callbacks.onUndoRedo(); + } + } + + public undo() { + this.execCommand(EditorCommandType.Undo); + this._callbacks.onUndoRedo(); + } + + public redo() { + this.execCommand(EditorCommandType.Redo); + this._callbacks.onUndoRedo(); + } + + public select(anchor: number, head: number) { + this.editor.dispatch(this.editor.state.update({ + selection: { anchor, head }, + scrollIntoView: true, + })); + } + + public clearHistory() { + this._callbacks.onClearHistory(); + } + + public setScrollPercent(fraction: number) { + const maxScroll = this.editor.scrollDOM.scrollHeight - this.editor.scrollDOM.clientHeight; + this.editor.scrollDOM.scrollTop = fraction * maxScroll; + } + + public insertText(text: string) { + this.editor.dispatch(this.editor.state.replaceSelection(text)); + } + + public updateBody(newBody: string) { + // TODO: doc.toString() can be slow for large documents. + const currentBody = this.editor.state.doc.toString(); + + if (newBody !== currentBody) { + // For now, collapse the selection to a single cursor + // to ensure that the selection stays within the document + // (and thus avoids an exception). + const mainCursorPosition = this.editor.state.selection.main.anchor; + const newCursorPosition = Math.min(mainCursorPosition, newBody.length); + + this.editor.dispatch(this.editor.state.update({ + changes: { + from: 0, + to: this.editor.state.doc.length, + insert: newBody, + }, + selection: EditorSelection.cursor(newCursorPosition), + scrollIntoView: true, + })); + + return true; + } + + return false; + } + + public updateLink(newLabel: string, newUrl: string) { + updateLink(newLabel, newUrl)(this.editor); + } + + public updateSettings(newSettings: EditorSettings) { + this._callbacks.onSettingsChange(newSettings); + } + + public setSearchState(newState: SearchState) { + const query = new SearchQuery({ + search: newState.searchText, + caseSensitive: newState.caseSensitive, + regexp: newState.useRegex, + replace: newState.replaceText, + }); + this.editor.dispatch({ + effects: setSearchQuery.of(query), + }); + } + + public addStyles(...styles: Parameters) { + this.editor.dispatch({ + effects: StateEffect.appendConfig.of(EditorView.theme(...styles)), + }); + } + + public setPlugins(plugins: PluginData[]) { + return this._pluginControl.setPlugins(plugins); + } + + public remove() { + this._pluginControl.remove(); + this._callbacks.onRemove(); + } +} diff --git a/packages/editor/CodeMirror/PluginLoader.ts b/packages/editor/CodeMirror/PluginLoader.ts new file mode 100644 index 000000000..51ec3cc60 --- /dev/null +++ b/packages/editor/CodeMirror/PluginLoader.ts @@ -0,0 +1,156 @@ +import { LogMessageCallback, PluginData } from '../types'; +import CodeMirrorControl from './CodeMirrorControl'; + +let pluginScriptIdCounter = 0; + +type OnScriptLoadCallback = (exports: any)=> void; +type OnPluginRemovedCallback = ()=> void; + +export default class PluginLoader { + private pluginScriptsContainer: HTMLElement; + private loadedPluginIds: string[] = []; + private pluginRemovalCallbacks: Record = {}; + + public constructor(private editor: CodeMirrorControl, private logMessage: LogMessageCallback) { + this.pluginScriptsContainer = document.createElement('div'); + this.pluginScriptsContainer.style.display = 'none'; + + // For testing + this.pluginScriptsContainer.id = 'joplin-plugin-scripts-container'; + + document.body.appendChild(this.pluginScriptsContainer); + + (window as any).scriptLoadCallbacks ??= Object.create(null); + } + + public async setPlugins(plugins: PluginData[]) { + for (const plugin of plugins) { + if (!this.loadedPluginIds.includes(plugin.pluginId)) { + this.addPlugin(plugin); + } + } + + // Remove old plugins + const pluginIds = plugins.map(plugin => plugin.pluginId); + const removedIds = this.loadedPluginIds + .filter(id => !pluginIds.includes(id)); + + for (const id of removedIds) { + if (id in this.pluginRemovalCallbacks) { + this.pluginRemovalCallbacks[id](); + } + } + } + + private addPlugin(plugin: PluginData) { + const onRemoveCallbacks: OnPluginRemovedCallback[] = []; + + this.logMessage(`Loading plugin ${plugin.pluginId}`); + + const addScript = (onLoad: OnScriptLoadCallback) => { + const scriptElement = document.createElement('script'); + + onRemoveCallbacks.push(() => { + scriptElement.remove(); + }); + + void (async () => { + const scriptId = pluginScriptIdCounter++; + const js = await plugin.contentScriptJs(); + + // Stop if cancelled + if (!this.loadedPluginIds.includes(plugin.pluginId)) { + return; + } + + scriptElement.innerText = ` + (async () => { + const exports = {}; + + ${js}; + + window.scriptLoadCallbacks[${scriptId}](exports); + })(); + `; + + (window as any).scriptLoadCallbacks[scriptId] = onLoad; + + this.pluginScriptsContainer.appendChild(scriptElement); + })(); + }; + + const addStyles = (cssStrings: string[]) => { + // A container for style elements + const styleContainer = document.createElement('div'); + + onRemoveCallbacks.push(() => { + styleContainer.remove(); + }); + + for (const cssText of cssStrings) { + const style = document.createElement('style'); + style.innerText = cssText; + styleContainer.appendChild(style); + } + + this.pluginScriptsContainer.appendChild(styleContainer); + }; + + this.pluginRemovalCallbacks[plugin.pluginId] = () => { + for (const callback of onRemoveCallbacks) { + callback(); + } + + this.loadedPluginIds = this.loadedPluginIds.filter(id => { + return id !== plugin.pluginId; + }); + }; + + addScript(exports => { + if (!exports?.default || !(typeof exports.default === 'function')) { + throw new Error('All plugins must have a function default export'); + } + + const context = { + postMessage: plugin.postMessageHandler, + pluginId: plugin.pluginId, + contentScriptId: plugin.contentScriptId, + }; + const loadedPlugin = exports.default(context); + + loadedPlugin.plugin?.(this.editor); + + if (loadedPlugin.codeMirrorOptions) { + for (const key in loadedPlugin.codeMirrorOptions) { + this.editor.setOption(key, loadedPlugin.codeMirrorOptions[key]); + } + } + + if (loadedPlugin.assets) { + const cssStrings = []; + + for (const asset of loadedPlugin.assets()) { + if (!asset.inline) { + this.logMessage('Warning: The CM6 plugin API currently only supports inline CSS.'); + continue; + } + + if (asset.mime !== 'text/css') { + throw new Error('Inline assets must have property "mime" set to "text/css"'); + } + + cssStrings.push(asset.text); + } + + addStyles(cssStrings); + } + }); + + this.loadedPluginIds.push(plugin.pluginId); + } + + public remove() { + this.pluginScriptsContainer.remove(); + } +} + diff --git a/packages/editor/CodeMirror/configFromSettings.ts b/packages/editor/CodeMirror/configFromSettings.ts new file mode 100644 index 000000000..23ba4de13 --- /dev/null +++ b/packages/editor/CodeMirror/configFromSettings.ts @@ -0,0 +1,70 @@ +import { EditorView, keymap } from '@codemirror/view'; +import { closeBrackets } from '@codemirror/autocomplete'; +import { EditorKeymap, EditorLanguageType, EditorSettings } from '../types'; +import createTheme from './theme'; +import { EditorState } from '@codemirror/state'; +import { markdown, markdownLanguage } from '@codemirror/lang-markdown'; +import { GFM as GitHubFlavoredMarkdownExtension } from '@lezer/markdown'; +import { MarkdownMathExtension } from './markdown/markdownMathParser'; +import syntaxHighlightingLanguages from './markdown/syntaxHighlightingLanguages'; +import { html } from '@codemirror/lang-html'; +import { defaultKeymap, emacsStyleKeymap } from '@codemirror/commands'; +import { vim } from '@replit/codemirror-vim'; +import { indentUnit } from '@codemirror/language'; + +const configFromSettings = (settings: EditorSettings) => { + const languageExtension = (() => { + const openingBrackets = '`([{\'"‘“(《「『【〔〖〘〚'.split(''); + + const language = settings.language; + if (language === EditorLanguageType.Markdown) { + return [ + markdown({ + extensions: [ + GitHubFlavoredMarkdownExtension, + + // Don't highlight KaTeX if the user disabled it + settings.katexEnabled ? MarkdownMathExtension : [], + ], + codeLanguages: syntaxHighlightingLanguages, + }), + markdownLanguage.data.of({ closeBrackets: openingBrackets }), + ]; + } else if (language === EditorLanguageType.Html) { + return html(); + } else { + const exhaustivenessCheck: never = language; + return exhaustivenessCheck; + } + })(); + + const extensions = [ + languageExtension, + createTheme(settings.themeData), + EditorView.contentAttributes.of({ + autocapitalize: 'sentence', + autocorrect: settings.spellcheckEnabled ? 'true' : 'false', + spellcheck: settings.spellcheckEnabled ? 'true' : 'false', + }), + EditorState.readOnly.of(settings.readOnly), + indentUnit.of(settings.indentWithTabs ? '\t' : ' '), + ]; + + if (settings.automatchBraces) { + extensions.push(closeBrackets()); + } + + if (settings.keymap === EditorKeymap.Vim) { + extensions.push(vim()); + } else if (settings.keymap === EditorKeymap.Emacs) { + extensions.push(keymap.of(emacsStyleKeymap)); + } + + if (!settings.ignoreModifiers) { + extensions.push(keymap.of(defaultKeymap)); + } + + return extensions; +}; + +export default configFromSettings; diff --git a/packages/editor/CodeMirror/createEditor.test.ts b/packages/editor/CodeMirror/createEditor.test.ts new file mode 100644 index 000000000..bc5350b16 --- /dev/null +++ b/packages/editor/CodeMirror/createEditor.test.ts @@ -0,0 +1,122 @@ +/** + * @jest-environment jsdom + */ + +import createEditor from './createEditor'; +import Setting from '@joplin/lib/models/Setting'; +import { forceParsing } from '@codemirror/language'; +import loadLangauges from './testUtil/loadLanguages'; + +import { expect, describe, it } from '@jest/globals'; +import createEditorSettings from './testUtil/createEditorSettings'; + + +describe('createEditor', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + // This checks for a regression -- occasionally, when updating packages, + // syntax highlighting in the CodeMirror editor stops working. This is usually + // fixed by + // 1. removing all `@codemirror/` and `@lezer/` dependencies from yarn.lock, + // 2. upgrading all CodeMirror packages to the latest versions in package.json, and + // 3. re-running `yarn install`. + // + // See https://github.com/laurent22/joplin/issues/7253 + it('should give headings a different style', async () => { + const headerLineText = '# Testing...'; + const initialText = `${headerLineText}\nThis is a test.`; + const editorSettings = createEditorSettings(Setting.THEME_LIGHT); + + await loadLangauges(); + const editor = createEditor(document.body, { + initialText, + settings: editorSettings, + onEvent: _event => {}, + onLogMessage: _message => {}, + }); + + // Force the generation of the syntax tree now. + forceParsing(editor.editor); + + const headerLine = document.body.querySelector('.cm-headerLine')!; + expect(headerLine.textContent).toBe(headerLineText); + + // CodeMirror nests the tag that styles the header within .cm-headerLine: + //
Testing...
+ const headerLineContent = document.body.querySelectorAll('.cm-headerLine > span'); + expect(headerLineContent.length).toBeGreaterThanOrEqual(1); + for (const part of headerLineContent) { + const style = getComputedStyle(part); + expect(style.fontSize).toBe('1.6em'); + } + + // Cleanup + editor.remove(); + }); + + it('should support loading plugins', async () => { + const initialText = '# Test\nThis is a test.'; + const editorSettings = createEditorSettings(Setting.THEME_LIGHT); + + const editor = createEditor(document.body, { + initialText, + settings: editorSettings, + onEvent: _event => {}, + onLogMessage: _message => {}, + }); + + const getContentScriptJs = jest.fn(async () => { + return ` + exports.default = context => { + context.postMessage(context.pluginId); + }; + `; + }); + const postMessageHandler = jest.fn(); + + const testPlugin1 = { + pluginId: 'a.plugin.id', + contentScriptId: 'a.plugin.id.contentScript', + contentScriptJs: getContentScriptJs, + postMessageHandler, + }; + const testPlugin2 = { + pluginId: 'another.plugin.id', + contentScriptId: 'another.plugin.id.contentScript', + contentScriptJs: getContentScriptJs, + postMessageHandler, + }; + + // Should be able to load a plugin + await editor.setPlugins([ + testPlugin1, + ]); + + // Allow plugins to load + await jest.runAllTimersAsync(); + + // Because plugin loading is done by adding script elements to the document, + // we test for the presence of these script elements, rather than waiting for + // them to run. + expect(document.querySelectorAll('#joplin-plugin-scripts-container')).toHaveLength(1); + + // Only one script should be present. + const scriptContainer = document.querySelector('#joplin-plugin-scripts-container'); + expect(scriptContainer.querySelectorAll('script')).toHaveLength(1); + + // Adding another plugin should add another script element + await editor.setPlugins([ + testPlugin2, testPlugin1, + ]); + await jest.runAllTimersAsync(); + + // There should now be script elements for each plugin + expect(scriptContainer.querySelectorAll('script')).toHaveLength(2); + + // Removing the editor should remove the script container + editor.remove(); + expect(document.querySelectorAll('#joplin-plugin-scripts-container')).toHaveLength(0); + }); +}); diff --git a/packages/editor/CodeMirror/createEditor.ts b/packages/editor/CodeMirror/createEditor.ts new file mode 100644 index 000000000..cf59e6a2f --- /dev/null +++ b/packages/editor/CodeMirror/createEditor.ts @@ -0,0 +1,303 @@ +import { Compartment, EditorState } from '@codemirror/state'; +import { indentOnInput, syntaxHighlighting } from '@codemirror/language'; +import { + openSearchPanel, closeSearchPanel, getSearchQuery, + highlightSelectionMatches, search, +} from '@codemirror/search'; + +import { classHighlighter } from '@lezer/highlight'; + +import { + EditorView, drawSelection, highlightSpecialChars, ViewUpdate, Command, +} from '@codemirror/view'; +import { history, undoDepth, redoDepth, standardKeymap } from '@codemirror/commands'; + +import { keymap, KeyBinding } from '@codemirror/view'; +import { searchKeymap } from '@codemirror/search'; +import { historyKeymap } from '@codemirror/commands'; + +import { SearchState, EditorProps, EditorSettings } from '../types'; +import { EditorEventType, SelectionRangeChangeEvent } from '../events'; +import { + decreaseIndent, increaseIndent, + toggleBolded, toggleCode, + toggleItalicized, toggleMath, +} from './markdown/markdownCommands'; +import decoratorExtension from './markdown/decoratorExtension'; +import computeSelectionFormatting from './markdown/computeSelectionFormatting'; +import { selectionFormattingEqual } from '../SelectionFormatting'; +import configFromSettings from './configFromSettings'; +import getScrollFraction from './getScrollFraction'; +import CodeMirrorControl from './CodeMirrorControl'; + +const createEditor = ( + parentElement: HTMLElement, props: EditorProps, +): CodeMirrorControl => { + const initialText = props.initialText; + let settings = props.settings; + + props.onLogMessage('Initializing CodeMirror...'); + + let searchVisible = false; + + // Handles firing an event when the undo/redo stack changes + let schedulePostUndoRedoDepthChangeId_: ReturnType|null = null; + let lastUndoDepth = 0; + let lastRedoDepth = 0; + const schedulePostUndoRedoDepthChange = (editor: EditorView, doItNow = false) => { + if (schedulePostUndoRedoDepthChangeId_ !== null) { + if (doItNow) { + clearTimeout(schedulePostUndoRedoDepthChangeId_); + } else { + return; + } + } + + schedulePostUndoRedoDepthChangeId_ = setTimeout(() => { + schedulePostUndoRedoDepthChangeId_ = null; + const newUndoDepth = undoDepth(editor.state); + const newRedoDepth = redoDepth(editor.state); + + if (newUndoDepth !== lastUndoDepth || newRedoDepth !== lastRedoDepth) { + props.onEvent({ + kind: EditorEventType.UndoRedoDepthChange, + undoDepth: newUndoDepth, + redoDepth: newRedoDepth, + }); + lastUndoDepth = newUndoDepth; + lastRedoDepth = newRedoDepth; + } + }, doItNow ? 0 : 1000); + }; + + let currentDocText = props.initialText; + const notifyDocChanged = (viewUpdate: ViewUpdate) => { + if (viewUpdate.docChanged) { + currentDocText = editor.state.doc.toString(); + props.onEvent({ + kind: EditorEventType.Change, + value: currentDocText, + }); + + schedulePostUndoRedoDepthChange(editor); + } + }; + + const notifyLinkEditRequest = () => { + props.onEvent({ + kind: EditorEventType.EditLink, + }); + }; + + const onSearchDialogUpdate = () => { + const query = getSearchQuery(editor.state); + const searchState: SearchState = { + searchText: query.search, + replaceText: query.replace, + useRegex: query.regexp, + caseSensitive: query.caseSensitive, + dialogVisible: searchVisible, + }; + props.onEvent({ + kind: EditorEventType.UpdateSearchDialog, + searchState, + }); + }; + + const showSearchDialog = () => { + if (!searchVisible) { + openSearchPanel(editor); + } + searchVisible = true; + onSearchDialogUpdate(); + }; + + const hideSearchDialog = () => { + if (searchVisible) { + closeSearchPanel(editor); + } + searchVisible = false; + onSearchDialogUpdate(); + }; + + const globalSpellcheckEnabled = () => { + return editor.contentDOM.spellcheck; + }; + + const notifySelectionChange = (viewUpdate: ViewUpdate) => { + if (!viewUpdate.state.selection.eq(viewUpdate.startState.selection)) { + const mainRange = viewUpdate.state.selection.main; + const event: SelectionRangeChangeEvent = { + kind: EditorEventType.SelectionRangeChange, + + anchor: mainRange.anchor, + head: mainRange.head, + from: mainRange.from, + to: mainRange.to, + }; + props.onEvent(event); + } + }; + + const notifySelectionFormattingChange = (viewUpdate?: ViewUpdate) => { + const spellcheck = globalSpellcheckEnabled(); + + // If we can't determine the previous formatting, post the update regardless + if (!viewUpdate) { + const formatting = computeSelectionFormatting(editor.state, spellcheck); + props.onEvent({ + kind: EditorEventType.SelectionFormattingChange, + formatting, + }); + } else if (viewUpdate.docChanged || !viewUpdate.state.selection.eq(viewUpdate.startState.selection)) { + + // Only post the update if something changed + const oldFormatting = computeSelectionFormatting(viewUpdate.startState, spellcheck); + const newFormatting = computeSelectionFormatting(viewUpdate.state, spellcheck); + + if (!selectionFormattingEqual(oldFormatting, newFormatting)) { + props.onEvent({ + kind: EditorEventType.SelectionFormattingChange, + formatting: newFormatting, + }); + } + } + }; + + // Returns a keyboard command that returns true (so accepts the keybind) + // alwaysActive: true if this command should be registered even if ignoreModifiers is given. + const keyCommand = (key: string, run: Command, alwaysActive?: boolean): KeyBinding => { + return { + key, + run: editor => { + if (settings.ignoreModifiers && !alwaysActive) return false; + + return run(editor); + }, + }; + }; + + const historyCompartment = new Compartment(); + const dynamicConfig = new Compartment(); + + const editor = new EditorView({ + state: EditorState.create({ + // See https://github.com/codemirror/basic-setup/blob/main/src/codemirror.ts + // for a sample configuration. + extensions: [ + dynamicConfig.of(configFromSettings(props.settings)), + historyCompartment.of(history()), + + search(settings.useExternalSearch ? { + createPanel(_: EditorView) { + return { + // The actual search dialog is implemented with react native, + // use a dummy element. + dom: document.createElement('div'), + mount() { + showSearchDialog(); + }, + destroy() { + hideSearchDialog(); + }, + }; + }, + } : undefined), + drawSelection(), + highlightSpecialChars(), + highlightSelectionMatches(), + indentOnInput(), + + EditorView.domEventHandlers({ + scroll: (_event, view) => { + props.onEvent({ + kind: EditorEventType.Scroll, + fraction: getScrollFraction(view), + }); + }, + }), + + EditorState.tabSize.of(4), + + // Apply styles to entire lines (block-display decorations) + decoratorExtension, + + // Adds additional CSS classes to tokens (the default CSS classes are + // auto-generated and thus unstable). + syntaxHighlighting(classHighlighter), + + EditorView.lineWrapping, + EditorView.updateListener.of((viewUpdate: ViewUpdate) => { + notifyDocChanged(viewUpdate); + notifySelectionChange(viewUpdate); + notifySelectionFormattingChange(viewUpdate); + }), + keymap.of([ + // Custom mod-f binding: Toggle the external dialog implementation + // (don't show/hide the Panel dialog). + keyCommand('Mod-f', (_: EditorView) => { + if (searchVisible) { + hideSearchDialog(); + } else { + showSearchDialog(); + } + return true; + }), + // Markdown formatting keyboard shortcuts + keyCommand('Mod-b', toggleBolded), + keyCommand('Mod-i', toggleItalicized), + keyCommand('Mod-$', toggleMath), + keyCommand('Mod-`', toggleCode), + keyCommand('Mod-[', decreaseIndent), + keyCommand('Mod-]', increaseIndent), + keyCommand('Mod-k', (_: EditorView) => { + notifyLinkEditRequest(); + return true; + }), + keyCommand('Tab', increaseIndent, true), + keyCommand('Shift-Tab', decreaseIndent, true), + + ...standardKeymap, ...historyKeymap, ...searchKeymap, + ]), + ], + doc: initialText, + }), + parent: parentElement, + }); + + const editorControls = new CodeMirrorControl(editor, { + onClearHistory: () => { + // Clear history by removing then re-add the history extension. + // Just re-adding the history extension isn't enough. + editor.dispatch({ + effects: historyCompartment.reconfigure([]), + }); + editor.dispatch({ + effects: historyCompartment.reconfigure(history()), + }); + }, + onSettingsChange: (newSettings: EditorSettings) => { + settings = newSettings; + editor.dispatch({ + effects: dynamicConfig.reconfigure( + configFromSettings(newSettings), + ), + }); + }, + onUndoRedo: () => { + // This callback is triggered when undo/redo is called + // directly. Show visual feedback immediately. + schedulePostUndoRedoDepthChange(editor, true); + }, + onLogMessage: props.onLogMessage, + onRemove: () => { + editor.destroy(); + }, + }); + + return editorControls; +}; + +export default createEditor; + + diff --git a/packages/editor/CodeMirror/editorCommands/editorCommands.ts b/packages/editor/CodeMirror/editorCommands/editorCommands.ts new file mode 100644 index 000000000..2d2997a4f --- /dev/null +++ b/packages/editor/CodeMirror/editorCommands/editorCommands.ts @@ -0,0 +1,69 @@ +import { EditorView } from '@codemirror/view'; +import { EditorCommandType, ListType } from '../../types'; +import { undo, redo, selectAll, indentSelection, cursorDocStart, cursorDocEnd, cursorLineStart, cursorLineEnd, deleteToLineStart, deleteToLineEnd, undoSelection, redoSelection, cursorPageDown, cursorPageUp, cursorCharRight, cursorCharLeft, insertNewlineAndIndent, cursorLineDown, cursorLineUp } from '@codemirror/commands'; +import { + decreaseIndent, increaseIndent, + toggleBolded, toggleCode, + toggleHeaderLevel, toggleItalicized, + toggleList, toggleMath, +} from '../markdown/markdownCommands'; +import swapLine, { SwapLineDirection } from './swapLine'; +import { closeSearchPanel, findNext, findPrevious, openSearchPanel, replaceAll, replaceNext } from '@codemirror/search'; + +type EditorCommandFunction = (editor: EditorView)=> void; + +const editorCommands: Record = { + [EditorCommandType.Undo]: undo, + [EditorCommandType.Redo]: redo, + [EditorCommandType.SelectAll]: selectAll, + [EditorCommandType.Focus]: editor => editor.focus(), + + [EditorCommandType.ToggleBolded]: toggleBolded, + [EditorCommandType.ToggleItalicized]: toggleItalicized, + [EditorCommandType.ToggleCode]: toggleCode, + [EditorCommandType.ToggleMath]: toggleMath, + [EditorCommandType.ToggleNumberedList]: toggleList(ListType.OrderedList), + [EditorCommandType.ToggleBulletedList]: toggleList(ListType.UnorderedList), + [EditorCommandType.ToggleCheckList]: toggleList(ListType.CheckList), + [EditorCommandType.ToggleHeading]: toggleHeaderLevel(2), + [EditorCommandType.ToggleHeading1]: toggleHeaderLevel(1), + [EditorCommandType.ToggleHeading2]: toggleHeaderLevel(2), + [EditorCommandType.ToggleHeading3]: toggleHeaderLevel(3), + [EditorCommandType.ToggleHeading4]: toggleHeaderLevel(4), + [EditorCommandType.ToggleHeading5]: toggleHeaderLevel(5), + + [EditorCommandType.ScrollSelectionIntoView]: editor => { + editor.dispatch(editor.state.update({ + scrollIntoView: true, + })); + }, + [EditorCommandType.DeleteToLineEnd]: deleteToLineEnd, + [EditorCommandType.DeleteToLineStart]: deleteToLineStart, + [EditorCommandType.IndentMore]: increaseIndent, + [EditorCommandType.IndentLess]: decreaseIndent, + [EditorCommandType.IndentAuto]: indentSelection, + [EditorCommandType.InsertNewlineAndIndent]: insertNewlineAndIndent, + [EditorCommandType.SwapLineUp]: swapLine(SwapLineDirection.Up), + [EditorCommandType.SwapLineDown]: swapLine(SwapLineDirection.Down), + [EditorCommandType.GoDocEnd]: cursorDocEnd, + [EditorCommandType.GoDocStart]: cursorDocStart, + [EditorCommandType.GoLineStart]: cursorLineStart, + [EditorCommandType.GoLineEnd]: cursorLineEnd, + [EditorCommandType.GoLineUp]: cursorLineUp, + [EditorCommandType.GoLineDown]: cursorLineDown, + [EditorCommandType.GoPageUp]: cursorPageUp, + [EditorCommandType.GoPageDown]: cursorPageDown, + [EditorCommandType.GoCharLeft]: cursorCharLeft, + [EditorCommandType.GoCharRight]: cursorCharRight, + [EditorCommandType.UndoSelection]: undoSelection, + [EditorCommandType.RedoSelection]: redoSelection, + + [EditorCommandType.ShowSearch]: openSearchPanel, + [EditorCommandType.HideSearch]: closeSearchPanel, + [EditorCommandType.FindNext]: findNext, + [EditorCommandType.FindPrevious]: findPrevious, + [EditorCommandType.ReplaceNext]: replaceNext, + [EditorCommandType.ReplaceAll]: replaceAll, +}; +export default editorCommands; + diff --git a/packages/editor/CodeMirror/editorCommands/supportsCommand.ts b/packages/editor/CodeMirror/editorCommands/supportsCommand.ts new file mode 100644 index 000000000..ef1393444 --- /dev/null +++ b/packages/editor/CodeMirror/editorCommands/supportsCommand.ts @@ -0,0 +1,11 @@ + +import { EditorCommandType } from '../../types'; + +// The CodeMirror 6 editor supports all EditorCommandTypes. +// Note that this file is separate from editorCommands.ts to allow importing it in +// non-browser contexts. +const supportsCommand = (commandName: EditorCommandType) => { + return Object.values(EditorCommandType).includes(commandName); +}; + +export default supportsCommand; diff --git a/packages/editor/CodeMirror/editorCommands/swapLine.ts b/packages/editor/CodeMirror/editorCommands/swapLine.ts new file mode 100644 index 000000000..c6fc21bae --- /dev/null +++ b/packages/editor/CodeMirror/editorCommands/swapLine.ts @@ -0,0 +1,49 @@ +import { EditorSelection } from '@codemirror/state'; +import { Command, EditorView } from '@codemirror/view'; + +export enum SwapLineDirection { + Up = -1, + Down = 1, +} + +const swapLine = (direction: SwapLineDirection): Command => (editor: EditorView) => { + const state = editor.state; + const doc = state.doc; + + const transaction = state.changeByRange(range => { + const currentLine = doc.lineAt(range.anchor); + const otherLineNumber = currentLine.number + direction; + + // Out of range? No changes. + if (otherLineNumber <= 0 || otherLineNumber > doc.lines) { + return { range }; + } + + const otherLine = doc.line(otherLineNumber); + + let deltaPos; + if (direction === SwapLineDirection.Down) { + // +1: include newline + deltaPos = otherLine.length + 1; + } else { + deltaPos = otherLine.from - currentLine.from; + } + + return { + range: EditorSelection.range(range.anchor + deltaPos, range.head + deltaPos), + changes: [{ + from: currentLine.from, + to: currentLine.to, + insert: otherLine.text, + }, { + from: otherLine.from, + to: otherLine.to, + insert: currentLine.text, + }], + }; + }); + + editor.dispatch(transaction); + return true; +}; +export default swapLine; diff --git a/packages/editor/CodeMirror/getScrollFraction.ts b/packages/editor/CodeMirror/getScrollFraction.ts new file mode 100644 index 000000000..5c93ec154 --- /dev/null +++ b/packages/editor/CodeMirror/getScrollFraction.ts @@ -0,0 +1,11 @@ +import { EditorView } from '@codemirror/view'; + + +const getScrollFraction = (view: EditorView) => { + const maxScroll = view.scrollDOM.scrollHeight - view.scrollDOM.clientHeight; + + // Prevent division by zero + return maxScroll > 0 ? view.scrollDOM.scrollTop / maxScroll : 0; +}; + +export default getScrollFraction; diff --git a/packages/editor/CodeMirror/markdown/computeSelectionFormatting.ts b/packages/editor/CodeMirror/markdown/computeSelectionFormatting.ts new file mode 100644 index 000000000..bfd7c7430 --- /dev/null +++ b/packages/editor/CodeMirror/markdown/computeSelectionFormatting.ts @@ -0,0 +1,123 @@ +import SelectionFormatting, { MutableSelectionFormatting, defaultSelectionFormatting } from '../../SelectionFormatting'; +import { syntaxTree } from '@codemirror/language'; +import { EditorState } from '@codemirror/state'; + +const computeSelectionFormatting = (state: EditorState, globalSpellcheck: boolean): SelectionFormatting => { + const range = state.selection.main; + const formatting: MutableSelectionFormatting = { + ...defaultSelectionFormatting, + selectedText: state.doc.sliceString(range.from, range.to), + spellChecking: globalSpellcheck, + }; + + const parseLinkData = (nodeText: string) => { + const linkMatch = nodeText.match(/\[([^\]]*)\]\(([^)]*)\)/); + + if (linkMatch) { + return { + linkText: linkMatch[1], + linkURL: linkMatch[2], + }; + } + + return null; + }; + + // Find nodes that overlap/are within the selected region + syntaxTree(state).iterate({ + from: range.from, to: range.to, + enter: node => { + // Checklists don't have a specific containing node. As such, + // we're in a checklist if we've selected a 'Task' node. + if (node.name === 'Task') { + formatting.inChecklist = true; + } + + // Only handle notes that contain the entire range. + if (node.from > range.from || node.to < range.to) { + return; + } + // Lazily compute the node's text + const nodeText = () => state.doc.sliceString(node.from, node.to); + + switch (node.name) { + case 'StrongEmphasis': + formatting.bolded = true; + break; + case 'Emphasis': + formatting.italicized = true; + break; + case 'ListItem': + formatting.listLevel += 1; + break; + case 'BulletList': + formatting.inUnorderedList = true; + break; + case 'OrderedList': + formatting.inOrderedList = true; + break; + case 'TaskList': + formatting.inChecklist = true; + break; + case 'InlineCode': + case 'FencedCode': + formatting.inCode = true; + formatting.unspellCheckableRegion = true; + break; + case 'InlineMath': + case 'BlockMath': + formatting.inMath = true; + formatting.unspellCheckableRegion = true; + break; + case 'ATXHeading1': + formatting.headerLevel = 1; + break; + case 'ATXHeading2': + formatting.headerLevel = 2; + break; + case 'ATXHeading3': + formatting.headerLevel = 3; + break; + case 'ATXHeading4': + formatting.headerLevel = 4; + break; + case 'ATXHeading5': + formatting.headerLevel = 5; + break; + case 'URL': + formatting.inLink = true; + formatting.linkData = { + ...formatting.linkData, + linkURL: nodeText(), + }; + formatting.unspellCheckableRegion = true; + break; + case 'Link': + formatting.inLink = true; + formatting.linkData = parseLinkData(nodeText()); + break; + } + }, + }); + + // The markdown parser marks checklists as unordered lists. Ensure + // that they aren't marked as such. + if (formatting.inChecklist) { + if (!formatting.inUnorderedList) { + // Even if the selection contains a Task, because an unordered list node + // must contain a valid Task node, we're only in a checklist if we're also in + // an unordered list. + formatting.inChecklist = false; + } else { + formatting.inUnorderedList = false; + } + } + + if (formatting.unspellCheckableRegion) { + formatting.spellChecking = false; + } + + return formatting; +}; +export default computeSelectionFormatting; + diff --git a/packages/app-mobile/components/NoteEditor/CodeMirror/decoratorExtension.ts b/packages/editor/CodeMirror/markdown/decoratorExtension.ts similarity index 79% rename from packages/app-mobile/components/NoteEditor/CodeMirror/decoratorExtension.ts rename to packages/editor/CodeMirror/markdown/decoratorExtension.ts index c0e8c70b6..558de6dab 100644 --- a/packages/app-mobile/components/NoteEditor/CodeMirror/decoratorExtension.ts +++ b/packages/editor/CodeMirror/markdown/decoratorExtension.ts @@ -40,6 +40,10 @@ const urlDecoration = Decoration.mark({ attributes: { class: 'cm-url', ...noSpellCheckAttrs }, }); +const htmlTagNameDecoration = Decoration.mark({ + attributes: { class: 'cm-htmlTag', ...noSpellCheckAttrs }, +}); + const blockQuoteDecoration = Decoration.line({ attributes: { class: 'cm-blockQuote' }, }); @@ -48,6 +52,26 @@ const headerLineDecoration = Decoration.line({ attributes: { class: 'cm-headerLine' }, }); +const tableHeaderDecoration = Decoration.line({ + attributes: { class: 'cm-tableHeader' }, +}); + +const tableBodyDecoration = Decoration.line({ + attributes: { class: 'cm-tableRow' }, +}); + +const tableDelimiterDecoration = Decoration.line({ + attributes: { class: 'cm-tableDelimiter' }, +}); + +const horizontalRuleDecoration = Decoration.mark({ + attributes: { class: 'cm-hr' }, +}); + +const taskMarkerDecoration = Decoration.mark({ + attributes: { class: 'cm-taskMarker' }, +}); + type DecorationDescription = { pos: number; length?: number; decoration: Decoration }; // Returns a set of [Decoration]s, associated with block syntax groups that require @@ -125,6 +149,25 @@ const computeDecorations = (view: EditorView) => { case 'ATXHeading6': addDecorationToLines(viewFrom, viewTo, headerLineDecoration); break; + case 'HTMLTag': + case 'TagName': + addDecorationToRange(viewFrom, viewTo, htmlTagNameDecoration); + break; + case 'TableHeader': + addDecorationToLines(viewFrom, viewTo, tableHeaderDecoration); + break; + case 'TableDelimiter': + addDecorationToLines(viewFrom, viewTo, tableDelimiterDecoration); + break; + case 'TableRow': + addDecorationToLines(viewFrom, viewTo, tableBodyDecoration); + break; + case 'HorizontalRule': + addDecorationToRange(viewFrom, viewTo, horizontalRuleDecoration); + break; + case 'TaskMarker': + addDecorationToRange(viewFrom, viewTo, taskMarkerDecoration); + break; } // Only block decorations will have differing first and last lines diff --git a/packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.bulletedVsChecklist.test.ts b/packages/editor/CodeMirror/markdown/markdownCommands.bulletedVsChecklist.test.ts similarity index 87% rename from packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.bulletedVsChecklist.test.ts rename to packages/editor/CodeMirror/markdown/markdownCommands.bulletedVsChecklist.test.ts index f3d5d0b9b..5da09a5c5 100644 --- a/packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.bulletedVsChecklist.test.ts +++ b/packages/editor/CodeMirror/markdown/markdownCommands.bulletedVsChecklist.test.ts @@ -1,6 +1,6 @@ import { EditorSelection } from '@codemirror/state'; -import { ListType } from '../types'; -import createEditor from './testUtil/createEditor'; +import { ListType } from '../../types'; +import createTestEditor from '../testUtil/createTestEditor'; import { toggleList } from './markdownCommands'; describe('markdownCommands.bulletedVsChecklist', () => { @@ -13,7 +13,7 @@ describe('markdownCommands.bulletedVsChecklist', () => { const expectedTags = ['BulletList', 'Task']; it('should remove a checklist following a bulleted list without modifying the bulleted list', async () => { - const editor = await createEditor( + const editor = await createTestEditor( initialDocText, EditorSelection.cursor(bulletedListPart.length + 5), expectedTags, ); @@ -24,7 +24,7 @@ describe('markdownCommands.bulletedVsChecklist', () => { }); it('should remove an unordered list following a checklist without modifying the checklist', async () => { - const editor = await createEditor( + const editor = await createTestEditor( initialDocText, EditorSelection.cursor(bulletedListPart.length - 5), expectedTags, ); @@ -35,7 +35,7 @@ describe('markdownCommands.bulletedVsChecklist', () => { }); it('should replace a selection of unordered and task lists with a correctly-numbered list', async () => { - const editor = await createEditor( + const editor = await createTestEditor( initialDocText, EditorSelection.range(0, initialDocText.length), expectedTags, ); diff --git a/packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.test.ts b/packages/editor/CodeMirror/markdown/markdownCommands.test.ts similarity index 87% rename from packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.test.ts rename to packages/editor/CodeMirror/markdown/markdownCommands.test.ts index 2bc58a614..4d4f5e206 100644 --- a/packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.test.ts +++ b/packages/editor/CodeMirror/markdown/markdownCommands.test.ts @@ -2,7 +2,7 @@ import { EditorSelection } from '@codemirror/state'; import { toggleBolded, toggleCode, toggleHeaderLevel, toggleItalicized, toggleMath, updateLink, } from './markdownCommands'; -import createEditor from './testUtil/createEditor'; +import createTestEditor from '../testUtil/createTestEditor'; import { blockMathTagName } from './markdownMathParser'; describe('markdownCommands', () => { @@ -11,7 +11,7 @@ describe('markdownCommands', () => { it('should bold/italicize everything selected', async () => { const initialDocText = 'Testing...'; - const editor = await createEditor( + const editor = await createTestEditor( initialDocText, EditorSelection.range(0, initialDocText.length), [], ); @@ -38,7 +38,7 @@ describe('markdownCommands', () => { it('for a cursor, bolding, then italicizing, should produce a bold-italic region', async () => { const initialDocText = ''; - const editor = await createEditor( + const editor = await createTestEditor( initialDocText, EditorSelection.cursor(0), [], ); @@ -56,7 +56,7 @@ describe('markdownCommands', () => { it('toggling math should both create and navigate out of math regions', async () => { const initialDocText = 'Testing... '; - const editor = await createEditor(initialDocText, EditorSelection.cursor(initialDocText.length), []); + const editor = await createTestEditor(initialDocText, EditorSelection.cursor(initialDocText.length), []); toggleMath(editor); expect(editor.state.doc.toString()).toBe('Testing... $$'); @@ -72,7 +72,7 @@ describe('markdownCommands', () => { it('toggling inline code should both create and navigate out of an inline code region', async () => { const initialDocText = 'Testing...\n\n'; - const editor = await createEditor(initialDocText, EditorSelection.cursor(initialDocText.length), []); + const editor = await createTestEditor(initialDocText, EditorSelection.cursor(initialDocText.length), []); toggleCode(editor); editor.dispatch(editor.state.replaceSelection('f(x) = ...')); @@ -84,7 +84,7 @@ describe('markdownCommands', () => { it('should set headers to the proper levels (when toggling)', async () => { const initialDocText = 'Testing...\nThis is a test.'; - const editor = await createEditor(initialDocText, EditorSelection.cursor(3), []); + const editor = await createTestEditor(initialDocText, EditorSelection.cursor(3), []); toggleHeaderLevel(1)(editor); @@ -110,7 +110,7 @@ describe('markdownCommands', () => { it('headers should toggle properly within block quotes', async () => { const initialDocText = 'Testing...\n\n> This is a test.\n> ...a test'; - const editor = await createEditor( + const editor = await createTestEditor( initialDocText, EditorSelection.cursor('Testing...\n\n> This'.length), ['Blockquote'], @@ -134,7 +134,7 @@ describe('markdownCommands', () => { it('block math should be created correctly within block quotes', async () => { const initialDocText = 'Testing...\n\n> This is a test.\n> y = mx + b\n> ...a test'; - const editor = await createEditor( + const editor = await createTestEditor( initialDocText, EditorSelection.range( 'Testing...\n\n> This'.length, @@ -157,7 +157,7 @@ describe('markdownCommands', () => { it('block math should be correctly removed within block quotes', async () => { const initialDocText = 'Testing...\n\n> $$\n> This is a test.\n> y = mx + b\n> $$\n> ...a test'; - const editor = await createEditor( + const editor = await createTestEditor( initialDocText, EditorSelection.cursor('Testing...\n\n> $$\n> This is'.length), ['Blockquote', blockMathTagName], @@ -173,7 +173,7 @@ describe('markdownCommands', () => { it('updateLink should replace link titles and isolate URLs if no title is given', async () => { const initialDocText = '[foo](http://example.com/)'; - const editor = await createEditor(initialDocText, EditorSelection.cursor('[f'.length), ['Link']); + const editor = await createTestEditor(initialDocText, EditorSelection.cursor('[f'.length), ['Link']); updateLink('bar', 'https://example.com/')(editor); expect(editor.state.doc.toString()).toBe( @@ -188,7 +188,7 @@ describe('markdownCommands', () => { it('toggling math twice, starting on a line with content, should a math block', async () => { const initialDocText = 'Testing... '; - const editor = await createEditor(initialDocText, EditorSelection.cursor(initialDocText.length), []); + const editor = await createTestEditor(initialDocText, EditorSelection.cursor(initialDocText.length), []); toggleMath(editor); toggleMath(editor); @@ -198,7 +198,7 @@ describe('markdownCommands', () => { it('toggling math twice on an empty line should create an empty math block', async () => { const initialDocText = 'Testing...\n\n'; - const editor = await createEditor(initialDocText, EditorSelection.cursor(initialDocText.length), []); + const editor = await createTestEditor(initialDocText, EditorSelection.cursor(initialDocText.length), []); toggleMath(editor); toggleMath(editor); @@ -208,7 +208,7 @@ describe('markdownCommands', () => { it('toggling code twice on an empty line should create an empty code block', async () => { const initialDocText = 'Testing...\n\n'; - const editor = await createEditor(initialDocText, EditorSelection.cursor(initialDocText.length), []); + const editor = await createTestEditor(initialDocText, EditorSelection.cursor(initialDocText.length), []); // Toggling code twice should create a block code region toggleCode(editor); @@ -222,7 +222,7 @@ describe('markdownCommands', () => { it('toggling math twice inside a block quote should produce an empty math block', async () => { const initialDocText = '> Testing...> \n> '; - const editor = await createEditor(initialDocText, EditorSelection.cursor(initialDocText.length), ['Blockquote']); + const editor = await createTestEditor(initialDocText, EditorSelection.cursor(initialDocText.length), ['Blockquote']); toggleMath(editor); toggleMath(editor); diff --git a/packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.toggleList.test.ts b/packages/editor/CodeMirror/markdown/markdownCommands.toggleList.test.ts similarity index 95% rename from packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.toggleList.test.ts rename to packages/editor/CodeMirror/markdown/markdownCommands.toggleList.test.ts index 09fe6b241..ae26ed26c 100644 --- a/packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.toggleList.test.ts +++ b/packages/editor/CodeMirror/markdown/markdownCommands.toggleList.test.ts @@ -2,8 +2,8 @@ import { EditorSelection, EditorState } from '@codemirror/state'; import { increaseIndent, toggleList, } from './markdownCommands'; -import { ListType } from '../types'; -import createEditor from './testUtil/createEditor'; +import { ListType } from '../../types'; +import createTestEditor from '../testUtil/createTestEditor'; describe('markdownCommands.toggleList', () => { @@ -12,7 +12,7 @@ describe('markdownCommands.toggleList', () => { it('should remove the same type of list', async () => { const initialDocText = '- testing\n- this is a `test`\n'; - const editor = await createEditor( + const editor = await createTestEditor( initialDocText, EditorSelection.cursor(5), ['BulletList', 'InlineCode'], @@ -26,7 +26,7 @@ describe('markdownCommands.toggleList', () => { it('should insert a numbered list with correct numbering', async () => { const initialDocText = 'Testing...\nThis is a test\nof list toggling...'; - const editor = await createEditor( + const editor = await createTestEditor( initialDocText, EditorSelection.cursor('Testing...\nThis is a'.length), [], @@ -51,7 +51,7 @@ describe('markdownCommands.toggleList', () => { const unorderedListText = '- 1\n- 2\n- 3\n- 4\n- 5\n- 6\n- 7'; it('should correctly replace an unordered list with a numbered list', async () => { - const editor = await createEditor( + const editor = await createTestEditor( unorderedListText, EditorSelection.cursor(unorderedListText.length), ['BulletList'], @@ -154,7 +154,7 @@ describe('markdownCommands.toggleList', () => { it('should toggle a numbered list without changing its sublists', async () => { const initialDocText = '1. Foo\n2. Bar\n3. Baz\n\t- Test\n\t- of\n\t- sublists\n4. Foo'; - const editor = await createEditor( + const editor = await createTestEditor( initialDocText, EditorSelection.cursor(0), ['OrderedList', 'BulletList'], @@ -169,7 +169,7 @@ describe('markdownCommands.toggleList', () => { it('should toggle a sublist without changing the parent list', async () => { const initialDocText = '1. This\n2. is\n3. '; - const editor = await createEditor( + const editor = await createTestEditor( initialDocText, EditorSelection.cursor(initialDocText.length), ['OrderedList'], @@ -192,7 +192,7 @@ describe('markdownCommands.toggleList', () => { it('should toggle lists properly within block quotes', async () => { const preSubListText = '> # List test\n> * This\n> * is\n'; const initialDocText = `${preSubListText}> \t* a\n> \t* test\n> * of list toggling`; - const editor = await createEditor( + const editor = await createTestEditor( initialDocText, EditorSelection.cursor(preSubListText.length + 3), ['BlockQuote', 'BulletList'], ); diff --git a/packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.ts b/packages/editor/CodeMirror/markdown/markdownCommands.ts similarity index 99% rename from packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.ts rename to packages/editor/CodeMirror/markdown/markdownCommands.ts index 7f3c8edb8..c4c3217fd 100644 --- a/packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.ts +++ b/packages/editor/CodeMirror/markdown/markdownCommands.ts @@ -2,7 +2,7 @@ import { EditorView, Command } from '@codemirror/view'; -import { ListType } from '../types'; +import { ListType } from '../../types'; import { SelectionRange, EditorSelection, ChangeSpec, Line, TransactionSpec, } from '@codemirror/state'; diff --git a/packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.test.ts b/packages/editor/CodeMirror/markdown/markdownMathParser.test.ts similarity index 97% rename from packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.test.ts rename to packages/editor/CodeMirror/markdown/markdownMathParser.test.ts index a0822d4d3..9d4473a25 100644 --- a/packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.test.ts +++ b/packages/editor/CodeMirror/markdown/markdownMathParser.test.ts @@ -3,11 +3,11 @@ import { SyntaxNode } from '@lezer/common'; import { EditorSelection, EditorState } from '@codemirror/state'; import { blockMathTagName, inlineMathContentTagName, inlineMathTagName } from './markdownMathParser'; -import createEditor from './testUtil/createEditor'; +import createTestEditor from '../testUtil/createTestEditor'; // Creates an EditorState with math and markdown extensions const createEditorState = async (initialText: string, expectedTags: string[]): Promise => { - return (await createEditor(initialText, EditorSelection.cursor(0), expectedTags)).state; + return (await createTestEditor(initialText, EditorSelection.cursor(0), expectedTags)).state; }; // Returns a list of all nodes with the given name in the given editor's syntax tree. diff --git a/packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.ts b/packages/editor/CodeMirror/markdown/markdownMathParser.ts similarity index 100% rename from packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.ts rename to packages/editor/CodeMirror/markdown/markdownMathParser.ts diff --git a/packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.test.ts b/packages/editor/CodeMirror/markdown/markdownReformatter.test.ts similarity index 100% rename from packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.test.ts rename to packages/editor/CodeMirror/markdown/markdownReformatter.test.ts diff --git a/packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.ts b/packages/editor/CodeMirror/markdown/markdownReformatter.ts similarity index 100% rename from packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.ts rename to packages/editor/CodeMirror/markdown/markdownReformatter.ts diff --git a/packages/app-mobile/components/NoteEditor/CodeMirror/syntaxHighlightingLanguages.ts b/packages/editor/CodeMirror/markdown/syntaxHighlightingLanguages.ts similarity index 100% rename from packages/app-mobile/components/NoteEditor/CodeMirror/syntaxHighlightingLanguages.ts rename to packages/editor/CodeMirror/markdown/syntaxHighlightingLanguages.ts diff --git a/packages/editor/CodeMirror/testUtil/createEditorSettings.ts b/packages/editor/CodeMirror/testUtil/createEditorSettings.ts new file mode 100644 index 000000000..344564b4e --- /dev/null +++ b/packages/editor/CodeMirror/testUtil/createEditorSettings.ts @@ -0,0 +1,24 @@ +import { themeStyle } from '@joplin/lib/theme'; +import { EditorKeymap, EditorLanguageType, EditorSettings } from '../../types'; + +const createEditorSettings = (themeId: number) => { + const themeData = themeStyle(themeId); + const editorSettings: EditorSettings = { + katexEnabled: true, + spellcheckEnabled: true, + useExternalSearch: true, + readOnly: false, + automatchBraces: false, + ignoreModifiers: false, + + keymap: EditorKeymap.Default, + language: EditorLanguageType.Markdown, + themeData, + + indentWithTabs: true, + }; + + return editorSettings; +}; + +export default createEditorSettings; diff --git a/packages/app-mobile/components/NoteEditor/CodeMirror/testUtil/createEditor.ts b/packages/editor/CodeMirror/testUtil/createTestEditor.ts similarity index 93% rename from packages/app-mobile/components/NoteEditor/CodeMirror/testUtil/createEditor.ts rename to packages/editor/CodeMirror/testUtil/createTestEditor.ts index 856a16b15..c3917c893 100644 --- a/packages/app-mobile/components/NoteEditor/CodeMirror/testUtil/createEditor.ts +++ b/packages/editor/CodeMirror/testUtil/createTestEditor.ts @@ -3,13 +3,13 @@ import { GFM as GithubFlavoredMarkdownExt } from '@lezer/markdown'; import { indentUnit, syntaxTree } from '@codemirror/language'; import { SelectionRange, EditorSelection, EditorState } from '@codemirror/state'; import { EditorView } from '@codemirror/view'; -import { MarkdownMathExtension } from '../markdownMathParser'; +import { MarkdownMathExtension } from '../markdown/markdownMathParser'; import forceFullParse from './forceFullParse'; import loadLangauges from './loadLanguages'; // Creates and returns a minimal editor with markdown extensions. Waits to return the editor // until all syntax tree tags in `expectedSyntaxTreeTags` exist. -const createEditor = async ( +const createTestEditor = async ( initialText: string, initialSelection: SelectionRange, expectedSyntaxTreeTags: string[], ): Promise => { await loadLangauges(); @@ -62,4 +62,4 @@ const createEditor = async ( return editor; }; -export default createEditor; +export default createTestEditor; diff --git a/packages/app-mobile/components/NoteEditor/CodeMirror/testUtil/forceFullParse.ts b/packages/editor/CodeMirror/testUtil/forceFullParse.ts similarity index 100% rename from packages/app-mobile/components/NoteEditor/CodeMirror/testUtil/forceFullParse.ts rename to packages/editor/CodeMirror/testUtil/forceFullParse.ts diff --git a/packages/app-mobile/components/NoteEditor/CodeMirror/testUtil/loadLanguages.ts b/packages/editor/CodeMirror/testUtil/loadLanguages.ts similarity index 79% rename from packages/app-mobile/components/NoteEditor/CodeMirror/testUtil/loadLanguages.ts rename to packages/editor/CodeMirror/testUtil/loadLanguages.ts index 910bf3bf1..54a84d7c1 100644 --- a/packages/app-mobile/components/NoteEditor/CodeMirror/testUtil/loadLanguages.ts +++ b/packages/editor/CodeMirror/testUtil/loadLanguages.ts @@ -1,4 +1,4 @@ -import syntaxHighlightingLanguages from '../syntaxHighlightingLanguages'; +import syntaxHighlightingLanguages from '../markdown/syntaxHighlightingLanguages'; // Ensure languages we use are loaded. Without this, tests may randomly fail (LanguageDescriptions // are loaded asyncronously, in the background). diff --git a/packages/app-mobile/components/NoteEditor/CodeMirror/theme.ts b/packages/editor/CodeMirror/theme.ts similarity index 72% rename from packages/app-mobile/components/NoteEditor/CodeMirror/theme.ts rename to packages/editor/CodeMirror/theme.ts index 98fdd476b..ee9007572 100644 --- a/packages/app-mobile/components/NoteEditor/CodeMirror/theme.ts +++ b/packages/editor/CodeMirror/theme.ts @@ -8,7 +8,7 @@ import { tags } from '@lezer/highlight'; import { EditorView } from '@codemirror/view'; import { Extension } from '@codemirror/state'; -import { inlineMathTag, mathTag } from './markdownMathParser'; +import { inlineMathTag, mathTag } from './markdown/markdownMathParser'; // For an example on how to customize the theme, see: // @@ -25,6 +25,12 @@ import { inlineMathTag, mathTag } from './markdownMathParser'; // // [theme] should be a joplin theme (see @joplin/lib/theme) const createTheme = (theme: any): Extension[] => { + // If the theme hasn't loaded yet, return nothing. + // (createTheme should be called again after the theme has loaded). + if (!theme) { + return []; + } + const isDarkTheme = theme.appearance === 'dark'; const baseGlobalStyle: Record = { @@ -34,15 +40,19 @@ const createTheme = (theme: any): Extension[] => { // On iOS, apply system font scaling (e.g. font scaling // set in accessibility settings). font: '-apple-system-body', + + // Fill container horizontally + width: '100%', + boxSizing: 'border-box', }; const baseCursorStyle: Record = { }; const baseContentStyle: Record = { fontFamily: theme.fontFamily, + fontSize: `${theme.fontSize}${theme.fontSizeUnits ?? 'px'}`, - // To allow accessibility font scaling, we also need to set the - // fontSize to a value in `em`s (relative scaling relative to - // parent font size). - fontSize: `${theme.fontSize}em`, + // Avoid using units here -- 1.55em, for example, can cause lines to overlap + // if some lines contain text with a large enough font size. + lineHeight: theme.isDesktop ? '1.55' : undefined, }; const baseSelectionStyle: Record = { }; const blurredSelectionStyle: Record = { }; @@ -60,17 +70,42 @@ const createTheme = (theme: any): Extension[] => { blurredSelectionStyle.backgroundColor = '#444'; } - const baseTheme = EditorView.baseTheme({ + const monospaceStyle = { + fontFamily: theme.monospaceFont || 'monospace', + }; + + // This is equivalent to the default selection style -- our styling must + // be at least this specific. + const selectionBackgroundSelector = '&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground'; + + const codeMirrorTheme = EditorView.theme({ '&': baseGlobalStyle, // These must be !important or more specific than CodeMirror's built-ins '.cm-content': { fontFamily: theme.fontFamily, ...baseContentStyle, + paddingBottom: theme.isDesktop ? '400px' : undefined, }, '&.cm-focused .cm-cursor': baseCursorStyle, - '&.cm-focused .cm-selectionBackground, ::selection': baseSelectionStyle, - '.cm-selectionBackground': blurredSelectionStyle, + + // The desktop app sets the font for these elements to a specific font. + // Override this. + '& div, & span, & a': { + fontFamily: 'inherit', + }, + + // Override the default border around CodeMirror panels + '& > .cm-panels': { + border: 'none', + }, + + // &.cm-focused is used to give these styles higher specificity + // than the defaults. + [selectionBackgroundSelector]: baseSelectionStyle, + '&.cm-focused ::selection': baseSelectionStyle, + '& ::selection': blurredSelectionStyle, + '& .cm-selectionLayer .cm-selectionBackground': blurredSelectionStyle, '&.cm-editor.cm-focused': { outline: 'none !important', @@ -101,6 +136,8 @@ const createTheme = (theme: any): Extension[] => { borderStyle: 'solid', borderColor: theme.colorFaded, backgroundColor: 'rgba(155, 155, 155, 0.1)', + + ...(theme.isDesktop ? monospaceStyle : {}), }, // CodeMirror wraps the existing inline span in an additional element. @@ -113,12 +150,21 @@ const createTheme = (theme: any): Extension[] => { borderStyle: 'solid', borderColor: isDarkTheme ? 'rgba(200, 200, 200, 0.5)' : 'rgba(100, 100, 100, 0.5)', borderRadius: '4px', + + ...(theme.isDesktop ? monospaceStyle : {}), }, '& .cm-mathBlock, & .cm-inlineMath': { color: isDarkTheme ? '#9fa' : '#276', }, + '& .cm-tableHeader, & .cm-tableRow, & .cm-tableDelimiter': monospaceStyle, + '& .cm-taskMarker': monospaceStyle, + + // Override the default URL style when the URL is within a link + '& .tok-url.tok-link, & .tok-link.tok-meta, & .tok-link.tok-string': { + opacity: theme.isDesktop ? 0.6 : 1, + }, // Style the search widget. Use ':root' to increase the selector's precedence // (override the existing preset styles). @@ -128,9 +174,7 @@ const createTheme = (theme: any): Extension[] => { color: isDarkTheme ? 'white' : 'black', }, }, - }); - - const appearanceTheme = EditorView.theme({}, { dark: isDarkTheme }); + }, { dark: isDarkTheme }); const baseHeadingStyle = { fontWeight: 'bold', @@ -150,7 +194,6 @@ const createTheme = (theme: any): Extension[] => { ...baseHeadingStyle, tag: tags.heading1, fontSize: '1.6em', - borderBottom: `1px solid ${theme.dividerColor}`, }, { ...baseHeadingStyle, @@ -189,7 +232,7 @@ const createTheme = (theme: any): Extension[] => { { tag: tags.link, color: theme.urlColor, - textDecoration: 'underline', + textDecoration: theme.isDesktop ? undefined : 'underline', }, { tag: [mathTag, inlineMathTag], @@ -220,8 +263,7 @@ const createTheme = (theme: any): Extension[] => { ]); return [ - baseTheme, - appearanceTheme, + codeMirrorTheme, syntaxHighlighting(highlightingStyle), // If we haven't defined highlighting for tags, fall back diff --git a/packages/editor/SelectionFormatting.ts b/packages/editor/SelectionFormatting.ts new file mode 100644 index 000000000..ca78ea218 --- /dev/null +++ b/packages/editor/SelectionFormatting.ts @@ -0,0 +1,73 @@ +// Stores information about the current content of the user's selection +export interface MutableSelectionFormatting { + bolded: boolean; + italicized: boolean; + inChecklist: boolean; + inCode: boolean; + inUnorderedList: boolean; + inOrderedList: boolean; + inMath: boolean; + inLink: boolean; + spellChecking: boolean; + unspellCheckableRegion: boolean; + + // Link data, both fields are null if not in a link. + linkData: { + readonly linkText: string|null; + readonly linkURL: string|null; + }; + + // If [headerLevel], [listLevel], etc. are zero, then the + // selection isn't in a header/list + headerLevel: number; + listLevel: number; + + // Content of the selection + selectedText: string; +} +type SelectionFormatting = Readonly; +export default SelectionFormatting; + +export const defaultSelectionFormatting: SelectionFormatting = { + bolded: false, + italicized: false, + inChecklist: false, + inCode: false, + inUnorderedList: false, + inOrderedList: false, + inMath: false, + inLink: false, + spellChecking: false, + unspellCheckableRegion: false, + + linkData: { + linkText: null, + linkURL: null, + }, + + headerLevel: 0, + listLevel: 0, + + selectedText: '', +}; + +export const selectionFormattingEqual = (a: SelectionFormatting, b: SelectionFormatting): boolean => { + // Get keys from the default so that only SelectionFormatting key/value pairs are + // considered. If a and/or b inherit from SelectionFormatting, we want to ignore + // keys added by child interfaces. + const keys = Object.keys(defaultSelectionFormatting) as (keyof SelectionFormatting)[]; + + for (const key of keys) { + if (key === 'linkData') { + // A deeper check is required for linkData + if (a[key].linkText !== b[key].linkText || a[key].linkURL !== b[key].linkURL) { + return false; + } + } else if (a[key] !== b[key]) { + return false; + } + } + + return true; +}; + diff --git a/packages/editor/events.ts b/packages/editor/events.ts new file mode 100644 index 000000000..05485f054 --- /dev/null +++ b/packages/editor/events.ts @@ -0,0 +1,65 @@ +import type SelectionFormatting from './SelectionFormatting'; +import type { SearchState } from './types'; + +export enum EditorEventType { + Change, + UndoRedoDepthChange, + SelectionRangeChange, + SelectionFormattingChange, + UpdateSearchDialog, + EditLink, + Scroll, +} + +export interface ChangeEvent { + kind: EditorEventType.Change; + + // New editor content + value: string; +} + +export interface UndoRedoDepthChangeEvent { + kind: EditorEventType.UndoRedoDepthChange; + + undoDepth: number; + redoDepth: number; +} + +export interface SelectionRangeChangeEvent { + kind: EditorEventType.SelectionRangeChange; + + anchor: number; + head: number; + + from: number; + to: number; +} + +export interface SelectionFormattingChangeEvent { + kind: EditorEventType.SelectionFormattingChange; + formatting: SelectionFormatting; +} + +export interface EditorScrolledEvent { + kind: EditorEventType.Scroll; + + // A fraction from 0 to 1, where 1 corresponds to the end of the document + fraction: number; +} + +export interface UpdateSearchDialogEvent { + kind: EditorEventType.UpdateSearchDialog; + searchState: SearchState; +} + +export interface RequestEditLinkEvent { + kind: EditorEventType.EditLink; +} + + +export type EditorEvent = + ChangeEvent|UndoRedoDepthChangeEvent|SelectionRangeChangeEvent| + EditorScrolledEvent| + SelectionFormattingChangeEvent|UpdateSearchDialogEvent| + RequestEditLinkEvent; + diff --git a/packages/editor/jest.config.js b/packages/editor/jest.config.js new file mode 100644 index 000000000..101d4a327 --- /dev/null +++ b/packages/editor/jest.config.js @@ -0,0 +1,19 @@ +module.exports = { + 'moduleFileExtensions': [ + 'ts', + 'tsx', + 'js', + 'jsx', + ], + + 'transform': { + '\\.(ts|tsx)$': 'ts-jest', + }, + + testEnvironment: 'jsdom', + testMatch: ['**/*.test.(ts|tsx)'], + setupFilesAfterEnv: ['./jest.setup.js'], + + testPathIgnorePatterns: ['/node_modules/'], + slowTestThreshold: 40, +}; diff --git a/packages/editor/jest.setup.js b/packages/editor/jest.setup.js new file mode 100644 index 000000000..909afe187 --- /dev/null +++ b/packages/editor/jest.setup.js @@ -0,0 +1,16 @@ + +// Prevents the CodeMirror error "getClientRects is undefined". +// See https://github.com/jsdom/jsdom/issues/3002#issue-652790925 +document.createRange = () => { + const range = new Range(); + range.getBoundingClientRect = jest.fn(); + range.getClientRects = () => { + return { + length: 0, + item: () => null, + [Symbol.iterator]: jest.fn(), + }; + }; + + return range; +}; diff --git a/packages/editor/package.json b/packages/editor/package.json new file mode 100644 index 000000000..4aea3da8e --- /dev/null +++ b/packages/editor/package.json @@ -0,0 +1,48 @@ +{ + "name": "@joplin/editor", + "version": "2.13.0", + "description": "Web-based markdown editor", + "private": true, + "scripts": { + "tsc": "tsc --project tsconfig.json", + "watch": "tsc --watch --preserveWatchOutput --project tsconfig.json", + "postinstall": "yarn run tsc", + "test": "jest", + "test-ci": "yarn test" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/laurent22/joplin.git" + }, + "devDependencies": { + "@joplin/lib": "~2.13", + "@joplin/tools": "~2.13", + "@testing-library/react-hooks": "8.0.1", + "@types/jest": "29.5.3", + "@types/react": "18.0.24", + "@types/react-redux": "7.1.25", + "@types/styled-components": "5.1.26", + "jest": "29.5.0", + "jest-environment-jsdom": "29.5.0", + "ts-jest": "29.1.1", + "typescript": "5.1.3" + }, + "dependencies": { + "@codemirror/autocomplete": "6.9.0", + "@codemirror/commands": "6.2.5", + "@codemirror/lang-cpp": "6.0.2", + "@codemirror/lang-html": "6.4.6", + "@codemirror/lang-java": "6.0.1", + "@codemirror/lang-javascript": "6.2.1", + "@codemirror/lang-markdown": "6.2.1", + "@codemirror/lang-php": "6.0.1", + "@codemirror/lang-rust": "6.0.1", + "@codemirror/language": "6.9.0", + "@codemirror/legacy-modes": "6.3.3", + "@codemirror/search": "6.5.2", + "@codemirror/state": "6.2.1", + "@codemirror/view": "6.18.0", + "@lezer/markdown": "1.1.0", + "@replit/codemirror-vim": "6.0.14" + } +} diff --git a/packages/editor/tsconfig.json b/packages/editor/tsconfig.json new file mode 100644 index 000000000..abcd3a408 --- /dev/null +++ b/packages/editor/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "include": [ + "**/*.ts", + "**/*.tsx" + ], + "exclude": [ + "**/node_modules", + "**/dist" + ] +} diff --git a/packages/editor/types.ts b/packages/editor/types.ts new file mode 100644 index 000000000..58b8e6a7b --- /dev/null +++ b/packages/editor/types.ts @@ -0,0 +1,163 @@ +import type { Theme } from '@joplin/lib/themes/type'; +import type { EditorEvent } from './events'; + +// Editor commands. For compatibility, the string values of these commands +// should correspond with the CodeMirror 5 commands: +// https://codemirror.net/5/doc/manual.html#commands +export enum EditorCommandType { + Undo = 'undo', + Redo = 'redo', + SelectAll = 'selectAll', + Focus = 'focus', + + // Formatting editor commands + ToggleBolded = 'textBold', + ToggleItalicized = 'textItalic', + ToggleCode = 'textCode', + ToggleMath = 'textMath', + + ToggleNumberedList = 'textNumberedList', + ToggleBulletedList = 'textBulletedList', + ToggleCheckList = 'textCheckbox', + + ToggleHeading = 'textHeading', + ToggleHeading1 = 'textHeading1', + ToggleHeading2 = 'textHeading2', + ToggleHeading3 = 'textHeading3', + ToggleHeading4 = 'textHeading4', + ToggleHeading5 = 'textHeading5', + + // Find commands + ShowSearch = 'find', + HideSearch = 'hideSearchDialog', + FindNext = 'findNext', + FindPrevious = 'findPrev', + ReplaceNext = 'replace', + ReplaceAll = 'replaceAll', + + // Editing and navigation commands + ScrollSelectionIntoView = 'scrollSelectionIntoView', + DeleteToLineEnd = 'killLine', + DeleteToLineStart = 'delLineLeft', + IndentMore = 'indentMore', + IndentLess = 'indentLess', + IndentAuto = 'indentAuto', + InsertNewlineAndIndent = 'newlineAndIndent', + + SwapLineUp = 'swapLineUp', + SwapLineDown = 'swapLineDown', + + GoDocEnd = 'goDocEnd', + GoDocStart = 'goDocStart', + GoLineStart = 'goLineStart', + GoLineEnd = 'goLineEnd', + GoLineUp = 'goLineUp', + GoLineDown = 'goLineDown', + GoPageUp = 'goPageUp', + GoPageDown = 'goPageDown', + GoCharLeft = 'goCharLeft', + GoCharRight = 'goCharRight', + + UndoSelection = 'undoSelection', + RedoSelection = 'redoSelection', +} + +// Because the editor package can run in a WebView, plugin content scripts +// need to be provided as text, rather than as file paths. +export interface PluginData { + pluginId: string; + contentScriptId: string; + contentScriptJs: ()=> Promise; + postMessageHandler: (message: any)=> any; +} + +export interface EditorControl { + supportsCommand(name: EditorCommandType|string): boolean; + execCommand(name: EditorCommandType|string): void; + + undo(): void; + redo(): void; + + select(anchor: number, head: number): void; + + // 0 corresponds to the top, 1 corresponds to the bottom. + setScrollPercent(fraction: number): void; + + insertText(text: string): void; + updateBody(newBody: string): void; + + updateSettings(newSettings: EditorSettings): void; + + // Create a new link or update the currently selected link with + // the given [label] and [url]. + updateLink(label: string, url: string): void; + + setSearchState(state: SearchState): void; + + setPlugins(plugins: PluginData[]): Promise; +} + +export enum EditorLanguageType { + Markdown, + Html, +} + +export enum EditorKeymap { + Default = 'default', + Vim = 'vim', + Emacs = 'emacs', +} + +export interface EditorSettings { + // EditorSettings objects are deserialized within WebViews, where + // [themeStyle(themeId: number)] doesn't work. As such, we need both + // a Theme must be provided. + themeData: Theme; + + // True if the search panel is implemented outside of the editor (e.g. with + // React Native). + useExternalSearch: boolean; + + automatchBraces: boolean; + + // True if internal command keyboard shortcuts should be ignored (thus + // allowing Joplin shortcuts to run). + ignoreModifiers: boolean; + + language: EditorLanguageType; + + keymap: EditorKeymap; + + katexEnabled: boolean; + spellcheckEnabled: boolean; + readOnly: boolean; + + indentWithTabs: boolean; +} + +export type LogMessageCallback = (message: string)=> void; +export type OnEventCallback = (event: EditorEvent)=> void; + +export interface EditorProps { + settings: EditorSettings; + initialText: string; + + onEvent: OnEventCallback; + onLogMessage: LogMessageCallback; +} + +export interface SearchState { + useRegex: boolean; + caseSensitive: boolean; + + searchText: string; + replaceText: string; + dialogVisible: boolean; +} + +// Possible types of lists in the editor +export enum ListType { + CheckList, + OrderedList, + UnorderedList, +} diff --git a/packages/lib/models/Setting.ts b/packages/lib/models/Setting.ts index 02cb4f24c..a47203b8f 100644 --- a/packages/lib/models/Setting.ts +++ b/packages/lib/models/Setting.ts @@ -1022,23 +1022,6 @@ class Setting extends BaseModel { 'folders.sortOrder.reverse': { value: false, type: SettingItemType.Bool, storage: SettingStorage.File, isGlobal: true, public: true, label: () => _('Reverse sort order'), appTypes: [AppType.Cli] }, trackLocation: { value: true, type: SettingItemType.Bool, section: 'note', storage: SettingStorage.File, isGlobal: true, public: true, label: () => _('Save geo-location with notes') }, - // 2020-10-29: For now disable the beta editor due to - // underlying bugs in the TextInput component which we cannot - // fix. Also the editor crashes in Android and in some cases in - // iOS. - // https://discourse.joplinapp.org/t/anyone-using-the-beta-editor-on-ios/11658/9 - 'editor.beta': { - value: false, - type: SettingItemType.Bool, - section: 'note', - public: false, - appTypes: [AppType.Mobile], - label: () => 'Opt-in to the editor beta', - description: () => 'This beta adds list continuation and syntax highlighting. If you find bugs, please report them in the Discourse forum.', - storage: SettingStorage.File, - isGlobal: true, - }, - 'editor.usePlainText': { value: false, type: SettingItemType.Bool, @@ -1475,6 +1458,20 @@ class Setting extends BaseModel { isGlobal: true, }, + // 2023-09-07: This setting is now used to track the desktop beta editor. It + // was used to track the mobile beta editor previously. + 'editor.beta': { + value: false, + type: SettingItemType.Bool, + section: 'general', + public: true, + appTypes: [AppType.Desktop], + label: () => 'Opt-in to the editor beta', + description: () => 'This beta adds improved accessibility and plugin API compatibility with the mobile editor. If you find bugs, please report them in the Discourse forum.', + storage: SettingStorage.File, + isGlobal: true, + }, + 'net.customCertificates': { value: '', type: SettingItemType.String, diff --git a/yarn.lock b/yarn.lock index 4dfdfd741..760437af2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2778,13 +2778,13 @@ __metadata: languageName: node linkType: hard -"@bam.tech/react-native-image-resizer@npm:3.0.7": - version: 3.0.7 - resolution: "@bam.tech/react-native-image-resizer@npm:3.0.7" +"@bam.tech/react-native-image-resizer@npm:3.0.5": + version: 3.0.5 + resolution: "@bam.tech/react-native-image-resizer@npm:3.0.5" peerDependencies: react: "*" react-native: "*" - checksum: a4eaeb4a00fcc92e9f31d2e7bd7a55f0f90a76b25f8cd049e087ed95b99d961481bb0c5baad6393b57a551266ade10cacd608b37e27fc458a120c04b947729a6 + checksum: f555bd10aafab1a797b6f5142e59b1bb11f229b61d35c3e6c9d734bbd4999f97bcc86853f69965d758ca872aad024add7a852041da498b177eee923ef82e506d languageName: node linkType: hard @@ -2814,9 +2814,9 @@ __metadata: languageName: node linkType: hard -"@codemirror/autocomplete@npm:^6.0.0": - version: 6.4.2 - resolution: "@codemirror/autocomplete@npm:6.4.2" +"@codemirror/autocomplete@npm:6.9.0, @codemirror/autocomplete@npm:^6.0.0, @codemirror/autocomplete@npm:^6.7.1": + version: 6.9.0 + resolution: "@codemirror/autocomplete@npm:6.9.0" dependencies: "@codemirror/language": ^6.0.0 "@codemirror/state": ^6.0.0 @@ -2827,19 +2827,19 @@ __metadata: "@codemirror/state": ^6.0.0 "@codemirror/view": ^6.0.0 "@lezer/common": ^1.0.0 - checksum: c6cc4edb1c412153e6f6f27926674d7f1d386d1f30d6d4f60c5b52bfa0105870b0c70449b69891937bcf082340d8b0fa6d1f9f28f5eb60adc2974ed4c73aadc1 + checksum: a5f661944c75f40b02c90a193c9a459c0fd7e335c0ac5973420c19157dfb46010f573c2b70731591fe477e7a2ad10121ff3ae394a72d450946d7b886c28b0368 languageName: node linkType: hard -"@codemirror/commands@npm:6.2.2": - version: 6.2.2 - resolution: "@codemirror/commands@npm:6.2.2" +"@codemirror/commands@npm:6.2.5": + version: 6.2.5 + resolution: "@codemirror/commands@npm:6.2.5" dependencies: "@codemirror/language": ^6.0.0 "@codemirror/state": ^6.2.0 "@codemirror/view": ^6.0.0 "@lezer/common": ^1.0.0 - checksum: d3aa1ca8cbd7b9434eedba6b6d783411670796bf6ab61990afc4fd0c04645189fe4dd55bb95e23b943e9089f9739bc7e92aa4b2ac3eac09cfa2b91a45f608d3e + checksum: 6d373bcfd4337160243e1493c8703a8e367e208811742331679a6410a3645de36ae8a5664e11790fec521137b45f34d703e9292932a98c4de10139510f3f29a3 languageName: node linkType: hard @@ -2854,31 +2854,32 @@ __metadata: linkType: hard "@codemirror/lang-css@npm:^6.0.0": - version: 6.1.1 - resolution: "@codemirror/lang-css@npm:6.1.1" + version: 6.2.1 + resolution: "@codemirror/lang-css@npm:6.2.1" dependencies: "@codemirror/autocomplete": ^6.0.0 "@codemirror/language": ^6.0.0 "@codemirror/state": ^6.0.0 + "@lezer/common": ^1.0.2 "@lezer/css": ^1.0.0 - checksum: 9b0bf7c7544fb604b67325689d783981e4099560f577bc1f10c52cb18e9d275ebdbdbd3f335a1dbb9c4910c36320f74ca015fc92ef99f930ecb9d481a2bf3511 + checksum: 5a8457ee8a4310030a969f2d3128429f549c4dc9b7907ee8888b42119c80b65af99093801432efdf659b8ec36a147d2a947bc1ecbbf69a759395214e3f4834a8 languageName: node linkType: hard -"@codemirror/lang-html@npm:6.4.3, @codemirror/lang-html@npm:^6.0.0": - version: 6.4.3 - resolution: "@codemirror/lang-html@npm:6.4.3" +"@codemirror/lang-html@npm:6.4.6, @codemirror/lang-html@npm:^6.0.0": + version: 6.4.6 + resolution: "@codemirror/lang-html@npm:6.4.6" dependencies: "@codemirror/autocomplete": ^6.0.0 "@codemirror/lang-css": ^6.0.0 "@codemirror/lang-javascript": ^6.0.0 "@codemirror/language": ^6.4.0 "@codemirror/state": ^6.0.0 - "@codemirror/view": ^6.2.2 + "@codemirror/view": ^6.17.0 "@lezer/common": ^1.0.0 "@lezer/css": ^1.1.0 "@lezer/html": ^1.3.0 - checksum: 6177d19147580964ecd6910ae951201929a96e63f4f0e624c3138e2805fa87ec6d6d952a3a888c5a52af78b6dd6d04d7d8c76c6a9cd65b1921dc467b5dbaea72 + checksum: 8f884f4423ffc783181ee933f7212ad4ece204695cf8af9535a593f95e901d36515a8561fc336a0fbcf5782369b9484eeb0d2cec2167622868238177c5e6eb36 languageName: node linkType: hard @@ -2892,32 +2893,33 @@ __metadata: languageName: node linkType: hard -"@codemirror/lang-javascript@npm:6.1.5, @codemirror/lang-javascript@npm:^6.0.0": - version: 6.1.5 - resolution: "@codemirror/lang-javascript@npm:6.1.5" +"@codemirror/lang-javascript@npm:6.2.1, @codemirror/lang-javascript@npm:^6.0.0": + version: 6.2.1 + resolution: "@codemirror/lang-javascript@npm:6.2.1" dependencies: "@codemirror/autocomplete": ^6.0.0 "@codemirror/language": ^6.6.0 "@codemirror/lint": ^6.0.0 "@codemirror/state": ^6.0.0 - "@codemirror/view": ^6.0.0 + "@codemirror/view": ^6.17.0 "@lezer/common": ^1.0.0 "@lezer/javascript": ^1.0.0 - checksum: f0355f9577fac03437137356b5c8826ec073480d9b0efc62289eac483172d47dafe569f31bf788e4228e8b789197e50a0768cf10b0cde5f600e89b6b469f52cc + checksum: 3df38c4cced06195283a9a2a9365aaa7c8c1b157852b331bc3a118403f774bbba57d2a392de52f5e28d2b344a323bc0146bcf7c8ef8be2473f167d815e4a37cd languageName: node linkType: hard -"@codemirror/lang-markdown@npm:6.1.0": - version: 6.1.0 - resolution: "@codemirror/lang-markdown@npm:6.1.0" +"@codemirror/lang-markdown@npm:6.2.1": + version: 6.2.1 + resolution: "@codemirror/lang-markdown@npm:6.2.1" dependencies: + "@codemirror/autocomplete": ^6.7.1 "@codemirror/lang-html": ^6.0.0 "@codemirror/language": ^6.3.0 "@codemirror/state": ^6.0.0 "@codemirror/view": ^6.0.0 "@lezer/common": ^1.0.0 "@lezer/markdown": ^1.0.0 - checksum: faee880c5e695391fc5b92788d1500bed3f0cc3766c987077cdc1643cf38b97eb1774a29491a7a75064089478b895e7c8fe5a4f08ac93c9614ccbbe188f10b47 + checksum: ef3bdfd01e418efc7f7fdf0baa2e8e91875b37f870fcad98f846954763c7cc71bac95736591cd6c52b39cc380261d76ae7b37ca97ef1641c4c266476748046d3 languageName: node linkType: hard @@ -2944,9 +2946,9 @@ __metadata: languageName: node linkType: hard -"@codemirror/language@npm:6.6.0, @codemirror/language@npm:^6.0.0, @codemirror/language@npm:^6.3.0, @codemirror/language@npm:^6.4.0, @codemirror/language@npm:^6.6.0": - version: 6.6.0 - resolution: "@codemirror/language@npm:6.6.0" +"@codemirror/language@npm:6.9.0, @codemirror/language@npm:^6.0.0, @codemirror/language@npm:^6.3.0, @codemirror/language@npm:^6.4.0, @codemirror/language@npm:^6.6.0": + version: 6.9.0 + resolution: "@codemirror/language@npm:6.9.0" dependencies: "@codemirror/state": ^6.0.0 "@codemirror/view": ^6.0.0 @@ -2954,56 +2956,56 @@ __metadata: "@lezer/highlight": ^1.0.0 "@lezer/lr": ^1.0.0 style-mod: ^4.0.0 - checksum: bb9411620e2f231653a3f0c4429e0d19a3843bff5dbc117df4649d7bf783ec4ad809c0add8bc0887a4ec3f48b4f8f941621168e47d76101d5383f0d670af1722 + checksum: 9a897fb0f569159eeafb7dce83061b425af7244bbeae2649e0e677488548b2a02eaf0c13c0c5b4d59da55e8866e6f4dc7abe3dfaa09c13749a2fa2c0dbc0c565 languageName: node linkType: hard -"@codemirror/legacy-modes@npm:6.3.2": - version: 6.3.2 - resolution: "@codemirror/legacy-modes@npm:6.3.2" +"@codemirror/legacy-modes@npm:6.3.3": + version: 6.3.3 + resolution: "@codemirror/legacy-modes@npm:6.3.3" dependencies: "@codemirror/language": ^6.0.0 - checksum: fa5f5477fb9e19267251e2ecd3de8c1a4c2512813555bb60111dce3951f2c3f6080a2985a573b7542534ba1d2c34115f7e39ee23fdf8f6f81db6f8ce447c1efc + checksum: 3cd32b0f011b0a193e0948e5901b625f38aa6d9a8b24344531d6e142eb6fbb3e6cb5969429102044f3d04fbe53c4deaebd9f659c05067a0b18d17766290c9e05 languageName: node linkType: hard "@codemirror/lint@npm:^6.0.0": - version: 6.2.0 - resolution: "@codemirror/lint@npm:6.2.0" + version: 6.4.1 + resolution: "@codemirror/lint@npm:6.4.1" dependencies: "@codemirror/state": ^6.0.0 "@codemirror/view": ^6.0.0 crelt: ^1.0.5 - checksum: b97e55a07bca9f7e357e495853ba189ae0ff7dfe7e7ae445d7a0d6c6926ec792c7f5c6b6c13a1f137fd9fedf44a6624e9d500f76d0d46a3c3e9d19c2cda9d28a + checksum: ac8120ca96b5ef57abd2705b2620c15c7449b5056bca87053480e244c6772863e1537387a863cfb784f9f2af2c8b30be78a31660d96a815672059085beb51fd5 languageName: node linkType: hard -"@codemirror/search@npm:6.3.0": - version: 6.3.0 - resolution: "@codemirror/search@npm:6.3.0" +"@codemirror/search@npm:6.5.2": + version: 6.5.2 + resolution: "@codemirror/search@npm:6.5.2" dependencies: "@codemirror/state": ^6.0.0 "@codemirror/view": ^6.0.0 crelt: ^1.0.5 - checksum: b757eebbb541c9d74fe36ccfdd03bc3e4e7aebb08b491e207d5898f24aaa612558c393ba49de5bf375972f5774de817fcfbad1ac551dda1a34badb41cf130d36 + checksum: bc535151277fda0a370ac496b9b0d5751fd91bd8e3eb29dafbfe6bf3125dc450a7e361ebc302f0ebc4193ac337bdf555ab3d5ec753dbb44452225618a5630dd3 languageName: node linkType: hard -"@codemirror/state@npm:6.2.0, @codemirror/state@npm:^6.0.0, @codemirror/state@npm:^6.1.4, @codemirror/state@npm:^6.2.0": - version: 6.2.0 - resolution: "@codemirror/state@npm:6.2.0" - checksum: fdc99c773dc09c700dd02bf918f06132aa8d3069c262cc4eb6ca5c810ce24ae2d7e90719ae7630a8158fd263018de6d40bd78f312e6bfba754e737b64e6c6b3d +"@codemirror/state@npm:6.2.1, @codemirror/state@npm:^6.0.0, @codemirror/state@npm:^6.1.4, @codemirror/state@npm:^6.2.0": + version: 6.2.1 + resolution: "@codemirror/state@npm:6.2.1" + checksum: d12a321d0471b264b9d3259042bff913a8b939e8d28d408ff452004538a71ca9d5329df3f8a1d8a9183f5b42a7ef5b200737bcab1065714f5ae8e0a5ba9d59d3 languageName: node linkType: hard -"@codemirror/view@npm:6.9.3, @codemirror/view@npm:^6.0.0, @codemirror/view@npm:^6.2.2, @codemirror/view@npm:^6.6.0": - version: 6.9.3 - resolution: "@codemirror/view@npm:6.9.3" +"@codemirror/view@npm:6.18.0, @codemirror/view@npm:^6.0.0, @codemirror/view@npm:^6.17.0, @codemirror/view@npm:^6.6.0": + version: 6.18.0 + resolution: "@codemirror/view@npm:6.18.0" dependencies: "@codemirror/state": ^6.1.4 - style-mod: ^4.0.0 + style-mod: ^4.1.0 w3c-keyname: ^2.2.4 - checksum: 718ecbb021ca75eb89003f73c846a07d36a708dcfec8345f0f0dbcfc0d0df5ea6f114918694b2730a6d49e5e50502bcce79ce7ff94ce55748e068e5a35073755 + checksum: 275bf5898e884297f16f73e4dff1b520a196a5f7724fbeda634a927e7f4036f6786e816b124505942de99800fb66c538307e8c08e55234ad57483f1a009e3d35 languageName: node linkType: hard @@ -4056,6 +4058,47 @@ __metadata: languageName: node linkType: hard +"@jest/core@npm:^29.5.0, @jest/core@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/core@npm:29.7.0" + dependencies: + "@jest/console": ^29.7.0 + "@jest/reporters": ^29.7.0 + "@jest/test-result": ^29.7.0 + "@jest/transform": ^29.7.0 + "@jest/types": ^29.6.3 + "@types/node": "*" + ansi-escapes: ^4.2.1 + chalk: ^4.0.0 + ci-info: ^3.2.0 + exit: ^0.1.2 + graceful-fs: ^4.2.9 + jest-changed-files: ^29.7.0 + jest-config: ^29.7.0 + jest-haste-map: ^29.7.0 + jest-message-util: ^29.7.0 + jest-regex-util: ^29.6.3 + jest-resolve: ^29.7.0 + jest-resolve-dependencies: ^29.7.0 + jest-runner: ^29.7.0 + jest-runtime: ^29.7.0 + jest-snapshot: ^29.7.0 + jest-util: ^29.7.0 + jest-validate: ^29.7.0 + jest-watcher: ^29.7.0 + micromatch: ^4.0.4 + pretty-format: ^29.7.0 + slash: ^3.0.0 + strip-ansi: ^6.0.0 + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + checksum: af759c9781cfc914553320446ce4e47775ae42779e73621c438feb1e4231a5d4862f84b1d8565926f2d1aab29b3ec3dcfdc84db28608bdf5f29867124ebcfc0d + languageName: node + linkType: hard + "@jest/core@npm:^29.6.4": version: 29.6.4 resolution: "@jest/core@npm:29.6.4" @@ -4097,47 +4140,6 @@ __metadata: languageName: node linkType: hard -"@jest/core@npm:^29.7.0": - version: 29.7.0 - resolution: "@jest/core@npm:29.7.0" - dependencies: - "@jest/console": ^29.7.0 - "@jest/reporters": ^29.7.0 - "@jest/test-result": ^29.7.0 - "@jest/transform": ^29.7.0 - "@jest/types": ^29.6.3 - "@types/node": "*" - ansi-escapes: ^4.2.1 - chalk: ^4.0.0 - ci-info: ^3.2.0 - exit: ^0.1.2 - graceful-fs: ^4.2.9 - jest-changed-files: ^29.7.0 - jest-config: ^29.7.0 - jest-haste-map: ^29.7.0 - jest-message-util: ^29.7.0 - jest-regex-util: ^29.6.3 - jest-resolve: ^29.7.0 - jest-resolve-dependencies: ^29.7.0 - jest-runner: ^29.7.0 - jest-runtime: ^29.7.0 - jest-snapshot: ^29.7.0 - jest-util: ^29.7.0 - jest-validate: ^29.7.0 - jest-watcher: ^29.7.0 - micromatch: ^4.0.4 - pretty-format: ^29.7.0 - slash: ^3.0.0 - strip-ansi: ^6.0.0 - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - checksum: af759c9781cfc914553320446ce4e47775ae42779e73621c438feb1e4231a5d4862f84b1d8565926f2d1aab29b3ec3dcfdc84db28608bdf5f29867124ebcfc0d - languageName: node - linkType: hard - "@jest/create-cache-key-function@npm:^27.0.1": version: 27.4.2 resolution: "@jest/create-cache-key-function@npm:27.4.2" @@ -4637,6 +4639,7 @@ __metadata: "@electron/remote": 2.0.11 "@fortawesome/fontawesome-free": 5.15.4 "@joeattardi/emoji-button": 4.6.4 + "@joplin/editor": ~2.13 "@joplin/lib": ~2.13 "@joplin/renderer": ~2.13 "@joplin/tools": ~2.13 @@ -4712,20 +4715,8 @@ __metadata: "@babel/core": 7.20.2 "@babel/preset-env": 7.20.2 "@babel/runtime": 7.20.0 - "@bam.tech/react-native-image-resizer": 3.0.7 - "@codemirror/commands": 6.2.2 - "@codemirror/lang-cpp": 6.0.2 - "@codemirror/lang-html": 6.4.3 - "@codemirror/lang-java": 6.0.1 - "@codemirror/lang-javascript": 6.1.5 - "@codemirror/lang-markdown": 6.1.0 - "@codemirror/lang-php": 6.0.1 - "@codemirror/lang-rust": 6.0.1 - "@codemirror/language": 6.6.0 - "@codemirror/legacy-modes": 6.3.2 - "@codemirror/search": 6.3.0 - "@codemirror/state": 6.2.0 - "@codemirror/view": 6.9.3 + "@bam.tech/react-native-image-resizer": 3.0.5 + "@joplin/editor": ~2.13 "@joplin/lib": ~2.13 "@joplin/react-native-alarm-notification": ~2.13 "@joplin/react-native-saf-x": ~2.13 @@ -4822,6 +4813,40 @@ __metadata: languageName: unknown linkType: soft +"@joplin/editor@workspace:packages/editor, @joplin/editor@~2.13": + version: 0.0.0-use.local + resolution: "@joplin/editor@workspace:packages/editor" + dependencies: + "@codemirror/autocomplete": 6.9.0 + "@codemirror/commands": 6.2.5 + "@codemirror/lang-cpp": 6.0.2 + "@codemirror/lang-html": 6.4.6 + "@codemirror/lang-java": 6.0.1 + "@codemirror/lang-javascript": 6.2.1 + "@codemirror/lang-markdown": 6.2.1 + "@codemirror/lang-php": 6.0.1 + "@codemirror/lang-rust": 6.0.1 + "@codemirror/language": 6.9.0 + "@codemirror/legacy-modes": 6.3.3 + "@codemirror/search": 6.5.2 + "@codemirror/state": 6.2.1 + "@codemirror/view": 6.18.0 + "@joplin/lib": ~2.13 + "@joplin/tools": ~2.13 + "@lezer/markdown": 1.1.0 + "@replit/codemirror-vim": 6.0.14 + "@testing-library/react-hooks": 8.0.1 + "@types/jest": 29.5.3 + "@types/react": 18.0.24 + "@types/react-redux": 7.1.25 + "@types/styled-components": 5.1.26 + jest: 29.5.0 + jest-environment-jsdom: 29.5.0 + ts-jest: 29.1.1 + typescript: 5.1.3 + languageName: unknown + linkType: soft + "@joplin/fork-htmlparser2@^4.1.46, @joplin/fork-htmlparser2@workspace:packages/fork-htmlparser2": version: 0.0.0-use.local resolution: "@joplin/fork-htmlparser2@workspace:packages/fork-htmlparser2" @@ -6178,34 +6203,34 @@ __metadata: languageName: node linkType: hard -"@lezer/common@npm:^1.0.0": - version: 1.0.2 - resolution: "@lezer/common@npm:1.0.2" - checksum: bbcc58e07be02652bf0700d2856042ec089d5be0b95893d628b3e18192ade864fac83b61b19653e10b9f1472261a178b12318d934e9004edd5483a577c0db56b +"@lezer/common@npm:^1.0.0, @lezer/common@npm:^1.0.2": + version: 1.0.4 + resolution: "@lezer/common@npm:1.0.4" + checksum: 0bea82da76e0b89afad4e5159d3add460022916352c47906ec67b26d6fe5ec9cb8e23df0e2bf0adef765ae78bed1706fc573a11506d01a80112a5b6dd317730c languageName: node linkType: hard "@lezer/cpp@npm:^1.0.0": - version: 1.1.0 - resolution: "@lezer/cpp@npm:1.1.0" + version: 1.1.1 + resolution: "@lezer/cpp@npm:1.1.1" dependencies: "@lezer/highlight": ^1.0.0 "@lezer/lr": ^1.0.0 - checksum: 9b25c881fc9b64fd2b019a077a85b0ba7cfda0bbdd92dbb0ff43300c9ba1ec4360128fe912bfe0f06a1c1bb5a564c5ace375c8aad254d07a717768a8f268695d + checksum: c9e1db19776eafbfe0c3b8448d46c94d9a1d30f7fef630292e63bab82e6d5d6903a043ee8cf341bcbf84c00ee0d79b8c255bab8fd8e0a91355ae912b53c78935 languageName: node linkType: hard "@lezer/css@npm:^1.0.0, @lezer/css@npm:^1.1.0": - version: 1.1.1 - resolution: "@lezer/css@npm:1.1.1" + version: 1.1.3 + resolution: "@lezer/css@npm:1.1.3" dependencies: "@lezer/highlight": ^1.0.0 "@lezer/lr": ^1.0.0 - checksum: a7e4893aacaa7f26d5679c77a640f401b37d14155cb54863aa91b59dfd220b280360a341c0fedafc65d31101de13a5ae33cf3876c352f2da528344dafdc9b3d7 + checksum: c8069ef0a6751441d2dc9180f7ebfd7aeb35df0ca2f1a748a2f26203a9ef6cc30f17f3074e2b49520453eb39329dadfdbbb901c6d9d067dc955ceb58c1f8cc6a languageName: node linkType: hard -"@lezer/highlight@npm:1.1.4, @lezer/highlight@npm:^1.0.0, @lezer/highlight@npm:^1.1.3": +"@lezer/highlight@npm:1.1.4": version: 1.1.4 resolution: "@lezer/highlight@npm:1.1.4" dependencies: @@ -6214,53 +6239,62 @@ __metadata: languageName: node linkType: hard +"@lezer/highlight@npm:^1.0.0, @lezer/highlight@npm:^1.1.3": + version: 1.1.6 + resolution: "@lezer/highlight@npm:1.1.6" + dependencies: + "@lezer/common": ^1.0.0 + checksum: 411a702394c4c996b7d7f145a38f3a85a8cc698b3918acc7121c629255bb76d4ab383753f69009e011dc415210c6acbbb5b27bde613259ab67e600b29397b03b + languageName: node + linkType: hard + "@lezer/html@npm:^1.3.0": - version: 1.3.4 - resolution: "@lezer/html@npm:1.3.4" + version: 1.3.6 + resolution: "@lezer/html@npm:1.3.6" dependencies: "@lezer/common": ^1.0.0 "@lezer/highlight": ^1.0.0 "@lezer/lr": ^1.0.0 - checksum: 81dd134ac094edf7c40bae4c3b7126d336ce4c3c87756344bf604eff64d89b06fcb55f91618a4622eb0dae6d6015722f5bab58e2252d86e81fca8c3ced1a0c4d + checksum: 1d3af781660968505e5083a34f31ea3549fd5f3949227fa93cc318bca61bce76ffe977bd875624ba938a2039834ec1a33df5d365e94c48131c85dd26f980d92c languageName: node linkType: hard "@lezer/java@npm:^1.0.0": - version: 1.0.3 - resolution: "@lezer/java@npm:1.0.3" + version: 1.0.4 + resolution: "@lezer/java@npm:1.0.4" dependencies: "@lezer/highlight": ^1.0.0 "@lezer/lr": ^1.0.0 - checksum: 2fffea6627d130413ffad4e61040267974cca3167d98881b9e5b5e2455530de74a82c234d93603e92a4972fad314671453c49c0a76b0f4547c4617d671fd7b99 + checksum: 97f5a2c2d733afba5dc57a0da9a97515b19b5e63bb5937717dac4e8c9baed74d15c0cb5c1580858b678931f11d517c56d89f903968fa48931f9c62e2ea67a107 languageName: node linkType: hard "@lezer/javascript@npm:^1.0.0": - version: 1.4.2 - resolution: "@lezer/javascript@npm:1.4.2" + version: 1.4.7 + resolution: "@lezer/javascript@npm:1.4.7" dependencies: "@lezer/highlight": ^1.1.3 "@lezer/lr": ^1.3.0 - checksum: 542261c297709babfe450de1233c13fe2f5b111678d280cb0f8304f12bcdae294cb43c0ac64bbd647e5039de3286f6f0715d120fb132bd5af778363d1f612a1f + checksum: 37c05793e0e45280fa5d7b845a3132a84596105d48b7d2c195abea0a198477ea6719b07d1c8967679e80fc466388151956901fd6962479c130ffda64a6d09591 languageName: node linkType: hard "@lezer/lr@npm:^1.0.0, @lezer/lr@npm:^1.1.0, @lezer/lr@npm:^1.3.0": - version: 1.3.3 - resolution: "@lezer/lr@npm:1.3.3" + version: 1.3.10 + resolution: "@lezer/lr@npm:1.3.10" dependencies: "@lezer/common": ^1.0.0 - checksum: 1804074c794005a31c54d80ab72127f19ae5be29bb627c52bc001a57b1af97a9e62732ff13e3aeb7bc53b330202b6bd3747272c64d87f257dbba533e75a183a3 + checksum: 9d3c22bf692561cf7fe2f3d14e821913f87116ff9d73b8b550e7998b6135baae9f504563846a4257e1bb4eae97ae1b60c06c6066450ddeef5e03e8783526b2ae languageName: node linkType: hard -"@lezer/markdown@npm:^1.0.0": - version: 1.0.2 - resolution: "@lezer/markdown@npm:1.0.2" +"@lezer/markdown@npm:1.1.0, @lezer/markdown@npm:^1.0.0": + version: 1.1.0 + resolution: "@lezer/markdown@npm:1.1.0" dependencies: "@lezer/common": ^1.0.0 "@lezer/highlight": ^1.0.0 - checksum: c4bbfcd8a5a9d924a7cf2b5e5e99c78e7705473cc59804070278b5cfcf478af9dd567025d0926cbf03e3ea6abb8f173425220d3107c05a2d7e0ca3fe3d5c92ef + checksum: b3699c0724dd41e3e6e3078a0e1bcd272ccaebf17b20e5160de3ecf26200cdaa59aa19c9542aac5ab8c7e3aecce1003544b016bb5c32e458bbd5982add8ca0bf languageName: node linkType: hard @@ -6275,12 +6309,12 @@ __metadata: linkType: hard "@lezer/rust@npm:^1.0.0": - version: 1.0.0 - resolution: "@lezer/rust@npm:1.0.0" + version: 1.0.1 + resolution: "@lezer/rust@npm:1.0.1" dependencies: "@lezer/highlight": ^1.0.0 "@lezer/lr": ^1.0.0 - checksum: 0c42f415674f60ca2ef4274b446577621cdeec8f31168b1c3b90888a4377c513f02a89ee346421c264ec3a77fe2fa3e134996be6463ed506dbbc79b4b4505375 + checksum: 1e02fdf09206979e7d4f87b020589f410c4c5e452a7b7b0296f6772ce3571c1bd7ed37495fbeeecf3d4423000f2efdabd462ba8a949c2b351fd35550327a7613 languageName: node linkType: hard @@ -7189,6 +7223,19 @@ __metadata: languageName: node linkType: hard +"@replit/codemirror-vim@npm:6.0.14": + version: 6.0.14 + resolution: "@replit/codemirror-vim@npm:6.0.14" + peerDependencies: + "@codemirror/commands": ^6.0.0 + "@codemirror/language": ^6.1.0 + "@codemirror/search": ^6.2.0 + "@codemirror/state": ^6.0.1 + "@codemirror/view": ^6.0.3 + checksum: 43d14512172df23a47818a8f19ead1733cc1dc7c77cf27d6fd3bc75d645b0400affd96c15e32d2985404b603a09b9296dab9173c501096b42e5e8e8092dbfe0f + languageName: node + linkType: hard + "@rmp135/sql-ts@npm:1.18.0": version: 1.18.0 resolution: "@rmp135/sql-ts@npm:1.18.0" @@ -7887,6 +7934,16 @@ __metadata: languageName: node linkType: hard +"@types/jest@npm:29.5.3": + version: 29.5.3 + resolution: "@types/jest@npm:29.5.3" + dependencies: + expect: ^29.0.0 + pretty-format: ^29.0.0 + checksum: e36bb92e0b9e5ea7d6f8832baa42f087fc1697f6cd30ec309a07ea4c268e06ec460f1f0cfd2581daf5eff5763475190ec1ad8ac6520c49ccfe4f5c0a48bfa676 + languageName: node + linkType: hard + "@types/jest@npm:29.5.4": version: 29.5.4 resolution: "@types/jest@npm:29.5.4" @@ -8243,6 +8300,18 @@ __metadata: languageName: node linkType: hard +"@types/react-redux@npm:7.1.25": + version: 7.1.25 + resolution: "@types/react-redux@npm:7.1.25" + dependencies: + "@types/hoist-non-react-statics": ^3.3.0 + "@types/react": "*" + hoist-non-react-statics: ^3.3.0 + redux: ^4.0.0 + checksum: a61ec25cbf8bb3720850402d3c49493fcff4afb73ad447d161460b5d4c600c984ad48708e8564d2fd32052eaa3c3b3f655c5b300ce813429637cce9e5958329f + languageName: node + linkType: hard + "@types/react-redux@npm:7.1.26": version: 7.1.26 resolution: "@types/react-redux@npm:7.1.26" @@ -8275,6 +8344,17 @@ __metadata: languageName: node linkType: hard +"@types/react@npm:18.0.24": + version: 18.0.24 + resolution: "@types/react@npm:18.0.24" + dependencies: + "@types/prop-types": "*" + "@types/scheduler": "*" + csstype: ^3.0.2 + checksum: 7d06125bac61e1c6661e5dfbeeeb56d5b6d1d4c743292faebaa6b0f30f8414c7af3cadf674923fd86e4ca14e82566ff9156cd40c56786be024600c31b97d6c03 + languageName: node + linkType: hard + "@types/react@npm:18.2.21": version: 18.2.21 resolution: "@types/react@npm:18.2.21" @@ -20939,7 +21019,7 @@ __metadata: languageName: node linkType: hard -"jest-cli@npm:^29.6.4": +"jest-cli@npm:^29.5.0, jest-cli@npm:^29.6.4": version: 29.7.0 resolution: "jest-cli@npm:29.7.0" dependencies: @@ -21145,6 +21225,27 @@ __metadata: languageName: node linkType: hard +"jest-environment-jsdom@npm:29.5.0": + version: 29.5.0 + resolution: "jest-environment-jsdom@npm:29.5.0" + dependencies: + "@jest/environment": ^29.5.0 + "@jest/fake-timers": ^29.5.0 + "@jest/types": ^29.5.0 + "@types/jsdom": ^20.0.0 + "@types/node": "*" + jest-mock: ^29.5.0 + jest-util: ^29.5.0 + jsdom: ^20.0.0 + peerDependencies: + canvas: ^2.5.0 + peerDependenciesMeta: + canvas: + optional: true + checksum: 3df7fc85275711f20b483ac8cd8c04500704ed0f69791eb55c574b38f5a39470f03d775cf20c1025bc1884916ac0573aa2fa4db1bb74225bc7fdd95ba97ad0da + languageName: node + linkType: hard + "jest-environment-jsdom@npm:29.6.4": version: 29.6.4 resolution: "jest-environment-jsdom@npm:29.6.4" @@ -21919,6 +22020,25 @@ __metadata: languageName: node linkType: hard +"jest@npm:29.5.0": + version: 29.5.0 + resolution: "jest@npm:29.5.0" + dependencies: + "@jest/core": ^29.5.0 + "@jest/types": ^29.5.0 + import-local: ^3.0.2 + jest-cli: ^29.5.0 + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + bin: + jest: bin/jest.js + checksum: a8ff2eb0f421623412236e23cbe67c638127fffde466cba9606bc0c0553b4c1e5cb116d7e0ef990b5d1712851652c8ee461373b578df50857fe635b94ff455d5 + languageName: node + linkType: hard + "jest@npm:29.6.4": version: 29.6.4 resolution: "jest@npm:29.6.4" @@ -32766,6 +32886,13 @@ __metadata: languageName: node linkType: hard +"style-mod@npm:^4.1.0": + version: 4.1.0 + resolution: "style-mod@npm:4.1.0" + checksum: 8402b14ca11113a3640d46b3cf7ba49f05452df7846bc5185a3535d9b6a64a3019e7fb636b59ccbb7816aeb0725b24723e77a85b05612a9360e419958e13b4e6 + languageName: node + linkType: hard + "styled-components@npm:5.3.11": version: 5.3.11 resolution: "styled-components@npm:5.3.11" @@ -34349,6 +34476,16 @@ __metadata: languageName: node linkType: hard +"typescript@npm:5.1.3": + version: 5.1.3 + resolution: "typescript@npm:5.1.3" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: d9d51862d98efa46534f2800a1071a613751b1585dc78884807d0c179bcd93d6e9d4012a508e276742f5f33c480adefc52ffcafaf9e0e00ab641a14cde9a31c7 + languageName: node + linkType: hard + "typescript@npm:5.1.6": version: 5.1.6 resolution: "typescript@npm:5.1.6" @@ -34399,6 +34536,16 @@ __metadata: languageName: node linkType: hard +"typescript@patch:typescript@5.1.3#~builtin": + version: 5.1.3 + resolution: "typescript@patch:typescript@npm%3A5.1.3#~builtin::version=5.1.3&hash=5da071" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 6f0a9dca6bf4ce9dcaf4e282aade55ef4c56ecb5fb98d0a4a5c0113398815aea66d871b5611e83353e5953a19ed9ef103cf5a76ac0f276d550d1e7cd5344f61e + languageName: node + linkType: hard + "typescript@patch:typescript@5.1.6#~builtin": version: 5.1.6 resolution: "typescript@patch:typescript@npm%3A5.1.6#~builtin::version=5.1.6&hash=5da071"