You've already forked joplin
							
							
				mirror of
				https://github.com/laurent22/joplin.git
				synced 2025-10-31 00:07:48 +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