1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-08-10 22:11:50 +02:00

Mobile: Add a Rich Text Editor (#12748)

This commit is contained in:
Henry Heino
2025-07-29 12:25:43 -07:00
committed by GitHub
parent c899f63a41
commit 4c3eca1f18
154 changed files with 6405 additions and 1805 deletions

View File

@@ -55,6 +55,7 @@ packages/app-desktop/vendor/lib/
packages/app-mobile/packageInfo.js
packages/app-mobile/android
packages/app-mobile/**/*.bundle.js
packages/app-mobile/**/*.bundle.css
packages/app-mobile/web/public/pluginAssets/**/*
packages/app-mobile/ios
packages/app-mobile/lib/rnInjectedJs/
@@ -74,6 +75,7 @@ packages/lib/services/database/types.ts
packages/lib/vendor/
packages/lib/vendor/fountain.min.js
packages/lib/welcomeAssets.js
packages/editor/*/vendor/
packages/plugins/**/api
packages/plugins/**/dist
packages/server/dist/
@@ -663,6 +665,7 @@ packages/app-mobile/components/ExtendedWebView/index.jest.js
packages/app-mobile/components/ExtendedWebView/index.js
packages/app-mobile/components/ExtendedWebView/index.web.js
packages/app-mobile/components/ExtendedWebView/types.js
packages/app-mobile/components/ExtendedWebView/utils/useCss.js
packages/app-mobile/components/FolderPicker.js
packages/app-mobile/components/Icon.js
packages/app-mobile/components/IconButton.js
@@ -671,42 +674,28 @@ packages/app-mobile/components/ModalDialog.js
packages/app-mobile/components/NestableFlatList.js
packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.test.js
packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.js
packages/app-mobile/components/NoteBodyViewer/bundledJs/Renderer.test.js
packages/app-mobile/components/NoteBodyViewer/bundledJs/Renderer.js
packages/app-mobile/components/NoteBodyViewer/bundledJs/noteBodyViewerBundle.js
packages/app-mobile/components/NoteBodyViewer/bundledJs/types.js
packages/app-mobile/components/NoteBodyViewer/bundledJs/utils/addPluginAssets.js
packages/app-mobile/components/NoteBodyViewer/bundledJs/utils/makeResourceModel.js
packages/app-mobile/components/NoteBodyViewer/hooks/useContentScripts.js
packages/app-mobile/components/NoteBodyViewer/hooks/useEditPopup.test.js
packages/app-mobile/components/NoteBodyViewer/hooks/useEditPopup.js
packages/app-mobile/components/NoteBodyViewer/hooks/useOnMessage.js
packages/app-mobile/components/NoteBodyViewer/hooks/useOnResourceLongPress.js
packages/app-mobile/components/NoteBodyViewer/hooks/useRenderer.js
packages/app-mobile/components/NoteBodyViewer/hooks/useRerenderHandler.js
packages/app-mobile/components/NoteBodyViewer/hooks/useSource.js
packages/app-mobile/components/NoteBodyViewer/types.js
packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.js
packages/app-mobile/components/NoteEditor/EditLinkDialog.js
packages/app-mobile/components/NoteEditor/ImageEditor/ImageEditor.js
packages/app-mobile/components/NoteEditor/ImageEditor/autosave.js
packages/app-mobile/components/NoteEditor/ImageEditor/isEditableResource.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/applyTemplateToEditor.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/createJsDrawEditor.test.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/createJsDrawEditor.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/polyfills.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/startAutosaveLoop.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/types.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/watchEditorForTemplateChanges.js
packages/app-mobile/components/NoteEditor/ImageEditor/promptRestoreAutosave.js
packages/app-mobile/components/NoteEditor/ImageEditor/utils/useEditorMessenger.js
packages/app-mobile/components/NoteEditor/MarkdownEditor.js
packages/app-mobile/components/NoteEditor/NoteEditor.test.js
packages/app-mobile/components/NoteEditor/NoteEditor.js
packages/app-mobile/components/NoteEditor/RichTextEditor.test.js
packages/app-mobile/components/NoteEditor/RichTextEditor.js
packages/app-mobile/components/NoteEditor/SearchPanel.js
packages/app-mobile/components/NoteEditor/WarningBanner.js
packages/app-mobile/components/NoteEditor/commandDeclarations.js
packages/app-mobile/components/NoteEditor/hooks/useCodeMirrorPlugins.js
packages/app-mobile/components/NoteEditor/hooks/useEditorCommandHandler.test.js
packages/app-mobile/components/NoteEditor/hooks/useEditorCommandHandler.js
packages/app-mobile/components/NoteEditor/testing/createTestEditorProps.js
packages/app-mobile/components/NoteEditor/types.js
packages/app-mobile/components/NoteItem.js
packages/app-mobile/components/NoteList.js
@@ -861,6 +850,36 @@ packages/app-mobile/components/voiceTyping/AudioRecordingBanner.js
packages/app-mobile/components/voiceTyping/RecordingControls.js
packages/app-mobile/components/voiceTyping/SpeechToTextBanner.js
packages/app-mobile/components/voiceTyping/types.js
packages/app-mobile/contentScripts/imageEditorBundle/contentScript/applyTemplateToEditor.js
packages/app-mobile/contentScripts/imageEditorBundle/contentScript/index.test.js
packages/app-mobile/contentScripts/imageEditorBundle/contentScript/index.js
packages/app-mobile/contentScripts/imageEditorBundle/contentScript/startAutosaveLoop.js
packages/app-mobile/contentScripts/imageEditorBundle/contentScript/types.js
packages/app-mobile/contentScripts/imageEditorBundle/contentScript/watchEditorForTemplateChanges.js
packages/app-mobile/contentScripts/imageEditorBundle/useWebViewSetup.js
packages/app-mobile/contentScripts/imageEditorBundle/utils/useEditorMessenger.js
packages/app-mobile/contentScripts/markdownEditorBundle/contentScript.js
packages/app-mobile/contentScripts/markdownEditorBundle/types.js
packages/app-mobile/contentScripts/markdownEditorBundle/useWebViewSetup.js
packages/app-mobile/contentScripts/rendererBundle/contentScript/Renderer.test.js
packages/app-mobile/contentScripts/rendererBundle/contentScript/Renderer.js
packages/app-mobile/contentScripts/rendererBundle/contentScript/index.js
packages/app-mobile/contentScripts/rendererBundle/contentScript/types.js
packages/app-mobile/contentScripts/rendererBundle/contentScript/utils/addPluginAssets.js
packages/app-mobile/contentScripts/rendererBundle/contentScript/utils/afterFullPageRender.js
packages/app-mobile/contentScripts/rendererBundle/contentScript/utils/makeResourceModel.js
packages/app-mobile/contentScripts/rendererBundle/types.js
packages/app-mobile/contentScripts/rendererBundle/useWebViewSetup.js
packages/app-mobile/contentScripts/rendererBundle/utils/useContentScripts.js
packages/app-mobile/contentScripts/rendererBundle/utils/useEditPopup.test.js
packages/app-mobile/contentScripts/rendererBundle/utils/useEditPopup.js
packages/app-mobile/contentScripts/richTextEditorBundle/contentScript.js
packages/app-mobile/contentScripts/richTextEditorBundle/types.js
packages/app-mobile/contentScripts/richTextEditorBundle/useWebViewSetup.js
packages/app-mobile/contentScripts/types.js
packages/app-mobile/contentScripts/utils/polyfills.js
packages/app-mobile/contentScripts/utils/readFileToBase64.js
packages/app-mobile/contentScripts/utils/setUpLogger.js
packages/app-mobile/gulpfile.js
packages/app-mobile/index.web.js
packages/app-mobile/root.js
@@ -883,7 +902,7 @@ packages/app-mobile/services/voiceTyping/whisper.js
packages/app-mobile/setupQuickActions.js
packages/app-mobile/tools/buildInjectedJs/BundledFile.js
packages/app-mobile/tools/buildInjectedJs/constants.js
packages/app-mobile/tools/buildInjectedJs/copyJs.js
packages/app-mobile/tools/buildInjectedJs/copyAssets.js
packages/app-mobile/tools/buildInjectedJs/gulpTasks.js
packages/app-mobile/tools/copyAssets.js
packages/app-mobile/utils/ShareExtension.js
@@ -920,7 +939,6 @@ packages/app-mobile/utils/image/fileToImage.web.js
packages/app-mobile/utils/image/getImageDimensions.js
packages/app-mobile/utils/image/resizeImage.js
packages/app-mobile/utils/initializeCommandService.js
packages/app-mobile/utils/injectedJs.js
packages/app-mobile/utils/ipc/RNToWebViewMessenger.js
packages/app-mobile/utils/ipc/WebViewToRNMessenger.js
packages/app-mobile/utils/lockToSingleInstance.js
@@ -990,12 +1008,12 @@ packages/editor/CodeMirror/extensions/overwriteModeExtension.js
packages/editor/CodeMirror/extensions/searchExtension.js
packages/editor/CodeMirror/extensions/selectedNoteIdExtension.js
packages/editor/CodeMirror/getScrollFraction.js
packages/editor/CodeMirror/index.js
packages/editor/CodeMirror/pluginApi/PluginLoader.js
packages/editor/CodeMirror/pluginApi/codeMirrorRequire.js
packages/editor/CodeMirror/pluginApi/customEditorCompletion.test.js
packages/editor/CodeMirror/pluginApi/customEditorCompletion.js
packages/editor/CodeMirror/testing/createEditorControl.js
packages/editor/CodeMirror/testing/createEditorSettings.js
packages/editor/CodeMirror/testing/createTestEditor.js
packages/editor/CodeMirror/testing/findNodesWithName.js
packages/editor/CodeMirror/testing/forceFullParse.js
@@ -1031,9 +1049,43 @@ packages/editor/CodeMirror/utils/markdown/renumberSelectedLists.test.js
packages/editor/CodeMirror/utils/markdown/renumberSelectedLists.js
packages/editor/CodeMirror/utils/markdown/stripBlockquote.js
packages/editor/CodeMirror/utils/setupVim.js
packages/editor/ProseMirror/commands.test.js
packages/editor/ProseMirror/commands.js
packages/editor/ProseMirror/createEditor.js
packages/editor/ProseMirror/index.js
packages/editor/ProseMirror/plugins/inputRulesPlugin.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin.js
packages/editor/ProseMirror/plugins/joplinEditorApiPlugin.js
packages/editor/ProseMirror/plugins/keymapPlugin.js
packages/editor/ProseMirror/plugins/linkTooltipPlugin.test.js
packages/editor/ProseMirror/plugins/linkTooltipPlugin.js
packages/editor/ProseMirror/plugins/listPlugin.js
packages/editor/ProseMirror/plugins/originalMarkupPlugin.js
packages/editor/ProseMirror/plugins/resourcePlaceholderPlugin.js
packages/editor/ProseMirror/plugins/searchPlugin.js
packages/editor/ProseMirror/schema.js
packages/editor/ProseMirror/styles.js
packages/editor/ProseMirror/testing/createTestEditor.js
packages/editor/ProseMirror/types.js
packages/editor/ProseMirror/utils/UndoStackSynchronizer.js
packages/editor/ProseMirror/utils/canReplaceSelectionWith.js
packages/editor/ProseMirror/utils/computeSelectionFormatting.js
packages/editor/ProseMirror/utils/extractSelectedLinesTo.test.js
packages/editor/ProseMirror/utils/extractSelectedLinesTo.js
packages/editor/ProseMirror/utils/jumpToHash.js
packages/editor/ProseMirror/utils/preprocessEditorInput.test.js
packages/editor/ProseMirror/utils/preprocessEditorInput.js
packages/editor/ProseMirror/utils/sanitizeHtml.js
packages/editor/ProseMirror/utils/trimEmptyParagraphs.js
packages/editor/ProseMirror/vendor/changedDescendants.js
packages/editor/ProseMirror/vendor/splitBlockAs.js
packages/editor/SelectionFormatting.js
packages/editor/events.js
packages/editor/polyfills.js
packages/editor/testing/createEditorSettings.js
packages/editor/testing/setUpLogger.js
packages/editor/types.js
packages/editor/utils/getFileFromPasteEvent.js
packages/fork-htmlparser2/src/CollectingHandler.js
packages/fork-htmlparser2/src/FeedHandler.spec.js
packages/fork-htmlparser2/src/FeedHandler.js
@@ -1113,6 +1165,8 @@ packages/lib/commands/toggleAllFolders.js
packages/lib/commands/toggleEditorPlugin.js
packages/lib/components/EncryptionConfigScreen/utils.test.js
packages/lib/components/EncryptionConfigScreen/utils.js
packages/lib/components/shared/NoteEditor/WarningBanner/onRichTextDismissLinkClick.js
packages/lib/components/shared/NoteEditor/WarningBanner/onRichTextReadMoreLinkClick.js
packages/lib/components/shared/NoteList/getEmptyFolderMessage.js
packages/lib/components/shared/NoteRevisionViewer/getHelpMessage.js
packages/lib/components/shared/NoteRevisionViewer/useDeleteHistoryClick.js

View File

@@ -23,6 +23,7 @@ module.exports = {
'FileSystemCreateWritableOptions': 'readonly',
'FileSystemHandle': 'readonly',
'IDBTransactionMode': 'readonly',
'FlatArray': 'readonly',
'BigInt': 'readonly',
'globalThis': 'readonly',

96
.gitignore vendored
View File

@@ -638,6 +638,7 @@ packages/app-mobile/components/ExtendedWebView/index.jest.js
packages/app-mobile/components/ExtendedWebView/index.js
packages/app-mobile/components/ExtendedWebView/index.web.js
packages/app-mobile/components/ExtendedWebView/types.js
packages/app-mobile/components/ExtendedWebView/utils/useCss.js
packages/app-mobile/components/FolderPicker.js
packages/app-mobile/components/Icon.js
packages/app-mobile/components/IconButton.js
@@ -646,42 +647,28 @@ packages/app-mobile/components/ModalDialog.js
packages/app-mobile/components/NestableFlatList.js
packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.test.js
packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.js
packages/app-mobile/components/NoteBodyViewer/bundledJs/Renderer.test.js
packages/app-mobile/components/NoteBodyViewer/bundledJs/Renderer.js
packages/app-mobile/components/NoteBodyViewer/bundledJs/noteBodyViewerBundle.js
packages/app-mobile/components/NoteBodyViewer/bundledJs/types.js
packages/app-mobile/components/NoteBodyViewer/bundledJs/utils/addPluginAssets.js
packages/app-mobile/components/NoteBodyViewer/bundledJs/utils/makeResourceModel.js
packages/app-mobile/components/NoteBodyViewer/hooks/useContentScripts.js
packages/app-mobile/components/NoteBodyViewer/hooks/useEditPopup.test.js
packages/app-mobile/components/NoteBodyViewer/hooks/useEditPopup.js
packages/app-mobile/components/NoteBodyViewer/hooks/useOnMessage.js
packages/app-mobile/components/NoteBodyViewer/hooks/useOnResourceLongPress.js
packages/app-mobile/components/NoteBodyViewer/hooks/useRenderer.js
packages/app-mobile/components/NoteBodyViewer/hooks/useRerenderHandler.js
packages/app-mobile/components/NoteBodyViewer/hooks/useSource.js
packages/app-mobile/components/NoteBodyViewer/types.js
packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.js
packages/app-mobile/components/NoteEditor/EditLinkDialog.js
packages/app-mobile/components/NoteEditor/ImageEditor/ImageEditor.js
packages/app-mobile/components/NoteEditor/ImageEditor/autosave.js
packages/app-mobile/components/NoteEditor/ImageEditor/isEditableResource.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/applyTemplateToEditor.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/createJsDrawEditor.test.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/createJsDrawEditor.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/polyfills.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/startAutosaveLoop.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/types.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/watchEditorForTemplateChanges.js
packages/app-mobile/components/NoteEditor/ImageEditor/promptRestoreAutosave.js
packages/app-mobile/components/NoteEditor/ImageEditor/utils/useEditorMessenger.js
packages/app-mobile/components/NoteEditor/MarkdownEditor.js
packages/app-mobile/components/NoteEditor/NoteEditor.test.js
packages/app-mobile/components/NoteEditor/NoteEditor.js
packages/app-mobile/components/NoteEditor/RichTextEditor.test.js
packages/app-mobile/components/NoteEditor/RichTextEditor.js
packages/app-mobile/components/NoteEditor/SearchPanel.js
packages/app-mobile/components/NoteEditor/WarningBanner.js
packages/app-mobile/components/NoteEditor/commandDeclarations.js
packages/app-mobile/components/NoteEditor/hooks/useCodeMirrorPlugins.js
packages/app-mobile/components/NoteEditor/hooks/useEditorCommandHandler.test.js
packages/app-mobile/components/NoteEditor/hooks/useEditorCommandHandler.js
packages/app-mobile/components/NoteEditor/testing/createTestEditorProps.js
packages/app-mobile/components/NoteEditor/types.js
packages/app-mobile/components/NoteItem.js
packages/app-mobile/components/NoteList.js
@@ -836,6 +823,36 @@ packages/app-mobile/components/voiceTyping/AudioRecordingBanner.js
packages/app-mobile/components/voiceTyping/RecordingControls.js
packages/app-mobile/components/voiceTyping/SpeechToTextBanner.js
packages/app-mobile/components/voiceTyping/types.js
packages/app-mobile/contentScripts/imageEditorBundle/contentScript/applyTemplateToEditor.js
packages/app-mobile/contentScripts/imageEditorBundle/contentScript/index.test.js
packages/app-mobile/contentScripts/imageEditorBundle/contentScript/index.js
packages/app-mobile/contentScripts/imageEditorBundle/contentScript/startAutosaveLoop.js
packages/app-mobile/contentScripts/imageEditorBundle/contentScript/types.js
packages/app-mobile/contentScripts/imageEditorBundle/contentScript/watchEditorForTemplateChanges.js
packages/app-mobile/contentScripts/imageEditorBundle/useWebViewSetup.js
packages/app-mobile/contentScripts/imageEditorBundle/utils/useEditorMessenger.js
packages/app-mobile/contentScripts/markdownEditorBundle/contentScript.js
packages/app-mobile/contentScripts/markdownEditorBundle/types.js
packages/app-mobile/contentScripts/markdownEditorBundle/useWebViewSetup.js
packages/app-mobile/contentScripts/rendererBundle/contentScript/Renderer.test.js
packages/app-mobile/contentScripts/rendererBundle/contentScript/Renderer.js
packages/app-mobile/contentScripts/rendererBundle/contentScript/index.js
packages/app-mobile/contentScripts/rendererBundle/contentScript/types.js
packages/app-mobile/contentScripts/rendererBundle/contentScript/utils/addPluginAssets.js
packages/app-mobile/contentScripts/rendererBundle/contentScript/utils/afterFullPageRender.js
packages/app-mobile/contentScripts/rendererBundle/contentScript/utils/makeResourceModel.js
packages/app-mobile/contentScripts/rendererBundle/types.js
packages/app-mobile/contentScripts/rendererBundle/useWebViewSetup.js
packages/app-mobile/contentScripts/rendererBundle/utils/useContentScripts.js
packages/app-mobile/contentScripts/rendererBundle/utils/useEditPopup.test.js
packages/app-mobile/contentScripts/rendererBundle/utils/useEditPopup.js
packages/app-mobile/contentScripts/richTextEditorBundle/contentScript.js
packages/app-mobile/contentScripts/richTextEditorBundle/types.js
packages/app-mobile/contentScripts/richTextEditorBundle/useWebViewSetup.js
packages/app-mobile/contentScripts/types.js
packages/app-mobile/contentScripts/utils/polyfills.js
packages/app-mobile/contentScripts/utils/readFileToBase64.js
packages/app-mobile/contentScripts/utils/setUpLogger.js
packages/app-mobile/gulpfile.js
packages/app-mobile/index.web.js
packages/app-mobile/root.js
@@ -858,7 +875,7 @@ packages/app-mobile/services/voiceTyping/whisper.js
packages/app-mobile/setupQuickActions.js
packages/app-mobile/tools/buildInjectedJs/BundledFile.js
packages/app-mobile/tools/buildInjectedJs/constants.js
packages/app-mobile/tools/buildInjectedJs/copyJs.js
packages/app-mobile/tools/buildInjectedJs/copyAssets.js
packages/app-mobile/tools/buildInjectedJs/gulpTasks.js
packages/app-mobile/tools/copyAssets.js
packages/app-mobile/utils/ShareExtension.js
@@ -895,7 +912,6 @@ packages/app-mobile/utils/image/fileToImage.web.js
packages/app-mobile/utils/image/getImageDimensions.js
packages/app-mobile/utils/image/resizeImage.js
packages/app-mobile/utils/initializeCommandService.js
packages/app-mobile/utils/injectedJs.js
packages/app-mobile/utils/ipc/RNToWebViewMessenger.js
packages/app-mobile/utils/ipc/WebViewToRNMessenger.js
packages/app-mobile/utils/lockToSingleInstance.js
@@ -965,12 +981,12 @@ packages/editor/CodeMirror/extensions/overwriteModeExtension.js
packages/editor/CodeMirror/extensions/searchExtension.js
packages/editor/CodeMirror/extensions/selectedNoteIdExtension.js
packages/editor/CodeMirror/getScrollFraction.js
packages/editor/CodeMirror/index.js
packages/editor/CodeMirror/pluginApi/PluginLoader.js
packages/editor/CodeMirror/pluginApi/codeMirrorRequire.js
packages/editor/CodeMirror/pluginApi/customEditorCompletion.test.js
packages/editor/CodeMirror/pluginApi/customEditorCompletion.js
packages/editor/CodeMirror/testing/createEditorControl.js
packages/editor/CodeMirror/testing/createEditorSettings.js
packages/editor/CodeMirror/testing/createTestEditor.js
packages/editor/CodeMirror/testing/findNodesWithName.js
packages/editor/CodeMirror/testing/forceFullParse.js
@@ -1006,9 +1022,43 @@ packages/editor/CodeMirror/utils/markdown/renumberSelectedLists.test.js
packages/editor/CodeMirror/utils/markdown/renumberSelectedLists.js
packages/editor/CodeMirror/utils/markdown/stripBlockquote.js
packages/editor/CodeMirror/utils/setupVim.js
packages/editor/ProseMirror/commands.test.js
packages/editor/ProseMirror/commands.js
packages/editor/ProseMirror/createEditor.js
packages/editor/ProseMirror/index.js
packages/editor/ProseMirror/plugins/inputRulesPlugin.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin.js
packages/editor/ProseMirror/plugins/joplinEditorApiPlugin.js
packages/editor/ProseMirror/plugins/keymapPlugin.js
packages/editor/ProseMirror/plugins/linkTooltipPlugin.test.js
packages/editor/ProseMirror/plugins/linkTooltipPlugin.js
packages/editor/ProseMirror/plugins/listPlugin.js
packages/editor/ProseMirror/plugins/originalMarkupPlugin.js
packages/editor/ProseMirror/plugins/resourcePlaceholderPlugin.js
packages/editor/ProseMirror/plugins/searchPlugin.js
packages/editor/ProseMirror/schema.js
packages/editor/ProseMirror/styles.js
packages/editor/ProseMirror/testing/createTestEditor.js
packages/editor/ProseMirror/types.js
packages/editor/ProseMirror/utils/UndoStackSynchronizer.js
packages/editor/ProseMirror/utils/canReplaceSelectionWith.js
packages/editor/ProseMirror/utils/computeSelectionFormatting.js
packages/editor/ProseMirror/utils/extractSelectedLinesTo.test.js
packages/editor/ProseMirror/utils/extractSelectedLinesTo.js
packages/editor/ProseMirror/utils/jumpToHash.js
packages/editor/ProseMirror/utils/preprocessEditorInput.test.js
packages/editor/ProseMirror/utils/preprocessEditorInput.js
packages/editor/ProseMirror/utils/sanitizeHtml.js
packages/editor/ProseMirror/utils/trimEmptyParagraphs.js
packages/editor/ProseMirror/vendor/changedDescendants.js
packages/editor/ProseMirror/vendor/splitBlockAs.js
packages/editor/SelectionFormatting.js
packages/editor/events.js
packages/editor/polyfills.js
packages/editor/testing/createEditorSettings.js
packages/editor/testing/setUpLogger.js
packages/editor/types.js
packages/editor/utils/getFileFromPasteEvent.js
packages/fork-htmlparser2/src/CollectingHandler.js
packages/fork-htmlparser2/src/FeedHandler.spec.js
packages/fork-htmlparser2/src/FeedHandler.js
@@ -1088,6 +1138,8 @@ packages/lib/commands/toggleAllFolders.js
packages/lib/commands/toggleEditorPlugin.js
packages/lib/components/EncryptionConfigScreen/utils.test.js
packages/lib/components/EncryptionConfigScreen/utils.js
packages/lib/components/shared/NoteEditor/WarningBanner/onRichTextDismissLinkClick.js
packages/lib/components/shared/NoteEditor/WarningBanner/onRichTextReadMoreLinkClick.js
packages/lib/components/shared/NoteList/getEmptyFolderMessage.js
packages/lib/components/shared/NoteRevisionViewer/getHelpMessage.js
packages/lib/components/shared/NoteRevisionViewer/useDeleteHistoryClick.js

View File

@@ -0,0 +1,13 @@
<p>A task list created by the TipTap editor:</p>
<ul data-type="taskList">
<li><label contenteditable="false"><input type="checkbox"><span></span></label>
<div>
<p>Testing...</p>
</div>
</li>
<li><label contenteditable="false"><input type="checkbox"><span></span></label>
<div>
<p>testing</p>
</div>
</li>
</ul>

View File

@@ -0,0 +1,5 @@
A task list created by the TipTap editor:
- [ ] Testing...
- [ ] testing

View File

@@ -0,0 +1,26 @@
<p>List 1:</p>
<ul>
<li><label><input type="checkbox"/>This</label></li>
<li><label><input type="checkbox" checked/>is a test.</label></li>
</ul>
<p>List 2:</p>
<ul>
<li>
<input type="checkbox" id="checkbox-1"/><label for="checkbox-1">This</label>
</li>
<li>
<input type="checkbox" checked id="checkbox-2"/><label for="checkbox-2">is another test.</label>
</li>
</ul>
<p>List 3:</p>
<ul>
<li>
<input type="checkbox" id="checkbox-a1"/><label for="checkbox-a1">This</label>
</li>
<li>
<input type="checkbox" checked id="checkbox-a2"/><label for="checkbox-a2">is another test.</label>
</li>
<li>
<input type="checkbox" checked id="checkbox-a3"/><label for="checkbox-a3"></label>
</li>
</ul>

View File

@@ -0,0 +1,15 @@
List 1:
- [ ] This
- [x] is a test.
List 2:
- [ ] This
- [x] is another test.
List 3:
- [ ] This
- [x] is another test.
- [x] &nbsp;

View File

@@ -1,7 +1,7 @@
<ul class="joplin-checklist">
<ul class="joplin-checklist" data-is-checklist="1">
<li>Not checked</li>
<li class="checked">Checked!!
<ul class="joplin-checklist">
<ul class="joplin-checklist" data-is-checklist="1">
<li class="checked">Indented, with <strong>bold</strong></li>
<li>Indented, not checked</li>
</ul>

View File

@@ -1,15 +1,15 @@
<div class="not-loaded-resource not-loaded-image-resource resource-status-notDownloaded" data-resource-id="a1test2a1test2a1test2a1test22345" data-original-alt data-original-title="test" contenteditable="false"><img src="data:image/svg+xml;utf8,
<span class="not-loaded-resource not-loaded-image-resource resource-status-notDownloaded" data-resource-id="a1test2a1test2a1test2a1test22345" data-original-alt data-original-title="test" contenteditable="false"><img src="data:image/svg+xml;utf8,
&Tab;&Tab;&lt;svg width=&quot;1700&quot; height=&quot;1536&quot; xmlns=&quot;http://www.w3.org/2000/svg&quot;&gt;
&Tab;&Tab; &lt;path d=&quot;M1280 1344c0-35-29-64-64-64s-64 29-64 64 29 64 64 64 64-29 64-64zm256 0c0-35-29-64-64-64s-64 29-64 64 29 64 64 64 64-29 64-64zm128-224v320c0 53-43 96-96 96H96c-53 0-96-43-96-96v-320c0-53 43-96 96-96h465l135 136c37 36 85 56 136 56s99-20 136-56l136-136h464c53 0 96 43 96 96zm-325-569c10 24 5 52-14 70l-448 448c-12 13-29 19-45 19s-33-6-45-19L339 621c-19-18-24-46-14-70 10-23 33-39 59-39h256V64c0-35 29-64 64-64h256c35 0 64 29 64 64v448h256c26 0 49 16 59 39z&quot;/&gt;
&Tab;&Tab;&lt;/svg&gt;
&Tab;"/></div>
<div class="not-loaded-resource not-loaded-image-resource resource-status-notDownloaded" data-resource-id="a1test2a1test2a1test2a1test22346" data-original-alt="test" data-original-title contenteditable="false"><img src="data:image/svg+xml;utf8,
&Tab;"/></span>
<span class="not-loaded-resource not-loaded-image-resource resource-status-notDownloaded" data-resource-id="a1test2a1test2a1test2a1test22346" data-original-alt="test" data-original-title contenteditable="false"><img src="data:image/svg+xml;utf8,
&Tab;&Tab;&lt;svg width=&quot;1700&quot; height=&quot;1536&quot; xmlns=&quot;http://www.w3.org/2000/svg&quot;&gt;
&Tab;&Tab; &lt;path d=&quot;M1280 1344c0-35-29-64-64-64s-64 29-64 64 29 64 64 64 64-29 64-64zm256 0c0-35-29-64-64-64s-64 29-64 64 29 64 64 64 64-29 64-64zm128-224v320c0 53-43 96-96 96H96c-53 0-96-43-96-96v-320c0-53 43-96 96-96h465l135 136c37 36 85 56 136 56s99-20 136-56l136-136h464c53 0 96 43 96 96zm-325-569c10 24 5 52-14 70l-448 448c-12 13-29 19-45 19s-33-6-45-19L339 621c-19-18-24-46-14-70 10-23 33-39 59-39h256V64c0-35 29-64 64-64h256c35 0 64 29 64 64v448h256c26 0 49 16 59 39z&quot;/&gt;
&Tab;&Tab;&lt;/svg&gt;
&Tab;"/></div>
<div class="not-loaded-resource not-loaded-image-resource resource-status-notDownloaded" data-resource-id="a1test2a1test2a1test2a1test22347" data-original-before=" " data-original-after=" class=&quot;jop-noMdConv&quot;/" contenteditable="false"><img src="data:image/svg+xml;utf8,
&Tab;"/></span>
<span class="not-loaded-resource not-loaded-image-resource resource-status-notDownloaded" data-resource-id="a1test2a1test2a1test2a1test22347" data-original-before=" " data-original-after=" class=&quot;jop-noMdConv&quot;/" contenteditable="false"><img src="data:image/svg+xml;utf8,
&Tab;&Tab;&lt;svg width=&quot;1700&quot; height=&quot;1536&quot; xmlns=&quot;http://www.w3.org/2000/svg&quot;&gt;
&Tab;&Tab; &lt;path d=&quot;M1280 1344c0-35-29-64-64-64s-64 29-64 64 29 64 64 64 64-29 64-64zm256 0c0-35-29-64-64-64s-64 29-64 64 29 64 64 64 64-29 64-64zm128-224v320c0 53-43 96-96 96H96c-53 0-96-43-96-96v-320c0-53 43-96 96-96h465l135 136c37 36 85 56 136 56s99-20 136-56l136-136h464c53 0 96 43 96 96zm-325-569c10 24 5 52-14 70l-448 448c-12 13-29 19-45 19s-33-6-45-19L339 621c-19-18-24-46-14-70 10-23 33-39 59-39h256V64c0-35 29-64 64-64h256c35 0 64 29 64 64v448h256c26 0 49 16 59 39z&quot;/&gt;
&Tab;&Tab;&lt;/svg&gt;
&Tab;"/></div>
&Tab;"/></span>

View File

@@ -4,7 +4,8 @@ import { AppState } from '../../../app.reducer';
import Setting from '@joplin/lib/models/Setting';
import BannerContent from './BannerContent';
import { _ } from '@joplin/lib/locale';
import bridge from '../../../services/bridge';
import onRichTextReadMoreLinkClick from '@joplin/lib/components/shared/NoteEditor/WarningBanner/onRichTextReadMoreLinkClick';
import onRichTextDismissLinkClick from '@joplin/lib/components/shared/NoteEditor/WarningBanner/onRichTextDismissLinkClick';
import { useMemo } from 'react';
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
import PluginService from '@joplin/lib/services/plugins/PluginService';
@@ -16,14 +17,6 @@ interface Props {
plugins: PluginStates;
}
const onRichTextDismissLinkClick = () => {
Setting.setValue('richTextBannerDismissed', true);
};
const onRichTextReadMoreLinkClick = () => {
void bridge().openExternal('https://joplinapp.org/help/apps/rich_text_editor');
};
const onSwitchToLegacyEditor = () => {
Setting.setValue('editor.legacyMarkdown', true);
};

View File

@@ -67,7 +67,8 @@ yarn-error.log
lib/csstojs/
lib/rnInjectedJs/
dist/
components/**/*.bundle.js
/**/*.bundle.js
/**/*.bundle.css
components/**/*.bundle.js.LICENSE.txt
components/**/*.bundle.js.md5
components/**/*.bundle.min.js

View File

@@ -1,13 +1,14 @@
import * as React from 'react';
import {
forwardRef, Ref, useEffect, useImperativeHandle, useMemo, useRef,
forwardRef, Ref, useCallback, useEffect, useImperativeHandle, useMemo, useRef,
} from 'react';
import { View } from 'react-native';
import Logger from '@joplin/utils/Logger';
import { Props, WebViewControl } from './types';
import { JSDOM } from 'jsdom';
import useCss from './utils/useCss';
const logger = Logger.create('ExtendedWebView');
@@ -18,11 +19,13 @@ const ExtendedWebView = (props: Props, ref: Ref<WebViewControl>) => {
return new JSDOM(props.html, { runScripts: 'dangerously', pretendToBeVisual: true });
}, [props.html]);
const injectJs = useCallback((js: string) => {
return dom.window.eval(js);
}, [dom]);
useImperativeHandle(ref, (): WebViewControl => {
const result = {
injectJS(js: string) {
return dom.window.eval(js);
},
injectJS: injectJs,
postMessage(message: unknown) {
const messageEventContent = {
data: message,
@@ -36,33 +39,24 @@ const ExtendedWebView = (props: Props, ref: Ref<WebViewControl>) => {
},
};
return result;
}, [dom]);
}, [dom, injectJs]);
const onMessageRef = useRef(props.onMessage);
onMessageRef.current = props.onMessage;
const { injectedJs: cssInjectedJavaScript } = useCss(
injectJs,
props.css,
);
// Don't re-load when injected JS changes. This should match the behavior of the native webview.
const injectedJavaScriptRef = useRef(props.injectedJavaScript);
injectedJavaScriptRef.current = props.injectedJavaScript;
injectedJavaScriptRef.current = props.injectedJavaScript + cssInjectedJavaScript;
useEffect(() => {
// JSDOM polyfills
dom.window.eval(`
// 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 = () => {};
range.getClientRects = () => {
return {
length: 0,
item: () => null,
[Symbol.iterator]: () => {},
};
};
return range;
};
window.scrollBy = (_amount) => { };
`);
dom.window.eval(`
@@ -80,7 +74,6 @@ const ExtendedWebView = (props: Props, ref: Ref<WebViewControl>) => {
dom.window.eval(injectedJavaScriptRef.current);
}, [dom]);
const onLoadEndRef = useRef(props.onLoadEnd);
onLoadEndRef.current = props.onLoadEnd;
const onLoadStartRef = useRef(props.onLoadStart);

View File

@@ -12,6 +12,7 @@ import Setting from '@joplin/lib/models/Setting';
import shim from '@joplin/lib/shim';
import Logger from '@joplin/utils/Logger';
import { Props, WebViewControl } from './types';
import useCss from './utils/useCss';
const logger = Logger.create('ExtendedWebView');
@@ -98,6 +99,9 @@ const ExtendedWebView = (props: Props, ref: Ref<WebViewControl>) => {
}, 250);
}, []);
const { injectedJs: cssInjectedJs } = useCss(webviewRef.current?.injectJavaScript, props.css);
const injectedJavaScript = props.injectedJavaScript + cssInjectedJs;
// - `setSupportMultipleWindows` must be `true` for security reasons:
// https://github.com/react-native-webview/react-native-webview/releases/tag/v11.0.0
@@ -131,7 +135,7 @@ const ExtendedWebView = (props: Props, ref: Ref<WebViewControl>) => {
allowFileAccess={true}
allowFileAccessFromFileURLs={props.allowFileAccessFromJs}
webviewDebuggingEnabled={allowWebviewDebugging}
injectedJavaScript={props.injectedJavaScript}
injectedJavaScript={injectedJavaScript}
onMessage={props.onMessage}
onError={props.onError ?? onError}
onLoadEnd={props.onLoadEnd}

View File

@@ -1,13 +1,14 @@
import * as React from 'react';
import {
forwardRef, Ref, useEffect, useImperativeHandle, useRef, useState,
forwardRef, Ref, useCallback, useEffect, useImperativeHandle, useRef, useState,
} from 'react';
import { Props, WebViewControl } from './types';
import { View, ViewStyle } from 'react-native';
import makeSandboxedIframe from '@joplin/lib/utils/dom/makeSandboxedIframe';
import Logger from '@joplin/utils/Logger';
import useCss from './utils/useCss';
const logger = Logger.create('ExtendedWebView');
@@ -20,9 +21,7 @@ const wrapperStyle: ViewStyle = { height: '100%', width: '100%', flex: 1 };
const ExtendedWebView = (props: Props, ref: Ref<WebViewControl>) => {
const iframeRef = useRef<HTMLIFrameElement|null>(null);
useImperativeHandle(ref, (): WebViewControl => {
return {
injectJS(js: string) {
const injectJs = useCallback((js: string) => {
if (!iframeRef.current) {
logger.warn(`WebView(${props.webviewInstanceId}): Tried to inject JavaScript after the iframe has unloaded.`);
return;
@@ -37,7 +36,11 @@ const ExtendedWebView = (props: Props, ref: Ref<WebViewControl>) => {
iframeRef.current.contentWindow.postMessage({
injectJs: js,
}, '*');
},
}, [props.webviewInstanceId]);
useImperativeHandle(ref, (): WebViewControl => {
return {
injectJS: injectJs,
postMessage(message: unknown) {
if (!iframeRef.current || !iframeRef.current.contentWindow) {
logger.warn(`WebView(${props.webviewInstanceId}): Tried to post a message to an unloaded iframe.`);
@@ -49,7 +52,7 @@ const ExtendedWebView = (props: Props, ref: Ref<WebViewControl>) => {
}, '*');
},
};
}, [props.webviewInstanceId]);
}, [props.webviewInstanceId, injectJs]);
const [containerElement, setContainerElement] = useState<HTMLDivElement>();
const containerRef = useRef(containerElement);
@@ -62,9 +65,15 @@ const ExtendedWebView = (props: Props, ref: Ref<WebViewControl>) => {
const onLoadStartRef = useRef(props.onLoadStart);
onLoadStartRef.current = props.onLoadStart;
const { injectedJs: cssInjectedJs } = useCss(
iframeRef.current ? injectJs : null,
props.css,
);
const injectedJavaScript = props.injectedJavaScript + cssInjectedJs;
// Don't re-load when injected JS changes. This should match the behavior of the native webview.
const injectedJavaScriptRef = useRef(props.injectedJavaScript);
injectedJavaScriptRef.current = props.injectedJavaScript;
const injectedJavaScriptRef = useRef(injectedJavaScript);
injectedJavaScriptRef.current = injectedJavaScript;
useEffect(() => {
const headHtml = `

View File

@@ -31,6 +31,7 @@ export interface Props {
// If HTML is still being loaded, [html] should be an empty string.
html: string;
css?: string;
// Initial javascript. Must evaluate to true.
injectedJavaScript: string;

View File

@@ -0,0 +1,38 @@
import { useEffect } from 'react';
type OnInjectJs = (js: string)=> void;
const webViewCssClassName = 'extended-webview-css';
const applyCssJs = (css: string) => `
(function() {
const styleId = ${JSON.stringify(webViewCssClassName)};
const oldStyle = document.getElementById(styleId);
if (oldStyle) {
oldStyle.remove();
}
const style = document.createElement('style');
style.setAttribute('id', styleId);
style.appendChild(document.createTextNode(${JSON.stringify(css)}));
document.head.appendChild(style);
})();
true;
`;
const useCss = (injectJs: OnInjectJs|null, css: string) => {
useEffect(() => {
if (injectJs && css) {
injectJs(applyCssJs(css));
}
}, [injectJs, css]);
return {
injectedJs: css ? applyCssJs(css) : '',
};
};
export default useCss;

View File

@@ -1,24 +1,20 @@
import * as React from 'react';
import useOnMessage, { HandleMessageCallback, OnMarkForDownloadCallback } from './hooks/useOnMessage';
import { useRef, useCallback, useState, useMemo } from 'react';
import { useRef, useCallback } from 'react';
import { View, ViewStyle } from 'react-native';
import ExtendedWebView from '../ExtendedWebView';
import { WebViewControl } from '../ExtendedWebView/types';
import useOnResourceLongPress from './hooks/useOnResourceLongPress';
import useRenderer from './hooks/useRenderer';
import { OnWebViewMessageHandler } from './types';
import useRerenderHandler, { ResourceInfo } from './hooks/useRerenderHandler';
import useSource from './hooks/useSource';
import Setting from '@joplin/lib/models/Setting';
import uuid from '@joplin/lib/uuid';
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
import useContentScripts from './hooks/useContentScripts';
import { MarkupLanguage } from '@joplin/renderer';
import shim from '@joplin/lib/shim';
import CommandService from '@joplin/lib/services/CommandService';
import { AppState } from '../../utils/types';
import { connect } from 'react-redux';
import useWebViewSetup from '../../contentScripts/rendererBundle/useWebViewSetup';
interface Props {
themeId: number;
@@ -69,27 +65,14 @@ function NoteBodyViewer(props: Props) {
onResourceLongPress,
});
const [webViewLoaded, setWebViewLoaded] = useState(false);
const [onWebViewMessage, setOnWebViewMessage] = useState<OnWebViewMessageHandler>(()=>()=>{});
// The renderer can write to whichever temporary directory we choose. As such,
// we use a subdirectory of the main temporary directory for security reasons.
const tempDir = useMemo(() => {
return `${Setting.value('tempDir')}/${uuid.createNano()}`;
}, []);
const renderer = useRenderer({
webViewLoaded,
onScroll,
const { api: renderer, pageSetup, webViewEventHandlers } = useWebViewSetup({
webviewRef,
onBodyScroll: onScroll,
onPostMessage,
setOnWebViewMessage,
tempDir,
pluginStates: props.pluginStates,
themeId: props.themeId,
});
const contentScripts = useContentScripts(props.pluginStates);
useRerenderHandler({
renderer,
fontSize: props.fontSize,
@@ -102,16 +85,14 @@ function NoteBodyViewer(props: Props) {
initialScroll: props.initialScroll,
paddingBottom: props.paddingBottom,
contentScripts,
});
const onLoadEnd = useCallback(() => {
setWebViewLoaded(true);
webViewEventHandlers.onLoadEnd();
if (props.onLoadEnd) props.onLoadEnd();
}, [props.onLoadEnd]);
}, [props.onLoadEnd, webViewEventHandlers]);
const { html, injectedJs } = useSource(tempDir, props.themeId);
const { html, js } = useSource(pageSetup, props.themeId);
return (
<View style={props.style}>
@@ -121,10 +102,10 @@ function NoteBodyViewer(props: Props) {
testID='NoteBodyViewer'
html={html}
allowFileAccessFromJs={true}
injectedJavaScript={injectedJs}
injectedJavaScript={js}
mixedContentMode="always"
onLoadEnd={onLoadEnd}
onMessage={onWebViewMessage}
onMessage={webViewEventHandlers.onMessage}
/>
</View>
);

View File

@@ -1,239 +0,0 @@
import { MarkupLanguage, MarkupToHtml } from '@joplin/renderer';
import type { MarkupToHtmlConverter, RenderOptions, RenderResultPluginAsset, FsDriver as RendererFsDriver } from '@joplin/renderer/types';
import makeResourceModel from './utils/makeResourceModel';
import addPluginAssets from './utils/addPluginAssets';
import { ExtraContentScriptSource } from './types';
import { ExtraContentScript } from '@joplin/lib/services/plugins/utils/loadContentScripts';
export interface RendererSetupOptions {
settings: {
safeMode: boolean;
tempDir: string;
resourceDir: string;
resourceDownloadMode: string;
};
// True if asset and resource files should be transferred to the WebView before rendering.
// This must be true on web, where asset and resource files are virtual and can't be accessed
// without transferring.
useTransferredFiles: boolean;
fsDriver: RendererFsDriver;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
pluginOptions: Record<string, any>;
}
export interface RendererSettings {
theme: string;
onResourceLoaded: ()=> void;
highlightedKeywords: string[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
resources: Record<string, any>;
codeTheme: string;
noteHash: string;
initialScroll: number;
createEditPopupSyntax: string;
destroyEditPopupSyntax: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
pluginSettings: Record<string, any>;
requestPluginSetting: (pluginId: string, settingKey: string)=> void;
readAssetBlob: (assetPath: string)=> Promise<Blob>;
}
export interface MarkupRecord {
language: MarkupLanguage;
markup: string;
}
export default class Renderer {
private markupToHtml: MarkupToHtmlConverter;
private lastSettings: RendererSettings|null = null;
private extraContentScripts: ExtraContentScript[] = [];
private lastRenderMarkup: MarkupRecord|null = null;
private resourcePathOverrides: Record<string, string> = Object.create(null);
public constructor(private setupOptions: RendererSetupOptions) {
this.recreateMarkupToHtml();
}
private recreateMarkupToHtml() {
this.markupToHtml = new MarkupToHtml({
extraRendererRules: this.extraContentScripts,
fsDriver: this.setupOptions.fsDriver,
isSafeMode: this.setupOptions.settings.safeMode,
tempDir: this.setupOptions.settings.tempDir,
ResourceModel: makeResourceModel(this.setupOptions.settings.resourceDir),
pluginOptions: this.setupOptions.pluginOptions,
});
}
// Intended for web, where resources can't be linked to normally.
public async setResourceFile(id: string, file: Blob) {
this.resourcePathOverrides[id] = URL.createObjectURL(file);
}
public getResourcePathOverride(resourceId: string) {
if (Object.prototype.hasOwnProperty.call(this.resourcePathOverrides, resourceId)) {
return this.resourcePathOverrides[resourceId];
}
return null;
}
public async setExtraContentScriptsAndRerender(
extraContentScripts: ExtraContentScriptSource[],
) {
this.extraContentScripts = extraContentScripts.map(script => {
const scriptModule = (eval(script.js))({
pluginId: script.pluginId,
contentScriptId: script.id,
});
if (!scriptModule.plugin) {
throw new Error(`
Expected content script ${script.id} to export a function that returns an object with a "plugin" property.
Found: ${scriptModule}, which has keys ${Object.keys(scriptModule)}.
`);
}
return {
...script,
module: scriptModule,
};
});
this.recreateMarkupToHtml();
// If possible, rerenders with the last rendering settings. The goal
// of this is to reduce the number of IPC calls between the viewer and
// React Native. We want the first render to be as fast as possible.
if (this.lastRenderMarkup) {
await this.rerender(this.lastRenderMarkup, this.lastSettings);
}
}
public async rerender(markup: MarkupRecord, settings: RendererSettings) {
this.lastSettings = settings;
this.lastRenderMarkup = markup;
const options: RenderOptions = {
onResourceLoaded: settings.onResourceLoaded,
highlightedKeywords: settings.highlightedKeywords,
resources: settings.resources,
codeTheme: settings.codeTheme,
postMessageSyntax: 'window.joplinPostMessage_',
enableLongPress: true,
// Show an 'edit' popup over SVG images
editPopupFiletypes: ['image/svg+xml'],
createEditPopupSyntax: settings.createEditPopupSyntax,
destroyEditPopupSyntax: settings.destroyEditPopupSyntax,
itemIdToUrl: this.setupOptions.useTransferredFiles ? (id: string) => this.getResourcePathOverride(id) : undefined,
settingValue: (pluginId: string, settingName: string) => {
const settingKey = `${pluginId}.${settingName}`;
if (!(settingKey in settings.pluginSettings)) {
// This should make the setting available on future renders.
settings.requestPluginSetting(pluginId, settingName);
return undefined;
}
return settings.pluginSettings[settingKey];
},
whiteBackgroundNoteRendering: markup.language === MarkupLanguage.Html,
};
this.markupToHtml.clearCache(markup.language);
const contentContainer = document.getElementById('joplin-container-content');
let html = '';
let pluginAssets: RenderResultPluginAsset[] = [];
try {
const result = await this.markupToHtml.render(
markup.language,
markup.markup,
JSON.parse(settings.theme),
options,
);
html = result.html;
pluginAssets = result.pluginAssets;
} catch (error) {
if (!contentContainer) {
alert(`Renderer error: ${error}`);
} else {
contentContainer.innerText = `
Error: ${error}
${error.stack ?? ''}
`;
}
throw error;
}
contentContainer.innerHTML = html;
// Adding plugin assets can be slow -- run it asynchronously.
void (async () => {
await addPluginAssets(pluginAssets, {
inlineAssets: this.setupOptions.useTransferredFiles,
readAssetBlob: settings.readAssetBlob,
});
// Some plugins require this event to be dispatched just after being added.
document.dispatchEvent(new Event('joplin-noteDidUpdate'));
})();
this.afterRender(settings);
}
private afterRender(renderSettings: RendererSettings) {
const readyStateCheckInterval = setInterval(() => {
if (document.readyState === 'complete') {
clearInterval(readyStateCheckInterval);
if (this.setupOptions.settings.resourceDownloadMode === 'manual') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
(window as any).webviewLib.setupResourceManualDownload();
}
const hash = renderSettings.noteHash;
const initialScroll = renderSettings.initialScroll;
// Don't scroll to a hash if we're given initial scroll (initial scroll
// overrides scrolling to a hash).
if ((initialScroll ?? null) !== null) {
const scrollingElement = document.scrollingElement ?? document.documentElement;
scrollingElement.scrollTop = initialScroll;
} else if (hash) {
// Gives it a bit of time before scrolling to the anchor
// so that images are loaded.
setTimeout(() => {
const e = document.getElementById(hash);
if (!e) {
console.warn('Cannot find hash', hash);
return;
}
e.scrollIntoView();
}, 500);
}
}
}, 10);
}
public clearCache(markupLanguage: MarkupLanguage) {
this.markupToHtml.clearCache(markupLanguage);
}
private extraCssElements: Record<string, HTMLStyleElement> = {};
public setExtraCss(key: string, css: string) {
if (this.extraCssElements.hasOwnProperty(key)) {
this.extraCssElements[key].remove();
}
const extraCssElement = document.createElement('style');
extraCssElement.appendChild(document.createTextNode(css));
document.head.appendChild(extraCssElement);
this.extraCssElements[key] = extraCssElement;
}
}

View File

@@ -1,67 +0,0 @@
import WebViewToRNMessenger from '../../../utils/ipc/WebViewToRNMessenger';
import { NoteViewerLocalApi, NoteViewerRemoteApi, RendererWebViewOptions, WebViewLib } from './types';
import Renderer from './Renderer';
declare global {
interface Window {
rendererWebViewOptions: RendererWebViewOptions;
webviewLib: WebViewLib;
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
declare const webviewLib: WebViewLib;
const messenger = new WebViewToRNMessenger<NoteViewerLocalApi, NoteViewerRemoteApi>(
'note-viewer',
null,
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
(window as any).joplinPostMessage_ = (message: string, _args: any) => {
return messenger.remoteApi.onPostMessage(message);
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
(window as any).webviewApi = {
postMessage: messenger.remoteApi.onPostPluginMessage,
};
webviewLib.initialize({
postMessage: (message: string) => {
messenger.remoteApi.onPostMessage(message);
},
});
// Share the webview library globally so that the renderer can access it.
window.webviewLib = webviewLib;
window.webviewLib = webviewLib;
const renderer = new Renderer({
...window.rendererWebViewOptions,
fsDriver: messenger.remoteApi.fsDriver,
});
messenger.setLocalInterface({
renderer,
jumpToHash: (hash: string) => {
location.hash = `#${hash}`;
},
});
const lastScrollTop: number|null = null;
const onMainContentScroll = () => {
const newScrollTop = document.scrollingElement.scrollTop;
if (lastScrollTop !== newScrollTop) {
messenger.remoteApi.onScroll(newScrollTop);
}
};
// Listen for events on both scrollingElement and window
// - On Android, scrollingElement.addEventListener('scroll', callback) doesn't call callback on
// scroll. However, window.addEventListener('scroll', callback) does.
// - iOS needs a listener to be added to scrollingElement -- events aren't received when
// the listener is added to window with window.addEventListener('scroll', ...).
document.scrollingElement?.addEventListener('scroll', onMainContentScroll);
window.addEventListener('scroll', onMainContentScroll);

View File

@@ -1,39 +0,0 @@
import type { FsDriver as RendererFsDriver } from '@joplin/renderer/types';
import Renderer from './Renderer';
export interface RendererWebViewOptions {
settings: {
safeMode: boolean;
tempDir: string;
resourceDir: string;
resourceDownloadMode: string;
};
useTransferredFiles: boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
pluginOptions: Record<string, any>;
}
export interface ExtraContentScriptSource {
id: string;
js: string;
assetPath: string;
pluginId: string;
}
export interface NoteViewerLocalApi {
renderer: Renderer;
jumpToHash: (hash: string)=> void;
}
export interface NoteViewerRemoteApi {
onScroll(scrollTop: number): void;
onPostMessage(message: string): void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
onPostPluginMessage(contentScriptId: string, message: any): Promise<any>;
fsDriver: RendererFsDriver;
}
export interface WebViewLib {
initialize(config: unknown): void;
}

View File

@@ -1,86 +0,0 @@
import { Dispatch, RefObject, SetStateAction, useEffect, useMemo, useRef } from 'react';
import { WebViewControl } from '../../ExtendedWebView/types';
import { OnScrollCallback, OnWebViewMessageHandler } from '../types';
import RNToWebViewMessenger from '../../../utils/ipc/RNToWebViewMessenger';
import { NoteViewerLocalApi, NoteViewerRemoteApi } from '../bundledJs/types';
import shim from '@joplin/lib/shim';
import { WebViewMessageEvent } from 'react-native-webview';
import PluginService from '@joplin/lib/services/plugins/PluginService';
import Logger from '@joplin/utils/Logger';
const logger = Logger.create('useRenderer');
interface Props {
webviewRef: RefObject<WebViewControl>;
onScroll: OnScrollCallback;
onPostMessage: (message: string)=> void;
setOnWebViewMessage: Dispatch<SetStateAction<OnWebViewMessageHandler>>;
webViewLoaded: boolean;
tempDir: string;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const onPostPluginMessage = async (contentScriptId: string, message: any) => {
logger.debug(`Handling message from content script: ${contentScriptId}:`, message);
const pluginService = PluginService.instance();
const pluginId = pluginService.pluginIdByContentScriptId(contentScriptId);
if (!pluginId) {
throw new Error(`Plugin not found for content script with ID ${contentScriptId}`);
}
const plugin = pluginService.pluginById(pluginId);
return plugin.emitContentScriptMessage(contentScriptId, message);
};
const useRenderer = (props: Props) => {
const onScrollRef = useRef(props.onScroll);
onScrollRef.current = props.onScroll;
const onPostMessageRef = useRef(props.onPostMessage);
onPostMessageRef.current = props.onPostMessage;
const messenger = useMemo(() => {
const fsDriver = shim.fsDriver();
const localApi = {
onScroll: (fraction: number) => onScrollRef.current?.(fraction),
onPostMessage: (message: string) => onPostMessageRef.current?.(message),
onPostPluginMessage,
fsDriver: {
writeFile: async (path: string, content: string, encoding?: string) => {
if (!await fsDriver.exists(props.tempDir)) {
await fsDriver.mkdir(props.tempDir);
}
// To avoid giving the WebView access to the entire main tempDir,
// we use props.tempDir (which should be different).
path = fsDriver.resolveRelativePathWithinDir(props.tempDir, path);
return await fsDriver.writeFile(path, content, encoding);
},
exists: fsDriver.exists,
cacheCssToFile: fsDriver.cacheCssToFile,
},
};
return new RNToWebViewMessenger<NoteViewerRemoteApi, NoteViewerLocalApi>(
'note-viewer', props.webviewRef, localApi,
);
}, [props.webviewRef, props.tempDir]);
useEffect(() => {
props.setOnWebViewMessage(() => (event: WebViewMessageEvent) => {
messenger.onWebViewMessage(event);
});
}, [messenger, props.setOnWebViewMessage]);
useEffect(() => {
if (props.webViewLoaded) {
messenger.onWebViewLoaded();
}
}, [messenger, props.webViewLoaded]);
return useMemo(() => {
return messenger.remoteApi.renderer;
}, [messenger]);
};
export default useRenderer;

View File

@@ -1,26 +1,20 @@
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
import usePrevious from '@joplin/lib/hooks/usePrevious';
import { themeStyle } from '@joplin/lib/theme';
import { MarkupLanguage } from '@joplin/renderer';
import useEditPopup from './useEditPopup';
import Renderer from '../bundledJs/Renderer';
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import Logger from '@joplin/utils/Logger';
import { ExtraContentScriptSource } from '../bundledJs/types';
import Setting from '@joplin/lib/models/Setting';
import shim from '@joplin/lib/shim';
import { ResourceEntity, ResourceLocalStateEntity } from '@joplin/lib/services/database/types';
import { RendererControl, RenderOptions } from '../../../contentScripts/rendererBundle/types';
import Resource from '@joplin/lib/models/Resource';
import { ResourceEntity } from '@joplin/lib/services/database/types';
import resolvePathWithinDir from '@joplin/lib/utils/resolvePathWithinDir';
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
export interface ResourceInfo {
localState: unknown;
localState: ResourceLocalStateEntity;
item: ResourceEntity;
}
interface Props {
renderer: Renderer;
renderer: RendererControl;
noteBody: string;
noteMarkupLanguage: MarkupLanguage;
@@ -33,8 +27,6 @@ interface Props {
initialScroll: number|undefined;
paddingBottom: number;
contentScripts: ExtraContentScriptSource[];
}
const onlyCheckboxHasChangedHack = (previousBody: string, newBody: string) => {
@@ -56,10 +48,35 @@ const onlyCheckboxHasChangedHack = (previousBody: string, newBody: string) => {
const logger = Logger.create('useRerenderHandler');
const useRerenderHandler = (props: Props) => {
const { createEditPopupSyntax, destroyEditPopupSyntax, editPopupCss } = useEditPopup(props.themeId);
const useResourceLoadCounter = (noteResources: Record<string, ResourceInfo>) => {
const [lastResourceLoadCounter, setLastResourceLoadCounter] = useState(0);
const [pluginSettingKeys, setPluginSettingKeys] = useState<Record<string, boolean>>({});
const lastDownloadCount = useRef(-1);
useEffect(() => {
let downloadedCount = 0;
for (const resource of Object.values(noteResources)) {
if (resource.localState.fetch_status === Resource.FETCH_STATUS_DONE) {
downloadedCount ++;
}
}
if (lastDownloadCount.current !== -1 && lastDownloadCount.current < downloadedCount) {
setLastResourceLoadCounter(counter => counter + 1);
}
lastDownloadCount.current = downloadedCount;
}, [noteResources]);
return lastResourceLoadCounter;
};
const useRerenderHandler = (props: Props) => {
const resourceDownloadRerenderCounter = useResourceLoadCounter(props.noteResources);
useEffect(() => {
// Whenever a resource state changes, for example when it goes from "not downloaded" to "downloaded", the "noteResources"
// props changes, thus triggering a render. The **content** of this noteResources array however is not changed because
// it doesn't contain info about the resource download state. Because of that, if we were to use the markupToHtml() cache
// it wouldn't re-render at all.
props.renderer.clearCache(props.noteMarkupLanguage);
}, [resourceDownloadRerenderCounter, props.renderer, props.noteMarkupLanguage]);
// To address https://github.com/laurent22/joplin/issues/433
//
@@ -82,8 +99,8 @@ const useRerenderHandler = (props: Props) => {
// below logic rely on this.
const effectDependencies = [
props.noteBody, props.noteMarkupLanguage, props.renderer, props.highlightedKeywords,
props.noteHash, props.noteResources, props.themeId, props.paddingBottom, lastResourceLoadCounter,
createEditPopupSyntax, destroyEditPopupSyntax, pluginSettingKeys, props.fontSize,
props.noteHash, props.noteResources, props.themeId, props.paddingBottom, resourceDownloadRerenderCounter,
props.fontSize,
];
const previousDeps = usePrevious(effectDependencies, []);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
@@ -99,125 +116,42 @@ const useRerenderHandler = (props: Props) => {
const previousHash = usePrevious(props.noteHash, '');
const hashChanged = previousHash !== props.noteHash;
useEffect(() => {
// Whenever a resource state changes, for example when it goes from "not downloaded" to "downloaded", the "noteResources"
// props changes, thus triggering a render. The **content** of this noteResources array however is not changed because
// it doesn't contain info about the resource download state. Because of that, if we were to use the markupToHtml() cache
// it wouldn't re-render at all.
props.renderer.clearCache(props.noteMarkupLanguage);
}, [lastResourceLoadCounter, props.renderer, props.noteMarkupLanguage]);
useEffect(() => {
void props.renderer.setExtraContentScriptsAndRerender(props.contentScripts);
}, [props.contentScripts, props.renderer]);
useAsyncEffect(async event => {
useAsyncEffect(async (event) => {
if (onlyNoteBodyHasChanged && onlyCheckboxesHaveChanged) {
logger.info('Only a checkbox has changed - not updating HTML');
return;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const pluginSettings: Record<string, any> = { };
for (const key in pluginSettingKeys) {
pluginSettings[key] = Setting.value(`plugin-${key}`);
}
let newPluginSettingKeys = pluginSettingKeys;
// On web, resources are virtual files and thus need to be transferred to the WebView.
if (shim.mobilePlatform() === 'web') {
for (const [resourceId, resource] of Object.entries(props.noteResources)) {
try {
await props.renderer.setResourceFile(
resourceId,
await shim.fsDriver().fileAtPath(Resource.fullPath(resource.item)),
);
} catch (error) {
if (error.code !== 'ENOENT') {
throw error;
}
// This can happen if a resource hasn't been downloaded yet
logger.warn('Error: Resource file not found (ENOENT)', Resource.fullPath(resource.item), 'for ID', resource.item.id);
}
}
}
const theme = themeStyle(props.themeId);
const config = {
// We .stringify the theme to avoid a JSON serialization error involving
// the color package.
theme: JSON.stringify({
const config: RenderOptions = {
themeId: props.themeId,
themeOverrides: {
bodyPaddingTop: '0.8em',
bodyPaddingBottom: props.paddingBottom,
...theme,
noteViewerFontSize: props.fontSize,
}),
codeTheme: theme.codeThemeCss,
onResourceLoaded: () => {
// Force a rerender when a resource loads
setLastResourceLoadCounter(lastResourceLoadCounter + 1);
},
highlightedKeywords: props.highlightedKeywords,
resources: props.noteResources,
pluginAssetContainerSelector: '#joplin-container-pluginAssetsContainer',
// If the hash changed, we don't set initial scroll -- we want to scroll to the hash
// instead.
initialScroll: (previousHash && hashChanged) ? undefined : props.initialScroll,
noteHash: props.noteHash,
pluginSettings,
requestPluginSetting: (pluginId: string, settingKey: string) => {
// Don't trigger additional renders
if (event.cancelled) return;
const key = `${pluginId}.${settingKey}`;
logger.debug(`Request plugin setting: plugin-${key}`);
if (!(key in newPluginSettingKeys)) {
newPluginSettingKeys = { ...newPluginSettingKeys, [`${pluginId}.${settingKey}`]: true };
setPluginSettingKeys(newPluginSettingKeys);
}
},
readAssetBlob: (assetPath: string) => {
// Built-in assets are in resourceDir, external plugin assets are in cacheDir.
const assetsDirs = [Setting.value('resourceDir'), Setting.value('cacheDir')];
let resolvedPath = null;
for (const assetDir of assetsDirs) {
resolvedPath ??= resolvePathWithinDir(assetDir, assetPath);
if (resolvedPath) break;
}
if (!resolvedPath) {
throw new Error(`Failed to load asset at ${assetPath} -- not in any of the allowed asset directories: ${assetsDirs.join(',')}.`);
}
return shim.fsDriver().fileAtPath(resolvedPath);
},
createEditPopupSyntax,
destroyEditPopupSyntax,
};
try {
logger.debug('Starting render...');
await props.renderer.rerender({
await props.renderer.rerenderToBody({
language: props.noteMarkupLanguage,
markup: props.noteBody,
}, config);
}, config, event);
logger.debug('Render complete.');
} catch (error) {
logger.error('Render failed:', error);
}
}, effectDependencies);
useEffect(() => {
props.renderer.setExtraCss('edit-popup', editPopupCss);
}, [editPopupCss, props.renderer]);
};
export default useRerenderHandler;

View File

@@ -1,49 +1,15 @@
import { useMemo } from 'react';
import shim from '@joplin/lib/shim';
import Setting from '@joplin/lib/models/Setting';
import { RendererWebViewOptions } from '../bundledJs/types';
import { themeStyle } from '../../global-style';
import { Platform } from 'react-native';
const useSource = (tempDirPath: string, themeId: number) => {
const injectedJs = useMemo(() => {
const subValues = Setting.subValues('markdown.plugin', Setting.toPlainObject());
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const pluginOptions: any = {};
for (const n in subValues) {
pluginOptions[n] = { enabled: subValues[n] };
}
const rendererWebViewOptions: RendererWebViewOptions = {
settings: {
safeMode: Setting.value('isSafeMode'),
tempDir: tempDirPath,
resourceDir: Setting.value('resourceDir'),
resourceDownloadMode: Setting.value('sync.resourceDownloadMode'),
},
// Web needs files to be transferred manually, since image SRCs can't reference
// the Origin Private File System.
useTransferredFiles: Platform.OS === 'web',
pluginOptions,
};
return `
window.rendererWebViewOptions = ${JSON.stringify(rendererWebViewOptions)};
if (!window.injectedJsLoaded) {
window.injectedJsLoaded = true;
${shim.injectedJs('webviewLib')}
${shim.injectedJs('noteBodyViewerBundle')}
}
`;
}, [tempDirPath]);
import { PageSetupSources } from '../../../contentScripts/types';
const useSource = (rendererSource: PageSetupSources, themeId: number) => {
const [paddingLeft, paddingRight] = useMemo(() => {
const theme = themeStyle(themeId);
return [theme.marginLeft, theme.marginRight];
}, [themeId]);
const rendererBaseCss = rendererSource.css;
const html = useMemo(() => {
// iOS doesn't automatically adjust the WebView's font size to match users'
// accessibility settings. To do this, we need to tell it to match the system font.
@@ -75,6 +41,7 @@ const useSource = (tempDirPath: string, themeId: number) => {
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
${defaultCss}
${rendererBaseCss}
${shim.mobilePlatform() === 'ios' ? iOSSpecificCss : ''}
</style>
</head>
@@ -84,9 +51,9 @@ const useSource = (tempDirPath: string, themeId: number) => {
</body>
</html>
`;
}, [paddingLeft, paddingRight]);
}, [paddingLeft, paddingRight, rendererBaseCss]);
return { html, injectedJs };
return { html, js: rendererSource.js };
};
export default useSource;

View File

@@ -1,91 +0,0 @@
/* eslint-disable import/prefer-default-export */
// This contains the CodeMirror instance, which needs to be built into a bundle
// using `yarn buildInjectedJs`. This bundle is then loaded from
// NoteEditor.tsx into the webview.
//
// In general, since this file is harder to debug due to the intermediate built
// step, it's better to keep it as light as possible - it should just be a light
// wrapper to access CodeMirror functionalities. Anything else should be done
// from NoteEditor.tsx.
import { EditorSettings } from '@joplin/editor/types';
import createEditor from '@joplin/editor/CodeMirror/createEditor';
import CodeMirrorControl from '@joplin/editor/CodeMirror/CodeMirrorControl';
import WebViewToRNMessenger from '../../../utils/ipc/WebViewToRNMessenger';
import { WebViewToEditorApi } from '../types';
import { focus } from '@joplin/lib/utils/focusHandler';
import Logger, { TargetType } from '@joplin/utils/Logger';
let loggerCreated = false;
export const setUpLogger = () => {
if (!loggerCreated) {
const logger = new Logger();
logger.addTarget(TargetType.Console);
logger.setLevel(Logger.LEVEL_WARN);
Logger.initializeGlobalLogger(logger);
loggerCreated = true;
}
};
export const initCodeMirror = (
parentElement: HTMLElement,
initialText: string,
initialNoteId: string,
settings: EditorSettings,
): CodeMirrorControl => {
const messenger = new WebViewToRNMessenger<CodeMirrorControl, WebViewToEditorApi>('editor', null);
const control = createEditor(parentElement, {
initialText,
initialNoteId,
settings,
onPasteFile: async (data) => {
const reader = new FileReader();
return new Promise<void>((resolve, reject) => {
reader.onload = async () => {
const dataUrl = reader.result as string;
const base64 = dataUrl.replace(/^data:.*;base64,/, '');
await messenger.remoteApi.onPasteFile(data.type, base64);
resolve();
};
reader.onerror = () => reject(new Error('Failed to load file.'));
reader.readAsDataURL(data);
});
},
onLogMessage: message => {
void messenger.remoteApi.logMessage(message);
},
onEvent: (event): void => {
void messenger.remoteApi.onEditorEvent(event);
},
});
// Works around https://github.com/laurent22/joplin/issues/10047 by handling
// the text/uri-list MIME type when pasting, rather than sending the paste event
// to CodeMirror.
//
// TODO: Remove this workaround when the issue has been fixed upstream.
control.on('paste', (_editor, event: ClipboardEvent) => {
const clipboardData = event.clipboardData;
if (clipboardData.types.length === 1 && clipboardData.types[0] === 'text/uri-list') {
event.preventDefault();
control.insertText(clipboardData.getData('text/uri-list'));
}
});
// Note: Just adding an onclick listener seems sufficient to focus the editor when its background
// is tapped.
parentElement.addEventListener('click', (event) => {
const activeElement = document.querySelector(':focus');
if (!parentElement.contains(activeElement) && event.target === parentElement) {
focus('initial editor focus', control);
}
});
messenger.setLocalInterface(control);
return control;
};

View File

@@ -1,60 +0,0 @@
<!--
Open this file in a web browser to more easily debug the CodeMirror editor.
Messages will show up in the console when posted.
-->
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>
<meta charset="utf-8"/>
<title>CodeMirror test</title>
</head>
<body>
<div class="CodeMirror"></div>
<script>
// Override the default postMessage — codeMirrorBundle expects
// this to be present.
window.ReactNativeWebView = {
postMessage: message => {
console.log('postMessage:', message);
},
};
</script>
<script src="./CodeMirror.bundle.js"></script>
<script>
const parent = document.querySelector('.CodeMirror');
const initialText = 'Testing...';
const settings = {
themeData: {
fontSize: 12, // px
fontFamily: 'serif',
backgroundColor: 'black',
color: 'white',
backgroundColor2: '#330',
color2: '#ff0',
backgroundColor3: '#404',
color3: '#f0f',
backgroundColor4: '#555',
color4: '#0ff',
appearance: 'dark',
},
themeId: 0,
spellcheckEnabled: true,
language: 'markdown',
katexEnabled: true,
useExternalSearch: false,
readOnly: false,
keymap: 'default',
automatchBraces: false,
ignoreModifiers: false,
indentWithTabs: false,
};
window.cm = codeMirrorBundle.initCodeMirror(parent, initialText, settings);
</script>
</body>
</html>

View File

@@ -1,20 +1,13 @@
import * as React from 'react';
import { _ } from '@joplin/lib/locale';
import Logger from '@joplin/utils/Logger';
import Setting from '@joplin/lib/models/Setting';
import shim from '@joplin/lib/shim';
import { themeStyle } from '@joplin/lib/theme';
import { Theme } from '@joplin/lib/themes/type';
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { Platform } from 'react-native';
import { useCallback, useContext, useEffect, useRef, useState } from 'react';
import ExtendedWebView from '../../ExtendedWebView';
import { OnMessageEvent, WebViewControl } from '../../ExtendedWebView/types';
import { clearAutosave } from './autosave';
import { LocalizedStrings } from './js-draw/types';
import { clearAutosave, writeAutosave } from './autosave';
import { DialogContext } from '../../DialogManager';
import useEditorMessenger from './utils/useEditorMessenger';
import BackButtonService from '../../../services/BackButtonService';
import useWebViewSetup, { ImageEditorControl } from '../../../contentScripts/imageEditorBundle/useWebViewSetup';
const logger = Logger.create('ImageEditor');
@@ -28,69 +21,15 @@ interface Props {
onExit: OnCancelCallback;
}
const useCss = (editorTheme: Theme) => {
return useMemo(() => {
// Ensure we have contrast between the background and selection. Some themes
// have the same backgroundColor and selectionColor2. (E.g. Aritim Dark)
let selectionBackgroundColor = editorTheme.selectedColor2;
if (selectionBackgroundColor === editorTheme.backgroundColor) {
selectionBackgroundColor = editorTheme.selectedColor;
}
return `
:root .imageEditorContainer {
--background-color-1: ${editorTheme.backgroundColor};
--foreground-color-1: ${editorTheme.color};
--background-color-2: ${editorTheme.backgroundColor3};
--foreground-color-2: ${editorTheme.color3};
--background-color-3: ${editorTheme.raisedBackgroundColor};
--foreground-color-3: ${editorTheme.raisedColor};
--selection-background-color: ${editorTheme.backgroundColorHover3};
--selection-foreground-color: ${editorTheme.color3};
--primary-action-foreground-color: ${editorTheme.color4};
--primary-shadow-color: ${editorTheme.colorFaded};
width: 100vw;
height: 100vh;
box-sizing: border-box;
}
body, html {
padding: 0;
margin: 0;
overflow: hidden;
}
/* Hide the scrollbar. See scrollbar accessibility concerns
(https://developer.mozilla.org/en-US/docs/Web/CSS/scrollbar-width#accessibility_concerns)
for why this isn't done in js-draw itself. */
.toolbar-tool-row::-webkit-scrollbar {
display: none;
height: 0;
}
/* Hide the save/close icons on small screens. This isn't done in the upstream
js-draw repository partially because it isn't as well localized as Joplin
(icons can be used to suggest the meaning of a button when a translation is
unavailable). */
.toolbar-edge-toolbar:not(.one-row) .toolwidget-tag--save .toolbar-icon,
.toolbar-edge-toolbar:not(.one-row) .toolwidget-tag--exit .toolbar-icon {
display: none;
}
`;
}, [editorTheme]);
};
const ImageEditor = (props: Props) => {
const editorTheme: Theme = themeStyle(props.themeId);
const webviewRef = useRef<WebViewControl|null>(null);
const webViewRef = useRef<WebViewControl|null>(null);
const [imageChanged, setImageChanged] = useState(false);
const editorControlRef = useRef<ImageEditorControl|null>(null);
const dialogs = useContext(DialogContext);
const onRequestCloseEditor = useCallback((promptIfUnsaved: boolean) => {
const onRequestCloseEditor = useCallback((promptIfUnsaved = true) => {
const discardChangesAndClose = async () => {
await clearAutosave();
props.onExit();
@@ -98,7 +37,7 @@ const ImageEditor = (props: Props) => {
if (!imageChanged || !promptIfUnsaved) {
void discardChangesAndClose();
return true;
return;
}
dialogs.prompt(
@@ -113,13 +52,12 @@ const ImageEditor = (props: Props) => {
onPress: () => {
// saveDrawing calls props.onSave(...) which may close the
// editor.
webviewRef.current.injectJS('window.editorControl.saveThenExit()');
void editorControlRef.current.saveThenExit();
},
},
],
);
return true;
}, [webviewRef, dialogs, props.onExit, imageChanged]);
}, [dialogs, props.onExit, imageChanged]);
useEffect(() => {
const hardwareBackPressListener = () => {
@@ -133,9 +71,18 @@ const ImageEditor = (props: Props) => {
};
}, [onRequestCloseEditor]);
const css = useCss(editorTheme);
const [html, setHtml] = useState('');
const { pageSetup, api: editorControl, webViewEventHandlers } = useWebViewSetup({
webViewRef,
themeId: props.themeId,
onSetImageChanged: setImageChanged,
onAutoSave: writeAutosave,
onSave: props.onSave,
onRequestCloseEditor,
resourceFilename: props.resourceFilename,
});
editorControlRef.current = editorControl;
const [html, setHtml] = useState('');
useEffect(() => {
setHtml(`
<!DOCTYPE html>
@@ -144,8 +91,8 @@ const ImageEditor = (props: Props) => {
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no"/>
<style id='main-style'>
${css}
<style>
${pageSetup.css}
</style>
</head>
<body></body>
@@ -160,112 +107,12 @@ const ImageEditor = (props: Props) => {
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps
}, []);
// A set of localization overrides (Joplin is better localized than js-draw).
// All localizable strings (some unused?) can be found at
// https://github.com/personalizedrefrigerator/js-draw/blob/main/.github/ISSUE_TEMPLATE/translation-js-draw-new.yml
const localizedStrings: LocalizedStrings = useMemo(() => ({
save: _('Save'),
close: _('Close'),
undo: _('Undo'),
redo: _('Redo'),
}), []);
const appInfo = useMemo(() => {
return {
name: 'Joplin',
description: `v${shim.appVersion()}`,
};
}, []);
const injectedJavaScript = useMemo(() => `
window.onerror = (message, source, lineno) => {
window.ReactNativeWebView.postMessage(
"error: " + message + " in file://" + source + ", line " + lineno,
);
};
window.onunhandledrejection = (error) => {
window.ReactNativeWebView.postMessage(
"error: " + error.reason,
);
};
try {
if (window.editorControl === undefined) {
${shim.injectedJs('svgEditorBundle')}
window.editorControl = svgEditorBundle.createJsDrawEditor(
svgEditorBundle.createMessenger().remoteApi,
${JSON.stringify(Setting.value('imageeditor.jsdrawToolbar'))},
${JSON.stringify(Setting.value('locale'))},
${JSON.stringify(localizedStrings)},
${JSON.stringify({
appInfo,
...(shim.mobilePlatform() === 'web' ? {
// Use the browser-default clipboard API on web.
clipboardApi: null,
} : {}),
})},
);
}
} catch(e) {
window.ReactNativeWebView.postMessage(
'error: ' + e.message + ': ' + JSON.stringify(e)
);
}
true;
`, [localizedStrings, appInfo]);
useEffect(() => {
webviewRef.current?.injectJS(`
document.querySelector('#main-style').textContent = ${JSON.stringify(css)};
if (window.editorControl) {
window.editorControl.onThemeUpdate();
}
`);
}, [css]);
const onReadyToLoadData = useCallback(async () => {
const getInitialInjectedData = async () => {
// On mobile, it's faster to load the image within the WebView with an XMLHttpRequest.
// In this case, the image is loaded elsewhere.
if (Platform.OS !== 'web') {
return undefined;
}
// On web, however, this doesn't work, so the image needs to be loaded here.
if (!props.resourceFilename) {
return '';
}
return await shim.fsDriver().readFile(props.resourceFilename, 'utf-8');
};
// It can take some time for initialSVGData to be transferred to the WebView.
// Thus, do so after the main content has been loaded.
webviewRef.current.injectJS(`(async () => {
if (window.editorControl) {
const initialSVGPath = ${JSON.stringify(props.resourceFilename)};
const initialTemplateData = ${JSON.stringify(Setting.value('imageeditor.imageTemplate'))};
const initialData = ${JSON.stringify(await getInitialInjectedData())};
editorControl.loadImageOrTemplate(initialSVGPath, initialTemplateData, initialData);
}
})();`);
}, [webviewRef, props.resourceFilename]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const onError = useCallback((event: any) => {
logger.error('ImageEditor: WebView error: ', event);
}, []);
const messenger = useEditorMessenger({
webviewRef,
setImageChanged,
onReadyToLoadData,
onSave: props.onSave,
onRequestCloseEditor,
});
const onWebViewMessage = webViewEventHandlers.onMessage;
const onMessage = useCallback((event: OnMessageEvent) => {
const data = event.nativeEvent.data;
if (typeof data === 'string' && data.startsWith('error:')) {
@@ -273,18 +120,18 @@ const ImageEditor = (props: Props) => {
return;
}
messenger.onWebViewMessage(event);
}, [messenger]);
onWebViewMessage(event);
}, [onWebViewMessage]);
return (
<ExtendedWebView
html={html}
injectedJavaScript={injectedJavaScript}
injectedJavaScript={pageSetup.js}
allowFileAccessFromJs={true}
onMessage={onMessage}
onLoadEnd={messenger.onWebViewLoaded}
onLoadEnd={webViewEventHandlers.onLoadEnd}
onError={onError}
ref={webviewRef}
ref={webViewRef}
webviewInstanceId={'image-editor-js-draw'}
/>
);

View File

@@ -1,11 +0,0 @@
// .replaceChildren is not supported in Chromium 83, which is the default for Android 11
// (unless auto-updated from the Google Play store).
HTMLElement.prototype.replaceChildren ??= function(this: HTMLElement, ...nodes: Node[]) {
while (this.children.length) {
this.children[0].remove();
}
for (const node of nodes) {
this.appendChild(node);
}
};

View File

@@ -0,0 +1,193 @@
import { themeStyle } from '@joplin/lib/theme';
import themeToCss from '@joplin/lib/services/style/themeToCss';
import ExtendedWebView from '../ExtendedWebView';
import * as React from 'react';
import { useEffect } from 'react';
import { useMemo, useCallback } from 'react';
import { NativeSyntheticEvent } from 'react-native';
import { EditorProps } from './types';
import { _ } from '@joplin/lib/locale';
import useCodeMirrorPlugins from './hooks/useCodeMirrorPlugins';
import { WebViewErrorEvent } from 'react-native-webview/lib/RNCWebViewNativeComponent';
import Logger from '@joplin/utils/Logger';
import { OnMessageEvent } from '../ExtendedWebView/types';
import useWebViewSetup from '../../contentScripts/markdownEditorBundle/useWebViewSetup';
const logger = Logger.create('MarkdownEditor');
function useCss(themeId: number): string {
return useMemo(() => {
const theme = themeStyle(themeId);
const themeVariableCss = themeToCss(theme);
return `
${themeVariableCss}
:root {
background-color: ${theme.backgroundColor};
}
body {
margin: 0;
height: 100vh;
/* Prefer 100% -- 100vw shows an unnecessary horizontal scrollbar in Google Chrome (desktop). */
width: 100%;
box-sizing: border-box;
padding-left: 1px;
padding-right: 1px;
padding-bottom: 1px;
padding-top: 10px;
font-size: 13pt;
}
* {
scrollbar-width: thin;
scrollbar-color: rgba(100, 100, 100, 0.7) rgba(0, 0, 0, 0.1);
}
@supports selector(::-webkit-scrollbar) {
*::-webkit-scrollbar {
width: 7px;
height: 7px;
}
*::-webkit-scrollbar-corner {
background: none;
}
*::-webkit-scrollbar-track {
border: none;
}
*::-webkit-scrollbar-thumb {
background: rgba(100, 100, 100, 0.3);
border-radius: 5px;
}
*::-webkit-scrollbar-track:hover {
background: rgba(0, 0, 0, 0.1);
}
*::-webkit-scrollbar-thumb:hover {
background: rgba(100, 100, 100, 0.7);
}
* {
scrollbar-width: unset;
scrollbar-color: unset;
}
}
`;
}, [themeId]);
}
function useHtml(): string {
return useMemo(() => `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>${_('Note editor')}</title>
<style>
/* For better scrolling on iOS (working scrollbar) we use external, rather than internal,
scrolling. */
.cm-scroller {
overflow: none;
}
</style>
</head>
<body>
<div class="CodeMirror" style="height:100%;" autocapitalize="on"></div>
</body>
</html>
`, []);
}
const MarkdownEditor: React.FC<EditorProps> = props => {
const webviewRef = props.webviewRef;
const editorWebViewSetup = useWebViewSetup({
initialSelection: props.initialSelection,
noteHash: props.noteHash,
globalSearch: props.globalSearch,
onEditorEvent: props.onEditorEvent,
onAttachFile: props.onAttach,
editorOptions: {
parentElementClassName: 'CodeMirror',
initialText: props.initialText,
initialNoteId: props.noteId,
settings: props.editorSettings,
},
webviewRef,
});
props.editorRef.current = editorWebViewSetup.api.editor;
const injectedJavaScript = `
window.onerror = (message, source, lineno) => {
console.error(message);
window.ReactNativeWebView.postMessage(
"error: " + message + " in file://" + source + ", line " + lineno
);
};
window.onunhandledrejection = (event) => {
window.ReactNativeWebView.postMessage(
"error: Unhandled promise rejection: " + event
);
};
try {
${editorWebViewSetup.pageSetup.js}
} catch (e) {
console.error('Setup error: ', e);
window.ReactNativeWebView.postMessage("error:" + e.message + ": " + JSON.stringify(e))
}
true;
`;
const css = useCss(props.themeId);
const html = useHtml();
const codeMirrorPlugins = useCodeMirrorPlugins(props.plugins);
useEffect(() => {
void editorWebViewSetup.api.editor.setContentScripts(codeMirrorPlugins);
}, [codeMirrorPlugins, editorWebViewSetup]);
const onMessage = useCallback((event: OnMessageEvent) => {
const data = event.nativeEvent.data;
if (typeof data === 'string' && data.indexOf('error:') === 0) {
logger.error('CodeMirror error', data);
return;
}
editorWebViewSetup.webViewEventHandlers.onMessage(event);
}, [editorWebViewSetup]);
const onError = useCallback((event: NativeSyntheticEvent<WebViewErrorEvent>) => {
logger.error(`Load error: Code ${event.nativeEvent.code}: ${event.nativeEvent.description}`);
}, []);
return (
<ExtendedWebView
ref={webviewRef}
webviewInstanceId='MarkdownEditor'
testID='MarkdownEditor'
scrollEnabled={true}
html={html}
injectedJavaScript={injectedJavaScript}
css={css}
hasPluginScripts={codeMirrorPlugins.length > 0}
onMessage={onMessage}
onLoadEnd={editorWebViewSetup.webViewEventHandlers.onLoadEnd}
onError={onError}
/>
);
};
export default MarkdownEditor;

View File

@@ -15,10 +15,31 @@ import mockCommandRuntimes from '../EditorToolbar/testing/mockCommandRuntimes';
import setupGlobalStore from '../../utils/testing/setupGlobalStore';
import { Store } from 'redux';
import { AppState } from '../../utils/types';
import { MarkupLanguage } from '@joplin/renderer';
import { EditorType } from './types';
let store: Store<AppState>;
let registeredRuntime: RegisteredRuntime;
const defaultEditorProps = {
themeId: Setting.THEME_ARITIM_DARK,
markupLanguage: MarkupLanguage.Markdown,
initialText: 'Testing...',
globalSearch: '',
noteId: '',
noteHash: '',
style: {},
toolbarEnabled: true,
readOnly: false,
onChange: ()=>{},
onSelectionChange: ()=>{},
onUndoRedoDepthChange: ()=>{},
onAttach: async ()=>{},
noteResources: {},
plugins: {},
mode: EditorType.Markdown,
};
describe('NoteEditor', () => {
beforeAll(() => {
// This allows the NoteEditor test to register editor commands without errors.
@@ -45,19 +66,8 @@ describe('NoteEditor', () => {
const wrappedNoteEditor = render(
<TestProviderStack store={store}>
<NoteEditor
themeId={Setting.THEME_ARITIM_DARK}
initialText='Testing...'
globalSearch=''
noteId=''
noteHash=''
style={{}}
toolbarEnabled={true}
readOnly={false}
onChange={()=>{}}
onSelectionChange={()=>{}}
onUndoRedoDepthChange={()=>{}}
onAttach={async ()=>{}}
plugins={{}}
ref={undefined}
{...defaultEditorProps}
/>
</TestProviderStack>,
);
@@ -99,4 +109,27 @@ describe('NoteEditor', () => {
wrappedNoteEditor.unmount();
});
it('should show a warning banner the first time the Rich Text Editor is used', () => {
const wrappedNoteEditor = render(
<TestProviderStack store={store}>
<NoteEditor
ref={undefined}
{...defaultEditorProps}
mode={EditorType.RichText}
/>
</TestProviderStack>,
);
const warningBannerQuery = /This Rich Text editor has a number of limitations.*/;
const warning = screen.getByText(warningBannerQuery);
expect(warning).toBeVisible();
// Pressing dismiss should dismiss the warning
const dismissButton = screen.getByHintText('Hides warning');
fireEvent.press(dismissButton);
expect(screen.queryByText(warningBannerQuery)).toBeNull();
wrappedNoteEditor.unmount();
});
});

View File

@@ -1,46 +1,49 @@
import Setting from '@joplin/lib/models/Setting';
import shim from '@joplin/lib/shim';
import { themeStyle } from '@joplin/lib/theme';
import themeToCss from '@joplin/lib/services/style/themeToCss';
import EditLinkDialog from './EditLinkDialog';
import { defaultSearchState, SearchPanel } from './SearchPanel';
import ExtendedWebView from '../ExtendedWebView';
import { WebViewControl } from '../ExtendedWebView/types';
import * as React from 'react';
import { forwardRef, RefObject, useEffect, useImperativeHandle } from 'react';
import { Ref, RefObject, useEffect, useImperativeHandle } from 'react';
import { useMemo, useState, useCallback, useRef } from 'react';
import { LayoutChangeEvent, NativeSyntheticEvent, View, ViewStyle } from 'react-native';
import { LayoutChangeEvent, View, ViewStyle } from 'react-native';
import { editorFont } from '../global-style';
import { EditorControl as EditorBodyControl, ContentScriptData } from '@joplin/editor/types';
import { EditorControl, EditorSettings, SelectionRange, WebViewToEditorApi } from './types';
import { EditorControl, EditorSettings, EditorType } from './types';
import { _ } from '@joplin/lib/locale';
import { ChangeEvent, EditorEvent, EditorEventType, SelectionRangeChangeEvent, UndoRedoDepthChangeEvent } from '@joplin/editor/events';
import { EditorCommandType, EditorKeymap, EditorLanguageType, SearchState } from '@joplin/editor/types';
import SelectionFormatting, { defaultSelectionFormatting } from '@joplin/editor/SelectionFormatting';
import useCodeMirrorPlugins from './hooks/useCodeMirrorPlugins';
import RNToWebViewMessenger from '../../utils/ipc/RNToWebViewMessenger';
import { WebViewErrorEvent } from 'react-native-webview/lib/RNCWebViewNativeComponent';
import Logger from '@joplin/utils/Logger';
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
import useEditorCommandHandler from './hooks/useEditorCommandHandler';
import { OnMessageEvent } from '../ExtendedWebView/types';
import { join, dirname } from 'path';
import * as mimeUtils from '@joplin/lib/mime-utils';
import uuid from '@joplin/lib/uuid';
import EditorToolbar from '../EditorToolbar/EditorToolbar';
import { SelectionRange } from '../../contentScripts/markdownEditorBundle/types';
import MarkdownEditor from './MarkdownEditor';
import RichTextEditor from './RichTextEditor';
import { ResourceInfos } from '@joplin/renderer/types';
import CommandService from '@joplin/lib/services/CommandService';
import Resource from '@joplin/lib/models/Resource';
import { join } from 'path';
import uuid from '@joplin/lib/uuid';
import shim from '@joplin/lib/shim';
import { dirname } from '@joplin/utils/path';
import { toFileExtension } from '@joplin/lib/mime-utils';
import { MarkupLanguage } from '@joplin/renderer';
import WarningBanner from './WarningBanner';
type ChangeEventHandler = (event: ChangeEvent)=> void;
type UndoRedoDepthChangeHandler = (event: UndoRedoDepthChangeEvent)=> void;
type SelectionChangeEventHandler = (event: SelectionRangeChangeEvent)=> void;
type OnAttachCallback = (filePath?: string)=> Promise<void>;
const logger = Logger.create('NoteEditor');
interface Props {
ref: Ref<EditorControl>;
themeId: number;
initialText: string;
mode: EditorType;
markupLanguage: MarkupLanguage;
noteId: string;
noteHash: string;
globalSearch: string;
@@ -49,6 +52,7 @@ interface Props {
toolbarEnabled: boolean;
readOnly: boolean;
plugins: PluginStates;
noteResources: ResourceInfos;
onChange: ChangeEventHandler;
onSelectionChange: SelectionChangeEventHandler;
@@ -61,103 +65,6 @@ function fontFamilyFromSettings() {
return font ? `${font}, sans-serif` : 'sans-serif';
}
function useCss(themeId: number): string {
return useMemo(() => {
const theme = themeStyle(themeId);
const themeVariableCss = themeToCss(theme);
return `
${themeVariableCss}
:root {
background-color: ${theme.backgroundColor};
}
body {
margin: 0;
height: 100vh;
/* Prefer 100% -- 100vw shows an unnecessary horizontal scrollbar in Google Chrome (desktop). */
width: 100%;
box-sizing: border-box;
padding-left: 1px;
padding-right: 1px;
padding-bottom: 1px;
padding-top: 10px;
font-size: 13pt;
}
* {
scrollbar-width: thin;
scrollbar-color: rgba(100, 100, 100, 0.7) rgba(0, 0, 0, 0.1);
}
@supports selector(::-webkit-scrollbar) {
*::-webkit-scrollbar {
width: 7px;
height: 7px;
}
*::-webkit-scrollbar-corner {
background: none;
}
*::-webkit-scrollbar-track {
border: none;
}
*::-webkit-scrollbar-thumb {
background: rgba(100, 100, 100, 0.3);
border-radius: 5px;
}
*::-webkit-scrollbar-track:hover {
background: rgba(0, 0, 0, 0.1);
}
*::-webkit-scrollbar-thumb:hover {
background: rgba(100, 100, 100, 0.7);
}
* {
scrollbar-width: unset;
scrollbar-color: unset;
}
}
`;
}, [themeId]);
}
const themeStyleSheetClassName = 'note-editor-styles';
function useHtml(initialCss: string): string {
const cssRef = useRef(initialCss);
cssRef.current = initialCss;
return useMemo(() => `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>${_('Note editor')}</title>
<style>
/* For better scrolling on iOS (working scrollbar) we use external, rather than internal,
scrolling. */
.cm-scroller {
overflow: none;
}
</style>
<style class=${JSON.stringify(themeStyleSheetClassName)}>
${cssRef.current}
</style>
</head>
<body>
<div class="CodeMirror" style="height:100%;" autocapitalize="on"></div>
</body>
</html>
`, []);
}
function editorTheme(themeId: number) {
const fontSizeInPx = Setting.value('style.editor.fontSize');
@@ -167,6 +74,7 @@ function editorTheme(themeId: number) {
const estimatedFontSizeInEm = fontSizeInPx / 16;
return {
themeId,
...themeStyle(themeId),
// To allow accessibility font scaling, we also need to set the
@@ -181,54 +89,54 @@ function editorTheme(themeId: number) {
type OnSetVisibleCallback = (visible: boolean)=> void;
type OnSearchStateChangeCallback = (state: SearchState)=> void;
const useEditorControl = (
bodyControl: EditorBodyControl,
editorRef: RefObject<EditorBodyControl>,
webviewRef: RefObject<WebViewControl>,
setLinkDialogVisible: OnSetVisibleCallback,
setSearchState: OnSearchStateChangeCallback,
): EditorControl => {
return useMemo(() => {
const execEditorCommand = (command: EditorCommandType) => {
void bodyControl.execCommand(command);
void editorRef.current.execCommand(command);
};
const setSearchStateCallback = (state: SearchState) => {
bodyControl.setSearchState(state);
editorRef.current.setSearchState(state);
setSearchState(state);
};
const control: EditorControl = {
supportsCommand(command: EditorCommandType) {
return bodyControl.supportsCommand(command);
return editorRef.current.supportsCommand(command);
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
execCommand(command, ...args: any[]) {
return bodyControl.execCommand(command, ...args);
return editorRef.current.execCommand(command, ...args);
},
focus() {
void bodyControl.execCommand(EditorCommandType.Focus);
void editorRef.current.execCommand(EditorCommandType.Focus);
},
undo() {
bodyControl.undo();
editorRef.current.undo();
},
redo() {
bodyControl.redo();
editorRef.current.redo();
},
select(anchor: number, head: number) {
bodyControl.select(anchor, head);
editorRef.current.select(anchor, head);
},
setScrollPercent(fraction: number) {
bodyControl.setScrollPercent(fraction);
editorRef.current.setScrollPercent(fraction);
},
insertText(text: string) {
bodyControl.insertText(text);
editorRef.current.insertText(text);
},
updateBody(newBody: string) {
bodyControl.updateBody(newBody);
editorRef.current.updateBody(newBody);
},
updateSettings(newSettings: EditorSettings) {
bodyControl.updateSettings(newSettings);
editorRef.current.updateSettings(newSettings);
},
toggleBolded() {
@@ -276,7 +184,7 @@ const useEditorControl = (
execEditorCommand(EditorCommandType.IndentLess);
},
updateLink(label: string, url: string) {
bodyControl.updateLink(label, url);
editorRef.current.updateLink(label, url);
},
scrollSelectionIntoView() {
execEditorCommand(EditorCommandType.ScrollSelectionIntoView);
@@ -292,7 +200,7 @@ const useEditorControl = (
},
setContentScripts: async (plugins: ContentScriptData[]) => {
return bodyControl.setContentScripts(plugins);
return editorRef.current.setContentScripts(plugins);
},
setSearchState: setSearchStateCallback,
@@ -320,37 +228,25 @@ const useEditorControl = (
setSearchState: setSearchStateCallback,
},
onResourceDownloaded: (id: string) => {
editorRef.current.onResourceDownloaded(id);
},
};
return control;
}, [webviewRef, bodyControl, setLinkDialogVisible, setSearchState]);
}, [webviewRef, editorRef, setLinkDialogVisible, setSearchState]);
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
function NoteEditor(props: Props, ref: any) {
function NoteEditor(props: Props) {
const webviewRef = useRef<WebViewControl>(null);
const setInitialSelectionJs = props.initialSelection ? `
cm.select(${props.initialSelection.start}, ${props.initialSelection.end});
cm.execCommand('scrollSelectionIntoView');
` : '';
const jumpToHashJs = props.noteHash ? `
cm.jumpToHash(${JSON.stringify(props.noteHash)});
` : '';
const setInitialSearchJs = props.globalSearch ? `
cm.setSearchState(${JSON.stringify({
...defaultSearchState,
searchText: props.globalSearch,
})})
` : '';
const editorSettings: EditorSettings = useMemo(() => ({
themeId: props.themeId,
themeData: editorTheme(props.themeId),
markdownMarkEnabled: Setting.value('markdown.plugin.mark'),
katexEnabled: Setting.value('markdown.plugin.katex'),
spellcheckEnabled: Setting.value('editor.mobile.spellcheckEnabled'),
language: EditorLanguageType.Markdown,
language: props.markupLanguage === MarkupLanguage.Html ? EditorLanguageType.Html : EditorLanguageType.Markdown,
useExternalSearch: true,
readOnly: props.readOnly,
@@ -365,147 +261,14 @@ function NoteEditor(props: Props, ref: any) {
indentWithTabs: true,
editorLabel: _('Markdown editor'),
}), [props.themeId, props.readOnly]);
}), [props.themeId, props.readOnly, props.markupLanguage]);
const injectedJavaScript = `
window.onerror = (message, source, lineno) => {
window.ReactNativeWebView.postMessage(
"error: " + message + " in file://" + source + ", line " + lineno
);
};
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
// the wrapper component.
window.cm = null;
try {
${shim.injectedJs('codeMirrorBundle')};
codeMirrorBundle.setUpLogger();
const parentElement = document.getElementsByClassName('CodeMirror')[0];
// On Android, injectJavaScript is run twice -- once before the parent element exists.
// To avoid logging unnecessary errors to the console, skip setup in this case:
if (parentElement) {
const initialText = ${JSON.stringify(props.initialText)};
const settings = ${JSON.stringify(editorSettings)};
window.cm = codeMirrorBundle.initCodeMirror(
parentElement,
initialText,
${JSON.stringify(props.noteId)},
settings
);
${jumpToHashJs}
// Set the initial selection after jumping to the header -- the initial selection,
// if specified, should take precedence.
${setInitialSelectionJs}
${setInitialSearchJs}
window.onresize = () => {
cm.execCommand('scrollSelectionIntoView');
};
} else {
console.warn('No parent element for the editor found. This may mean that the editor HTML is still loading.');
}
} catch (e) {
window.ReactNativeWebView.postMessage("error:" + e.message + ": " + JSON.stringify(e))
}
}
true;
`;
const css = useCss(props.themeId);
useEffect(() => {
if (webviewRef.current) {
webviewRef.current.injectJS(`
const styleClass = ${JSON.stringify(themeStyleSheetClassName)};
for (const oldStyle of [...document.getElementsByClassName(styleClass)]) {
oldStyle.remove();
}
const style = document.createElement('style');
style.classList.add(styleClass);
style.appendChild(document.createTextNode(${JSON.stringify(css)}));
document.head.appendChild(style);
`);
}
}, [css]);
// Scroll to the new hash, if it changes.
const isFirstScrollRef = useRef(true);
useEffect(() => {
// The first "jump to header" is handled during editor setup and shouldn't
// be handled a second time:
if (isFirstScrollRef.current) {
isFirstScrollRef.current = false;
return;
}
if (jumpToHashJs && webviewRef.current) {
webviewRef.current.injectJS(jumpToHashJs);
}
}, [jumpToHashJs]);
const html = useHtml(css);
const [selectionState, setSelectionState] = useState<SelectionFormatting>(defaultSelectionFormatting);
const [linkDialogVisible, setLinkDialogVisible] = useState(false);
const [searchState, setSearchState] = useState(defaultSearchState);
const onEditorEvent = useRef((_event: EditorEvent) => {});
const onAttachRef = useRef(props.onAttach);
onAttachRef.current = props.onAttach;
const editorMessenger = useMemo(() => {
const localApi: WebViewToEditorApi = {
async onEditorEvent(event) {
onEditorEvent.current(event);
},
async logMessage(message) {
logger.debug('CodeMirror:', message);
},
async onPasteFile(type, data) {
const tempFilePath = join(Setting.value('tempDir'), `paste.${uuid.createNano()}.${mimeUtils.toFileExtension(type)}`);
await shim.fsDriver().mkdir(dirname(tempFilePath));
try {
await shim.fsDriver().writeFile(tempFilePath, data, 'base64');
await onAttachRef.current(tempFilePath);
} finally {
await shim.fsDriver().remove(tempFilePath);
}
},
};
const messenger = new RNToWebViewMessenger<WebViewToEditorApi, EditorBodyControl>(
'editor', webviewRef, localApi,
);
return messenger;
}, []);
const editorControl = useEditorControl(
editorMessenger.remoteApi, webviewRef, setLinkDialogVisible, setSearchState,
);
useEffect(() => {
editorControl.updateSettings(editorSettings);
}, [editorSettings, editorControl]);
useEditorCommandHandler(editorControl);
useImperativeHandle(ref, () => {
return editorControl;
});
useEffect(() => {
onEditorEvent.current = (event: EditorEvent) => {
const editorControlRef = useRef<EditorControl|null>(null);
const onEditorEvent = (event: EditorEvent) => {
let exhaustivenessCheck: never;
switch (event.kind) {
case EditorEventType.Change:
@@ -523,6 +286,9 @@ function NoteEditor(props: Props, ref: any) {
case EditorEventType.EditLink:
editorControl.showLinkDialog();
break;
case EditorEventType.FollowLink:
void CommandService.instance().execute('openItem', event.link);
break;
case EditorEventType.UpdateSearchDialog:
setSearchState(event.searchState);
@@ -541,31 +307,36 @@ function NoteEditor(props: Props, ref: any) {
}
return;
};
}, [props.onChange, props.onUndoRedoDepthChange, props.onSelectionChange, editorControl]);
const codeMirrorPlugins = useCodeMirrorPlugins(props.plugins);
const editorRef = useRef<EditorBodyControl|null>(null);
const editorControl = useEditorControl(
editorRef, webviewRef, setLinkDialogVisible, setSearchState,
);
editorControlRef.current = editorControl;
useEffect(() => {
void editorControl.setContentScripts(codeMirrorPlugins);
}, [codeMirrorPlugins, editorControl]);
editorControl.updateSettings(editorSettings);
}, [editorSettings, editorControl]);
const onLoadEnd = useCallback(() => {
editorMessenger.onWebViewLoaded();
}, [editorMessenger]);
const onMessage = useCallback((event: OnMessageEvent) => {
const data = event.nativeEvent.data;
if (typeof data === 'string' && data.indexOf('error:') === 0) {
logger.error('CodeMirror error', data);
return;
const lastNoteResources = useRef<ResourceInfos>(props.noteResources);
useEffect(() => {
const isDownloaded = (resourceInfos: ResourceInfos, resourceId: string) => {
return resourceInfos[resourceId]?.localState?.fetch_status === Resource.FETCH_STATUS_DONE;
};
for (const key in props.noteResources) {
const wasDownloaded = isDownloaded(lastNoteResources.current, key);
if (!wasDownloaded && isDownloaded(props.noteResources, key)) {
editorControl.onResourceDownloaded(key);
}
}
}, [props.noteResources, editorControl]);
editorMessenger.onWebViewMessage(event);
}, [editorMessenger]);
useEditorCommandHandler(editorControl);
const onError = useCallback((event: NativeSyntheticEvent<WebViewErrorEvent>) => {
logger.error(`Load error: Code ${event.nativeEvent.code}: ${event.nativeEvent.description}`);
}, []);
useImperativeHandle(props.ref, () => {
return editorControl;
});
const [hasSpaceForToolbar, setHasSpaceForToolbar] = useState(true);
const toolbarEnabled = props.toolbarEnabled && hasSpaceForToolbar;
@@ -580,12 +351,24 @@ function NoteEditor(props: Props, ref: any) {
}
}, []);
const onAttach = useCallback(async (type: string, base64: string) => {
const tempFilePath = join(Setting.value('tempDir'), `paste.${uuid.createNano()}.${toFileExtension(type)}`);
await shim.fsDriver().mkdir(dirname(tempFilePath));
try {
await shim.fsDriver().writeFile(tempFilePath, base64, 'base64');
await props.onAttach(tempFilePath);
} finally {
await shim.fsDriver().remove(tempFilePath);
}
}, [props.onAttach]);
const toolbarEditorState = useMemo(() => ({
selectionState,
searchVisible: searchState.dialogVisible,
}), [selectionState, searchState.dialogVisible]);
const toolbar = <EditorToolbar editorState={toolbarEditorState} />;
const EditorComponent = props.mode === EditorType.Markdown ? MarkdownEditor : RichTextEditor;
return (
<View
@@ -607,20 +390,25 @@ function NoteEditor(props: Props, ref: any) {
flexShrink: 0,
minHeight: '30%',
}}>
<ExtendedWebView
webviewInstanceId='NoteEditor'
testID='NoteEditor'
scrollEnabled={true}
ref={webviewRef}
html={html}
injectedJavaScript={injectedJavaScript}
hasPluginScripts={codeMirrorPlugins.length > 0}
onMessage={onMessage}
onLoadEnd={onLoadEnd}
onError={onError}
<EditorComponent
editorRef={editorRef}
webviewRef={webviewRef}
themeId={props.themeId}
noteId={props.noteId}
noteHash={props.noteHash}
initialText={props.initialText}
initialSelection={props.initialSelection}
editorSettings={editorSettings}
globalSearch={props.globalSearch}
onEditorEvent={onEditorEvent}
noteResources={props.noteResources}
plugins={props.plugins}
onAttach={onAttach}
/>
</View>
<WarningBanner editorType={props.mode}/>
<SearchPanel
editorSettings={editorSettings}
searchControl={editorControl.searchControl}
@@ -632,4 +420,4 @@ function NoteEditor(props: Props, ref: any) {
);
}
export default forwardRef(NoteEditor);
export default NoteEditor;

View File

@@ -0,0 +1,357 @@
import * as React from 'react';
import { describe, it, beforeEach } from '@jest/globals';
import { render, waitFor } from '../../utils/testing/testingLibrary';
import Setting from '@joplin/lib/models/Setting';
import { createNoteAndResource, resourceFetcher, setupDatabaseAndSynchronizer, supportDir, switchClient, synchronizerStart } from '@joplin/lib/testing/test-utils';
import getWebViewWindowById from '../../utils/testing/getWebViewWindowById';
import TestProviderStack from '../testing/TestProviderStack';
import createMockReduxStore from '../../utils/testing/createMockReduxStore';
import RichTextEditor from './RichTextEditor';
import createTestEditorProps from './testing/createTestEditorProps';
import { EditorEvent, EditorEventType } from '@joplin/editor/events';
import { RefObject, useCallback, useMemo } from 'react';
import Note from '@joplin/lib/models/Note';
import shim from '@joplin/lib/shim';
import Resource from '@joplin/lib/models/Resource';
import { ResourceInfos } from '@joplin/renderer/types';
import { EditorControl, EditorLanguageType } from '@joplin/editor/types';
import attachedResources from '@joplin/lib/utils/attachedResources';
import { MarkupLanguage } from '@joplin/renderer';
import { NoteEntity } from '@joplin/lib/services/database/types';
import { EditorSettings } from './types';
import { pregQuote } from '@joplin/lib/string-utils';
interface WrapperProps {
ref?: RefObject<EditorControl>;
noteResources?: ResourceInfos;
onBodyChange: (newBody: string)=> void;
onLinkClick?: (link: string)=> void;
note?: NoteEntity;
noteBody: string;
}
const defaultEditorProps = createTestEditorProps();
const testStore = createMockReduxStore();
const WrappedEditor: React.FC<WrapperProps> = (
{
noteBody,
note,
onBodyChange,
onLinkClick,
noteResources,
ref,
}: WrapperProps,
) => {
const onEvent = useCallback((event: EditorEvent) => {
if (event.kind === EditorEventType.Change) {
onBodyChange(event.value);
} else if (event.kind === EditorEventType.FollowLink) {
if (!onLinkClick) {
throw new Error('No mock function for onLinkClick registered.');
}
onLinkClick(event.link);
}
}, [onBodyChange, onLinkClick]);
const editorSettings = useMemo((): EditorSettings => {
const isHtml = note?.markup_language === MarkupLanguage.Html;
return {
...defaultEditorProps.editorSettings,
language: isHtml ? EditorLanguageType.Html : EditorLanguageType.Markdown,
};
}, [note]);
return <TestProviderStack store={testStore}>
<RichTextEditor
{...defaultEditorProps}
editorSettings={editorSettings}
onEditorEvent={onEvent}
initialText={noteBody}
noteId={note?.id ?? defaultEditorProps.noteId}
noteResources={noteResources ?? defaultEditorProps.noteResources}
editorRef={ref ?? defaultEditorProps.editorRef}
/>
</TestProviderStack>;
};
const getEditorWindow = async () => {
return await getWebViewWindowById('RichTextEditor');
};
type EditorWindow = Window&typeof globalThis;
const getEditorControl = (window: EditorWindow) => {
if ('joplinRichTextEditor_' in window) {
return window.joplinRichTextEditor_ as EditorControl;
}
throw new Error('No editor control found. Is the editor loaded?');
};
const mockTyping = (window: EditorWindow, text: string) => {
const document = window.document;
const editor = document.querySelector('div[contenteditable]');
for (const character of text.split('')) {
editor.dispatchEvent(new window.KeyboardEvent('keydown', { key: character }));
const paragraphs = editor.querySelectorAll('p');
(paragraphs[paragraphs.length - 1] ?? editor).appendChild(document.createTextNode(character));
editor.dispatchEvent(new window.KeyboardEvent('keyup', { key: character }));
}
};
const mockSelectionMovement = (window: EditorWindow, position: number) => {
getEditorControl(window).select(position, position);
};
const findElement = async function<ElementType extends Element = Element>(selector: string) {
const window = await getEditorWindow();
return await waitFor(() => {
const element = window.document.querySelector<ElementType>(selector);
expect(element).toBeTruthy();
return element;
}, {
onTimeout: (error) => {
return new Error(`Failed to find element from selector ${selector}. DOM: ${window?.document?.body?.innerHTML}. \n\nFull error: ${error}`);
},
});
};
const createRemoteResourceAndNote = async (remoteClientId: number) => {
await setupDatabaseAndSynchronizer(remoteClientId);
await switchClient(remoteClientId);
let note = await Note.save({ title: 'Note 1', parent_id: '' });
note = await shim.attachFileToNote(note, `${supportDir}/photo.jpg`);
const allResources = await Resource.all();
expect(allResources.length).toBe(1);
const resourceId = allResources[0].id;
await synchronizerStart();
await switchClient(0);
await synchronizerStart();
return { noteId: note.id, resourceId };
};
describe('RichTextEditor', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(0);
await switchClient(0);
Setting.setValue('editor.codeView', false);
});
it('should render basic markdown', async () => {
render(<WrappedEditor
noteBody={'### Test\n\nParagraph `test`'}
onBodyChange={jest.fn()}
/>);
const dom = (await getEditorWindow()).document;
expect((await findElement('h3')).textContent).toBe('Test');
expect(dom.querySelector('p').textContent).toBe('Paragraph test');
expect(dom.querySelector('p code').textContent).toBe('test');
});
it('should dispatch events when the editor content changes', async () => {
let body = '**bold** normal';
render(<WrappedEditor
noteBody={body}
onBodyChange={newBody => { body = newBody; }}
/>);
const window = await getEditorWindow();
mockTyping(window, ' test');
await waitFor(async () => {
expect(body.trim()).toBe('**bold** normal test');
});
});
it('should render clickable checkboxes', async () => {
let body = '- [ ] Test\n- [x] Another test';
render(<WrappedEditor
noteBody={body}
onBodyChange={newBody => { body = newBody; }}
/>);
const firstCheckbox = await findElement<HTMLInputElement>('input[type=checkbox]');
const dom = (await getEditorWindow()).document;
const getCheckboxLabel = (checkbox: HTMLElement) => {
const labelledByAttr = checkbox.getAttribute('aria-labelledby');
const label = dom.getElementById(labelledByAttr);
return label;
};
// Should have the correct labels
expect(firstCheckbox.getAttribute('aria-labelledby')).toBeTruthy();
expect(getCheckboxLabel(firstCheckbox).textContent).toBe('Test');
// Should be correctly checked/unchecked
expect(firstCheckbox.checked).toBe(false);
// Clicking a checkbox should toggle it
firstCheckbox.click();
await waitFor(async () => {
// At present, lists are saved as non-tight lists:
expect(body.trim()).toBe('- [x] Test\n \n- [x] Another test');
});
});
it('should reload resource placeholders when the corresponding item downloads', async () => {
Setting.setValue('sync.resourceDownloadMode', 'manual');
const { noteId, resourceId } = await createRemoteResourceAndNote(1);
const note = await Note.load(noteId);
const localResource = await Resource.load(resourceId);
let localState = await Resource.localState(localResource);
expect(localState.fetch_status).toBe(Resource.FETCH_STATUS_IDLE);
const editorRef = React.createRef<EditorControl>();
const component = render(
<WrappedEditor
noteBody={note.body}
noteResources={{ [localResource.id]: { localState, item: localResource } }}
onBodyChange={jest.fn()}
ref={editorRef}
/>,
);
// The resource placeholder should have rendered
const placeholder = await findElement(`span[data-resource-id=${JSON.stringify(localResource.id)}]`);
expect([...placeholder.classList]).toContain('not-loaded-resource');
await resourceFetcher().markForDownload([localResource.id]);
await waitFor(async () => {
localState = await Resource.localState(localResource.id);
expect(localState).toMatchObject({ fetch_status: Resource.FETCH_STATUS_DONE });
});
component.rerender(
<WrappedEditor
noteBody={note.body}
noteResources={{ [localResource.id]: { localState, item: localResource } }}
onBodyChange={jest.fn()}
ref={editorRef}
/>,
);
editorRef.current.onResourceDownloaded(localResource.id);
expect(
await findElement(`img[data-resource-id=${JSON.stringify(localResource.id)}]`),
).toBeTruthy();
});
it('should render clickable internal note links', async () => {
const linkTarget = await Note.save({ title: 'test' });
const body = `[link](:/${linkTarget.id})`;
const onLinkClick = jest.fn();
render(<WrappedEditor
noteBody={body}
onBodyChange={jest.fn()}
onLinkClick={onLinkClick}
/>);
const window = await getEditorWindow();
const link = await findElement<HTMLAnchorElement>('a[href]');
expect(link.href).toBe(`:/${linkTarget.id}`);
mockSelectionMovement(window, 2);
const tooltipButton = await findElement<HTMLButtonElement>('.link-tooltip:not(.-hidden) > button');
tooltipButton.click();
await waitFor(() => {
expect(onLinkClick).toHaveBeenCalledWith(`:/${linkTarget.id}`);
});
});
it.each([
MarkupLanguage.Markdown, MarkupLanguage.Html,
])('should preserve image attachments on edit (case %#)', async (markupLanguage) => {
const { note, resource } = await createNoteAndResource({ markupLanguage });
let body = note.body;
const resources = await attachedResources(body);
render(<WrappedEditor
noteBody={note.body}
note={note}
onBodyChange={newBody => { body = newBody; }}
noteResources={resources}
/>);
const renderedImage = await findElement<HTMLImageElement>(`img[data-resource-id=${JSON.stringify(resource.id)}]`);
expect(renderedImage).toBeTruthy();
const window = await getEditorWindow();
mockTyping(window, ' test');
// The rendered image should still have the correct ALT and source
await waitFor(async () => {
const editorContent = body.trim();
if (markupLanguage === MarkupLanguage.Html) {
expect(editorContent).toMatch(
new RegExp(`^<p><img src=":/${pregQuote(resource.id)}" alt="${pregQuote(renderedImage.alt)}"[^>]*> test</p>$`),
);
} else {
expect(editorContent).toBe(`![${renderedImage.alt}](:/${resource.id}) test`);
}
});
});
it.each([
{ useValidSyntax: false },
{ useValidSyntax: true },
])('should preserve inline math on edit (%j)', async ({ useValidSyntax }) => {
const macros = '\\def\\<{\\langle} \\def\\>{\\rangle}';
let inlineMath = '| \\< u, v \\> |^2 \\leq \\< u, u \\>\\< v, v \\>';
// The \\< escapes are invalid without the above custom macro definitions.
// It should be possible for the editor to preserve invalid math syntax.
if (useValidSyntax) {
inlineMath = macros + inlineMath;
}
let body = `Inline math: $${inlineMath}$...`;
render(<WrappedEditor
noteBody={body}
onBodyChange={newBody => { body = newBody; }}
/>);
const renderedInlineMath = await findElement<HTMLElement>('span.joplin-editable');
expect(renderedInlineMath).toBeTruthy();
const window = await getEditorWindow();
mockTyping(window, ' testing');
await waitFor(async () => {
expect(body.trim()).toBe(`Inline math: $${inlineMath}$... testing`);
});
});
it('should preserve block math on edit', async () => {
let body = 'Test:\n\n$$3^2 + 4^2 = \\sqrt{625}$$\n\nTest.';
render(<WrappedEditor
noteBody={body}
onBodyChange={newBody => { body = newBody; }}
/>);
const renderedInlineMath = await findElement<HTMLElement>('div.joplin-editable');
expect(renderedInlineMath).toBeTruthy();
const window = await getEditorWindow();
mockTyping(window, ' testing');
await waitFor(async () => {
expect(body.trim()).toBe('Test:\n\n$$\n3^2 + 4^2 = \\sqrt{625}\n$$\n\nTest. testing');
});
});
});

View File

@@ -0,0 +1,155 @@
import { themeStyle } from '@joplin/lib/theme';
import themeToCss from '@joplin/lib/services/style/themeToCss';
import ExtendedWebView from '../ExtendedWebView';
import * as React from 'react';
import { useMemo, useCallback, useRef } from 'react';
import { NativeSyntheticEvent } from 'react-native';
import { EditorProps } from './types';
import { _ } from '@joplin/lib/locale';
import { WebViewErrorEvent } from 'react-native-webview/lib/RNCWebViewNativeComponent';
import Logger from '@joplin/utils/Logger';
import { OnMessageEvent } from '../ExtendedWebView/types';
import useWebViewSetup from '../../contentScripts/richTextEditorBundle/useWebViewSetup';
import CommandService from '@joplin/lib/services/CommandService';
import shim from '@joplin/lib/shim';
const logger = Logger.create('RichTextEditor');
function useCss(themeId: number, editorCss: string): string {
return useMemo(() => {
const theme = themeStyle(themeId);
const themeVariableCss = themeToCss(theme);
return `
${themeVariableCss}
${editorCss}
:root {
background-color: ${theme.backgroundColor};
}
body {
margin: 0;
height: 100vh;
/* Prefer 100% -- 100vw shows an unnecessary horizontal scrollbar in Google Chrome (desktop). */
width: 100%;
box-sizing: border-box;
padding-left: 4px;
padding-right: 4px;
padding-bottom: 1px;
padding-top: 10px;
font-size: 13pt;
font-family: ${JSON.stringify(theme.fontFamily)}, sans-serif;
}
`;
}, [themeId, editorCss]);
}
function useHtml(initialCss: string): string {
const cssRef = useRef(initialCss);
cssRef.current = initialCss;
return useMemo(() => `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>${_('Note editor')}</title>
</head>
<body>
<div class="RichTextEditor" style="height:100%;" autocapitalize="on"></div>
</body>
</html>
`, []);
}
const onPostMessage = async (message: string) => {
try {
await CommandService.instance().execute('openItem', message);
} catch (error) {
void shim.showErrorDialog(`postMessage failed: ${message}`);
}
};
const RichTextEditor: React.FC<EditorProps> = props => {
const webviewRef = props.webviewRef;
const editorWebViewSetup = useWebViewSetup({
parentElementClassName: 'RichTextEditor',
onEditorEvent: props.onEditorEvent,
initialText: props.initialText,
noteId: props.noteId,
settings: props.editorSettings,
webviewRef,
themeId: props.themeId,
pluginStates: props.plugins,
noteResources: props.noteResources,
onPostMessage: onPostMessage,
onAttachFile: props.onAttach,
});
props.editorRef.current = editorWebViewSetup.api;
const injectedJavaScript = `
window.onerror = (message, source, lineno) => {
console.error(message);
window.ReactNativeWebView.postMessage(
"error: " + message + " in file://" + source + ", line " + lineno
);
};
window.onunhandledrejection = (event) => {
window.ReactNativeWebView.postMessage(
"error: Unhandled promise rejection: " + event
);
};
try {
${editorWebViewSetup.pageSetup.js}
} catch (e) {
console.error('Setup error: ', e);
window.ReactNativeWebView.postMessage("error:" + e.message + ": " + JSON.stringify(e))
}
true;
`;
const css = useCss(props.themeId, editorWebViewSetup.pageSetup.css);
const html = useHtml(css);
const onMessage = useCallback((event: OnMessageEvent) => {
const data = event.nativeEvent.data;
if (typeof data === 'string' && data.indexOf('error:') === 0) {
logger.error('Rich Text Editor error', data);
return;
}
editorWebViewSetup.webViewEventHandlers.onMessage(event);
}, [editorWebViewSetup]);
const onError = useCallback((event: NativeSyntheticEvent<WebViewErrorEvent>) => {
logger.error(`Load error: Code ${event.nativeEvent.code}: ${event.nativeEvent.description}`);
}, []);
return (
<ExtendedWebView
ref={webviewRef}
webviewInstanceId='RichTextEditor'
testID='RichTextEditor'
scrollEnabled={true}
html={html}
injectedJavaScript={injectedJavaScript}
css={css}
hasPluginScripts={false}
onMessage={onMessage}
onLoadEnd={editorWebViewSetup.webViewEventHandlers.onLoadEnd}
onError={onError}
/>
);
};
export default RichTextEditor;

View File

@@ -192,7 +192,7 @@ export const SearchPanel = (props: SearchPanelProps) => {
}, [state.dialogVisible, control]);
const themeId = props.editorSettings.themeId;
const themeId = props.editorSettings.themeData.themeId;
const closeButton = (
<ActionButton
themeId={themeId}

View File

@@ -0,0 +1,47 @@
import * as React from 'react';
import { connect } from 'react-redux';
import { _ } from '@joplin/lib/locale';
import onRichTextReadMoreLinkClick from '@joplin/lib/components/shared/NoteEditor/WarningBanner/onRichTextReadMoreLinkClick';
import onRichTextDismissLinkClick from '@joplin/lib/components/shared/NoteEditor/WarningBanner/onRichTextDismissLinkClick';
import { AppState } from '../../utils/types';
import { EditorType } from './types';
import { Banner } from 'react-native-paper';
import { useMemo } from 'react';
interface Props {
editorType: EditorType;
richTextBannerDismissed: boolean;
}
const WarningBanner: React.FC<Props> = props => {
const actions = useMemo(() => [
{
label: _('Read more'),
onPress: onRichTextReadMoreLinkClick,
},
{
label: _('Dismiss'),
accessibilityHint: _('Hides warning'),
onPress: onRichTextDismissLinkClick,
},
], []);
if (props.editorType !== EditorType.RichText || props.richTextBannerDismissed) return null;
return (
<Banner
icon='alert-outline'
actions={actions}
// Avoid hiding with react-native-paper's "visible" prop to avoid potential accessibility issues
// related to how react-native-paper hides the banner.
visible={true}
>
{_('This Rich Text editor has a number of limitations and it is recommended to be aware of them before using it.')}
</Banner>
);
};
export default connect((state: AppState) => {
return {
richTextBannerDismissed: state.settings.richTextBannerDismissed,
};
})(WarningBanner);

View File

@@ -2,11 +2,22 @@ import { EditorCommandType } from '@joplin/editor/types';
import { _ } from '@joplin/lib/locale';
import { CommandDeclaration } from '@joplin/lib/services/CommandService';
export const enabledCondition = (_commandName: string) => {
const markdownEditorOnlyCommands = [
EditorCommandType.DuplicateLine,
EditorCommandType.SortSelectedLines,
EditorCommandType.SwapLineUp,
EditorCommandType.SwapLineDown,
].map(command => `editor.${command}`);
export const enabledCondition = (commandName: string) => {
const output = [
'!noteIsReadOnly',
];
if (markdownEditorOnlyCommands.includes(commandName)) {
output.push('!richTextEditorVisible');
}
return output.filter(c => !!c).join(' && ');
};

View File

@@ -0,0 +1,24 @@
import * as React from 'react';
import createEditorSettings from '@joplin/editor/testing/createEditorSettings';
import { EditorProps } from '../types';
import Setting from '@joplin/lib/models/Setting';
const defaultEditorSettings = { ...createEditorSettings(Setting.THEME_LIGHT), themeId: Setting.THEME_LIGHT };
const defaultWrapperProps: EditorProps = {
noteResources: {},
webviewRef: React.createRef(),
editorRef: React.createRef(),
themeId: Setting.THEME_LIGHT,
noteHash: '',
noteId: '',
initialText: '',
editorSettings: defaultEditorSettings,
initialSelection: { start: 0, end: 0 },
globalSearch: '',
plugins: {},
onAttach: () => Promise.resolve(),
onEditorEvent: () => {},
};
const createTestEditorProps = () => ({ ...defaultWrapperProps });
export default createTestEditorProps;

View File

@@ -1,7 +1,12 @@
// Types related to the NoteEditor
import { EditorEvent } from '@joplin/editor/events';
import { EditorControl as EditorBodyControl, EditorSettings as EditorBodySettings, SearchState } from '@joplin/editor/types';
import { RefObject } from 'react';
import { WebViewControl } from '../ExtendedWebView/types';
import { SelectionRange } from '../../contentScripts/markdownEditorBundle/types';
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
import { EditorEvent } from '@joplin/editor/events';
import { ResourceInfos } from '@joplin/renderer/types';
export interface SearchControl {
findNext(): void;
@@ -45,17 +50,27 @@ export interface EditorControl extends EditorBodyControl {
searchControl: SearchControl;
}
export interface EditorSettings extends EditorBodySettings {
export type EditorSettings = EditorBodySettings;
type OnAttachCallback = (mime: string, base64: string)=> Promise<void>;
export interface EditorProps {
noteResources: ResourceInfos;
editorRef: RefObject<EditorBodyControl>;
webviewRef: RefObject<WebViewControl>;
themeId: number;
noteId: string;
noteHash: string;
initialText: string;
initialSelection: SelectionRange;
editorSettings: EditorSettings;
globalSearch: string;
plugins: PluginStates;
onAttach: OnAttachCallback;
onEditorEvent: (event: EditorEvent)=> void;
}
export interface SelectionRange {
start: number;
end: number;
}
export interface WebViewToEditorApi {
onEditorEvent(event: EditorEvent): Promise<void>;
logMessage(message: string): Promise<void>;
onPasteFile(type: string, dataBase64: string): Promise<void>;
export enum EditorType {
Markdown = 'markdown',
RichText = 'rich-text',
}

View File

@@ -46,8 +46,8 @@ const getNoteViewerDom = async () => {
return await getWebViewDomById('NoteBodyViewer');
};
const getNoteEditorControl = async () => {
const noteEditor = await getWebViewWindowById('NoteEditor');
const getMarkdownEditorControl = async () => {
const noteEditor = await getWebViewWindowById('MarkdownEditor');
const getEditorControl = () => {
if ('cm' in noteEditor.window && noteEditor.window.cm) {
return noteEditor.window.cm as CodeMirrorControl;
@@ -213,7 +213,7 @@ describe('screens/Note', () => {
const noteScreen = render(<WrappedNoteScreen />);
await openEditor();
const editor = await getNoteEditorControl();
const editor = await getMarkdownEditorControl();
await act(async () => {
editor.select(defaultBody.length, defaultBody.length);
editor.insertText(' Testing!!!');

View File

@@ -43,7 +43,6 @@ import { ChangeEvent as EditorChangeEvent, SelectionRangeChangeEvent, UndoRedoDe
import { join } from 'path';
import { Dispatch } from 'redux';
import { RefObject, useContext } from 'react';
import { SelectionRange } from '../../NoteEditor/types';
import { getNoteCallbackUrl } from '@joplin/lib/callbackUrlUtils';
import { AppState } from '../../../utils/types';
import restoreItems from '@joplin/lib/services/trash/restoreItems';
@@ -57,7 +56,7 @@ import getImageDimensions from '../../../utils/image/getImageDimensions';
import resizeImage from '../../../utils/image/resizeImage';
import { CameraResult } from '../../CameraView/types';
import { DialogContext, DialogControl } from '../../DialogManager';
import { CommandRuntimeProps, EditorMode, PickerResponse } from './types';
import { CommandRuntimeProps, NoteViewerMode, PickerResponse } from './types';
import commands from './commands';
import { AttachFileAction, AttachFileOptions } from './commands/attachFile';
import PluginService from '@joplin/lib/services/plugins/PluginService';
@@ -72,6 +71,8 @@ import ShareNoteDialog from '../ShareNoteDialog';
import stateToWhenClauseContext from '../../../services/commands/stateToWhenClauseContext';
import { defaultWindowId } from '@joplin/lib/reducer';
import useVisiblePluginEditorViewIds from '@joplin/lib/hooks/plugins/useVisiblePluginEditorViewIds';
import { SelectionRange } from '../../../contentScripts/markdownEditorBundle/types';
import { EditorType } from '../../NoteEditor/types';
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const emptyArray: any[] = [];
@@ -95,6 +96,7 @@ interface Props extends BaseProps {
navigation: NoteNavigation;
dispatch: Dispatch;
noteId: string;
editorType: EditorType;
useEditorBeta: boolean;
plugins: PluginStates;
themeId: number;
@@ -119,7 +121,7 @@ interface ComponentProps extends Props {
interface State {
note: NoteEntity;
mode: EditorMode;
mode: NoteViewerMode;
readOnly: boolean;
folder: FolderEntity|null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
@@ -637,6 +639,13 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
});
}
if (prevState.mode !== this.state.mode) {
this.props.dispatch({
type: 'NOTE_EDITOR_VISIBLE_CHANGE',
visible: this.state.mode === 'edit' && !this.state.showCamera && !this.state.showImageEditor,
});
}
if (prevProps.noteId && this.props.noteId && prevProps.noteId !== this.props.noteId) {
// Easier to just go back, then go to the note since
// the Note screen doesn't handle reloading a different note
@@ -684,6 +693,11 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
this.commandRegistration_?.deregister();
this.commandRegistration_ = null;
this.props.dispatch({
type: 'SET_NOTE_EDITOR_VISIBLE',
visible: false,
});
}
private title_changeText(text: string) {
@@ -1227,10 +1241,11 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
const isSaved = note && note.id;
const readOnly = this.state.readOnly;
const isDeleted = !!this.state.note.deleted_time;
const isCodeView = this.props.editorType === EditorType.Markdown;
const pluginCommands = pluginUtils.commandNamesFromViews(this.props.plugins, 'noteToolbar');
const cacheKey = md5([isTodo, isSaved, pluginCommands.join(','), readOnly].join('_'));
const cacheKey = md5([isTodo, isSaved, pluginCommands.join(','), readOnly, this.state.mode, isCodeView].join('_'));
if (!this.menuOptionsCache_) this.menuOptionsCache_ = {};
if (this.menuOptionsCache_[cacheKey]) return this.menuOptionsCache_[cacheKey];
@@ -1347,6 +1362,16 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
},
});
if (this.state.mode === 'edit') {
const newCodeView = !isCodeView;
output.push({
title: newCodeView ? _('Edit as Markdown') : _('Edit as Rich Text'),
onPress: () => {
Setting.setValue('editor.codeView', newCodeView);
},
});
}
if (isDeleted) {
output.push({
title: _('Restore'),
@@ -1635,11 +1660,13 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
noteHash={this.props.noteHash}
initialText={note.body}
initialSelection={this.selection}
markupLanguage={this.state.note.markup_language}
globalSearch={this.props.searchQuery}
onChange={this.onMarkdownEditorTextChange}
onSelectionChange={this.onMarkdownEditorSelectionChange}
onUndoRedoDepthChange={this.onUndoRedoDepthChange}
onAttach={this.onAttach}
noteResources={this.state.noteResources}
readOnly={this.state.readOnly}
plugins={this.props.plugins}
style={{
@@ -1649,6 +1676,7 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
paddingLeft: 0,
paddingRight: 0,
}}
mode={this.props.editorType}
/>;
}
}
@@ -1798,6 +1826,8 @@ const NoteScreen = connect((state: AppState) => {
pluginHtmlContents: state.pluginService.pluginHtmlContents,
editorNoteReloadTimeRequest: state.editorNoteReloadTimeRequest,
editorType: state.settings['editor.codeView'] ? EditorType.Markdown : EditorType.RichText,
// What we call "beta editor" in this component is actually the (now
// default) CodeMirror editor. That should be refactored to make it less
// confusing.

View File

@@ -7,15 +7,15 @@ export interface PickerResponse {
fileName?: string;
}
export type EditorMode = 'view'|'edit';
export type NoteViewerMode = 'view'|'edit';
export interface CommandRuntimeProps {
attachFile(pickerResponse: PickerResponse, fileType: string): Promise<ResourceEntity|null>;
hideKeyboard(): void;
insertText(text: string): void;
getMode(): EditorMode;
setMode(mode: EditorMode): void;
getMode(): NoteViewerMode;
setMode(mode: NoteViewerMode): void;
setCameraVisible(visible: boolean): void;
setTagDialogVisible(visible: boolean): void;
setAudioRecorderVisible(visible: boolean): void;

View File

@@ -9,18 +9,18 @@ window.ResizeObserver = class { public observe() { } } as any;
import { describe, it, expect, jest } from '@jest/globals';
import { Color4, EditorImage, EditorSettings, Path, pathToRenderable, StrokeComponent } from 'js-draw';
import { RenderingMode } from 'js-draw';
import createJsDrawEditor from './createJsDrawEditor';
import { createJsDrawEditor } from './index';
import { BackgroundComponent } from 'js-draw';
import { BackgroundComponentBackgroundType } from 'js-draw';
import { ImageEditorCallbacks } from './types';
import { MainProcessApi } from './types';
import applyTemplateToEditor from './applyTemplateToEditor';
const createEditorWithCallbacks = (callbacks: Partial<ImageEditorCallbacks>) => {
const createEditorWithCallbacks = (callbacks: Partial<MainProcessApi>) => {
const toolbarState = '';
const locale = 'en';
const allCallbacks: ImageEditorCallbacks = {
const allCallbacks: MainProcessApi = {
save: () => {},
saveThenClose: ()=> {},
closeEditor: ()=> {},
@@ -49,7 +49,7 @@ const createEditorWithCallbacks = (callbacks: Partial<ImageEditorCallbacks>) =>
return createJsDrawEditor(allCallbacks, toolbarState, locale, localizations, editorOptions);
};
describe('createJsDrawEditor', () => {
describe('imageEditor/contentScript/index', () => {
it('should trigger autosave callback every few minutes', async () => {
let calledAutosaveCount = 0;

View File

@@ -1,13 +1,12 @@
import '../../utils/polyfills';
import { Editor, AbstractToolbar, EditorEventType, EditorSettings, getLocalizationTable, adjustEditorThemeForContrast, BaseWidget } from 'js-draw';
import { MaterialIconProvider } from '@js-draw/material-icons';
import 'js-draw/bundledStyles';
import applyTemplateToEditor from './applyTemplateToEditor';
import watchEditorForTemplateChanges from './watchEditorForTemplateChanges';
import { ImageEditorCallbacks, ImageEditorControl, LocalizedStrings } from './types';
import { MainProcessApi, LocalizedStrings, EditorProcessApi } from './types';
import startAutosaveLoop from './startAutosaveLoop';
import WebViewToRNMessenger from '../../../../utils/ipc/WebViewToRNMessenger';
import './polyfills';
import WebViewToRNMessenger from '../../../utils/ipc/WebViewToRNMessenger';
const restoreToolbarState = (toolbar: AbstractToolbar, state: string) => {
@@ -21,15 +20,8 @@ const restoreToolbarState = (toolbar: AbstractToolbar, state: string) => {
}
};
export const createMessenger = () => {
const messenger = new WebViewToRNMessenger<ImageEditorControl, ImageEditorCallbacks>(
'image-editor', {},
);
return messenger;
};
export const createJsDrawEditor = (
callbacks: ImageEditorCallbacks,
callbacks: MainProcessApi,
initialToolbarState: string,
locale: string,
defaultLocalizations: LocalizedStrings,
@@ -177,6 +169,9 @@ export const createJsDrawEditor = (
});
};
const themeStyles = document.createElement('style');
parentElement.appendChild(themeStyles);
const editorControl = {
editor,
loadImageOrTemplate: async (resourceUrl: string, templateData: string, svgData: string|undefined) => {
@@ -200,7 +195,11 @@ export const createJsDrawEditor = (
void startAutosaveLoop(editor, callbacks.save);
watchEditorForTemplateChanges(editor, templateData, callbacks.updateEditorTemplate);
},
onThemeUpdate: () => {
onThemeUpdate: (css: string|null) => {
if (css) {
themeStyles.textContent = css;
}
// Slightly adjusts the given editor's theme colors. This ensures that the colors chosen for
// the editor have proper contrast.
adjustEditorThemeForContrast(editor);
@@ -211,12 +210,24 @@ export const createJsDrawEditor = (
},
};
editorControl.onThemeUpdate();
editorControl.onThemeUpdate(null);
callbacks.onLoadedEditor();
return editorControl;
};
type EditorControl = ReturnType<typeof createJsDrawEditor>;
export const createMessenger = (getEditor: ()=> EditorControl) => {
const messenger = new WebViewToRNMessenger<EditorProcessApi, MainProcessApi>(
'image-editor', {
onThemeUpdate: async (css: string) => {
getEditor().onThemeUpdate(css);
},
saveThenExit: () => getEditor().saveThenExit(),
},
);
return messenger;
};
export default createJsDrawEditor;

View File

@@ -3,7 +3,7 @@ export type SaveDrawingCallback = (svgData: string, isAutosave: boolean)=> void;
export type UpdateEditorTemplateCallback = (newTemplate: string)=> void;
export type UpdateToolbarCallback = (toolbarData: string)=> void;
export interface ImageEditorCallbacks {
export interface MainProcessApi {
onLoadedEditor: ()=> void;
save: SaveDrawingCallback;
@@ -18,7 +18,10 @@ export interface ImageEditorCallbacks {
readClipboardText: ()=> Promise<string>;
}
export interface ImageEditorControl {}
export interface EditorProcessApi {
saveThenExit(): Promise<void>;
onThemeUpdate(newCss: string): Promise<void>;
}
// Overrides translations in js-draw -- as of the time of this writing,
// Joplin has many common strings localized better than js-draw.

View File

@@ -0,0 +1,200 @@
import * as React from 'react';
import { _ } from '@joplin/lib/locale';
import Setting from '@joplin/lib/models/Setting';
import shim from '@joplin/lib/shim';
import { themeStyle } from '@joplin/lib/theme';
import { Theme } from '@joplin/lib/themes/type';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { Platform } from 'react-native';
import useEditorMessenger from './utils/useEditorMessenger';
import { WebViewControl } from '../../components/ExtendedWebView/types';
import { LocalizedStrings } from './contentScript/types';
import { SetUpResult } from '../types';
type OnSaveCallback = (svgData: string)=> Promise<void>;
type OnCancelCallback = ()=> void;
interface Props {
themeId: number;
resourceFilename: string|null;
onSave: OnSaveCallback;
onAutoSave: OnSaveCallback;
onRequestCloseEditor: OnCancelCallback;
onSetImageChanged: (changed: boolean)=> void;
webViewRef: React.RefObject<WebViewControl>;
}
export interface ImageEditorControl {
saveThenExit(): Promise<void>;
}
const useCss = (editorTheme: Theme) => {
return useMemo(() => {
// Ensure we have contrast between the background and selection. Some themes
// have the same backgroundColor and selectionColor2. (E.g. Aritim Dark)
let selectionBackgroundColor = editorTheme.selectedColor2;
if (selectionBackgroundColor === editorTheme.backgroundColor) {
selectionBackgroundColor = editorTheme.selectedColor;
}
return `
:root .imageEditorContainer {
--background-color-1: ${editorTheme.backgroundColor};
--foreground-color-1: ${editorTheme.color};
--background-color-2: ${editorTheme.backgroundColor3};
--foreground-color-2: ${editorTheme.color3};
--background-color-3: ${editorTheme.raisedBackgroundColor};
--foreground-color-3: ${editorTheme.raisedColor};
--selection-background-color: ${editorTheme.backgroundColorHover3};
--selection-foreground-color: ${editorTheme.color3};
--primary-action-foreground-color: ${editorTheme.color4};
--primary-shadow-color: ${editorTheme.colorFaded};
width: 100vw;
height: 100vh;
box-sizing: border-box;
}
body, html {
padding: 0;
margin: 0;
overflow: hidden;
}
/* Hide the scrollbar. See scrollbar accessibility concerns
(https://developer.mozilla.org/en-US/docs/Web/CSS/scrollbar-width#accessibility_concerns)
for why this isn't done in js-draw itself. */
.toolbar-tool-row::-webkit-scrollbar {
display: none;
height: 0;
}
/* Hide the save/close icons on small screens. This isn't done in the upstream
js-draw repository partially because it isn't as well localized as Joplin
(icons can be used to suggest the meaning of a button when a translation is
unavailable). */
.toolbar-edge-toolbar:not(.one-row) .toolwidget-tag--save .toolbar-icon,
.toolbar-edge-toolbar:not(.one-row) .toolwidget-tag--exit .toolbar-icon {
display: none;
}
`;
}, [editorTheme]);
};
const useWebViewSetup = ({
webViewRef,
themeId,
resourceFilename,
onSetImageChanged,
onSave,
onAutoSave,
onRequestCloseEditor,
}: Props): SetUpResult<ImageEditorControl> => {
const editorTheme: Theme = themeStyle(themeId);
// A set of localization overrides (Joplin is better localized than js-draw).
// All localizable strings (some unused?) can be found at
// https://github.com/personalizedrefrigerator/js-draw/blob/main/.github/ISSUE_TEMPLATE/translation-js-draw-new.yml
const localizedStrings: LocalizedStrings = useMemo(() => ({
save: _('Save'),
close: _('Close'),
undo: _('Undo'),
redo: _('Redo'),
}), []);
const appInfo = useMemo(() => {
return {
name: 'Joplin',
description: `v${shim.appVersion()}`,
};
}, []);
const injectedJavaScript = useMemo(() => `
if (window.imageEditorControl === undefined) {
${shim.injectedJs('imageEditorBundle')}
const messenger = imageEditorBundle.createMessenger(() => window.imageEditorControl);
window.imageEditorControl = imageEditorBundle.createJsDrawEditor(
messenger.remoteApi,
${JSON.stringify(Setting.value('imageeditor.jsdrawToolbar'))},
${JSON.stringify(Setting.value('locale'))},
${JSON.stringify(localizedStrings)},
${JSON.stringify({
appInfo,
...(shim.mobilePlatform() === 'web' ? {
// Use the browser-default clipboard API on web.
clipboardApi: null,
} : {}),
})},
);
}
`, [localizedStrings, appInfo]);
const onReadyToLoadData = useCallback(async () => {
const getInitialInjectedData = async () => {
// On mobile, it's faster to load the image within the WebView with an XMLHttpRequest.
// In this case, the image is loaded elsewhere.
if (Platform.OS !== 'web') {
return undefined;
}
// On web, however, this doesn't work, so the image needs to be loaded here.
if (!resourceFilename) {
return '';
}
return await shim.fsDriver().readFile(resourceFilename, 'utf-8');
};
// It can take some time for initialSVGData to be transferred to the WebView.
// Thus, do so after the main content has been loaded.
webViewRef.current.injectJS(`(async () => {
if (window.imageEditorControl) {
const initialSVGPath = ${JSON.stringify(resourceFilename)};
const initialTemplateData = ${JSON.stringify(Setting.value('imageeditor.imageTemplate'))};
const initialData = ${JSON.stringify(await getInitialInjectedData())};
imageEditorControl.loadImageOrTemplate(initialSVGPath, initialTemplateData, initialData);
}
})();`);
}, [webViewRef, resourceFilename]);
const messenger = useEditorMessenger({
webViewRef,
setImageChanged: onSetImageChanged,
onReadyToLoadData,
onSave,
onAutoSave,
onRequestCloseEditor,
});
const messengerRef = useRef(messenger);
messengerRef.current = messenger;
const css = useCss(editorTheme);
useEffect(() => {
void messengerRef.current.remoteApi.onThemeUpdate(css);
}, [css]);
const editorControl = useMemo((): ImageEditorControl => {
return {
saveThenExit: () => messenger.remoteApi.saveThenExit(),
};
}, [messenger]);
return useMemo(() => {
return {
pageSetup: {
js: injectedJavaScript,
css,
},
api: editorControl,
webViewEventHandlers: {
onLoadEnd: messenger.onWebViewLoaded,
onMessage: messenger.onWebViewMessage,
},
};
}, [editorControl, messenger, injectedJavaScript, css]);
};
export default useWebViewSetup;

View File

@@ -1,25 +1,30 @@
import { RefObject, useMemo } from 'react';
import { WebViewControl } from '../../../ExtendedWebView/types';
import { ImageEditorCallbacks, ImageEditorControl } from '../js-draw/types';
import { RefObject, useMemo, useRef } from 'react';
import Setting from '@joplin/lib/models/Setting';
import RNToWebViewMessenger from '../../../../utils/ipc/RNToWebViewMessenger';
import { writeAutosave } from '../autosave';
import Clipboard from '@react-native-clipboard/clipboard';
import { MainProcessApi, EditorProcessApi } from '../contentScript/types';
import { WebViewControl } from '../../../components/ExtendedWebView/types';
import RNToWebViewMessenger from '../../../utils/ipc/RNToWebViewMessenger';
interface Props {
webviewRef: RefObject<WebViewControl>;
webViewRef: RefObject<WebViewControl>;
setImageChanged(changed: boolean): void;
onReadyToLoadData(): void;
onSave(data: string): void;
onAutoSave(data: string): void;
onRequestCloseEditor(promptIfUnsaved: boolean): void;
}
const useEditorMessenger = ({
webviewRef, setImageChanged, onReadyToLoadData, onRequestCloseEditor, onSave,
webViewRef: webviewRef, setImageChanged, onReadyToLoadData, onRequestCloseEditor, onSave, onAutoSave,
}: Props) => {
const events = { onRequestCloseEditor, onSave, onAutoSave, onReadyToLoadData };
// Use a ref to avoid unnecessary rerenders
const eventsRef = useRef(events);
eventsRef.current = events;
return useMemo(() => {
const localApi: ImageEditorCallbacks = {
const localApi: MainProcessApi = {
updateEditorTemplate: newTemplate => {
Setting.setValue('imageeditor.imageTemplate', newTemplate);
},
@@ -30,21 +35,21 @@ const useEditorMessenger = ({
setImageChanged(hasChanges);
},
onLoadedEditor: () => {
onReadyToLoadData();
eventsRef.current.onReadyToLoadData();
},
saveThenClose: svgData => {
onSave(svgData);
onRequestCloseEditor(false);
eventsRef.current.onSave(svgData);
eventsRef.current.onRequestCloseEditor(false);
},
save: (svgData, isAutosave) => {
if (isAutosave) {
return writeAutosave(svgData);
return eventsRef.current.onAutoSave(svgData);
} else {
return onSave(svgData);
return eventsRef.current.onSave(svgData);
}
},
closeEditor: promptIfUnsaved => {
onRequestCloseEditor(promptIfUnsaved);
eventsRef.current.onRequestCloseEditor(promptIfUnsaved);
},
writeClipboardText: async text => {
Clipboard.setString(text);
@@ -53,11 +58,11 @@ const useEditorMessenger = ({
return Clipboard.getString();
},
};
const messenger = new RNToWebViewMessenger<ImageEditorCallbacks, ImageEditorControl>(
const messenger = new RNToWebViewMessenger<MainProcessApi, EditorProcessApi>(
'image-editor', webviewRef, localApi,
);
return messenger;
}, [webviewRef, setImageChanged, onReadyToLoadData, onRequestCloseEditor, onSave]);
}, [webviewRef, setImageChanged]);
};
export default useEditorMessenger;

View File

@@ -0,0 +1,66 @@
import { createEditor } from '@joplin/editor/CodeMirror';
import { focus } from '@joplin/lib/utils/focusHandler';
import WebViewToRNMessenger from '../../utils/ipc/WebViewToRNMessenger';
import { EditorProcessApi, EditorProps, MainProcessApi } from './types';
import readFileToBase64 from '../utils/readFileToBase64';
export { default as setUpLogger } from '../utils/setUpLogger';
export const initializeEditor = ({
parentElementClassName,
initialText,
initialNoteId,
settings,
}: EditorProps) => {
const messenger = new WebViewToRNMessenger<EditorProcessApi, MainProcessApi>('markdownEditor', null);
const parentElement = document.getElementsByClassName(parentElementClassName)[0] as HTMLElement;
if (!parentElement) {
throw new Error(`Unable to find parent element for editor (class name: ${JSON.stringify(parentElementClassName)})`);
}
const control = createEditor(parentElement, {
initialText,
initialNoteId,
settings,
onPasteFile: async (data) => {
const base64 = await readFileToBase64(data);
await messenger.remoteApi.onPasteFile(data.type, base64);
},
onLogMessage: message => {
void messenger.remoteApi.logMessage(message);
},
onEvent: (event): void => {
void messenger.remoteApi.onEditorEvent(event);
},
});
// Works around https://github.com/laurent22/joplin/issues/10047 by handling
// the text/uri-list MIME type when pasting, rather than sending the paste event
// to CodeMirror.
//
// TODO: Remove this workaround when the issue has been fixed upstream.
control.on('paste', (_editor, event: ClipboardEvent) => {
const clipboardData = event.clipboardData;
if (clipboardData.types.length === 1 && clipboardData.types[0] === 'text/uri-list') {
event.preventDefault();
control.insertText(clipboardData.getData('text/uri-list'));
}
});
// Note: Just adding an onclick listener seems sufficient to focus the editor when its background
// is tapped.
parentElement.addEventListener('click', (event) => {
const activeElement = document.querySelector(':focus');
if (!parentElement.contains(activeElement) && event.target === parentElement) {
focus('initial editor focus', control);
}
});
messenger.setLocalInterface({
editor: control,
});
return control;
};

View File

@@ -0,0 +1,24 @@
import { EditorEvent } from '@joplin/editor/events';
import { EditorControl, EditorSettings } from '@joplin/editor/types';
export interface EditorProcessApi {
editor: EditorControl;
}
export interface SelectionRange {
start: number;
end: number;
}
export interface EditorProps {
parentElementClassName: string;
initialText: string;
initialNoteId: string;
settings: EditorSettings;
}
export interface MainProcessApi {
onEditorEvent(event: EditorEvent): Promise<void>;
logMessage(message: string): Promise<void>;
onPasteFile(type: string, dataBase64: string): Promise<void>;
}

View File

@@ -0,0 +1,139 @@
import shim from '@joplin/lib/shim';
import { EditorProcessApi, EditorProps as EditorOptions, SelectionRange, MainProcessApi } from './types';
import { SetUpResult } from '../types';
import { SearchState } from '@joplin/editor/types';
import { RefObject, useEffect, useMemo, useRef } from 'react';
import { OnMessageEvent, WebViewControl } from '../../components/ExtendedWebView/types';
import { EditorEvent } from '@joplin/editor/events';
import Logger from '@joplin/utils/Logger';
import RNToWebViewMessenger from '../../utils/ipc/RNToWebViewMessenger';
const logger = Logger.create('markdownEditor');
interface Props {
editorOptions: EditorOptions;
initialSelection: SelectionRange;
noteHash: string;
globalSearch: string;
onEditorEvent: (event: EditorEvent)=> void;
onAttachFile: (mime: string, base64: string)=> void;
webviewRef: RefObject<WebViewControl>;
}
const defaultSearchState: SearchState = {
useRegex: false,
caseSensitive: false,
searchText: '',
replaceText: '',
dialogVisible: false,
};
const useWebViewSetup = ({
editorOptions, initialSelection, noteHash, globalSearch, webviewRef, onEditorEvent, onAttachFile,
}: Props): SetUpResult<EditorProcessApi> => {
const setInitialSelectionJs = initialSelection ? `
cm.select(${initialSelection.start}, ${initialSelection.end});
cm.execCommand('scrollSelectionIntoView');
` : '';
const jumpToHashJs = noteHash ? `
cm.jumpToHash(${JSON.stringify(noteHash)});
` : '';
const setInitialSearchJs = globalSearch ? `
cm.setSearchState(${JSON.stringify({
...defaultSearchState,
searchText: globalSearch,
})})
` : '';
const injectedJavaScript = useMemo(() => `
if (!window.cm) {
${shim.injectedJs('markdownEditorBundle')};
markdownEditorBundle.setUpLogger();
window.cm = markdownEditorBundle.initializeEditor(
${JSON.stringify(editorOptions)}
);
${jumpToHashJs}
// Set the initial selection after jumping to the header -- the initial selection,
// if specified, should take precedence.
${setInitialSelectionJs}
${setInitialSearchJs}
window.onresize = () => {
cm.execCommand('scrollSelectionIntoView');
};
}
`, [jumpToHashJs, setInitialSearchJs, setInitialSelectionJs, editorOptions]);
// Scroll to the new hash, if it changes.
const isFirstScrollRef = useRef(true);
useEffect(() => {
// The first "jump to header" is handled during editor setup and shouldn't
// be handled a second time:
if (isFirstScrollRef.current) {
isFirstScrollRef.current = false;
return;
}
if (jumpToHashJs && webviewRef.current) {
webviewRef.current.injectJS(jumpToHashJs);
}
}, [jumpToHashJs, webviewRef]);
const onEditorEventRef = useRef(onEditorEvent);
onEditorEventRef.current = onEditorEvent;
const onAttachRef = useRef(onAttachFile);
onAttachRef.current = onAttachFile;
const editorMessenger = useMemo(() => {
const localApi: MainProcessApi = {
async onEditorEvent(event) {
onEditorEventRef.current(event);
},
async logMessage(message) {
logger.debug('CodeMirror:', message);
},
async onPasteFile(type, data) {
onAttachRef.current(type, data);
},
};
const messenger = new RNToWebViewMessenger<MainProcessApi, EditorProcessApi>(
'markdownEditor', webviewRef, localApi,
);
return messenger;
}, [webviewRef]);
const webViewEventHandlers = useMemo(() => {
return {
onLoadEnd: () => {
editorMessenger.onWebViewLoaded();
},
onMessage: (event: OnMessageEvent) => {
editorMessenger.onWebViewMessage(event);
},
};
}, [editorMessenger]);
const api = useMemo(() => {
return editorMessenger.remoteApi;
}, [editorMessenger]);
const editorSettings = editorOptions.settings;
useEffect(() => {
api.editor.updateSettings(editorSettings);
}, [api, editorSettings]);
return useMemo(() => ({
pageSetup: {
js: injectedJavaScript,
css: '',
},
api,
webViewEventHandlers,
}), [injectedJavaScript, api, webViewEventHandlers]);
};
export default useWebViewSetup;

View File

@@ -1,12 +1,11 @@
/** @jest-environment jsdom */
import Setting from '@joplin/lib/models/Setting';
import Renderer, { RendererSettings, RendererSetupOptions } from './Renderer';
import Renderer, { RenderSettings, RendererSetupOptions } from './Renderer';
import shim from '@joplin/lib/shim';
import { MarkupLanguage } from '@joplin/renderer';
const defaultRendererSettings: RendererSettings = {
const defaultRendererSettings: RenderSettings = {
theme: JSON.stringify({ cacheKey: 'test' }),
onResourceLoaded: ()=>{},
highlightedKeywords: [],
resources: {},
codeTheme: 'atom-one-light.css',
@@ -16,6 +15,8 @@ const defaultRendererSettings: RendererSettings = {
createEditPopupSyntax: '',
destroyEditPopupSyntax: '',
pluginAssetContainerSelector: '#asset-container',
splitted: false,
pluginSettings: {},
requestPluginSetting: () => { },
@@ -47,25 +48,25 @@ describe('Renderer', () => {
document.body.appendChild(contentContainer);
const pluginAssetsContainer = document.createElement('div');
pluginAssetsContainer.id = 'joplin-container-pluginAssetsContainer';
pluginAssetsContainer.id = 'asset-container';
document.body.appendChild(pluginAssetsContainer);
});
afterEach(() => {
document.querySelector('#joplin-container-content')?.remove();
document.querySelector('#joplin-container-pluginAssetsContainer')?.remove();
document.querySelector('#asset-container')?.remove();
});
test('should support rendering markdown', async () => {
const renderer = makeRenderer({});
await renderer.rerender(
await renderer.rerenderToBody(
{ language: MarkupLanguage.Markdown, markup: '**test**' },
defaultRendererSettings,
);
expect(getRenderedContent().innerHTML.trim()).toBe('<p><strong>test</strong></p>');
await renderer.rerender(
await renderer.rerenderToBody(
{ language: MarkupLanguage.Markdown, markup: '*test*' },
defaultRendererSettings,
);
@@ -92,7 +93,7 @@ describe('Renderer', () => {
pluginId: 'com.example.test-plugin',
},
]);
await renderer.rerender(
await renderer.rerenderToBody(
{ language: MarkupLanguage.Markdown, markup: '```\ntest\n```' },
defaultRendererSettings,
);
@@ -100,7 +101,7 @@ describe('Renderer', () => {
// Should support removing plugin scripts
await renderer.setExtraContentScriptsAndRerender([]);
await renderer.rerender(
await renderer.rerenderToBody(
{ language: MarkupLanguage.Markdown, markup: '```\ntest\n```' },
defaultRendererSettings,
);
@@ -113,14 +114,14 @@ describe('Renderer', () => {
const requestPluginSetting = jest.fn();
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const rerender = (pluginSettings: Record<string, any>) => {
return renderer.rerender(
const rerenderToBody = (pluginSettings: Record<string, any>) => {
return renderer.rerenderToBody(
{ language: MarkupLanguage.Markdown, markup: '```\ntest\n```' },
{ ...defaultRendererSettings, pluginSettings, requestPluginSetting },
);
};
await rerender({});
await rerenderToBody({});
expect(requestPluginSetting).toHaveBeenCalledTimes(0);
const pluginId = 'com.example.test-plugin';
@@ -146,7 +147,7 @@ describe('Renderer', () => {
// Should call .requestPluginSetting for missing settings
expect(requestPluginSetting).toHaveBeenCalledTimes(1);
await rerender({});
await rerenderToBody({ someOtherSetting: 1 });
expect(requestPluginSetting).toHaveBeenCalledTimes(2);
expect(requestPluginSetting).toHaveBeenLastCalledWith('com.example.test-plugin', 'setting');
@@ -154,11 +155,11 @@ describe('Renderer', () => {
expect(getRenderedContent().querySelector('#setting-value').innerHTML).toBe('Setting value: undefined');
// Should expect only namespaced plugin settings
await rerender({ 'setting': 'test' });
await rerenderToBody({ 'setting': 'test' });
expect(requestPluginSetting).toHaveBeenCalledTimes(3);
// Should not request plugin settings when all settings are present.
await rerender({ [`${pluginId}.setting`]: 'test' });
await rerenderToBody({ [`${pluginId}.setting`]: 'test' });
expect(requestPluginSetting).toHaveBeenCalledTimes(3);
expect(getRenderedContent().querySelector('#setting-value').innerHTML).toBe('Setting value: test');
});

View File

@@ -0,0 +1,202 @@
import { MarkupLanguage, MarkupToHtml } from '@joplin/renderer';
import type { MarkupToHtmlConverter, RenderOptions, FsDriver as RendererFsDriver, ResourceInfos } from '@joplin/renderer/types';
import makeResourceModel from './utils/makeResourceModel';
import addPluginAssets from './utils/addPluginAssets';
import { ExtraContentScriptSource, ForwardedJoplinSettings, MarkupRecord } from '../types';
import { ExtraContentScript } from '@joplin/lib/services/plugins/utils/loadContentScripts';
import { PluginOptions } from '@joplin/renderer/MarkupToHtml';
import afterFullPageRender from './utils/afterFullPageRender';
export interface RendererSetupOptions {
settings: ForwardedJoplinSettings;
useTransferredFiles: boolean;
pluginOptions: PluginOptions;
fsDriver: RendererFsDriver;
}
export interface RenderSettings {
theme: string;
highlightedKeywords: string[];
resources: ResourceInfos;
codeTheme: string;
noteHash: string;
initialScroll: number;
// If [null], plugin assets are not added to the document
pluginAssetContainerSelector: string|null;
splitted?: boolean; // Move CSS into a separate output
mapsToLine?: boolean; // Sourcemaps
createEditPopupSyntax: string;
destroyEditPopupSyntax: string;
pluginSettings: Record<string, unknown>;
requestPluginSetting: (pluginId: string, settingKey: string)=> void;
readAssetBlob: (assetPath: string)=> Promise<Blob>;
}
export interface RendererOutput {
getOutputElement: ()=> HTMLElement;
afterRender: (setupOptions: RendererSetupOptions, renderSettings: RenderSettings)=> void;
}
export default class Renderer {
private markupToHtml_: MarkupToHtmlConverter;
private lastBodyRenderSettings_: RenderSettings|null = null;
private extraContentScripts_: ExtraContentScript[] = [];
private lastBodyMarkup_: MarkupRecord|null = null;
private lastPluginSettingsCacheKey_: string|null = null;
private resourcePathOverrides_: Record<string, string> = Object.create(null);
public constructor(private setupOptions_: RendererSetupOptions) {
this.recreateMarkupToHtml_();
}
private recreateMarkupToHtml_() {
this.markupToHtml_ = new MarkupToHtml({
extraRendererRules: this.extraContentScripts_,
fsDriver: this.setupOptions_.fsDriver,
isSafeMode: this.setupOptions_.settings.safeMode,
tempDir: this.setupOptions_.settings.tempDir,
ResourceModel: makeResourceModel(this.setupOptions_.settings.resourceDir),
pluginOptions: this.setupOptions_.pluginOptions,
});
}
// Intended for web, where resources can't be linked to normally.
public async setResourceFile(id: string, file: Blob) {
this.resourcePathOverrides_[id] = URL.createObjectURL(file);
}
public getResourcePathOverride(resourceId: string) {
if (Object.prototype.hasOwnProperty.call(this.resourcePathOverrides_, resourceId)) {
return this.resourcePathOverrides_[resourceId];
}
return null;
}
public async setExtraContentScriptsAndRerender(
extraContentScripts: ExtraContentScriptSource[],
) {
this.extraContentScripts_ = extraContentScripts.map(script => {
const scriptModule = ((0, eval)(script.js))({
pluginId: script.pluginId,
contentScriptId: script.id,
});
if (!scriptModule.plugin) {
throw new Error(`
Expected content script ${script.id} to export a function that returns an object with a "plugin" property.
Found: ${scriptModule}, which has keys ${Object.keys(scriptModule)}.
`);
}
return {
...script,
module: scriptModule,
};
});
this.recreateMarkupToHtml_();
// If possible, rerenders with the last rendering settings. The goal
// of this is to reduce the number of IPC calls between the viewer and
// React Native. We want the first render to be as fast as possible.
if (this.lastBodyMarkup_) {
await this.rerenderToBody(this.lastBodyMarkup_, this.lastBodyRenderSettings_);
}
}
public async render(markup: MarkupRecord, settings: RenderSettings) {
const options: RenderOptions = {
highlightedKeywords: settings.highlightedKeywords,
resources: settings.resources,
codeTheme: settings.codeTheme,
postMessageSyntax: 'window.joplinPostMessage_',
enableLongPress: true,
// Show an 'edit' popup over SVG images
editPopupFiletypes: ['image/svg+xml'],
createEditPopupSyntax: settings.createEditPopupSyntax,
destroyEditPopupSyntax: settings.destroyEditPopupSyntax,
itemIdToUrl: this.setupOptions_.useTransferredFiles ? (id: string) => this.getResourcePathOverride(id) : undefined,
settingValue: (pluginId: string, settingName: string) => {
const settingKey = `${pluginId}.${settingName}`;
if (!(settingKey in settings.pluginSettings)) {
// This should make the setting available on future renders.
settings.requestPluginSetting(pluginId, settingName);
return undefined;
}
return settings.pluginSettings[settingKey];
},
splitted: settings.splitted,
mapsToLine: settings.mapsToLine,
whiteBackgroundNoteRendering: markup.language === MarkupLanguage.Html,
};
const pluginSettingsCacheKey = JSON.stringify(settings.pluginSettings);
if (pluginSettingsCacheKey !== this.lastPluginSettingsCacheKey_) {
this.lastPluginSettingsCacheKey_ = pluginSettingsCacheKey;
this.markupToHtml_.clearCache(markup.language);
}
const result = await this.markupToHtml_.render(
markup.language,
markup.markup,
JSON.parse(settings.theme),
options,
);
// Adding plugin assets can be slow -- run it asynchronously.
if (settings.pluginAssetContainerSelector) {
void (async () => {
await addPluginAssets(result.pluginAssets, {
inlineAssets: this.setupOptions_.useTransferredFiles,
readAssetBlob: settings.readAssetBlob,
container: document.querySelector(settings.pluginAssetContainerSelector),
});
// Some plugins require this event to be dispatched just after being added.
document.dispatchEvent(new Event('joplin-noteDidUpdate'));
})();
}
return result;
}
public async rerenderToBody(markup: MarkupRecord, settings: RenderSettings) {
this.lastBodyMarkup_ = markup;
this.lastBodyRenderSettings_ = settings;
const contentContainer = document.getElementById('joplin-container-content') ?? document.body;
let html = '';
try {
const result = await this.render(markup, settings);
html = result.html;
} catch (error) {
if (!contentContainer) {
alert(`Renderer error: ${error}`);
} else {
contentContainer.textContent = `
Error: ${error}
${error.stack ?? ''}
`;
}
throw error;
}
if (contentContainer) {
contentContainer.innerHTML = html;
}
afterFullPageRender(this.setupOptions_, settings);
}
public clearCache(markupLanguage: MarkupLanguage) {
this.markupToHtml_.clearCache(markupLanguage);
}
}

View File

@@ -0,0 +1,79 @@
import Renderer from './Renderer';
import WebViewToRNMessenger from '../../../utils/ipc/WebViewToRNMessenger';
import { RendererProcessApi, MainProcessApi, RendererWebViewOptions } from '../types';
interface WebViewLib {
initialize(config: unknown): void;
}
interface WebViewApi {
postMessage: (contentScriptId: string, args: unknown)=> void;
}
interface ExtendedWindow extends Window {
webviewLib: WebViewLib;
webviewApi: WebViewApi;
joplinPostMessage_: (message: string, args: unknown)=> void;
}
declare const window: ExtendedWindow;
declare const webviewLib: WebViewLib;
const initializeMessenger = (options: RendererWebViewOptions) => {
const messenger = new WebViewToRNMessenger<RendererProcessApi, MainProcessApi>(
'renderer',
null,
);
window.joplinPostMessage_ = (message: string, _args: unknown) => {
return messenger.remoteApi.onPostMessage(message);
};
window.webviewApi = {
postMessage: messenger.remoteApi.onPostPluginMessage,
};
webviewLib.initialize({
postMessage: (message: string) => {
messenger.remoteApi.onPostMessage(message);
},
});
// Share the webview library globally so that the renderer can access it.
window.webviewLib = webviewLib;
const renderer = new Renderer({
...options,
fsDriver: messenger.remoteApi.fsDriver,
});
messenger.setLocalInterface({
renderer,
jumpToHash: (hash: string) => {
location.hash = `#${hash}`;
},
});
return { messenger };
};
// eslint-disable-next-line import/prefer-default-export -- This is a bundle entrypoint
export const initialize = (options: RendererWebViewOptions) => {
const { messenger } = initializeMessenger(options);
const lastScrollTop: number|null = null;
const onMainContentScroll = () => {
const newScrollTop = document.scrollingElement.scrollTop;
if (lastScrollTop !== newScrollTop) {
messenger.remoteApi.onScroll(newScrollTop);
}
};
// Listen for events on both scrollingElement and window
// - On Android, scrollingElement.addEventListener('scroll', callback) doesn't call callback on
// scroll. However, window.addEventListener('scroll', callback) does.
// - iOS needs a listener to be added to scrollingElement -- events aren't received when
// the listener is added to window with window.addEventListener('scroll', ...).
document.scrollingElement?.addEventListener('scroll', onMainContentScroll);
window.addEventListener('scroll', onMainContentScroll);
};

View File

@@ -0,0 +1,5 @@
export interface WebViewLib {
initialize(config: unknown): void;
setupResourceManualDownload(): void;
}

View File

@@ -39,6 +39,7 @@ const rewriteInternalAssetLinks = async (asset: RenderResultPluginAsset, content
interface Options {
inlineAssets: boolean;
container: HTMLElement;
readAssetBlob?(path: string): Promise<Blob>;
}
@@ -47,7 +48,7 @@ interface Options {
const addPluginAssets = async (assets: RenderResultPluginAsset[], options: Options) => {
if (!assets) return;
const pluginAssetsContainer = document.getElementById('joplin-container-pluginAssetsContainer');
const pluginAssetsContainer = options.container;
const prepareAssetBlobUrls = () => {
for (const asset of assets) {

View File

@@ -0,0 +1,45 @@
import { RenderSettings, RendererSetupOptions } from '../Renderer';
import { WebViewLib } from '../types';
interface ExtendedWindow extends Window {
webviewLib: WebViewLib;
}
declare const window: ExtendedWindow;
const afterFullPageRender = (
setupOptions: RendererSetupOptions,
renderSettings: RenderSettings,
) => {
const readyStateCheckInterval = setInterval(() => {
if (document.readyState === 'complete') {
clearInterval(readyStateCheckInterval);
if (setupOptions.settings.resourceDownloadMode === 'manual') {
window.webviewLib.setupResourceManualDownload();
}
const hash = renderSettings.noteHash;
const initialScroll = renderSettings.initialScroll;
// Don't scroll to a hash if we're given initial scroll (initial scroll
// overrides scrolling to a hash).
if ((initialScroll ?? null) !== null) {
const scrollingElement = document.scrollingElement ?? document.documentElement;
scrollingElement.scrollTop = initialScroll;
} else if (hash) {
// Gives it a bit of time before scrolling to the anchor
// so that images are loaded.
setTimeout(() => {
const e = document.getElementById(hash);
if (!e) {
console.warn('Cannot find hash', hash);
return;
}
e.scrollIntoView();
}, 500);
}
}
}, 10);
};
export default afterFullPageRender;

View File

@@ -0,0 +1,73 @@
import type { FsDriver as RendererFsDriver, RenderResult, ResourceInfos } from '@joplin/renderer/types';
import type Renderer from './contentScript/Renderer';
import { MarkupLanguage, PluginOptions } from '@joplin/renderer/MarkupToHtml';
// Joplin settings (as from Setting.value(...)) that should
// remain constant during editing.
export interface ForwardedJoplinSettings {
safeMode: boolean;
tempDir: string;
resourceDir: string;
resourceDownloadMode: string;
}
export interface RendererWebViewOptions {
settings: ForwardedJoplinSettings;
// True if asset and resource files should be transferred to the WebView before rendering.
// This must be true on web, where asset and resource files are virtual and can't be accessed
// without transferring.
useTransferredFiles: boolean;
// Enabled/disabled Markdown plugins
pluginOptions: PluginOptions;
}
export interface ExtraContentScriptSource {
id: string;
js: string;
assetPath: string;
pluginId: string;
}
export interface RendererProcessApi {
renderer: Renderer;
jumpToHash: (hash: string)=> void;
}
export interface MainProcessApi {
onScroll(scrollTop: number): void;
onPostMessage(message: string): void;
onPostPluginMessage(contentScriptId: string, message: unknown): Promise<unknown>;
fsDriver: RendererFsDriver;
}
export type OnScrollCallback = (scrollTop: number)=> void;
export interface MarkupRecord {
language: MarkupLanguage;
markup: string;
}
export interface RenderOptions {
themeId: number;
highlightedKeywords: string[];
resources: ResourceInfos;
themeOverrides: Record<string, string|number>;
// If null, plugin assets will not be added to the document.
pluginAssetContainerSelector: string|null;
noteHash: string;
initialScroll: number;
// Forwarded renderer settings
splitted?: boolean;
mapsToLine?: boolean;
}
type CancelEvent = { cancelled: boolean };
export interface RendererControl {
rerenderToBody(markup: MarkupRecord, options: RenderOptions, cancelEvent?: CancelEvent): Promise<string|void>;
render(markup: MarkupRecord, options: RenderOptions): Promise<RenderResult>;
clearCache(markupLanguage: MarkupLanguage): void;
}

View File

@@ -0,0 +1,277 @@
import { RefObject, useEffect, useMemo, useRef } from 'react';
import shim from '@joplin/lib/shim';
import Setting from '@joplin/lib/models/Setting';
import { Platform } from 'react-native';
import { SetUpResult } from '../types';
import { themeStyle } from '../../components/global-style';
import Logger from '@joplin/utils/Logger';
import { WebViewControl } from '../../components/ExtendedWebView/types';
import { MainProcessApi, OnScrollCallback, RendererControl, RendererProcessApi, RendererWebViewOptions, RenderOptions } from './types';
import PluginService from '@joplin/lib/services/plugins/PluginService';
import RNToWebViewMessenger from '../../utils/ipc/RNToWebViewMessenger';
import useEditPopup from './utils/useEditPopup';
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
import { RenderSettings } from './contentScript/Renderer';
import resolvePathWithinDir from '@joplin/lib/utils/resolvePathWithinDir';
import Resource from '@joplin/lib/models/Resource';
import { ResourceInfos } from '@joplin/renderer/types';
import useContentScripts from './utils/useContentScripts';
import uuid from '@joplin/lib/uuid';
const logger = Logger.create('renderer/useWebViewSetup');
interface Props {
webviewRef: RefObject<WebViewControl>;
onBodyScroll: OnScrollCallback|null;
onPostMessage: (message: string)=> void;
pluginStates: PluginStates;
themeId: number;
}
const useSource = (tempDirPath: string) => {
const injectedJs = useMemo(() => {
const subValues = Setting.subValues('markdown.plugin', Setting.toPlainObject());
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const pluginOptions: any = {};
for (const n in subValues) {
pluginOptions[n] = { enabled: subValues[n] };
}
const rendererWebViewStaticOptions: RendererWebViewOptions = {
settings: {
safeMode: Setting.value('isSafeMode'),
tempDir: tempDirPath,
resourceDir: Setting.value('resourceDir'),
resourceDownloadMode: Setting.value('sync.resourceDownloadMode'),
},
// Web needs files to be transferred manually, since image SRCs can't reference
// the Origin Private File System.
useTransferredFiles: Platform.OS === 'web',
pluginOptions,
};
return `
if (!window.rendererJsLoaded) {
window.rendererJsLoaded = true;
${shim.injectedJs('webviewLib')}
${shim.injectedJs('rendererBundle')}
rendererBundle.initialize(${JSON.stringify(rendererWebViewStaticOptions)});
}
`;
}, [tempDirPath]);
return { css: '', injectedJs };
};
const onPostPluginMessage = async (contentScriptId: string, message: unknown) => {
logger.debug(`Handling message from content script: ${contentScriptId}:`, message);
const pluginService = PluginService.instance();
const pluginId = pluginService.pluginIdByContentScriptId(contentScriptId);
if (!pluginId) {
throw new Error(`Plugin not found for content script with ID ${contentScriptId}`);
}
const plugin = pluginService.pluginById(pluginId);
return plugin.emitContentScriptMessage(contentScriptId, message);
};
type UseMessengerProps = Props & { tempDirPath: string };
const useMessenger = (props: UseMessengerProps) => {
const onScrollRef = useRef(props.onBodyScroll);
onScrollRef.current = props.onBodyScroll;
const onPostMessageRef = useRef(props.onPostMessage);
onPostMessageRef.current = props.onPostMessage;
const messenger = useMemo(() => {
const fsDriver = shim.fsDriver();
const localApi = {
onScroll: (fraction: number) => onScrollRef.current?.(fraction),
onPostMessage: (message: string) => onPostMessageRef.current?.(message),
onPostPluginMessage,
fsDriver: {
writeFile: async (path: string, content: string, encoding?: string) => {
if (!await fsDriver.exists(props.tempDirPath)) {
await fsDriver.mkdir(props.tempDirPath);
}
// To avoid giving the WebView access to the entire main tempDir,
// we use props.tempDir (which should be different).
path = fsDriver.resolveRelativePathWithinDir(props.tempDirPath, path);
return await fsDriver.writeFile(path, content, encoding);
},
exists: fsDriver.exists,
cacheCssToFile: fsDriver.cacheCssToFile,
},
};
return new RNToWebViewMessenger<MainProcessApi, RendererProcessApi>(
'renderer', props.webviewRef, localApi,
);
}, [props.webviewRef, props.tempDirPath]);
return messenger;
};
const useTempDirPath = () => {
// The renderer can write to whichever temporary directory is chosen here. As such,
// use a subdirectory of the main temporary directory for security reasons.
const tempDirPath = useMemo(() => {
return `${Setting.value('tempDir')}/${uuid.createNano()}`;
}, []);
useEffect(() => {
return () => {
void (async () => {
if (await shim.fsDriver().exists(tempDirPath)) {
await shim.fsDriver().remove(tempDirPath);
}
})();
};
}, [tempDirPath]);
return tempDirPath;
};
const useWebViewSetup = (props: Props): SetUpResult<RendererControl> => {
const tempDirPath = useTempDirPath();
const { css, injectedJs } = useSource(tempDirPath);
const { editPopupCss, createEditPopupSyntax, destroyEditPopupSyntax } = useEditPopup(props.themeId);
const messenger = useMessenger({ ...props, tempDirPath });
const pluginSettingKeysRef = useRef(new Set<string>());
const contentScripts = useContentScripts(props.pluginStates);
useEffect(() => {
void messenger.remoteApi.renderer.setExtraContentScriptsAndRerender(contentScripts);
}, [messenger, contentScripts]);
const rendererControl = useMemo((): RendererControl => {
const renderer = messenger.remoteApi.renderer;
const transferResources = async (resources: ResourceInfos) => {
// On web, resources are virtual files and thus need to be transferred to the WebView.
if (shim.mobilePlatform() === 'web') {
for (const [resourceId, resource] of Object.entries(resources)) {
try {
await renderer.setResourceFile(
resourceId,
await shim.fsDriver().fileAtPath(Resource.fullPath(resource.item)),
);
} catch (error) {
if (error.code !== 'ENOENT') {
throw error;
}
// This can happen if a resource hasn't been downloaded yet
logger.warn('Error: Resource file not found (ENOENT)', Resource.fullPath(resource.item), 'for ID', resource.item.id);
}
}
}
};
const prepareRenderer = async (options: RenderOptions) => {
const theme = themeStyle(options.themeId);
const loadPluginSettings = () => {
const output: Record<string, unknown> = Object.create(null);
for (const key of pluginSettingKeysRef.current) {
output[key] = Setting.value(`plugin-${key}`);
}
return output;
};
let settingsChanged = false;
const settings: RenderSettings = {
...options,
codeTheme: theme.codeThemeCss,
// We .stringify the theme to avoid a JSON serialization error involving
// the color package.
theme: JSON.stringify({
...theme,
...options.themeOverrides,
}),
createEditPopupSyntax,
destroyEditPopupSyntax,
pluginSettings: loadPluginSettings(),
requestPluginSetting: (pluginId: string, settingKey: string) => {
const key = `${pluginId}.${settingKey}`;
if (!pluginSettingKeysRef.current.has(key)) {
pluginSettingKeysRef.current.add(key);
settingsChanged = true;
}
},
readAssetBlob: (assetPath: string): Promise<Blob> => {
// Built-in assets are in resourceDir, external plugin assets are in cacheDir.
const assetsDirs = [Setting.value('resourceDir'), Setting.value('cacheDir')];
let resolvedPath = null;
for (const assetDir of assetsDirs) {
resolvedPath ??= resolvePathWithinDir(assetDir, assetPath);
if (resolvedPath) break;
}
if (!resolvedPath) {
throw new Error(`Failed to load asset at ${assetPath} -- not in any of the allowed asset directories: ${assetsDirs.join(',')}.`);
}
return shim.fsDriver().fileAtPath(resolvedPath);
},
};
await transferResources(options.resources);
return {
settings,
getSettingsChanged() {
return settingsChanged;
},
};
};
return {
rerenderToBody: async (markup, options, cancelEvent) => {
const { settings, getSettingsChanged } = await prepareRenderer(options);
if (cancelEvent?.cancelled) return null;
const output = await renderer.rerenderToBody(markup, settings);
if (cancelEvent?.cancelled) return null;
if (getSettingsChanged()) {
return await renderer.rerenderToBody(markup, settings);
}
return output;
},
render: async (markup, options) => {
const { settings, getSettingsChanged } = await prepareRenderer(options);
const output = await renderer.render(markup, settings);
if (getSettingsChanged()) {
return await renderer.render(markup, settings);
}
return output;
},
clearCache: async markupLanguage => {
await renderer.clearCache(markupLanguage);
},
};
}, [createEditPopupSyntax, destroyEditPopupSyntax, messenger]);
return useMemo(() => {
return {
api: rendererControl,
pageSetup: {
css: `${css} ${editPopupCss}`,
js: injectedJs,
},
webViewEventHandlers: {
onLoadEnd: messenger.onWebViewLoaded,
onMessage: messenger.onWebViewMessage,
},
};
}, [css, injectedJs, messenger, editPopupCss, rendererControl]);
};
export default useWebViewSetup;

View File

@@ -4,8 +4,8 @@ import { ContentScriptType } from '@joplin/lib/services/plugins/api/types';
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
import shim from '@joplin/lib/shim';
import { useRef, useState } from 'react';
import { ExtraContentScriptSource } from '../bundledJs/types';
import Logger from '@joplin/utils/Logger';
import { ExtraContentScriptSource } from '../types';
const logger = Logger.create('NoteBodyViewer/hooks/useContentScripts');

View File

@@ -0,0 +1,131 @@
import '../utils/polyfills';
import { createEditor } from '@joplin/editor/ProseMirror';
import { EditorProcessApi, EditorProps, MainProcessApi } from './types';
import WebViewToRNMessenger from '../../utils/ipc/WebViewToRNMessenger';
import { MarkupLanguage } from '@joplin/renderer';
import '@joplin/editor/ProseMirror/styles';
import HtmlToMd from '@joplin/lib/HtmlToMd';
import readFileToBase64 from '../utils/readFileToBase64';
import { EditorLanguageType } from '@joplin/editor/types';
const postprocessHtml = (html: HTMLElement) => {
// Fix resource URLs
const resources = html.querySelectorAll<HTMLImageElement>('img[data-resource-id]');
for (const resource of resources) {
const resourceId = resource.getAttribute('data-resource-id');
resource.src = `:/${resourceId}`;
}
// Re-add newlines to data-joplin-source-* that were removed
// by ProseMirror.
// TODO: Try to find a better solution
const sourceBlocks = html.querySelectorAll<HTMLPreElement>(
'pre[data-joplin-source-open][data-joplin-source-close].joplin-source',
);
for (const sourceBlock of sourceBlocks) {
const isBlock = sourceBlock.parentElement.tagName !== 'SPAN';
if (isBlock) {
const originalOpen = sourceBlock.getAttribute('data-joplin-source-open');
const originalClose = sourceBlock.getAttribute('data-joplin-source-close');
sourceBlock.setAttribute('data-joplin-source-open', `${originalOpen}\n`);
sourceBlock.setAttribute('data-joplin-source-close', `\n${originalClose}`);
}
}
return html;
};
const wrapHtmlForMarkdownConversion = (html: HTMLElement) => {
// Add a container element -- when converting to HTML, Turndown
// sometimes doesn't process the toplevel element in the same way
// as other elements (e.g. in the case of Joplin source blocks).
const wrapper = html.ownerDocument.createElement('div');
wrapper.appendChild(html.cloneNode(true));
return wrapper;
};
const htmlToMd = new HtmlToMd();
const htmlToMarkdown = (html: HTMLElement): string => {
html = postprocessHtml(html);
return htmlToMd.parse(html, { preserveColorStyles: true });
};
export const initialize = async ({
settings,
initialText,
initialNoteId,
parentElementClassName,
}: EditorProps) => {
const messenger = new WebViewToRNMessenger<EditorProcessApi, MainProcessApi>('rich-text-editor', null);
const parentElement = document.getElementsByClassName(parentElementClassName)[0];
if (!parentElement) throw new Error('Parent element not found');
if (!(parentElement instanceof HTMLElement)) {
throw new Error('Parent node is not an element.');
}
const assetContainer = document.createElement('div');
assetContainer.id = 'joplin-container-pluginAssetsContainer';
document.body.appendChild(assetContainer);
const editor = await createEditor(parentElement, {
settings,
initialText,
initialNoteId,
onPasteFile: async (data) => {
const base64 = await readFileToBase64(data);
await messenger.remoteApi.onPasteFile(data.type, base64);
},
onLogMessage: (message: string) => {
void messenger.remoteApi.logMessage(message);
},
onEvent: (event) => {
void messenger.remoteApi.onEditorEvent(event);
},
}, {
renderMarkupToHtml: async (markup) => {
return await messenger.remoteApi.onRender({
markup,
language: settings.language === EditorLanguageType.Html ? MarkupLanguage.Html : MarkupLanguage.Markdown,
}, {
pluginAssetContainerSelector: `#${assetContainer.id}`,
splitted: true,
mapsToLine: true,
});
},
renderHtmlToMarkup: (node) => {
// By default, if `src` is specified on an image, the browser will try to load the image, even if it isn't added
// to the DOM. (A similar problem is described here: https://stackoverflow.com/q/62019538).
// Since :/resourceId isn't a valid image URI, this results in a large number of warnings. As a workaround,
// move the element to a temporary document before processing:
const dom = document.implementation.createHTMLDocument();
node = dom.importNode(node, true);
let html: HTMLElement;
if ((node instanceof HTMLElement)) {
html = node;
} else {
const container = document.createElement('div');
container.appendChild(html);
html = container;
}
if (settings.language === EditorLanguageType.Markdown) {
return htmlToMarkdown(wrapHtmlForMarkdownConversion(html));
} else {
return postprocessHtml(html).outerHTML;
}
},
});
messenger.setLocalInterface({
editor,
});
return editor;
};
export { default as setUpLogger } from '../utils/setUpLogger';

View File

@@ -0,0 +1,33 @@
import { EditorEvent } from '@joplin/editor/events';
import { EditorControl, EditorSettings } from '@joplin/editor/types';
import { MarkupRecord, RendererControl } from '../rendererBundle/types';
import { RenderResult } from '@joplin/renderer/types';
export interface EditorProps {
initialText: string;
initialNoteId: string;
parentElementClassName: string;
settings: EditorSettings;
}
export interface EditorProcessApi {
editor: EditorControl;
}
type RenderOptionsSlice = {
pluginAssetContainerSelector: string;
splitted: boolean;
mapsToLine: true;
};
export interface MainProcessApi {
onEditorEvent(event: EditorEvent): Promise<void>;
logMessage(message: string): Promise<void>;
onRender(markup: MarkupRecord, options: RenderOptionsSlice): Promise<RenderResult>;
onPasteFile(type: string, base64: string): Promise<void>;
}
export interface RichTextEditorControl {
editor: EditorControl;
renderer: RendererControl;
}

View File

@@ -0,0 +1,166 @@
import { RefObject, useEffect, useMemo, useRef } from 'react';
import { WebViewControl } from '../../components/ExtendedWebView/types';
import { SetUpResult } from '../types';
import { EditorControl, EditorSettings } from '@joplin/editor/types';
import RNToWebViewMessenger from '../../utils/ipc/RNToWebViewMessenger';
import { EditorProcessApi, EditorProps, MainProcessApi } from './types';
import useRendererSetup from '../rendererBundle/useWebViewSetup';
import { EditorEvent } from '@joplin/editor/events';
import Logger from '@joplin/utils/Logger';
import shim from '@joplin/lib/shim';
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
import { RendererControl, RenderOptions } from '../rendererBundle/types';
import { ResourceInfos } from '@joplin/renderer/types';
const logger = Logger.create('useWebViewSetup');
interface Props {
initialText: string;
noteId: string;
settings: EditorSettings;
parentElementClassName: string;
themeId: number;
pluginStates: PluginStates;
noteResources: ResourceInfos;
onAttachFile: (mime: string, base64: string)=> void;
onPostMessage: (message: string)=> void;
onEditorEvent: (event: EditorEvent)=> void;
webviewRef: RefObject<WebViewControl>;
}
type UseMessengerProps = Props & { renderer: SetUpResult<RendererControl> };
const useMessenger = (props: UseMessengerProps) => {
const onEditorEventRef = useRef(props.onEditorEvent);
onEditorEventRef.current = props.onEditorEvent;
const rendererRef = useRef(props.renderer);
rendererRef.current = props.renderer;
const onAttachRef = useRef(props.onAttachFile);
onAttachRef.current = props.onAttachFile;
const markupRenderingSettings = useRef<RenderOptions>(null);
markupRenderingSettings.current = {
themeId: props.themeId,
highlightedKeywords: [],
resources: props.noteResources,
themeOverrides: {},
noteHash: '',
initialScroll: 0,
pluginAssetContainerSelector: null,
};
return useMemo(() => {
const api: MainProcessApi = {
onEditorEvent: (event: EditorEvent) => {
onEditorEventRef.current(event);
return Promise.resolve();
},
logMessage: (message: string) => {
logger.info(message);
return Promise.resolve();
},
onRender: async (markup, options) => {
const renderResult = await rendererRef.current.api.render(
markup,
{
...markupRenderingSettings.current,
splitted: options.splitted,
pluginAssetContainerSelector: options.pluginAssetContainerSelector,
mapsToLine: options.mapsToLine,
},
);
return renderResult;
},
onPasteFile: async (type: string, base64: string) => {
onAttachRef.current(type, base64);
},
};
const messenger = new RNToWebViewMessenger<MainProcessApi, EditorProcessApi>(
'rich-text-editor',
props.webviewRef,
api,
);
return messenger;
}, [props.webviewRef]);
};
type UseSourceProps = Props & { renderer: SetUpResult<RendererControl> };
const useSource = (props: UseSourceProps) => {
const propsRef = useRef(props);
propsRef.current = props;
const rendererJs = props.renderer.pageSetup.js;
const rendererCss = props.renderer.pageSetup.css;
return useMemo(() => {
const editorOptions: EditorProps = {
parentElementClassName: propsRef.current.parentElementClassName,
initialText: propsRef.current.initialText,
initialNoteId: propsRef.current.noteId,
settings: propsRef.current.settings,
};
return {
css: `
${shim.injectedCss('richTextEditorBundle')}
${rendererCss}
/* Increase the size of the editor to make it easier to focus the editor. */
.prosemirror-editor {
min-height: 75vh;
}
`,
js: `
${rendererJs}
if (!window.richTextEditorCreated) {
window.richTextEditorCreated = true;
${shim.injectedJs('richTextEditorBundle')}
richTextEditorBundle.setUpLogger();
richTextEditorBundle.initialize(${JSON.stringify(editorOptions)}).then(function(editor) {
/* For testing */
window.joplinRichTextEditor_ = editor;
});
}
`,
};
}, [rendererJs, rendererCss]);
};
const useWebViewSetup = (props: Props): SetUpResult<EditorControl> => {
const renderer = useRendererSetup({
webviewRef: props.webviewRef,
onBodyScroll: null,
onPostMessage: props.onPostMessage,
pluginStates: props.pluginStates,
themeId: props.themeId,
});
const messenger = useMessenger({ ...props, renderer });
const pageSetup = useSource({ ...props, renderer });
useEffect(() => {
void messenger.remoteApi.editor.updateSettings(props.settings);
}, [props.settings, messenger]);
return useMemo(() => {
return {
api: messenger.remoteApi.editor,
pageSetup: pageSetup,
webViewEventHandlers: {
onLoadEnd: () => {
messenger.onWebViewLoaded();
renderer.webViewEventHandlers.onLoadEnd();
},
onMessage: (event) => {
messenger.onWebViewMessage(event);
renderer.webViewEventHandlers.onMessage(event);
},
},
};
}, [messenger, pageSetup, renderer.webViewEventHandlers]);
};
export default useWebViewSetup;

View File

@@ -0,0 +1,17 @@
import { OnMessageEvent } from '../components/ExtendedWebView/types';
interface WebViewEventHandlers {
onLoadEnd: ()=> void;
onMessage: (event: OnMessageEvent)=> void;
}
export interface PageSetupSources {
css: string;
js: string;
}
export interface SetUpResult<Api> {
api: Api;
pageSetup: PageSetupSources;
webViewEventHandlers: WebViewEventHandlers;
}

View File

@@ -0,0 +1,27 @@
// .replaceChildren is not supported in Chromium 83, which is the default for Android 11
// (unless auto-updated from the Google Play store).
HTMLElement.prototype.replaceChildren ??= function(this: HTMLElement, ...nodes: Node[]) {
while (this.children.length) {
this.children[0].remove();
}
for (const node of nodes) {
this.appendChild(node);
}
};
Array.prototype.flat ??= function<A, D extends number = 1>(this: A, depthParam?: D): FlatArray<A, D>[] {
if (!Array.isArray(this)) throw new Error('Not an array');
const depth = depthParam ?? 1;
const result = [] as FlatArray<A, D>[];
for (let i = 0; i < this.length; i++) {
if (Array.isArray(this[i]) && depth > 0) {
result.push(...this[i].flat(depth - 1));
} else {
result.push(this[i]);
}
}
return result;
};

View File

@@ -0,0 +1,15 @@
const readFileToBase64 = (file: Blob) => {
const reader = new FileReader();
return new Promise<string>((resolve, reject) => {
reader.onload = async () => {
const dataUrl = reader.result as string;
const base64 = dataUrl.replace(/^data:.*;base64,/, '');
resolve(base64);
};
reader.onerror = () => reject(new Error('Failed to load file.'));
reader.readAsDataURL(file);
});
};
export default readFileToBase64;

View File

@@ -0,0 +1,14 @@
import Logger, { TargetType } from '@joplin/utils/Logger';
let loggerCreated = false;
const setUpLogger = () => {
if (!loggerCreated) {
const logger = new Logger();
logger.addTarget(TargetType.Console);
logger.setLevel(Logger.LEVEL_WARN);
Logger.initializeGlobalLogger(logger);
loggerCreated = true;
}
};
export default setUpLogger;

View File

@@ -27,22 +27,14 @@ utils.registerGulpTasks(gulp, tasks);
gulp.task('buildInjectedJs', gulp.series(
'beforeBundle',
'buildCodeMirrorEditor',
'buildJsDrawEditor',
'buildPluginBackgroundScript',
'buildNoteViewerBundle',
'buildBundledJs',
'copyWebviewLib',
));
gulp.task('watchInjectedJs', gulp.series(
'beforeBundle',
'copyWebviewLib',
gulp.parallel(
'watchCodeMirrorEditor',
'watchJsDrawEditor',
'watchPluginBackgroundScript',
'watchNoteViewerBundle',
),
'watchBundledJs',
));
gulp.task('build', gulp.series(

View File

@@ -3,7 +3,7 @@
const { afterEachCleanUp, afterAllCleanUp } = require('@joplin/lib/testing/test-utils.js');
const shim = require('@joplin/lib/shim').default;
const { shimInit } = require('@joplin/lib/shim-init-node.js');
const injectedJs = require('./utils/injectedJs.js').default;
const injectedJs = require('./utils/shim-init-react/injectedJs.js').default;
const { mkdir, rm } = require('fs-extra');
const path = require('path');
const sharp = require('sharp');
@@ -30,7 +30,13 @@ shim.injectedJs = (name) => {
if (!(name in injectedJs)) {
throw new Error(`Cannot find injected JS with ID ${name}`);
}
return injectedJs[name];
return injectedJs[name].js;
};
shim.injectedCss = (name) => {
if (!(name in injectedJs)) {
throw new Error(`Cannot find injected CSS with ID ${name}`);
}
return injectedJs[name].css;
};
shim.fsDriver().getAppDirectoryPath = () => {
// On mobile, the rootProfileDirectory and the app directory

View File

@@ -92,6 +92,8 @@
"@babel/preset-env": "7.25.3",
"@babel/runtime": "7.25.0",
"@joplin/tools": "~3.4",
"@joplin/turndown": "~4.0.80",
"@joplin/turndown-plugin-gfm": "~1.0.62",
"@js-draw/material-icons": "1.30.0",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.15",
"@react-native-community/cli": "16.0.3",
@@ -113,6 +115,7 @@
"babel-loader": "9.1.3",
"babel-plugin-module-resolver": "4.1.0",
"babel-plugin-react-native-web": "0.19.12",
"esbuild": "0.25.3",
"fast-deep-equal": "3.1.3",
"fs-extra": "11.2.0",
"gulp": "4.0.2",

View File

@@ -420,6 +420,10 @@ const appReducer = (state = appDefaultState, action: any) => {
case 'KEYBOARD_VISIBLE_CHANGE':
newState = { ...state, keyboardVisible: action.visible };
break;
case 'NOTE_EDITOR_VISIBLE_CHANGE':
newState = { ...state, noteEditorVisible: action.visible };
break;
}
} catch (error) {
error.message = `In reducer: ${error.message} Action: ${JSON.stringify(action)}`;

View File

@@ -10,6 +10,8 @@ const stateToWhenClauseContext = (state: AppState, options: WhenClauseContextOpt
return {
...libStateToWhenClauseContext(state, options),
keyboardVisible: state.keyboardVisible,
markdownEditorVisible: state.noteEditorVisible && state.settings['editor.codeView'],
richTextEditorVisible: state.noteEditorVisible && !state.settings['editor.codeView'],
};
};

View File

@@ -5,212 +5,97 @@
import { dirname, extname, basename } from 'path';
// We need this to be transpiled to `const webpack = require('webpack')`.
// As such, do a namespace import. See https://www.typescriptlang.org/tsconfig#esModuleInterop
import * as webpack from 'webpack';
import copyJs from './copyJs';
import * as esbuild from 'esbuild';
import copyAssets from './copyAssets';
import { writeFile } from 'fs-extra';
export default class BundledFile {
private readonly bundleOutputPath: string;
private readonly bundleBaseName: string;
private readonly rootFileDirectory: string;
private readonly bundleOutputPathBase_: string;
private readonly bundleBaseName_: string;
private readonly rootFileDirectory_: string;
public constructor(
public readonly bundleName: string,
private readonly sourceFilePath: string,
private readonly sourceFilePath_: string,
) {
this.rootFileDirectory = dirname(sourceFilePath);
this.bundleBaseName = basename(sourceFilePath, extname(sourceFilePath));
this.bundleOutputPath = `${this.rootFileDirectory}/${this.bundleBaseName}.bundle.js`;
this.rootFileDirectory_ = dirname(sourceFilePath_);
this.bundleBaseName_ = basename(sourceFilePath_, extname(sourceFilePath_));
this.bundleOutputPathBase_ = `${this.rootFileDirectory_}/${this.bundleBaseName_}.bundle`;
}
private getWebpackOptions(mode: 'production' | 'development'): webpack.Configuration {
const config: webpack.Configuration = {
mode,
entry: this.sourceFilePath,
private makeBuildContext(mode: 'production' | 'development') {
return esbuild.context({
entryPoints: [this.sourceFilePath_],
outfile: `${this.bundleOutputPathBase_}.js`,
minify: mode === 'production',
bundle: true,
sourcemap: true,
format: 'iife',
globalName: this.bundleName,
metafile: false,
// es5: Have Webpack's generated code target ES5. This doesn't apply to code not
// generated by Webpack.
target: ['web', 'es5'],
target: ['chrome58', 'safari14'],
output: {
path: this.rootFileDirectory,
filename: `${this.bundleBaseName}.bundle.js`,
library: {
type: 'window',
name: this.bundleName,
},
},
// See https://webpack.js.org/guides/typescript/
module: {
rules: [
plugins: [
{
// Include .tsx to include react components
test: /\.tsx?$/,
use: 'ts-loader',
exclude: /node_modules/,
},
{
test: (value) => {
const isModuleFile = !!(/node_modules/.exec(value));
// Some libraries don't work with older browsers/WebViews.
// Because Babel transpilation can be slow, we only transpile
// these libraries.
const moduleNeedsTranspilation = !!(
// Replit's CodeMirror-vim library uses a?.b syntax which seems to be unsupported in iOS 12 Safari.
/.*node_modules.*replit.*\.[mc]?js$/.exec(value) ||
// js-draw uses a ??= b syntax, which is unsupported in old Android WebView versions
/.*node_modules.*js-draw.*\.[mc]?js$/.exec(value)
);
if (isModuleFile && !moduleNeedsTranspilation) {
return false;
name: 'joplin--node-polyfills',
setup: build => {
build.onResolve({ filter: /^(path|events)$/ }, args => {
let path = args.path;
if (args.path === 'path') {
path = require.resolve('path-browserify');
} else if (args.path === 'events') {
path = require.resolve('events/');
}
const isJsFile = !!(/\.[cm]?js$/.exec(value));
return isJsFile;
return { path };
});
},
use: {
loader: 'babel-loader',
options: {
cacheDirectory: false,
// Disable using babel.config.js to prevent conflicts with React Native's
// Babel configuration.
babelrc: false,
configFile: false,
presets: [
['@babel/preset-env', { targets: { ios: 12, chrome: 80 } }],
],
},
{
name: 'joplin--copy-final',
setup: build => {
build.onEnd(async (result) => {
if (result.errors.length === 0) {
console.log('copy output');
await this.copyToImportableFile_();
} else {
console.warn('Copying skipped. Build produced errors');
}
});
},
},
],
},
// Increase the minimum size required
// to trigger warnings.
// See https://stackoverflow.com/a/53517149/17055750
performance: {
maxAssetSize: 2_000_000, // 2-ish MiB
maxEntrypointSize: 2_000_000,
},
resolve: {
extensions: ['.tsx', '.ts', '.js'],
// Some of these are used by the plugin background script
fallback: {
path: require.resolve('path-browserify'),
events: require.resolve('events/'),
},
},
cache: {
type: 'filesystem',
},
};
return config;
});
}
// Creates a file that can be imported by React native. This file contains the
// bundled JS as a string.
private async copyToImportableFile() {
await copyJs(`${this.bundleName}.bundle`, this.bundleOutputPath);
}
private handleErrors(error: Error | undefined | null, stats: webpack.Stats | undefined): boolean {
let failed = false;
if (error) {
console.error(`Error (${this.bundleName}): ${error.name}`, error.message, error.stack);
failed = true;
} else if (stats?.hasErrors() || stats?.hasWarnings()) {
const data = stats.toJson();
if (data.warnings && data.warningsCount) {
console.warn(`Warnings (${this.bundleName}): `, data.warningsCount);
for (const warning of data.warnings) {
// Stack contains the message
if (warning.stack) {
console.warn(warning.stack);
} else {
console.warn(warning.message);
}
}
}
if (data.errors && data.errorsCount) {
console.error(`Errors (${this.bundleName}): `, data.errorsCount);
for (const error of data.errors) {
if (error.stack) {
console.error(error.stack);
} else {
console.error(error.message);
}
console.error();
}
failed = true;
}
}
return failed;
private async copyToImportableFile_() {
await copyAssets(`${this.bundleName}.bundle`, {
js: `${this.bundleOutputPathBase_}.js`,
css: `${this.bundleOutputPathBase_}.css`,
});
}
// Create a minified JS file in the same directory as `this.sourceFilePath` with
// the same name.
public build() {
const compiler = webpack(this.getWebpackOptions('production'));
return new Promise<void>((resolve, reject) => {
public async build() {
console.info(`Building bundle: ${this.bundleName}...`);
const compiler = await this.makeBuildContext('production');
const result = await compiler.rebuild();
await compiler.dispose();
compiler.run((buildError, stats) => {
// Always output stats, even on success
console.log(`Bundle ${this.bundleName} built: `, stats?.toString());
let failed = this.handleErrors(buildError, stats);
// Clean up.
compiler.close(async (closeError) => {
if (closeError) {
console.error('Error cleaning up:', closeError);
failed = true;
}
let copyError;
if (!failed) {
try {
await this.copyToImportableFile();
} catch (error) {
console.error('Error copying', error);
failed = true;
copyError = error;
if (result?.metafile) {
await writeFile(`${this.bundleOutputPathBase_}.meta.json`, JSON.stringify(result.metafile, undefined, '\t'));
}
}
if (!failed) {
resolve();
} else {
reject(closeError ?? buildError ?? copyError);
}
});
});
});
}
public startWatching() {
const compiler = webpack(this.getWebpackOptions('development'));
const watchOptions = {
ignored: '**/node_modules',
};
console.info('Watching bundle: ', this.bundleName);
compiler.watch(watchOptions, async (error, stats) => {
const failed = this.handleErrors(error, stats);
if (!failed) {
await this.copyToImportableFile();
}
});
public async startWatching() {
console.info(`Watching bundle: ${this.bundleName}...`);
const compiler = await this.makeBuildContext('development');
await compiler.watch();
}
}

View File

@@ -0,0 +1,29 @@
import { exists, readFile, writeFile } from 'fs-extra';
import { outputDir } from './constants';
type InputFilePaths = {
js: string;
css?: string;
};
// Stores the contents of the file at [filePath] as an importable string.
// [name] should be the name (excluding the .js extension) of the output file that will contain
// the JSON-ified file content.
const copyAssets = async (name: string, input: InputFilePaths) => {
const outputPath = `${outputDir}/${name}.js`;
console.info(`Creating: ${outputPath}`);
const hasJs = await exists(input.js);
const js = hasJs ? await readFile(input.js, 'utf-8') : null;
const hasCss = !!input.css && await exists(input.css);
const css = hasCss ? await readFile(input.css, 'utf-8') : null;
const json = `module.exports = {
js: ${JSON.stringify(js)},
css: ${JSON.stringify(css)}
};`;
await writeFile(outputPath, json);
};
export default copyAssets;

View File

@@ -1,16 +0,0 @@
import { readFile, writeFile } from 'fs-extra';
import { outputDir } from './constants';
// Stores the contents of the file at [filePath] as an importable string.
// [name] should be the name (excluding the .js extension) of the output file that will contain
// the JSON-ified file content.
const copyJs = async (name: string, filePath: string) => {
const outputPath = `${outputDir}/${name}.js`;
console.info(`Creating: ${outputPath}`);
const js = await readFile(filePath, 'utf-8');
const json = `module.exports = ${JSON.stringify(js)};`;
await writeFile(outputPath, json);
};
export default copyJs;

View File

@@ -1,59 +1,58 @@
import BundledFile from './BundledFile';
import { mkdirp } from 'fs-extra';
import { mobileDir, outputDir } from './constants';
import copyJs from './copyJs';
import copyAssets from './copyAssets';
import { readdir, existsSync } from 'fs-extra';
import { join } from 'path';
const getBundles = async () => {
// All folders in the contentScripts/ directories are bundles
const contentScriptDir = `${mobileDir}/contentScripts/`;
const bundles = [];
const codeMirrorBundle = new BundledFile(
'codeMirrorBundle',
`${mobileDir}/components/NoteEditor/CodeMirror/CodeMirror.ts`,
);
for (const folderOrFileName of await readdir(contentScriptDir)) {
// Only check subfolders
if (folderOrFileName.includes('.')) continue;
// Skip utilities shared between bundles
if (folderOrFileName === 'utils') continue;
const jsDrawBundle = new BundledFile(
'svgEditorBundle',
`${mobileDir}/components/NoteEditor/ImageEditor/js-draw/createJsDrawEditor.ts`,
);
const bundlePath = join(contentScriptDir, folderOrFileName);
const indexPath = join(bundlePath, 'contentScript', 'index.ts');
const contentScriptPath = existsSync(indexPath) ? indexPath : join(bundlePath, 'contentScript.ts');
bundles.push(new BundledFile(folderOrFileName, contentScriptPath));
}
const pluginBackgroundPageBundle = new BundledFile(
// Bundled JS may also exist within the components/ directory:
bundles.push(new BundledFile(
'pluginBackgroundPage',
`${mobileDir}/components/plugins/backgroundPage/pluginRunnerBackgroundPage.ts`,
);
));
const noteViewerBundle = new BundledFile(
'noteBodyViewerBundle',
`${mobileDir}/components/NoteBodyViewer/bundledJs/noteBodyViewerBundle.ts`,
);
return bundles;
};
const gulpTasks = {
beforeBundle: {
fn: () => mkdirp(outputDir),
},
buildCodeMirrorEditor: {
fn: () => codeMirrorBundle.build(),
buildBundledJs: {
fn: async () => {
for (const bundle of await getBundles()) {
await bundle.build();
}
},
buildJsDrawEditor: {
fn: () => jsDrawBundle.build(),
},
buildNoteViewerBundle: {
fn: () => noteViewerBundle.build(),
watchBundledJs: {
fn: async () => {
const watchPromises = [];
for (const bundle of await getBundles()) {
watchPromises.push(bundle.startWatching());
}
await Promise.all(watchPromises);
},
watchCodeMirrorEditor: {
fn: () => codeMirrorBundle.startWatching(),
},
watchJsDrawEditor: {
fn: () => jsDrawBundle.startWatching(),
},
buildPluginBackgroundScript: {
fn: () => pluginBackgroundPageBundle.build(),
},
watchPluginBackgroundScript: {
fn: () => pluginBackgroundPageBundle.startWatching(),
},
watchNoteViewerBundle: {
fn: () => noteViewerBundle.startWatching(),
},
copyWebviewLib: {
fn: () => copyJs('webviewLib', `${mobileDir}/../lib/renderers/webviewLib.js`),
fn: () => copyAssets('webviewLib', { js: `${mobileDir}/../lib/renderers/webviewLib.js` }),
},
};

View File

@@ -16,6 +16,7 @@ const appDefaultState: AppState = {
isOnMobileData: false,
disableSideMenuGestures: false,
showPanelsDialog: false,
noteEditorVisible: false,
...defaultState,
// On mobile, it's possible to select notes that aren't in the selected folder/tag/etc.

View File

@@ -1,10 +0,0 @@
const injectedJs = {
webviewLib: require('@joplin/lib/rnInjectedJs/webviewLib'),
codeMirrorBundle: require('../lib/rnInjectedJs/codeMirrorBundle.bundle'),
svgEditorBundle: require('../lib/rnInjectedJs/svgEditorBundle.bundle'),
pluginBackgroundPage: require('../lib/rnInjectedJs/pluginBackgroundPage.bundle'),
noteBodyViewerBundle: require('../lib/rnInjectedJs/noteBodyViewerBundle.bundle'),
};
export default injectedJs;

View File

@@ -1,9 +1,10 @@
const injectedJs = {
webviewLib: require('@joplin/lib/rnInjectedJs/webviewLib'),
codeMirrorBundle: require('../../lib/rnInjectedJs/codeMirrorBundle.bundle'),
svgEditorBundle: require('../../lib/rnInjectedJs/svgEditorBundle.bundle'),
webviewLib: require('../../lib/rnInjectedJs/webviewLib'),
markdownEditorBundle: require('../../lib/rnInjectedJs/markdownEditorBundle.bundle'),
richTextEditorBundle: require('../../lib/rnInjectedJs/richTextEditorBundle.bundle'),
imageEditorBundle: require('../../lib/rnInjectedJs/imageEditorBundle.bundle'),
pluginBackgroundPage: require('../../lib/rnInjectedJs/pluginBackgroundPage.bundle'),
noteBodyViewerBundle: require('../../lib/rnInjectedJs/noteBodyViewerBundle.bundle'),
rendererBundle: require('../../lib/rnInjectedJs/rendererBundle.bundle'),
};
export default injectedJs;

View File

@@ -85,7 +85,12 @@ const shimInitShared = () => {
shim.injectedJs = function(name) {
if (!(name in injectedJs)) throw new Error(`Cannot find injectedJs file (add it to "injectedJs" object): ${name}`);
return injectedJs[name as keyof typeof injectedJs];
return injectedJs[name as keyof typeof injectedJs].js;
};
shim.injectedCss = function(name) {
if (!(name in injectedJs)) throw new Error(`Cannot find CSS file (add it to "injectedJs" object): ${name}`);
return injectedJs[name as keyof typeof injectedJs].css;
};
shim.setTimeout = (fn, interval) => {

View File

@@ -1,6 +1,6 @@
import { screen, waitFor } from './testingLibrary';
const getWebViewWindowById = async (id: string): Promise<Window> => {
const getWebViewWindowById = async (id: string): Promise<Window & typeof globalThis> => {
const webviewContent = await screen.findByTestId(id);
expect(webviewContent).toBeVisible();

View File

@@ -10,4 +10,5 @@ export interface AppState extends State {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
noteSideMenuOptions: any;
disableSideMenuGestures: boolean;
noteEditorVisible: boolean;
}

View File

@@ -229,6 +229,10 @@ export default class CodeMirrorControl extends CodeMirror5Emulation implements E
};
}
public onResourceDownloaded(_id: string) {
// Unused
}
public setContentScripts(plugins: ContentScriptData[]) {
return this._pluginControl.setPlugins(plugins);
}

View File

@@ -8,7 +8,7 @@ import { forceParsing } from '@codemirror/language';
import loadLanguages from './testing/loadLanguages';
import { expect, describe, it } from '@jest/globals';
import createEditorSettings from './testing/createEditorSettings';
import createEditorSettings from '../testing/createEditorSettings';
describe('createEditor', () => {

View File

@@ -0,0 +1,6 @@
// This index.ts file helps prevent code duplication issues when bundling
// as it allows ESBuild to select the TypeScript version of createEditor.
import '../polyfills';
// eslint-disable-next-line import/prefer-default-export
export { default as createEditor } from './createEditor';

View File

@@ -1,6 +1,6 @@
import Setting from '@joplin/lib/models/Setting';
import createEditor from '../createEditor';
import createEditorSettings from './createEditorSettings';
import createEditorSettings from '../../testing/createEditorSettings';
const createEditorControl = (initialText: string) => {
const editorSettings = createEditorSettings(Setting.THEME_LIGHT);

View File

@@ -1,24 +1,9 @@
import { EditorView } from '@codemirror/view';
import { PasteFileCallback } from '../../types';
import getFileFromPasteEvent from '../../utils/getFileFromPasteEvent';
const handlePasteEvent = (event: ClipboardEvent|DragEvent, _view: EditorView, onPaste: PasteFileCallback) => {
const dataTransfer = 'clipboardData' in event ? event.clipboardData : event.dataTransfer;
const files = dataTransfer.files;
let fileToPaste: File|null = null;
// Prefer image files, if available.
for (const file of files) {
if (['image/png', 'image/jpeg', 'image/svg+xml'].includes(file.type)) {
fileToPaste = file;
break;
}
}
// Fall back to other files
if (files.length && !fileToPaste) {
fileToPaste = files[0];
}
const fileToPaste = getFileFromPasteEvent(event);
if (fileToPaste) {
event.preventDefault();

View File

@@ -0,0 +1,80 @@
import { EditorView } from 'prosemirror-view';
import { EditorCommandType } from '../types';
import commands from './commands';
import createTestEditor from './testing/createTestEditor';
const selectAll = (editor: EditorView) => {
commands[EditorCommandType.SelectAll](editor.state, editor.dispatch, editor);
};
describe('ProseMirror/commands', () => {
test('textBold should toggle bold formatting', () => {
const editor = createTestEditor({ html: '<p>Test</p>' });
selectAll(editor);
commands[EditorCommandType.ToggleBolded](editor.state, editor.dispatch, editor);
expect(editor.state.doc.toJSON()).toMatchObject({
content: [{
type: 'paragraph',
content: [{
marks: [
{ type: 'strong' },
],
text: 'Test',
}],
}],
});
});
test('toggleHeading should add and remove header formatting', () => {
const editor = createTestEditor({ html: '<p>Test</p><p>Test 2</p>' });
selectAll(editor);
commands[EditorCommandType.ToggleHeading](editor.state, editor.dispatch, editor);
expect(editor.state.doc.toJSON()).toMatchObject({
content: [{
type: 'heading',
content: [{
text: 'Test',
}],
}, {
type: 'heading',
content: [{
text: 'Test 2',
}],
}],
});
commands[EditorCommandType.ToggleHeading](editor.state, editor.dispatch, editor);
expect(editor.state.doc.toJSON()).toMatchObject({
content: [{
type: 'paragraph',
content: [{
text: 'Test',
}],
}, {
type: 'paragraph',
content: [{
text: 'Test 2',
}],
}],
});
});
test('jumpToHash should scroll to a heading with a matching hash', () => {
const editor = createTestEditor({ html: '<h1>Test heading 1</h1><p>Test 2</p><h2>Test heading 2</h2><p>Test 3</p>' });
const jumpToHash = (hash: string) => {
return commands[EditorCommandType.JumpToHash](editor.state, editor.dispatch, editor, [hash]);
};
expect(jumpToHash('test-heading-1')).toBe(true);
expect(editor.state.selection.$anchor.parent.textContent).toBe('Test heading 1');
expect(jumpToHash('test-heading-2')).toBe(true);
expect(editor.state.selection.$anchor.parent.textContent).toBe('Test heading 2');
});
});

View File

@@ -0,0 +1,206 @@
import { Command, EditorState, Transaction } from 'prosemirror-state';
import { EditorCommandType } from '../types';
import { redo, undo } from 'prosemirror-history';
import { autoJoin, selectAll, setBlockType, toggleMark } from 'prosemirror-commands';
import { focus } from '@joplin/lib/utils/focusHandler';
import schema from './schema';
import { liftListItem, sinkListItem, wrapRangeInList } from 'prosemirror-schema-list';
import { NodeType } from 'prosemirror-model';
import { getSearchVisible, setSearchVisible } from './plugins/searchPlugin';
import { findNext, findPrev, replaceAll, replaceNext } from 'prosemirror-search';
import { getEditorApi } from './plugins/joplinEditorApiPlugin';
import { EditorEventType } from '../events';
import extractSelectedLinesTo from './utils/extractSelectedLinesTo';
import { EditorView } from 'prosemirror-view';
import jumpToHash from './utils/jumpToHash';
import canReplaceSelectionWith from './utils/canReplaceSelectionWith';
type Dispatch = (tr: Transaction)=> void;
type ExtendedCommand = (state: EditorState, dispatch: Dispatch, view?: EditorView, options?: string[])=> boolean;
const toggleHeading = (level: number): Command => {
const enableCommand: Command = (state, dispatch) => {
const result = extractSelectedLinesTo({
type: schema.nodes.heading,
attrs: { level },
}, state.tr, state.selection);
if (!result) return false;
if (dispatch) {
dispatch(result.transaction);
}
return true;
};
const resetCommand = setBlockType(schema.nodes.paragraph);
return (state, dispatch, view) => {
if (enableCommand(state, dispatch, view)) {
return true;
}
return resetCommand(state, dispatch, view);
};
};
const toggleList = (type: NodeType): Command => {
const enableCommand: Command = autoJoin((state, dispatch) => {
const extractionResult = extractSelectedLinesTo({
type: schema.nodes.paragraph,
attrs: {},
}, state.tr, state.selection);
let transaction = extractionResult?.transaction;
if (!transaction) {
transaction = state.tr;
}
const selection = extractionResult?.finalSelection ?? state.selection;
const range = selection.$from.blockRange(selection.$to);
const result = wrapRangeInList(transaction, range, type);
if (dispatch && result) {
dispatch(transaction);
}
return result;
}, [type.name]);
const liftCommand = liftListItem(schema.nodes.list_item);
return (state, dispatch, view) => {
return enableCommand(state, dispatch, view) || liftCommand(state, dispatch, view);
};
};
const toggleCode: Command = (state, dispatch, view) => {
return toggleMark(schema.marks.code)(state, dispatch, view) || setBlockType(schema.nodes.paragraph)(state, dispatch, view);
};
const listItemTypes = [schema.nodes.list_item, schema.nodes.task_list_item];
const commands: Record<EditorCommandType, ExtendedCommand|null> = {
[EditorCommandType.Undo]: undo,
[EditorCommandType.Redo]: redo,
[EditorCommandType.SelectAll]: selectAll,
[EditorCommandType.Focus]: (_state, _dispatch?, view?) => {
if (view) {
focus('commands::focus', view);
}
return true;
},
[EditorCommandType.ToggleBolded]: toggleMark(schema.marks.strong),
[EditorCommandType.ToggleItalicized]: toggleMark(schema.marks.emphasis),
[EditorCommandType.ToggleCode]: toggleCode,
[EditorCommandType.ToggleMath]: (state, _dispatch, view) => {
const renderer = getEditorApi(state).renderer;
const selectedText = state.doc.textBetween(state.selection.from, state.selection.to);
const block = selectedText.includes('\n');
const nodeType = block ? schema.nodes.joplinEditableBlock : schema.nodes.joplinEditableInline;
if (canReplaceSelectionWith(state.selection, nodeType)) {
void (async () => {
const separator = block ? '$$' : '$';
const rendered = await renderer.renderMarkupToHtml(`${separator}${selectedText}${separator}`);
if (view) {
view.pasteHTML(rendered.html);
}
})();
return true;
}
return false;
},
[EditorCommandType.ToggleComment]: null,
[EditorCommandType.DuplicateLine]: null,
[EditorCommandType.SortSelectedLines]: null,
[EditorCommandType.ToggleNumberedList]: toggleList(schema.nodes.ordered_list),
[EditorCommandType.ToggleBulletedList]: toggleList(schema.nodes.bullet_list),
[EditorCommandType.ToggleCheckList]: toggleList(schema.nodes.task_list),
[EditorCommandType.ToggleHeading]: toggleHeading(2),
[EditorCommandType.ToggleHeading1]: toggleHeading(1),
[EditorCommandType.ToggleHeading2]: toggleHeading(2),
[EditorCommandType.ToggleHeading3]: toggleHeading(3),
[EditorCommandType.ToggleHeading4]: toggleHeading(4),
[EditorCommandType.ToggleHeading5]: toggleHeading(5),
[EditorCommandType.InsertHorizontalRule]: null,
[EditorCommandType.ToggleSearch]: (state, dispatch, view) => {
const command = setSearchVisible(!getSearchVisible(state));
return command(state, dispatch, view);
},
[EditorCommandType.ShowSearch]: setSearchVisible(true),
[EditorCommandType.HideSearch]: setSearchVisible(false),
[EditorCommandType.FindNext]: findNext,
[EditorCommandType.FindPrevious]: findPrev,
[EditorCommandType.ReplaceNext]: replaceNext,
[EditorCommandType.ReplaceAll]: replaceAll,
[EditorCommandType.EditLink]: (state: EditorState, dispatch) => {
if (dispatch) {
const onEvent = getEditorApi(state).onEvent;
onEvent({
kind: EditorEventType.EditLink,
});
}
return true;
},
[EditorCommandType.ScrollSelectionIntoView]: (state, dispatch) => {
if (dispatch) {
dispatch(state.tr.scrollIntoView());
}
return true;
},
[EditorCommandType.DeleteLine]: (state, dispatch) => {
const anchor = state.selection.$anchor;
const transaction = state.tr;
for (let i = anchor.depth; i > 0; i--) {
if (anchor.node(i).isBlock) {
const deleteFrom = anchor.before(i);
const deleteTo = anchor.after(i);
if (dispatch) {
dispatch(transaction.deleteRange(deleteFrom, deleteTo));
}
return true;
}
}
return false;
},
[EditorCommandType.DeleteToLineEnd]: null,
[EditorCommandType.DeleteToLineStart]: null,
[EditorCommandType.IndentMore]: (state, dispatch, view) => {
return listItemTypes.some(type => sinkListItem(type)(state, dispatch, view));
},
[EditorCommandType.IndentLess]: (state, dispatch, view) => {
return listItemTypes.some(type => liftListItem(type)(state, dispatch, view));
},
[EditorCommandType.IndentAuto]: null,
[EditorCommandType.InsertNewlineAndIndent]: null,
[EditorCommandType.SwapLineUp]: null,
[EditorCommandType.SwapLineDown]: null,
[EditorCommandType.GoDocEnd]: null,
[EditorCommandType.GoDocStart]: null,
[EditorCommandType.GoLineStart]: null,
[EditorCommandType.GoLineEnd]: null,
[EditorCommandType.GoLineUp]: null,
[EditorCommandType.GoLineDown]: null,
[EditorCommandType.GoPageUp]: null,
[EditorCommandType.GoPageDown]: null,
[EditorCommandType.GoCharLeft]: null,
[EditorCommandType.GoCharRight]: null,
[EditorCommandType.UndoSelection]: null,
[EditorCommandType.RedoSelection]: null,
[EditorCommandType.SelectedText]: null,
[EditorCommandType.InsertText]: (state, dispatch, _view, [text]) => {
if (dispatch) {
dispatch(state.tr.insertText(text));
}
return true;
},
[EditorCommandType.ReplaceSelection]: null,
[EditorCommandType.SetText]: null,
[EditorCommandType.JumpToHash]: (state, dispatch, view, [targetHash]) => {
return jumpToHash(targetHash, schema.nodes.heading)(state, dispatch, view);
},
};
export default commands;

View File

@@ -0,0 +1,265 @@
import { ContentScriptData, EditorCommandType, EditorControl, EditorProps, EditorSettings, SearchState, UpdateBodyOptions, UserEventSource } from '../types';
import { EditorState, TextSelection, Transaction } from 'prosemirror-state';
import { EditorView } from 'prosemirror-view';
import { DOMParser as ProseMirrorDomParser } from 'prosemirror-model';
import { history } from 'prosemirror-history';
import commands from './commands';
import schema from './schema';
import { gapCursor } from 'prosemirror-gapcursor';
import { dropCursor } from 'prosemirror-dropcursor';
import { EditorEventType } from '../events';
import UndoStackSynchronizer from './utils/UndoStackSynchronizer';
import computeSelectionFormatting from './utils/computeSelectionFormatting';
import { defaultSelectionFormatting, selectionFormattingEqual } from '../SelectionFormatting';
import joplinEditablePlugin from './plugins/joplinEditablePlugin';
import keymapExtension from './plugins/keymapPlugin';
import inputRulesExtension from './plugins/inputRulesPlugin';
import originalMarkupPlugin from './plugins/originalMarkupPlugin';
import { tableEditing } from 'prosemirror-tables';
import preprocessEditorInput from './utils/preprocessEditorInput';
import listPlugin from './plugins/listPlugin';
import searchExtension from './plugins/searchPlugin';
import joplinEditorApiPlugin, { setEditorApi } from './plugins/joplinEditorApiPlugin';
import linkTooltipPlugin from './plugins/linkTooltipPlugin';
import { RendererControl } from './types';
import resourcePlaceholderPlugin, { onResourceDownloaded } from './plugins/resourcePlaceholderPlugin';
import getFileFromPasteEvent from '../utils/getFileFromPasteEvent';
import { RenderResult } from '../../renderer/types';
const createEditor = async (
parentElement: HTMLElement,
props: EditorProps,
renderer: RendererControl,
): Promise<EditorControl> => {
const renderNodeToMarkup = (node: Node|DocumentFragment) => {
return renderer.renderHtmlToMarkup(node);
};
const proseMirrorParser = ProseMirrorDomParser.fromSchema(schema);
const cssContainer = document.createElement('style');
parentElement.appendChild(cssContainer);
const { plugin: markupTracker, stateToMarkup } = originalMarkupPlugin(renderNodeToMarkup);
const { plugin: searchPlugin, updateState: updateSearchState } = searchExtension(props.onEvent);
const renderAndPostprocessHtml = async (markup: string) => {
const renderResult = await renderer.renderMarkupToHtml(markup);
const dom = new DOMParser().parseFromString(renderResult.html, 'text/html');
preprocessEditorInput(dom, markup);
return { renderResult, dom };
};
const updateGlobalCss = (renderResult: RenderResult) => {
cssContainer.replaceChildren(
document.createTextNode(renderResult.cssStrings.join('\n')),
);
};
let settings = props.settings;
const createInitialState = async (markup: string) => {
const { renderResult, dom } = await renderAndPostprocessHtml(markup);
updateGlobalCss(renderResult);
let state = EditorState.create({
doc: proseMirrorParser.parse(dom),
plugins: [
inputRulesExtension,
keymapExtension,
gapCursor(),
dropCursor(),
history(),
searchPlugin,
joplinEditablePlugin,
markupTracker,
listPlugin,
linkTooltipPlugin,
tableEditing({ allowTableNodeSelection: true }),
joplinEditorApiPlugin,
resourcePlaceholderPlugin,
].flat(),
});
state = state.apply(
setEditorApi(state.tr, {
onEvent: props.onEvent,
renderer,
}),
);
return state;
};
const undoStackSynchronizer = new UndoStackSynchronizer(props.onEvent);
const onDocumentUpdate = (newState: EditorState) => {
props.onEvent({
kind: EditorEventType.Change,
value: stateToMarkup(newState),
});
};
let lastSelectionFormatting = defaultSelectionFormatting;
const onUpdateSelection = (newState: EditorState) => {
const selectionFormatting = computeSelectionFormatting(newState, settings);
if (!selectionFormattingEqual(lastSelectionFormatting, selectionFormatting)) {
lastSelectionFormatting = selectionFormatting;
props.onEvent({
kind: EditorEventType.SelectionFormattingChange,
formatting: selectionFormatting,
});
}
};
const view = new EditorView(parentElement, {
state: await createInitialState(props.initialText),
dispatchTransaction: transaction => {
const newState = view.state.apply(transaction);
if (transaction.docChanged) {
onDocumentUpdate(newState);
}
if (transaction.selectionSet || transaction.docChanged || transaction.storedMarksSet) {
onUpdateSelection(newState);
}
undoStackSynchronizer.schedulePostUndoRedoDepthChange(view);
view.updateState(newState);
},
attributes: {
'aria-label': settings.editorLabel,
class: 'prosemirror-editor',
},
handleDOMEvents: {
paste: (_view, event) => {
const fileToPaste = getFileFromPasteEvent(event);
if (fileToPaste) {
event.preventDefault();
void props.onPasteFile(fileToPaste);
return true;
}
return false;
},
},
});
const editorControl: EditorControl = {
supportsCommand: (name: EditorCommandType | string) => {
return name in commands && !!commands[name as keyof typeof commands];
},
execCommand: (name: EditorCommandType | string, ...args) => {
if (!editorControl.supportsCommand(name)) {
throw new Error(`Unsupported command: ${name}`);
}
commands[name as keyof typeof commands](view.state, view.dispatch, view, args);
},
undo: () => {
void editorControl.execCommand(EditorCommandType.Undo);
},
redo: () => {
void editorControl.execCommand(EditorCommandType.Redo);
},
select: function(anchor: number, head: number): void {
const transaction = view.state.tr;
transaction.setSelection(
TextSelection.create(transaction.doc, anchor, head),
);
view.dispatch(transaction);
},
setScrollPercent: (fraction: number) => {
// TODO: Handle this in a better way?
document.scrollingElement.scrollTop = fraction * document.scrollingElement.scrollHeight;
},
insertText: async (text: string, _source?: UserEventSource) => {
const { dom } = await renderAndPostprocessHtml(text);
view.pasteHTML(new XMLSerializer().serializeToString(dom));
},
updateBody: async (newBody: string, _updateBodyOptions?: UpdateBodyOptions) => {
view.updateState(await createInitialState(newBody));
},
updateSettings: async (newSettings: EditorSettings) => {
const oldSettings = settings;
settings = newSettings;
if (oldSettings.themeData.themeId !== newSettings.themeData.themeId) {
// Refresh global CSS when the theme changes -- render the full document
// to avoid required CSS being omitted due to missing markup.
const { renderResult } = await renderAndPostprocessHtml(stateToMarkup(view.state));
updateGlobalCss(renderResult);
}
},
updateLink: (label: string, url: string) => {
const doc = view.state.doc;
const selection = view.state.selection;
let transaction: Transaction = view.state.tr;
let linkFrom = selection.from;
let linkTo = selection.to;
doc.nodesBetween(selection.from, selection.to, (node, position) => {
const linkMark = node.marks.find(mark => mark.type === schema.marks.link);
if (linkMark) {
linkFrom = position;
linkTo = position + node.nodeSize;
transaction = transaction.removeMark(
position, position + node.nodeSize, schema.marks.link,
);
}
});
// Helper functions that return a point at the current stage of
// the transaction:
const map = (position: number, associativity: number) => transaction.mapping.map(position, associativity);
// Update the link text -- if an existing link, replace just the text
// in that link.
if (label !== transaction.doc.textBetween(linkFrom, linkTo)) {
transaction = transaction.insertText(
label,
linkFrom,
linkTo,
);
linkFrom = map(linkFrom, -1); // Ensure that linkFrom is to the left of the text
linkTo = map(linkTo, 1); // Ensure that linkTo is to the right of the text
}
// Add the URL
if (url) {
transaction = transaction.addMark(
// Use the entire selection,
linkFrom,
linkTo,
schema.mark(schema.marks.link, { href: url }),
);
}
view.dispatch(transaction);
},
setSearchState: (newState: SearchState) => {
view.dispatch(updateSearchState(view.state, newState));
},
setContentScripts: (_plugins: ContentScriptData[]) => {
throw new Error('setContentScripts not implemented.');
},
onResourceDownloaded: async (resourceId: string) => {
const rendered = await renderAndPostprocessHtml(`<img src=":/${resourceId}"/>`);
const renderedImage = rendered.dom.querySelector('img');
// The resource might not be an image. If so, skip.
if (!renderedImage) {
return;
}
const stillNotLoaded = renderedImage.classList.contains('not-loaded-resource');
if (stillNotLoaded) {
return;
}
const resourceSrc = renderedImage?.src;
onResourceDownloaded(view, resourceId, resourceSrc);
},
};
return editorControl;
};
export default createEditor;

View File

@@ -0,0 +1,6 @@
// This index.ts file helps prevent code duplication issues when bundling
// as it allows ESBuild to select the TypeScript version of createEditor.
import '../polyfills';
// eslint-disable-next-line import/prefer-default-export
export { default as createEditor } from './createEditor';

View File

@@ -0,0 +1,181 @@
import { buildInputRules } from 'prosemirror-example-setup';
import schema from '../schema';
import { MarkType, ResolvedPos } from 'prosemirror-model';
import { EditorState, Plugin, Transaction } from 'prosemirror-state';
import { EditorView } from 'prosemirror-view';
import { closeHistory } from 'prosemirror-history';
interface InlineInputRule {
match: RegExp;
matchEndCharacter: string;
handler: (state: EditorState, match: RegExpMatchArray, start: number, end: number, commitCharacter: string)=> Transaction|null;
}
// A custom input rule extension for inline input replacements.
//
// Ref: https://github.com/ProseMirror/prosemirror-inputrules/blob/43ef04ce9c1512ef8f2289578309c40b431ed3c5/src/inputrules.ts#L82
// See https://discuss.prosemirror.net/t/trigger-inputrule-on-enter/1118 for why this approach is needed.
const inlineInputRules = (rules: InlineInputRule[], commitCharacterExpression: RegExp) => {
const getContentBeforeCursor = (cursorInformation: ResolvedPos) => {
const parent = cursorInformation.parent;
const offsetInParent = cursorInformation.parentOffset;
const maxLength = 256;
return parent.textBetween(Math.max(offsetInParent - maxLength, 0), offsetInParent);
};
const getApplicableRule = (state: EditorState, cursor: number, justTypedText: string) => {
if (!rules.some(rule => justTypedText.endsWith(rule.matchEndCharacter))) {
return false;
}
const cursorInformation = state.doc.resolve(cursor);
const inCode = cursorInformation.parent.type.spec.code;
if (inCode) {
return false;
}
const beforeCursor = getContentBeforeCursor(cursorInformation) + justTypedText;
for (const rule of rules) {
const match = beforeCursor.match(rule.match);
if (!match) continue;
return rule;
}
return null;
};
type PluginState = { pendingRule: InlineInputRule };
const run = (view: EditorView, cursor: number, commitData: string) => {
const commitCharacter = commitCharacterExpression.exec(commitData) ? commitData : '';
// Commit on either a commit character or when the user presses "enter".
if (!commitCharacter && commitData !== 'Enter') return false;
const availableRule = plugin.getState(view.state)?.pendingRule;
if (!availableRule) return false;
const beforeCursor = getContentBeforeCursor(view.state.doc.resolve(cursor));
const match = beforeCursor.match(availableRule.match);
if (match) {
const transaction = availableRule.handler(view.state, match, cursor - match[0].length, cursor, commitCharacter);
if (transaction) {
// closeHistory: Move the markup completion to a separate history event so that it
// can be undone separately.
view.dispatch(closeHistory(transaction));
return true;
}
}
return false;
};
const plugin = new Plugin<PluginState|null>({
state: {
init: () => null,
apply: (tr, lastValue) => {
const pendingRule = tr.getMeta(plugin);
if (pendingRule) return pendingRule;
if (tr.docChanged || tr.selectionSet) return null;
return lastValue;
},
},
props: {
handleTextInput(view, _from, to, text, defaultAction) {
const proposedRule = getApplicableRule(view.state, to, text);
if (proposedRule) {
const transaction = defaultAction().setMeta(plugin, { pendingRule: proposedRule });
view.dispatch(transaction);
return true;
}
return false;
},
handleDOMEvents: {
compositionend: (view, event) => {
if (view.state.selection.empty) {
return run(view, view.state.selection.to, event.data);
}
return false;
},
},
handleKeyDown: (view, event) => {
return run(view, view.state.selection.to, event.key);
},
},
// TODO: Uncomment this? Doing so allows the undoInputRule command to undo this input rule.
// However, it will require storing additional information in the plugin state (e.g. last transaction,
// start, end position) to be compatible with the upstream input rule extension.
// isInputRules: true,
});
return plugin;
};
const makeMarkInputRule = (
regExpString: string, matchEndCharacter: string, replacement: (matches: RegExpMatchArray)=> string, mark: MarkType,
): InlineInputRule => {
const commitCharacterExp = '[.?!,:;¡¿() \\n]';
const regex = new RegExp(`(^|${commitCharacterExp})${regExpString}$`);
return {
match: regex,
matchEndCharacter,
handler: (state, match, start, end, endCommitCharacter) => {
let transaction = state.tr.delete(start, end);
const marks = [schema.mark(mark)];
const startCommitCharacter = match[1];
// Remove the commit-character-related matches before forwarding the input
// to the replacement function.
const matchesWithoutCommitCharacters: RegExpMatchArray = [
match[0].substring(startCommitCharacter.length, match[0].length),
...match.slice(2, match.length),
];
matchesWithoutCommitCharacters.groups = match.groups;
const replacementText = replacement(matchesWithoutCommitCharacters);
transaction = transaction.insert(
transaction.mapping.map(start, -1),
[
!!startCommitCharacter && schema.text(startCommitCharacter),
!!replacementText && schema.text(replacementText, marks),
!!endCommitCharacter && schema.text(endCommitCharacter),
].filter(node => !!node),
);
return transaction;
},
};
};
const baseInputRules = buildInputRules(schema);
const inlineContentExp = '\\S[^\\n]*\\S|\\S';
const inputRulesExtension = [
baseInputRules,
inlineInputRules([
makeMarkInputRule(
`\\*\\*(${inlineContentExp})\\*\\*`,
'*',
(match) => match[1],
schema.marks.strong,
),
makeMarkInputRule(
`\\*(${inlineContentExp})\\*`,
'*',
(match) => match[1],
schema.marks.emphasis,
),
makeMarkInputRule(
`_(${inlineContentExp})_`,
'_',
(match) => match[1],
schema.marks.emphasis,
),
makeMarkInputRule(
`[\`](${inlineContentExp})[\`]`,
'`',
(match) => match[1],
schema.marks.code,
),
], /[ .,?)!;]/),
];
export default inputRulesExtension;

View File

@@ -0,0 +1,69 @@
import { Plugin } from 'prosemirror-state';
import { Node, NodeSpec } from 'prosemirror-model';
import { NodeView } from 'prosemirror-view';
import sanitizeHtml from '../utils/sanitizeHtml';
// See the fold example for more information about
// writing similar ProseMirror plugins:
// https://prosemirror.net/examples/fold/
const makeJoplinEditableSpec = (inline: boolean): NodeSpec => ({
group: inline ? 'inline' : 'block',
inline: inline,
draggable: true,
attrs: {
contentHtml: { default: '', validate: 'string' },
},
parseDOM: [
{
tag: `${inline ? 'span' : 'div'}.joplin-editable`,
getAttrs: node => ({
contentHtml: node.innerHTML,
}),
},
],
toDOM: node => {
const content = document.createElement(inline ? 'span' : 'div');
content.classList.add('joplin-editable');
content.innerHTML = sanitizeHtml(node.attrs.contentHtml);
return content;
},
});
export const nodeSpecs = {
joplinEditableInline: makeJoplinEditableSpec(true),
joplinEditableBlock: makeJoplinEditableSpec(false),
};
class EditableSourceBlockView implements NodeView {
public readonly dom: HTMLElement;
public constructor(node: Node, inline: boolean) {
if ((node.attrs.contentHtml ?? undefined) === undefined) {
throw new Error(`Unable to create a SourceBlockView for a node lacking contentHtml. Node: ${node}.`);
}
this.dom = document.createElement(inline ? 'span' : 'div');
this.dom.classList.add('joplin-editable');
this.dom.innerHTML = sanitizeHtml(node.attrs.contentHtml);
}
public selectNode() {
this.dom.classList.add('-selected');
}
public deselectNode() {
this.dom.classList.remove('-selected');
}
}
const joplinEditablePlugin = new Plugin({
props: {
nodeViews: {
joplinEditableInline: node => new EditableSourceBlockView(node, true),
joplinEditableBlock: node => new EditableSourceBlockView(node, false),
},
},
});
export default joplinEditablePlugin;

View File

@@ -0,0 +1,43 @@
import { EditorState, Plugin, Transaction } from 'prosemirror-state';
import { OnEventCallback } from '../../types';
import { RendererControl } from '../types';
export interface EditorApi {
renderer: RendererControl;
onEvent: OnEventCallback;
}
export const getEditorApi = (state: EditorState) => {
return joplinEditorApiPlugin.getState(state);
};
export const setEditorApi = (transaction: Transaction, api: EditorApi) => {
return transaction.setMeta(joplinEditorApiPlugin, api);
};
// Stores the editor event handler callback in the editor state.
const joplinEditorApiPlugin = new Plugin<EditorApi>({
state: {
init: () => ({
onEvent: ()=>{},
renderer: {
renderHtmlToMarkup: () => {
throw new Error('Not initialized');
},
renderMarkupToHtml: () => {
throw new Error('Not initialized');
},
},
}),
apply: (tr, value) => {
const proposedValue = tr.getMeta(joplinEditorApiPlugin);
if (proposedValue) {
return proposedValue;
}
return value;
},
},
});
export default joplinEditorApiPlugin;

View File

@@ -0,0 +1,110 @@
import { buildKeymap as buildBaseKeymap } from 'prosemirror-example-setup';
import schema from '../schema';
import { keymap } from 'prosemirror-keymap';
import { baseKeymap, chainCommands, exitCode, liftEmptyBlock, newlineInCode } from 'prosemirror-commands';
import { liftListItem, sinkListItem, splitListItem } from 'prosemirror-schema-list';
import commands from '../commands';
import { EditorCommandType } from '../../types';
import { Command, EditorState, TextSelection } from 'prosemirror-state';
import splitBlockAs from '../vendor/splitBlockAs';
import canReplaceSelectionWith from '../utils/canReplaceSelectionWith';
const splitBlockAsDefault = splitBlockAs();
const splitBlockAsParagraph = splitBlockAs(() => ({ type: schema.nodes.paragraph }));
const convertDoubleHardBreakToNewParagraph: Command = (state, dispatch) => {
const { from, to } = state.selection;
let foundHardBreak = false;
let hardBreakFrom = -1;
state.doc.nodesBetween(from - 1, to, (node, pos) => {
if (node.type === schema.nodes.hard_break) {
foundHardBreak = true;
hardBreakFrom = pos;
}
return !foundHardBreak;
});
if (foundHardBreak) {
const updatedSelection = TextSelection.create(state.doc, hardBreakFrom, to);
const tr = state.tr.setSelection(updatedSelection);
const splitBlockTransaction = splitBlockAsParagraph(updatedSelection, tr) || splitBlockAsDefault(updatedSelection, tr);
if (splitBlockTransaction && dispatch) {
dispatch(splitBlockTransaction);
}
return !!splitBlockTransaction;
}
return false;
};
const listItemTypes = [
// Apply the list item keymap to all list item types
// Ref: Default keymap in prosemirror-example-setup.
schema.nodes.list_item, schema.nodes.task_list_item,
];
const isInEmptyListItem = (state: EditorState) => {
const anchor = state.selection.$anchor;
const selectionGrandparent = anchor.node(Math.max(0, anchor.depth - 1));
const inList = listItemTypes.includes(selectionGrandparent?.type);
return inList && anchor.parent.content.size === 0;
};
const isInEmptyParagraph = (state: EditorState) => {
const selectionParent = state.selection.$anchor.parent;
return state.selection.empty &&
state.selection.$anchor.parent.type === schema.nodes.paragraph &&
selectionParent.content.size === 0;
};
const insertHardBreak: Command = (state, dispatch) => {
// Avoid adding hard breaks at the beginning of list items
if (isInEmptyListItem(state)) return false;
// Avoid adding hard breaks at the beginning of paragraphs -- certain input rules
// only work when the cursor is at the beginning of a paragraph. If a paragraph
// starts with a hard break, it may incorrectly appear to the user that the cursor is at the
// start of a paragraph, leading to unexpected behavior related to input rules.
if (isInEmptyParagraph(state)) return false;
if (!canReplaceSelectionWith(state.selection, schema.nodes.hard_break)) return false;
if (dispatch) {
const hardBreak = schema.nodes.hard_break.create();
// Default to inserting a hard break. See https://github.com/ProseMirror/prosemirror-example-setup/blob/8c11be6850604081dceda8f36e08d2426875e19a/src/keymap.ts#L77C26-L77C39
dispatch(
state.tr.replaceSelectionWith(hardBreak)
.scrollIntoView(),
);
}
return true;
};
const keymapExtension = [
listItemTypes.map(itemType => keymap({
'Enter': splitListItem(itemType),
'Mod-[': liftListItem(itemType),
'Mod-]': sinkListItem(itemType),
})),
keymap({
'Enter': chainCommands(
newlineInCode,
exitCode,
liftEmptyBlock,
convertDoubleHardBreakToNewParagraph,
insertHardBreak,
),
}),
keymap({
'Mod-k': commands[EditorCommandType.EditLink],
'Mod-i': commands[EditorCommandType.ToggleItalicized],
'Mod-`': commands[EditorCommandType.ToggleCode],
'Mod-f': commands[EditorCommandType.ToggleSearch],
}),
keymap(buildBaseKeymap(schema)),
keymap(baseKeymap),
].flat();
export default keymapExtension;

View File

@@ -0,0 +1,51 @@
import { TextSelection } from 'prosemirror-state';
import createTestEditor from '../testing/createTestEditor';
import joplinEditorApiPlugin from './joplinEditorApiPlugin';
import linkTooltipPlugin from './linkTooltipPlugin';
import { EditorView } from 'prosemirror-view';
const getTooltip = () => {
return document.querySelector('.link-tooltip:not(.-hidden)');
};
let editor: EditorView;
describe('linkTooltipPlugin', () => {
beforeEach(() => {
editor = createTestEditor({
parent: document.body,
html: '<p><a href="#test-heading">Jump to "Test"</a></p><h1>Test heading</h1><p>Done</p>',
plugins: [
linkTooltipPlugin,
joplinEditorApiPlugin,
],
});
});
afterEach(() => {
document.body.replaceChildren();
});
test('should show a link tooltip when the cursor is in a link', () => {
expect(getTooltip()).toBeFalsy();
editor.dispatch(
editor.state.tr.setSelection(TextSelection.create(editor.state.tr.doc, 3)),
);
expect(getTooltip()).toBeTruthy();
});
test('clicking on a hash link should move the cursor to the corresponding header', () => {
editor.dispatch(
editor.state.tr.setSelection(
TextSelection.create(editor.state.tr.doc, 3),
),
);
getTooltip().querySelector('button').click();
expect(editor.state.selection.$to.parent.textContent).toBe('Test heading');
});
});

View File

@@ -0,0 +1,94 @@
import { EditorState, Plugin } from 'prosemirror-state';
import { EditorView } from 'prosemirror-view';
import schema from '../schema';
import { getEditorApi } from './joplinEditorApiPlugin';
import { EditorEventType } from '../../events';
import { OnEventCallback } from '../../types';
import jumpToHash from '../utils/jumpToHash';
// This plugin is similar to https://prosemirror.net/examples/tooltip/
class LinkTooltip {
private tooltip_: HTMLElement;
private tooltipContent_: HTMLButtonElement;
private onEditorEvent_: OnEventCallback|null = null;
public constructor(view: EditorView) {
this.tooltip_ = document.createElement('div');
this.tooltip_.classList.add('link-tooltip', '-hidden');
this.tooltipContent_ = document.createElement('button');
this.tooltipContent_.classList.add('link');
this.tooltipContent_.role = 'link';
this.tooltip_.appendChild(this.tooltipContent_);
view.dom.parentElement.appendChild(this.tooltip_);
this.update(view, null);
}
public update(view: EditorView, lastState: EditorState) {
const state = view.state;
this.onEditorEvent_ = getEditorApi(state).onEvent;
const sameSelection = lastState && state.selection.eq(lastState.selection);
const sameDoc = lastState && state.doc.eq(lastState.doc);
if (sameSelection && sameDoc) {
return;
}
const getMarksNearSelection = () => {
let marks = state.selection.$from.marks();
const parentStart = state.selection.$from.posAtIndex(0);
const indexBefore = state.selection.from - 1;
if (indexBefore >= parentStart) {
const beforeSelection = state.doc.resolve(indexBefore);
const marksJustBeforeSelection = beforeSelection.marks();
marks = marks.concat(marksJustBeforeSelection);
}
return marks;
};
const linkMark = getMarksNearSelection().find(mark => mark.type === schema.marks.link);
const show = state.selection.empty && linkMark;
if (!show) {
this.tooltip_.classList.add('-hidden');
this.tooltipContent_.onclick = () => {};
} else {
this.tooltipContent_.textContent = linkMark.attrs.href;
this.tooltipContent_.onclick = () => {
const href = linkMark.attrs.href;
if (href.startsWith('#')) {
const command = jumpToHash(href.substring(1), schema.nodes.heading);
command(view.state, view.dispatch, view);
} else {
this.onEditorEvent_({
kind: EditorEventType.FollowLink,
link: linkMark.attrs.href,
});
}
};
this.tooltip_.classList.remove('-hidden');
const position = view.coordsAtPos(state.selection.from);
// Fall back to document.body to support testing environments:
const parentBox = (this.tooltip_.offsetParent ?? document.body).getBoundingClientRect();
const tooltipBox = this.tooltip_.getBoundingClientRect();
this.tooltip_.style.top = `${position.top - parentBox.top + tooltipBox.height}px`;
this.tooltip_.style.left = `${Math.max(position.left - parentBox.left - tooltipBox.width / 2, 0)}px`;
}
}
public destroy() {
this.tooltip_.remove();
}
}
const linkTooltipPlugin = new Plugin({
view: view => new LinkTooltip(view),
});
export default linkTooltipPlugin;

Some files were not shown because too many files have changed in this diff Show More