You've already forked joplin
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:
@@ -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
|
||||
|
@@ -23,6 +23,7 @@ module.exports = {
|
||||
'FileSystemCreateWritableOptions': 'readonly',
|
||||
'FileSystemHandle': 'readonly',
|
||||
'IDBTransactionMode': 'readonly',
|
||||
'FlatArray': 'readonly',
|
||||
'BigInt': 'readonly',
|
||||
'globalThis': 'readonly',
|
||||
|
||||
|
96
.gitignore
vendored
96
.gitignore
vendored
@@ -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
|
||||
|
@@ -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>
|
@@ -0,0 +1,5 @@
|
||||
A task list created by the TipTap editor:
|
||||
|
||||
- [ ] Testing...
|
||||
|
||||
- [ ] testing
|
26
packages/app-cli/tests/html_to_md/task_lists.html
Normal file
26
packages/app-cli/tests/html_to_md/task_lists.html
Normal 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>
|
15
packages/app-cli/tests/html_to_md/task_lists.md
Normal file
15
packages/app-cli/tests/html_to_md/task_lists.md
Normal 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]
|
@@ -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>
|
||||
|
@@ -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,
|
||||
		<svg width="1700" height="1536" xmlns="http://www.w3.org/2000/svg">
|
||||
		 <path d="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"/>
|
||||
		</svg>
|
||||
	"/></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,
|
||||
	"/></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,
|
||||
		<svg width="1700" height="1536" xmlns="http://www.w3.org/2000/svg">
|
||||
		 <path d="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"/>
|
||||
		</svg>
|
||||
	"/></div>
|
||||
<div class="not-loaded-resource not-loaded-image-resource resource-status-notDownloaded" data-resource-id="a1test2a1test2a1test2a1test22347" data-original-before=" " data-original-after=" class="jop-noMdConv"/" contenteditable="false"><img src="data:image/svg+xml;utf8,
|
||||
	"/></span>
|
||||
<span class="not-loaded-resource not-loaded-image-resource resource-status-notDownloaded" data-resource-id="a1test2a1test2a1test2a1test22347" data-original-before=" " data-original-after=" class="jop-noMdConv"/" contenteditable="false"><img src="data:image/svg+xml;utf8,
|
||||
		<svg width="1700" height="1536" xmlns="http://www.w3.org/2000/svg">
|
||||
		 <path d="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"/>
|
||||
		</svg>
|
||||
	"/></div>
|
||||
	"/></span>
|
@@ -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);
|
||||
};
|
||||
|
3
packages/app-mobile/.gitignore
vendored
3
packages/app-mobile/.gitignore
vendored
@@ -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
|
||||
|
@@ -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);
|
||||
|
@@ -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}
|
||||
|
@@ -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,24 +21,26 @@ const wrapperStyle: ViewStyle = { height: '100%', width: '100%', flex: 1 };
|
||||
const ExtendedWebView = (props: Props, ref: Ref<WebViewControl>) => {
|
||||
const iframeRef = useRef<HTMLIFrameElement|null>(null);
|
||||
|
||||
const injectJs = useCallback((js: string) => {
|
||||
if (!iframeRef.current) {
|
||||
logger.warn(`WebView(${props.webviewInstanceId}): Tried to inject JavaScript after the iframe has unloaded.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// react-native-webview doesn't seem to show a warning in the case where JavaScript
|
||||
// is injected before the first page loads.
|
||||
if (!iframeRef.current.contentWindow) {
|
||||
return;
|
||||
}
|
||||
|
||||
iframeRef.current.contentWindow.postMessage({
|
||||
injectJs: js,
|
||||
}, '*');
|
||||
}, [props.webviewInstanceId]);
|
||||
|
||||
useImperativeHandle(ref, (): WebViewControl => {
|
||||
return {
|
||||
injectJS(js: string) {
|
||||
if (!iframeRef.current) {
|
||||
logger.warn(`WebView(${props.webviewInstanceId}): Tried to inject JavaScript after the iframe has unloaded.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// react-native-webview doesn't seem to show a warning in the case where JavaScript
|
||||
// is injected before the first page loads.
|
||||
if (!iframeRef.current.contentWindow) {
|
||||
return;
|
||||
}
|
||||
|
||||
iframeRef.current.contentWindow.postMessage({
|
||||
injectJs: js,
|
||||
}, '*');
|
||||
},
|
||||
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 = `
|
||||
|
@@ -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;
|
||||
|
@@ -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;
|
@@ -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>
|
||||
);
|
||||
|
@@ -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;
|
||||
}
|
||||
}
|
@@ -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);
|
@@ -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;
|
||||
}
|
||||
|
@@ -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;
|
@@ -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;
|
||||
|
@@ -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;
|
||||
|
@@ -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;
|
||||
};
|
@@ -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>
|
@@ -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'}
|
||||
/>
|
||||
);
|
||||
|
@@ -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);
|
||||
}
|
||||
};
|
193
packages/app-mobile/components/NoteEditor/MarkdownEditor.tsx
Normal file
193
packages/app-mobile/components/NoteEditor/MarkdownEditor.tsx
Normal 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;
|
@@ -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();
|
||||
});
|
||||
});
|
||||
|
@@ -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,208 +261,83 @@ 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 editorControlRef = useRef<EditorControl|null>(null);
|
||||
const onEditorEvent = (event: EditorEvent) => {
|
||||
let exhaustivenessCheck: never;
|
||||
switch (event.kind) {
|
||||
case EditorEventType.Change:
|
||||
props.onChange(event);
|
||||
break;
|
||||
case EditorEventType.UndoRedoDepthChange:
|
||||
props.onUndoRedoDepthChange(event);
|
||||
break;
|
||||
case EditorEventType.SelectionRangeChange:
|
||||
props.onSelectionChange(event);
|
||||
break;
|
||||
case EditorEventType.SelectionFormattingChange:
|
||||
setSelectionState(event.formatting);
|
||||
break;
|
||||
case EditorEventType.EditLink:
|
||||
editorControl.showLinkDialog();
|
||||
break;
|
||||
case EditorEventType.FollowLink:
|
||||
void CommandService.instance().execute('openItem', event.link);
|
||||
break;
|
||||
case EditorEventType.UpdateSearchDialog:
|
||||
setSearchState(event.searchState);
|
||||
|
||||
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;
|
||||
}, []);
|
||||
if (event.searchState.dialogVisible) {
|
||||
editorControl.searchControl.showSearch();
|
||||
} else {
|
||||
editorControl.searchControl.hideSearch();
|
||||
}
|
||||
break;
|
||||
case EditorEventType.Scroll:
|
||||
// Not handled
|
||||
break;
|
||||
default:
|
||||
exhaustivenessCheck = event;
|
||||
return exhaustivenessCheck;
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
const editorRef = useRef<EditorBodyControl|null>(null);
|
||||
const editorControl = useEditorControl(
|
||||
editorMessenger.remoteApi, webviewRef, setLinkDialogVisible, setSearchState,
|
||||
editorRef, webviewRef, setLinkDialogVisible, setSearchState,
|
||||
);
|
||||
editorControlRef.current = editorControl;
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
editorControl.updateSettings(editorSettings);
|
||||
}, [editorSettings, editorControl]);
|
||||
|
||||
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]);
|
||||
|
||||
useEditorCommandHandler(editorControl);
|
||||
|
||||
useImperativeHandle(ref, () => {
|
||||
useImperativeHandle(props.ref, () => {
|
||||
return editorControl;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
onEditorEvent.current = (event: EditorEvent) => {
|
||||
let exhaustivenessCheck: never;
|
||||
switch (event.kind) {
|
||||
case EditorEventType.Change:
|
||||
props.onChange(event);
|
||||
break;
|
||||
case EditorEventType.UndoRedoDepthChange:
|
||||
props.onUndoRedoDepthChange(event);
|
||||
break;
|
||||
case EditorEventType.SelectionRangeChange:
|
||||
props.onSelectionChange(event);
|
||||
break;
|
||||
case EditorEventType.SelectionFormattingChange:
|
||||
setSelectionState(event.formatting);
|
||||
break;
|
||||
case EditorEventType.EditLink:
|
||||
editorControl.showLinkDialog();
|
||||
break;
|
||||
case EditorEventType.UpdateSearchDialog:
|
||||
setSearchState(event.searchState);
|
||||
|
||||
if (event.searchState.dialogVisible) {
|
||||
editorControl.searchControl.showSearch();
|
||||
} else {
|
||||
editorControl.searchControl.hideSearch();
|
||||
}
|
||||
break;
|
||||
case EditorEventType.Scroll:
|
||||
// Not handled
|
||||
break;
|
||||
default:
|
||||
exhaustivenessCheck = event;
|
||||
return exhaustivenessCheck;
|
||||
}
|
||||
return;
|
||||
};
|
||||
}, [props.onChange, props.onUndoRedoDepthChange, props.onSelectionChange, editorControl]);
|
||||
|
||||
const codeMirrorPlugins = useCodeMirrorPlugins(props.plugins);
|
||||
useEffect(() => {
|
||||
void editorControl.setContentScripts(codeMirrorPlugins);
|
||||
}, [codeMirrorPlugins, 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;
|
||||
}
|
||||
|
||||
editorMessenger.onWebViewMessage(event);
|
||||
}, [editorMessenger]);
|
||||
|
||||
const onError = useCallback((event: NativeSyntheticEvent<WebViewErrorEvent>) => {
|
||||
logger.error(`Load error: Code ${event.nativeEvent.code}: ${event.nativeEvent.description}`);
|
||||
}, []);
|
||||
|
||||
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;
|
||||
|
@@ -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(` 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');
|
||||
});
|
||||
});
|
||||
});
|
155
packages/app-mobile/components/NoteEditor/RichTextEditor.tsx
Normal file
155
packages/app-mobile/components/NoteEditor/RichTextEditor.tsx
Normal 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;
|
@@ -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}
|
||||
|
47
packages/app-mobile/components/NoteEditor/WarningBanner.tsx
Normal file
47
packages/app-mobile/components/NoteEditor/WarningBanner.tsx
Normal 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);
|
@@ -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(' && ');
|
||||
};
|
||||
|
||||
|
@@ -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;
|
@@ -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',
|
||||
}
|
||||
|
@@ -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!!!');
|
||||
|
@@ -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.
|
||||
|
@@ -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;
|
||||
|
@@ -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;
|
||||
|
@@ -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;
|
@@ -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.
|
@@ -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;
|
@@ -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;
|
@@ -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;
|
||||
};
|
@@ -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>;
|
||||
}
|
@@ -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;
|
@@ -1,24 +1,25 @@
|
||||
/** @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',
|
||||
noteHash: '',
|
||||
initialScroll: 0,
|
||||
readAssetBlob: async (_path: string)=>new Blob(),
|
||||
readAssetBlob: async (_path: string) => new Blob(),
|
||||
|
||||
createEditPopupSyntax: '',
|
||||
destroyEditPopupSyntax: '',
|
||||
pluginAssetContainerSelector: '#asset-container',
|
||||
splitted: false,
|
||||
|
||||
pluginSettings: {},
|
||||
requestPluginSetting: ()=>{},
|
||||
requestPluginSetting: () => { },
|
||||
};
|
||||
|
||||
const makeRenderer = (options: Partial<RendererSetupOptions>) => {
|
||||
@@ -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');
|
||||
});
|
@@ -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);
|
||||
}
|
||||
}
|
@@ -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);
|
||||
};
|
||||
|
@@ -0,0 +1,5 @@
|
||||
export interface WebViewLib {
|
||||
initialize(config: unknown): void;
|
||||
setupResourceManualDownload(): void;
|
||||
}
|
||||
|
@@ -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) {
|
@@ -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;
|
73
packages/app-mobile/contentScripts/rendererBundle/types.ts
Normal file
73
packages/app-mobile/contentScripts/rendererBundle/types.ts
Normal 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;
|
||||
}
|
@@ -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;
|
@@ -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');
|
||||
|
@@ -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';
|
||||
|
@@ -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;
|
||||
}
|
@@ -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;
|
17
packages/app-mobile/contentScripts/types.ts
Normal file
17
packages/app-mobile/contentScripts/types.ts
Normal 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;
|
||||
}
|
27
packages/app-mobile/contentScripts/utils/polyfills.ts
Normal file
27
packages/app-mobile/contentScripts/utils/polyfills.ts
Normal 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;
|
||||
};
|
15
packages/app-mobile/contentScripts/utils/readFileToBase64.ts
Normal file
15
packages/app-mobile/contentScripts/utils/readFileToBase64.ts
Normal 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;
|
14
packages/app-mobile/contentScripts/utils/setUpLogger.ts
Normal file
14
packages/app-mobile/contentScripts/utils/setUpLogger.ts
Normal 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;
|
@@ -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(
|
||||
|
@@ -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
|
||||
|
@@ -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",
|
||||
|
@@ -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)}`;
|
||||
|
@@ -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'],
|
||||
};
|
||||
};
|
||||
|
||||
|
@@ -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: [
|
||||
{
|
||||
// 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;
|
||||
plugins: [
|
||||
{
|
||||
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;
|
||||
},
|
||||
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 } }],
|
||||
],
|
||||
},
|
||||
},
|
||||
return { path };
|
||||
});
|
||||
},
|
||||
],
|
||||
},
|
||||
// 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;
|
||||
{
|
||||
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');
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// 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) => {
|
||||
console.info(`Building bundle: ${this.bundleName}...`);
|
||||
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 (!failed) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(closeError ?? buildError ?? copyError);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
if (result?.metafile) {
|
||||
await writeFile(`${this.bundleOutputPathBase_}.meta.json`, JSON.stringify(result.metafile, undefined, '\t'));
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
29
packages/app-mobile/tools/buildInjectedJs/copyAssets.ts
Normal file
29
packages/app-mobile/tools/buildInjectedJs/copyAssets.ts
Normal 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;
|
@@ -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;
|
@@ -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(
|
||||
'pluginBackgroundPage',
|
||||
`${mobileDir}/components/plugins/backgroundPage/pluginRunnerBackgroundPage.ts`,
|
||||
);
|
||||
// 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(),
|
||||
},
|
||||
watchCodeMirrorEditor: {
|
||||
fn: () => codeMirrorBundle.startWatching(),
|
||||
},
|
||||
watchJsDrawEditor: {
|
||||
fn: () => jsDrawBundle.startWatching(),
|
||||
},
|
||||
buildPluginBackgroundScript: {
|
||||
fn: () => pluginBackgroundPageBundle.build(),
|
||||
},
|
||||
watchPluginBackgroundScript: {
|
||||
fn: () => pluginBackgroundPageBundle.startWatching(),
|
||||
},
|
||||
watchNoteViewerBundle: {
|
||||
fn: () => noteViewerBundle.startWatching(),
|
||||
watchBundledJs: {
|
||||
fn: async () => {
|
||||
const watchPromises = [];
|
||||
for (const bundle of await getBundles()) {
|
||||
watchPromises.push(bundle.startWatching());
|
||||
}
|
||||
await Promise.all(watchPromises);
|
||||
},
|
||||
},
|
||||
copyWebviewLib: {
|
||||
fn: () => copyJs('webviewLib', `${mobileDir}/../lib/renderers/webviewLib.js`),
|
||||
fn: () => copyAssets('webviewLib', { js: `${mobileDir}/../lib/renderers/webviewLib.js` }),
|
||||
},
|
||||
};
|
||||
|
||||
|
@@ -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.
|
||||
|
@@ -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;
|
@@ -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;
|
||||
|
@@ -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) => {
|
||||
|
@@ -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();
|
||||
|
||||
|
@@ -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;
|
||||
}
|
||||
|
@@ -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);
|
||||
}
|
||||
|
@@ -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', () => {
|
||||
|
6
packages/editor/CodeMirror/index.ts
Normal file
6
packages/editor/CodeMirror/index.ts
Normal 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';
|
@@ -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);
|
||||
|
@@ -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();
|
||||
|
80
packages/editor/ProseMirror/commands.test.ts
Normal file
80
packages/editor/ProseMirror/commands.test.ts
Normal 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');
|
||||
});
|
||||
});
|
206
packages/editor/ProseMirror/commands.ts
Normal file
206
packages/editor/ProseMirror/commands.ts
Normal 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;
|
265
packages/editor/ProseMirror/createEditor.ts
Normal file
265
packages/editor/ProseMirror/createEditor.ts
Normal 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;
|
6
packages/editor/ProseMirror/index.ts
Normal file
6
packages/editor/ProseMirror/index.ts
Normal 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';
|
181
packages/editor/ProseMirror/plugins/inputRulesPlugin.ts
Normal file
181
packages/editor/ProseMirror/plugins/inputRulesPlugin.ts
Normal 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;
|
69
packages/editor/ProseMirror/plugins/joplinEditablePlugin.ts
Normal file
69
packages/editor/ProseMirror/plugins/joplinEditablePlugin.ts
Normal 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;
|
43
packages/editor/ProseMirror/plugins/joplinEditorApiPlugin.ts
Normal file
43
packages/editor/ProseMirror/plugins/joplinEditorApiPlugin.ts
Normal 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;
|
110
packages/editor/ProseMirror/plugins/keymapPlugin.ts
Normal file
110
packages/editor/ProseMirror/plugins/keymapPlugin.ts
Normal 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;
|
@@ -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');
|
||||
});
|
||||
});
|
94
packages/editor/ProseMirror/plugins/linkTooltipPlugin.ts
Normal file
94
packages/editor/ProseMirror/plugins/linkTooltipPlugin.ts
Normal 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
Reference in New Issue
Block a user