1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-08-24 20:19:10 +02:00

Compare commits

...

85 Commits

Author SHA1 Message Date
Laurent Cozic
7c3fb47b44 fix 2025-08-05 12:41:12 +01:00
Laurent Cozic
d3954e769f Merge branch 'dev' into renovate/major-eslint 2025-08-05 12:28:53 +01:00
renovate[bot]
4d8a16bda7 Update dependency @types/mustache to v4.2.6 (#12867)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-05 12:28:03 +01:00
pedr
f725d3895f Transcribe: Resolves #12862: Add log statement signaling that the startup has finished (#12876) 2025-08-05 12:26:10 +01:00
pedr
0e19dce0d1 Transcribe: Fixes #12863: Improve error handling (#12873) 2025-08-05 10:53:42 +01:00
Joplin Bot
31c5058d5e Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-08-04 18:29:30 +00:00
Laurent Cozic
4d760303bc Android 3.4.3 2025-08-04 18:39:19 +01:00
Laurent Cozic
23e63e5fec Desktop release v3.4.4 2025-08-04 18:33:28 +01:00
renovate[bot]
3880352f53 Update dependency @react-native-documents/picker to v10.1.3 (#12865)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-03 20:34:46 +00:00
summoner
42a3c40702 All: Translation: Update hu_HU.po (#12864) 2025-08-03 12:56:34 -04:00
mrjo118
8e585640e7 Android: Fixes #12821: Fix on screen keyboard covers the markdown toolbar and contents on Android 15+ (#12838) 2025-08-03 17:30:15 +01:00
renovate[bot]
cd3fb4e7ad Update dependency sharp to v0.34.0 (#12854)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-03 17:27:35 +01:00
Laurent Cozic
702b5b3c63 Server: Trying to fix a request parsing error that can potentially crash the error (#12860) 2025-08-03 15:04:54 +01:00
renovate[bot]
0ad9e8f112 Update eslint 2025-08-03 02:42:38 +00:00
PanWor
a80406dcb7 All: Translation: Update Polish pl_PL.po (#12857) 2025-08-02 17:06:09 -04:00
renovate[bot]
ea8b6485d8 Update dependency pg-boss to v10.2.0 (#12850)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-02 13:17:06 +01:00
renovate[bot]
1a2ef78726 Update dependency babel-plugin-react-native-web to v0.20.0 (#12848)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-02 09:23:47 +01:00
renovate[bot]
63d5ffc796 Update dependency @types/serviceworker to v0.0.133 (#12846)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-02 09:23:28 +01:00
renovate[bot]
15918a57aa Update dependency react-refresh to v0.17.0 (#12847)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-02 09:23:13 +01:00
renovate[bot]
032e8b5596 Update dependency @types/react-dom to v18.3.7 (#12845)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-01 20:14:16 +01:00
Laurent Cozic
ee091ede52 Update renovate.json5 2025-08-01 17:51:06 +01:00
Henry Heino
763e3f7479 Chore: Resolves #12814: Add additional logging to DecryptionWorker and EncryptionService (#12824) 2025-08-01 17:39:37 +01:00
Laurent Cozic
0089c62493 Transcribe v3.4.9 2025-08-01 17:32:56 +01:00
Laurent Cozic
20d6d56c02 Doc: Hide "Edit this page" link when printing 2025-08-01 17:32:33 +01:00
pedr
8b999f8dc6 Transcribe: Resolves #12831: Add support for transcribe server to the server docker compose configuration (#12832)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2025-08-01 17:31:59 +01:00
Laurent Cozic
0067ac126d Doc: Added technical info to JSB page 2025-08-01 16:28:01 +01:00
renovate[bot]
c6f47a9084 Update eslint (#12833)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2025-08-01 14:46:54 +01:00
Laurent Cozic
22817317f1 Transcribe v3.4.8 2025-08-01 13:30:54 +01:00
Laurent Cozic
9ba1c0db4e Chore: Also build ARM64 image for Transcribe 2025-08-01 13:30:34 +01:00
renovate[bot]
70d6c1225c Update types (#12834)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-01 12:50:46 +01:00
Laurent Cozic
b1f013a8c2 Transcribe v3.4.7 2025-08-01 12:14:32 +01:00
Laurent Cozic
8c66349907 Chore: Fixed credential for Transcribe release 2025-08-01 12:14:09 +01:00
Laurent Cozic
86b4f713ee Chore: Trying to disable Renovate React monorepo rule 2025-08-01 12:08:20 +01:00
Henry Heino
f50dc6a536 Chore: Work around test failure in newer NodeJS versions (#12830) 2025-08-01 11:45:09 +01:00
Henry Heino
825ce51a3c Chore: Resolves #12813: Move performance logger file labels to the corresponding log statements (#12820) 2025-08-01 11:43:05 +01:00
mrjo118
c5b6f0bca1 Mobile: Fixes #12783: Improve usability of inline search in notes (#12791) 2025-08-01 11:39:07 +01:00
Laurent Cozic
86934d502e Transcribe v3.4.6 2025-08-01 11:14:18 +01:00
Laurent Cozic
c63ad17f98 Chore: Fixed Transcribe Docker image 2025-08-01 11:13:57 +01:00
Joplin Bot
c746b5fdc2 Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-08-01 01:19:23 +00:00
Laurent Cozic
949fb85755 Transcribe v3.4.5 2025-07-31 21:36:35 +01:00
Laurent Cozic
0f94cb8c17 Chore: Updated script to allow deploying Transcribe server (#12828) 2025-07-31 21:35:03 +01:00
Joplin Bot
7ba61bb585 Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-07-31 18:28:37 +00:00
Henry Heino
00e4657a39 Desktop: Resolves #12714: Make more settings per-profile (application layout, note list style, and note list order) (#12825) 2025-07-31 17:12:31 +01:00
pedr
cbdc98553a Desktop, Server: Add transcribe functionality to Desktop though Joplin Server (#12670) 2025-07-31 16:42:03 +01:00
Laurent Cozic
e3c2589a12 Doc: Allow setting the initial hosting type on the Plans page 2025-07-31 14:48:25 +01:00
Henry Heino
56b3cc3dc2 Android: Fixes #12782: Fix save button is invisible in release builds (#12826) 2025-07-31 13:59:25 +01:00
Suchith
d59a09fd29 Desktop: Fixes #12233: Add tooltips to sidebar buttons (#12798) 2025-07-30 15:42:08 +01:00
Laurent Cozic
5a64222276 Desktop: Fixes #12816: Date/Time dialog button not visible in dark mode 2025-07-30 15:22:34 +01:00
Henry Heino
012297d52a Android: Fixes #12781: Fix editor becomes blank after dismissing search (#12818) 2025-07-30 10:53:12 +01:00
Henry Heino
5e70bce2c3 Mobile: Performance: Improve Rich Text Editor startup performance (#12819) 2025-07-30 10:52:57 +01:00
Henry Heino
4c3eca1f18 Mobile: Add a Rich Text Editor (#12748) 2025-07-29 20:25:43 +01:00
Henry Heino
c899f63a41 Chore: Resolves #12088: Desktop: Add performance logging statements to the startup code (#12812) 2025-07-29 19:57:12 +01:00
Liffindra Angga Zaaldian
c838b86413 All: Translation: Update id_ID.po (#12815) 2025-07-29 06:57:18 -04:00
pedr
90d6d1747a Transcribe: Fixes #12765: Removes file from temporary folder after storing it (#12795) 2025-07-28 18:32:10 +01:00
renovate[bot]
6e8ba8a536 Update dependency jsdom to v26 (#12809)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-28 12:24:57 +01:00
bwat47
ffeb5f887a Doc: Update s3.md for provider Cloudflare R2 (#12805) 2025-07-27 15:54:10 +01:00
Laurent Cozic
65bde86263 Doc: Added documentation for the CLA consent records tool and archives 2025-07-27 10:00:33 +01:00
Laurent Cozic
1c236ca73c Doc: Update CLA consent records 2025-07-27 09:36:28 +01:00
Laurent Cozic
2881280100 Merge branch 'cla_signatures' into dev 2025-07-27 09:33:50 +01:00
Laurent Cozic
954b48b779 Merge branch 'dev' into cla_signatures 2025-07-27 09:33:24 +01:00
Laurent Cozic
53e7b672b0 Chore: Recorded CLA consent documents 2025-07-27 09:33:22 +01:00
krevad
ceaaab77e8 All: Translation: Update sv.po (#12800) 2025-07-26 18:00:43 -04:00
Eric Duarte
c29bbe96f7 All: Translation: Update es_ES.po and ca.po (#12760) 2025-07-26 17:03:20 -04:00
Mihai Vasiliu
db323ac585 All: Translation: Update ro_RO.po and ro_MD.po (#12799) 2025-07-26 16:54:55 -04:00
krevad
dc8e3242f3 All: Translation: Update sv.po (#12797) 2025-07-26 11:00:38 -04:00
github-actions[bot]
9705941538 @krevad has signed the CLA in laurent22/joplin#12797 2025-07-26 09:58:22 +00:00
Joplin Bot
0cf9981ac7 Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-07-26 01:09:51 +00:00
Laurent Cozic
b93ee3469b Desktop release v3.4.3 2025-07-25 19:22:30 +01:00
Laurent Cozic
73e5bc74a5 Android 3.4.2 2025-07-25 19:21:50 +01:00
Henry Heino
6c761b3fb4 Desktop,Mobile: Fixes #12573: Markdown editor: Make list indentation size equivalent to four spaces (#12794) 2025-07-25 19:18:14 +01:00
Henry Heino
e13985a952 Desktop: Fixes #12790: Plugins: Fix importing sqlite3 (#12792) 2025-07-25 19:17:40 +01:00
github-actions[bot]
83f1fcc228 @jordanhandy has signed the CLA in laurent22/joplin#12701 2025-07-11 13:05:02 +00:00
github-actions[bot]
b03e370d2b @khemarato has signed the CLA in laurent22/joplin#12696 2025-07-11 06:21:31 +00:00
github-actions[bot]
4ddd5c4558 @laurent22 has signed the CLA in laurent22/joplin#12571 2025-07-04 16:36:54 +00:00
github-actions[bot]
7746694dca @SAYAN02-DEV has signed the CLA in laurent22/joplin#12666 2025-07-03 07:56:53 +00:00
github-actions[bot]
f1ac95a1c7 @bekemax has signed the CLA in laurent22/joplin#12586 2025-06-27 17:11:39 +00:00
github-actions[bot]
78e9ced96c @jhult has signed the CLA in laurent22/joplin#12567 2025-06-20 14:38:18 +00:00
github-actions[bot]
bba6ede569 @Robin-Sch has signed the CLA in laurent22/joplin#12563 2025-06-18 21:52:29 +00:00
github-actions[bot]
7a26d4f336 @Gustavo-V-F has signed the CLA in laurent22/joplin#12537 2025-06-15 20:44:49 +00:00
github-actions[bot]
04a976e459 @ShawnZhang31 has signed the CLA in laurent22/joplin#12345 2025-05-27 01:28:32 +00:00
github-actions[bot]
ecfef1a9da @NBA2K1 has signed the CLA in laurent22/joplin#12316 2025-05-18 07:40:04 +00:00
github-actions[bot]
e422a88bb0 @eyaaba has signed the CLA in laurent22/joplin#12260 2025-05-10 11:48:28 +00:00
github-actions[bot]
74ef89d25b @SilviaAC has signed the CLA in laurent22/joplin#12256 2025-05-09 09:43:31 +00:00
github-actions[bot]
28b7251e16 @yatishgoel has signed the CLA in laurent22/joplin#12185 2025-04-29 09:12:38 +00:00
github-actions[bot]
9218c7df1f @BellezaEmporium has signed the CLA in laurent22/joplin#12161 2025-04-28 09:42:54 +00:00
279 changed files with 18253 additions and 7681 deletions

View File

@@ -15,6 +15,23 @@
# POSTGRES_PORT=5432
# POSTGRES_HOST=localhost
# =============================================================================
# TRANSCRIBE CONFIG EXAMPLE
# -----------------------------------------------------------------------------
# This service is not required, and it will be ignored by using --profile server
# when running docker-compose. If you want to use it, you need to set the
# following environment variables.
# =============================================================================
# TRANSCRIBE_API_KEY=secret_string_shared_between_server_and_transcribe
# TRANSCRIBE_ENABLED=true
# QUEUE_DATABASE_NAME=transcribe
# QUEUE_DATABASE_USER=transcribe
# QUEUE_DATABASE_PASSWORD=transcribe
# QUEUE_DATABASE_PORT=5431
# HTR_CLI_IMAGES_FOLDER=/home/user/images_storage
# =============================================================================
# DEV CONFIG EXAMPLE
# -----------------------------------------------------------------------------

View File

@@ -11,8 +11,9 @@ QUEUE_RETRY_COUNT=2
QUEUE_MAINTENANCE_INTERVAL=30000
HTR_CLI_DOCKER_IMAGE=joplin/htr-cli:0.0.2
# Fullpath to images folder
HTR_CLI_IMAGES_FOLDER=/home/user/joplin/packages/transcribe/images
# Fullpath to images folder e.g.:
#HTR_CLI_IMAGES_FOLDER=/home/user/joplin/packages/transcribe/images
HTR_CLI_IMAGES_FOLDER=
QUEUE_DRIVER=pg
# QUEUE_DRIVER=sqlite
@@ -27,4 +28,5 @@ QUEUE_DRIVER=pg
QUEUE_DATABASE_NAME=transcribe
QUEUE_DATABASE_USER=transcribe
QUEUE_DATABASE_PASSWORD=transcribe
QUEUE_DATABASE_PORT=5432
QUEUE_DATABASE_PORT=5432
QUEUE_DATABASE_HOST=localhost

View File

@@ -55,6 +55,7 @@ packages/app-desktop/vendor/lib/
packages/app-mobile/packageInfo.js
packages/app-mobile/android
packages/app-mobile/**/*.bundle.js
packages/app-mobile/**/*.bundle.css
packages/app-mobile/web/public/pluginAssets/**/*
packages/app-mobile/ios
packages/app-mobile/lib/rnInjectedJs/
@@ -74,6 +75,7 @@ packages/lib/services/database/types.ts
packages/lib/vendor/
packages/lib/vendor/fountain.min.js
packages/lib/welcomeAssets.js
packages/editor/*/vendor/
packages/plugins/**/api
packages/plugins/**/dist
packages/server/dist/
@@ -663,6 +665,7 @@ packages/app-mobile/components/ExtendedWebView/index.jest.js
packages/app-mobile/components/ExtendedWebView/index.js
packages/app-mobile/components/ExtendedWebView/index.web.js
packages/app-mobile/components/ExtendedWebView/types.js
packages/app-mobile/components/ExtendedWebView/utils/useCss.js
packages/app-mobile/components/FolderPicker.js
packages/app-mobile/components/Icon.js
packages/app-mobile/components/IconButton.js
@@ -671,42 +674,28 @@ packages/app-mobile/components/ModalDialog.js
packages/app-mobile/components/NestableFlatList.js
packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.test.js
packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.js
packages/app-mobile/components/NoteBodyViewer/bundledJs/Renderer.test.js
packages/app-mobile/components/NoteBodyViewer/bundledJs/Renderer.js
packages/app-mobile/components/NoteBodyViewer/bundledJs/noteBodyViewerBundle.js
packages/app-mobile/components/NoteBodyViewer/bundledJs/types.js
packages/app-mobile/components/NoteBodyViewer/bundledJs/utils/addPluginAssets.js
packages/app-mobile/components/NoteBodyViewer/bundledJs/utils/makeResourceModel.js
packages/app-mobile/components/NoteBodyViewer/hooks/useContentScripts.js
packages/app-mobile/components/NoteBodyViewer/hooks/useEditPopup.test.js
packages/app-mobile/components/NoteBodyViewer/hooks/useEditPopup.js
packages/app-mobile/components/NoteBodyViewer/hooks/useOnMessage.js
packages/app-mobile/components/NoteBodyViewer/hooks/useOnResourceLongPress.js
packages/app-mobile/components/NoteBodyViewer/hooks/useRenderer.js
packages/app-mobile/components/NoteBodyViewer/hooks/useRerenderHandler.js
packages/app-mobile/components/NoteBodyViewer/hooks/useSource.js
packages/app-mobile/components/NoteBodyViewer/types.js
packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.js
packages/app-mobile/components/NoteEditor/EditLinkDialog.js
packages/app-mobile/components/NoteEditor/ImageEditor/ImageEditor.js
packages/app-mobile/components/NoteEditor/ImageEditor/autosave.js
packages/app-mobile/components/NoteEditor/ImageEditor/isEditableResource.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/applyTemplateToEditor.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/createJsDrawEditor.test.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/createJsDrawEditor.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/polyfills.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/startAutosaveLoop.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/types.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/watchEditorForTemplateChanges.js
packages/app-mobile/components/NoteEditor/ImageEditor/promptRestoreAutosave.js
packages/app-mobile/components/NoteEditor/ImageEditor/utils/useEditorMessenger.js
packages/app-mobile/components/NoteEditor/MarkdownEditor.js
packages/app-mobile/components/NoteEditor/NoteEditor.test.js
packages/app-mobile/components/NoteEditor/NoteEditor.js
packages/app-mobile/components/NoteEditor/RichTextEditor.test.js
packages/app-mobile/components/NoteEditor/RichTextEditor.js
packages/app-mobile/components/NoteEditor/SearchPanel.js
packages/app-mobile/components/NoteEditor/WarningBanner.js
packages/app-mobile/components/NoteEditor/commandDeclarations.js
packages/app-mobile/components/NoteEditor/hooks/useCodeMirrorPlugins.js
packages/app-mobile/components/NoteEditor/hooks/useEditorCommandHandler.test.js
packages/app-mobile/components/NoteEditor/hooks/useEditorCommandHandler.js
packages/app-mobile/components/NoteEditor/testing/createTestEditorProps.js
packages/app-mobile/components/NoteEditor/types.js
packages/app-mobile/components/NoteItem.js
packages/app-mobile/components/NoteList.js
@@ -861,6 +850,37 @@ 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/convertHtmlToMarkdown.js
packages/app-mobile/contentScripts/richTextEditorBundle/contentScript/index.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 +903,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 +940,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 +1009,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 +1050,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 +1166,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
@@ -1289,6 +1344,7 @@ packages/lib/services/database/migrations/44.js
packages/lib/services/database/migrations/45.js
packages/lib/services/database/migrations/46.js
packages/lib/services/database/migrations/47.js
packages/lib/services/database/migrations/48.js
packages/lib/services/database/migrations/index.js
packages/lib/services/database/sqlStringToLines.js
packages/lib/services/database/types.js
@@ -1357,6 +1413,8 @@ packages/lib/services/ocr/OcrDriverBase.js
packages/lib/services/ocr/OcrService.test.js
packages/lib/services/ocr/OcrService.js
packages/lib/services/ocr/drivers/OcrDriverTesseract.js
packages/lib/services/ocr/drivers/OcrDriverTranscribe.test.js
packages/lib/services/ocr/drivers/OcrDriverTranscribe.js
packages/lib/services/ocr/utils/filterOcrText.test.js
packages/lib/services/ocr/utils/filterOcrText.js
packages/lib/services/ocr/utils/types.js
@@ -1707,6 +1765,7 @@ packages/tools/release-electron.js
packages/tools/release-ios.js
packages/tools/release-plugin-repo-cli.js
packages/tools/release-server.js
packages/tools/release-transcribe.js
packages/tools/saveClaConsentRecords.js
packages/tools/setupNewRelease.js
packages/tools/spellcheck.js

View File

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

View File

@@ -7,9 +7,13 @@
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
ROOT_DIR="$SCRIPT_DIR/../.."
TRANSCRIBE_TAG_PREFIX=transcribe
TRANSCRIBE_REPOSITORY=joplin/transcribe
IS_PULL_REQUEST=0
IS_DESKTOP_RELEASE=0
IS_SERVER_RELEASE=0
IS_TRANSCRIBE_RELEASE=0
IS_LINUX=0
IS_MACOS=0
@@ -23,6 +27,10 @@ if [[ $GIT_TAG_NAME = $SERVER_TAG_PREFIX-* ]]; then
IS_SERVER_RELEASE=1
fi
if [[ $GIT_TAG_NAME = $TRANSCRIBE_TAG_PREFIX-* ]]; then
IS_TRANSCRIBE_RELEASE=1
fi
if [[ $GIT_TAG_NAME = v* ]]; then
IS_DESKTOP_RELEASE=1
fi
@@ -41,15 +49,17 @@ DOCKER_IMAGE_PLATFORM="linux/amd64"
# a release
RUN_TESTS=0
if [ "$IS_SERVER_RELEASE" = 0 ] && [ "$IS_DESKTOP_RELEASE" = 0 ]; then
if [ "$IS_SERVER_RELEASE" = 0 ] && [ "$IS_DESKTOP_RELEASE" = 0 ] && [ "$IS_TRANSCRIBE_RELEASE" = 0 ]; then
RUN_TESTS=1
fi
if [ "$RUNNER_ARCH" == "ARM64" ] && [ "$IS_SERVER_RELEASE" == "0" ]; then
# We exit now because nothing works properly with the ARM64 architecture.
# We only proceed if building the server image.
echo "Running on ARM64 and not trying to build server image - early exit"
exit 0
if [ "$RUNNER_ARCH" == "ARM64" ]; then
if [ "$IS_SERVER_RELEASE" == "0" ] && [ "$IS_TRANSCRIBE_RELEASE" == "0" ]; then
# We exit now because nothing works properly with the ARM64 architecture.
# We only proceed if building the server image.
echo "Running on ARM64 and not trying to build server image - early exit"
exit 0
fi
fi
if [ "$RUNNER_ARCH" == "ARM64" ]; then
@@ -80,12 +90,14 @@ echo "GIT_TAG_NAME=$GIT_TAG_NAME"
echo "BUILD_SEQUENCIAL=$BUILD_SEQUENCIAL"
echo "SERVER_REPOSITORY=$SERVER_REPOSITORY"
echo "SERVER_TAG_PREFIX=$SERVER_TAG_PREFIX"
echo "TRANSCRIBE_TAG_PREFIX=$TRANSCRIBE_TAG_PREFIX"
echo "DOCKER_IMAGE_PLATFORM=$DOCKER_IMAGE_PLATFORM"
echo "IS_CONTINUOUS_INTEGRATION=$IS_CONTINUOUS_INTEGRATION"
echo "IS_PULL_REQUEST=$IS_PULL_REQUEST"
echo "IS_DESKTOP_RELEASE=$IS_DESKTOP_RELEASE"
echo "IS_SERVER_RELEASE=$IS_SERVER_RELEASE"
echo "IS_TRANSCRIBE_RELEASE=$IS_TRANSCRIBE_RELEASE"
echo "RUN_TESTS=$RUN_TESTS"
echo "IS_LINUX=$IS_LINUX"
echo "IS_MACOS=$IS_MACOS"
@@ -301,9 +313,13 @@ if [ "$IS_DESKTOP_RELEASE" == "1" ]; then
USE_HARD_LINKS=false yarn dist
fi
elif [[ $IS_LINUX = 1 ]] && [ "$IS_SERVER_RELEASE" == "1" ]; then
echo "Step: Building Docker Image..."
echo "Step: Building Joplin Server Docker Image..."
cd "$ROOT_DIR"
yarn buildServerDocker --platform $DOCKER_IMAGE_PLATFORM --tag-name $GIT_TAG_NAME --push-images --repository $SERVER_REPOSITORY
yarn buildServerDocker --docker-file Dockerfile.server --platform $DOCKER_IMAGE_PLATFORM --tag-name $GIT_TAG_NAME --push-images --repository $SERVER_REPOSITORY
elif [[ $IS_LINUX = 1 ]] && [ "$IS_TRANSCRIBE_RELEASE" == "1" ]; then
echo "Step: Building Joplin Transcribe Docker Image..."
cd "$ROOT_DIR"
yarn buildServerDocker --docker-file Dockerfile.transcribe --platform $DOCKER_IMAGE_PLATFORM --tag-name $GIT_TAG_NAME --push-images --repository $TRANSCRIBE_REPOSITORY
else
echo "Step: Building but *not* publishing desktop application..."

View File

@@ -17,7 +17,6 @@ jobs:
uses: ./.github/workflows/shared/setup-build-environment
- name: Install Docker Engine
# if: runner.os == 'Linux' && startsWith(github.ref, 'refs/tags/server-v')
if: runner.os == 'Linux'
run: |
sudo apt-get install -y apt-transport-https
@@ -36,7 +35,7 @@ jobs:
# a pull request it will fail because the PR doesn't have access to
# secrets
- uses: docker/login-action@v3
if: runner.os == 'Linux' && startsWith(github.ref, 'refs/tags/server-v')
if: runner.os == 'Linux' && (startsWith(github.ref, 'refs/tags/server-v') || startsWith(github.ref, 'refs/tags/transcribe-v'))
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -141,7 +140,7 @@ jobs:
echo "DOCKER_IMAGE_PLATFORM=$DOCKER_IMAGE_PLATFORM"
yarn install
yarn buildServerDocker --platform $DOCKER_IMAGE_PLATFORM --tag-name server-v0.0.0 --repository joplin/server
yarn buildServerDocker --docker-file Dockerfile.server --platform $DOCKER_IMAGE_PLATFORM --tag-name server-v0.0.0 --repository joplin/server
# Basic test to ensure that the created build is valid. It should exit with
# code 0 if it works.

101
.gitignore vendored
View File

@@ -638,6 +638,7 @@ packages/app-mobile/components/ExtendedWebView/index.jest.js
packages/app-mobile/components/ExtendedWebView/index.js
packages/app-mobile/components/ExtendedWebView/index.web.js
packages/app-mobile/components/ExtendedWebView/types.js
packages/app-mobile/components/ExtendedWebView/utils/useCss.js
packages/app-mobile/components/FolderPicker.js
packages/app-mobile/components/Icon.js
packages/app-mobile/components/IconButton.js
@@ -646,42 +647,28 @@ packages/app-mobile/components/ModalDialog.js
packages/app-mobile/components/NestableFlatList.js
packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.test.js
packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.js
packages/app-mobile/components/NoteBodyViewer/bundledJs/Renderer.test.js
packages/app-mobile/components/NoteBodyViewer/bundledJs/Renderer.js
packages/app-mobile/components/NoteBodyViewer/bundledJs/noteBodyViewerBundle.js
packages/app-mobile/components/NoteBodyViewer/bundledJs/types.js
packages/app-mobile/components/NoteBodyViewer/bundledJs/utils/addPluginAssets.js
packages/app-mobile/components/NoteBodyViewer/bundledJs/utils/makeResourceModel.js
packages/app-mobile/components/NoteBodyViewer/hooks/useContentScripts.js
packages/app-mobile/components/NoteBodyViewer/hooks/useEditPopup.test.js
packages/app-mobile/components/NoteBodyViewer/hooks/useEditPopup.js
packages/app-mobile/components/NoteBodyViewer/hooks/useOnMessage.js
packages/app-mobile/components/NoteBodyViewer/hooks/useOnResourceLongPress.js
packages/app-mobile/components/NoteBodyViewer/hooks/useRenderer.js
packages/app-mobile/components/NoteBodyViewer/hooks/useRerenderHandler.js
packages/app-mobile/components/NoteBodyViewer/hooks/useSource.js
packages/app-mobile/components/NoteBodyViewer/types.js
packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.js
packages/app-mobile/components/NoteEditor/EditLinkDialog.js
packages/app-mobile/components/NoteEditor/ImageEditor/ImageEditor.js
packages/app-mobile/components/NoteEditor/ImageEditor/autosave.js
packages/app-mobile/components/NoteEditor/ImageEditor/isEditableResource.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/applyTemplateToEditor.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/createJsDrawEditor.test.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/createJsDrawEditor.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/polyfills.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/startAutosaveLoop.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/types.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/watchEditorForTemplateChanges.js
packages/app-mobile/components/NoteEditor/ImageEditor/promptRestoreAutosave.js
packages/app-mobile/components/NoteEditor/ImageEditor/utils/useEditorMessenger.js
packages/app-mobile/components/NoteEditor/MarkdownEditor.js
packages/app-mobile/components/NoteEditor/NoteEditor.test.js
packages/app-mobile/components/NoteEditor/NoteEditor.js
packages/app-mobile/components/NoteEditor/RichTextEditor.test.js
packages/app-mobile/components/NoteEditor/RichTextEditor.js
packages/app-mobile/components/NoteEditor/SearchPanel.js
packages/app-mobile/components/NoteEditor/WarningBanner.js
packages/app-mobile/components/NoteEditor/commandDeclarations.js
packages/app-mobile/components/NoteEditor/hooks/useCodeMirrorPlugins.js
packages/app-mobile/components/NoteEditor/hooks/useEditorCommandHandler.test.js
packages/app-mobile/components/NoteEditor/hooks/useEditorCommandHandler.js
packages/app-mobile/components/NoteEditor/testing/createTestEditorProps.js
packages/app-mobile/components/NoteEditor/types.js
packages/app-mobile/components/NoteItem.js
packages/app-mobile/components/NoteList.js
@@ -836,6 +823,37 @@ 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/convertHtmlToMarkdown.js
packages/app-mobile/contentScripts/richTextEditorBundle/contentScript/index.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 +876,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 +913,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 +982,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 +1023,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 +1139,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
@@ -1264,6 +1317,7 @@ packages/lib/services/database/migrations/44.js
packages/lib/services/database/migrations/45.js
packages/lib/services/database/migrations/46.js
packages/lib/services/database/migrations/47.js
packages/lib/services/database/migrations/48.js
packages/lib/services/database/migrations/index.js
packages/lib/services/database/sqlStringToLines.js
packages/lib/services/database/types.js
@@ -1332,6 +1386,8 @@ packages/lib/services/ocr/OcrDriverBase.js
packages/lib/services/ocr/OcrService.test.js
packages/lib/services/ocr/OcrService.js
packages/lib/services/ocr/drivers/OcrDriverTesseract.js
packages/lib/services/ocr/drivers/OcrDriverTranscribe.test.js
packages/lib/services/ocr/drivers/OcrDriverTranscribe.js
packages/lib/services/ocr/utils/filterOcrText.test.js
packages/lib/services/ocr/utils/filterOcrText.js
packages/lib/services/ocr/utils/types.js
@@ -1682,6 +1738,7 @@ packages/tools/release-electron.js
packages/tools/release-ios.js
packages/tools/release-plugin-repo-cli.js
packages/tools/release-server.js
packages/tools/release-transcribe.js
packages/tools/saveClaConsentRecords.js
packages/tools/setupNewRelease.js
packages/tools/spellcheck.js

View File

@@ -219,10 +219,7 @@
$('.feature-description-' + featureId).toggle(200);
});
});
</script>
<script>
const setHostingType = (type) => {
const other = type === 'managed' ? 'self' : 'managed';
$('.toggle-button-' + type).addClass('active');
@@ -244,6 +241,7 @@
setHostingType('self');
});
setHostingType('managed');
const initialHostingType = urlQuery.get('hosting') ? urlQuery.get('hosting') : 'managed';
setHostingType(initialHostingType);
</script>
</div>

View File

@@ -23,7 +23,6 @@ RUN corepack enable
WORKDIR /app
COPY .yarn/plugins ./.yarn/plugins
COPY .yarn/releases ./.yarn/releases
COPY .yarn/patches ./.yarn/patches
COPY package.json .

View File

@@ -5,7 +5,7 @@
"version": "latest",
"platforms": ["aarch64-darwin", "x86_64-darwin"],
},
"yarn": "latest",
"yarn": "1.22.19",
"vips.dev": {
"platforms": ["aarch64-darwin"],
},

View File

@@ -17,11 +17,21 @@
version: '3'
networks:
app-network:
transcribe-network:
shared-network:
services:
db:
image: postgres:16
profiles:
- full
- server
volumes:
- ./data/postgres:/var/lib/postgresql/data
networks:
- app-network
ports:
- "5432:5432"
restart: unless-stopped
@@ -31,10 +41,17 @@ services:
- POSTGRES_DB=${POSTGRES_DATABASE}
app:
image: joplin/server:latest
profiles:
- full
- server
depends_on:
- db
- transcribe
ports:
- "22300:22300"
networks:
- app-network
- shared-network
restart: unless-stopped
environment:
- APP_PORT=22300
@@ -45,3 +62,48 @@ services:
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PORT=${POSTGRES_PORT}
- POSTGRES_HOST=db
- TRANSCRIBE_API_KEY=${TRANSCRIBE_API_KEY}
- TRANSCRIBE_BASE_URL=http://transcribe:4567
- TRANSCRIBE_ENABLED=${TRANSCRIBE_ENABLED}
transcribe-db:
image: postgres:16
profiles:
- full
volumes:
- ./data/transcribe-postgres:/var/lib/postgresql/data
networks:
- transcribe-network
ports:
- "${QUEUE_DATABASE_PORT}:5432"
restart: unless-stopped
environment:
- POSTGRES_PASSWORD=${QUEUE_DATABASE_PASSWORD}
- POSTGRES_USER=${QUEUE_DATABASE_USER}
- POSTGRES_DB=${QUEUE_DATABASE_NAME}
command: -p ${QUEUE_DATABASE_PORT}
transcribe:
image: joplin/transcribe:latest
profiles:
- full
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ${HTR_CLI_IMAGES_FOLDER}:/app/packages/transcribe/images
depends_on:
- transcribe-db
ports:
- "4567:4567"
networks:
- transcribe-network
- shared-network
restart: unless-stopped
environment:
- APP_PORT=4567
- DB_CLIENT=pg
- QUEUE_DATABASE_NAME=${QUEUE_DATABASE_NAME}
- QUEUE_DATABASE_USER=${QUEUE_DATABASE_USER}
- QUEUE_DATABASE_PASSWORD=${QUEUE_DATABASE_PASSWORD}
- QUEUE_DATABASE_PORT=${QUEUE_DATABASE_PORT}
- QUEUE_DATABASE_HOST=transcribe-db
- API_KEY=${TRANSCRIBE_API_KEY}
- HTR_CLI_IMAGES_FOLDER=${HTR_CLI_IMAGES_FOLDER}

View File

@@ -51,6 +51,8 @@
"releasePluginGenerator": "node packages/tools/release-plugin-generator.js",
"releasePluginRepoCli": "node packages/tools/release-plugin-repo-cli.js",
"releaseServer": "node packages/tools/release-server.js",
"releaseTranscribe": "node packages/tools/release-transcribe.js",
"saveClaConsentRecords": "node packages/tools/saveClaConsentRecords.js",
"setupNewRelease": "node ./packages/tools/setupNewRelease",
"spellcheck": "node packages/tools/spellcheck.js",
"tagServerLatest": "node packages/tools/tagServerLatest.js",
@@ -69,15 +71,15 @@
"@crowdin/cli": "4",
"@joplin/utils": "~2.12",
"@seiyab/eslint-plugin-react-hooks": "4.5.1-beta.0",
"@typescript-eslint/eslint-plugin": "6.21.0",
"@typescript-eslint/parser": "6.21.0",
"@typescript-eslint/eslint-plugin": "8.20.0",
"@typescript-eslint/parser": "8.20.0",
"cspell": "5.21.2",
"eslint": "8.57.0",
"eslint-interactive": "10.8.0",
"eslint-plugin-import": "2.29.1",
"eslint-plugin-jest": "27.9.0",
"eslint-plugin-promise": "6.2.0",
"eslint-plugin-react": "7.34.3",
"eslint": "9.18.0",
"eslint-interactive": "11.1.0",
"eslint-plugin-import": "2.31.0",
"eslint-plugin-jest": "28.11.0",
"eslint-plugin-promise": "7.2.1",
"eslint-plugin-react": "7.37.4",
"execa": "5.1.1",
"fs-extra": "11.2.0",
"glob": "11.0.2",
@@ -87,11 +89,11 @@
"lint-staged": "15.5.1",
"madge": "8.0.0",
"npm-package-json-lint": "8.0.0",
"typescript": "5.4.5"
"typescript": "5.8.2"
},
"dependencies": {
"@types/fs-extra": "11.0.4",
"eslint-plugin-github": "4.10.2",
"eslint-plugin-github": "5.1.5",
"http-server": "14.1.1",
"node-gyp": "11.2.0",
"nodemon": "3.1.10"
@@ -100,7 +102,7 @@
"resolutions": {
"react-native-camera@4.2.1": "patch:react-native-camera@npm%3A4.2.1#./.yarn/patches/react-native-camera-npm-4.2.1-24b2600a7e.patch",
"react-native-vosk@0.1.12": "patch:react-native-vosk@npm%3A0.1.12#./.yarn/patches/react-native-vosk-npm-0.1.12-76b1caaae8.patch",
"eslint": "patch:eslint@8.57.0#./.yarn/patches/eslint-npm-8.39.0-d92bace04d.patch",
"eslint": "patch:eslint@9.18.0#./.yarn/patches/eslint-npm-8.39.0-d92bace04d.patch",
"app-builder-lib@24.4.0": "patch:app-builder-lib@npm%3A24.4.0#./.yarn/patches/app-builder-lib-npm-24.4.0-05322ff057.patch",
"nanoid": "patch:nanoid@npm%3A3.3.7#./.yarn/patches/nanoid-npm-3.3.7-98824ba130.patch",
"pdfjs-dist": "patch:pdfjs-dist@npm%3A3.11.174#./.yarn/patches/pdfjs-dist-npm-3.11.174-67f2fee6d6.patch",

View File

@@ -57,7 +57,7 @@
"proper-lockfile": "4.1.2",
"redux": "4.2.1",
"server-destroy": "1.0.1",
"sharp": "0.33.5",
"sharp": "0.34.0",
"sprintf-js": "1.1.3",
"sqlite3": "5.1.6",
"string-padding": "1.0.2",
@@ -72,12 +72,12 @@
"devDependencies": {
"@joplin/tools": "~3.4",
"@types/fs-extra": "11.0.4",
"@types/jest": "29.5.12",
"@types/jest": "29.5.14",
"@types/node": "18.19.87",
"@types/proper-lockfile": "^4.1.2",
"gulp": "4.0.2",
"jest": "29.7.0",
"temp": "0.9.4",
"typescript": "5.4.5"
"typescript": "5.8.2"
}
}

View File

@@ -1,6 +1,6 @@
import MarkupToHtml, { MarkupLanguage } from '@joplin/renderer/MarkupToHtml';
import { RenderResult } from '@joplin/renderer/types';
import MarkupToHtml from '@joplin/renderer/MarkupToHtml';
import { RenderResult, MarkupLanguage } from '@joplin/renderer/types';
describe('MarkupToHtml', () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -55,11 +55,16 @@ import userFetcher, { initializeUserFetcher } from '@joplin/lib/utils/userFetche
import { parseNotesParent } from '@joplin/lib/reducer';
import OcrService from '@joplin/lib/services/ocr/OcrService';
import OcrDriverTesseract from '@joplin/lib/services/ocr/drivers/OcrDriverTesseract';
import OcrDriverTranscribe from '@joplin/lib/services/ocr/drivers/OcrDriverTranscribe';
import SearchEngine from '@joplin/lib/services/search/SearchEngine';
import { PackageInfo } from '@joplin/lib/versionInfo';
import { CustomProtocolHandler } from './utils/customProtocols/handleCustomProtocols';
import { refreshFolders } from '@joplin/lib/folders-screen-utils';
import initializeCommandService from './utils/initializeCommandService';
import OcrDriverBase from '@joplin/lib/services/ocr/OcrDriverBase';
import PerformanceLogger from '@joplin/lib/PerformanceLogger';
const perfLogger = PerformanceLogger.create();
const pluginClasses = [
require('./plugins/GotoAnything').default,
@@ -67,6 +72,8 @@ const pluginClasses = [
const appDefaultState = createAppDefaultState(resourceEditWatcherDefaultState);
type StartupTask = { label: string; task: ()=> void|Promise<void> };
class Application extends BaseApplication {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
@@ -348,16 +355,19 @@ class Application extends BaseApplication {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const Tesseract = (window as any).Tesseract;
const driver = new OcrDriverTesseract(
const drivers: OcrDriverBase[] = [];
drivers.push(new OcrDriverTesseract(
{ createWorker: Tesseract.createWorker },
{
workerPath: `${bridge().buildDir()}/tesseract.js/worker.min.js`,
corePath: `${bridge().buildDir()}/tesseract.js-core`,
languageDataPath: Setting.value('ocr.languageDataPath') || null,
},
);
));
this.ocrService_ = new OcrService(driver);
drivers.push(new OcrDriverTranscribe());
this.ocrService_ = new OcrService(drivers);
}
void this.ocrService_.runInBackground();
@@ -411,56 +421,53 @@ class Application extends BaseApplication {
});
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
public async start(argv: string[], startOptions: StartOptions = null): Promise<any> {
// If running inside a package, the command line, instead of being "node.exe <path> <flags>" is "joplin.exe <flags>" so
// insert an extra argument so that they can be processed in a consistent way everywhere.
if (!bridge().electronIsDev()) argv.splice(1, 0, '.');
private buildStartupTasks_() {
const tasks: StartupTask[] = [];
const addTask = (label: string, task: StartupTask['task']) => {
tasks.push({ label, task });
};
argv = await super.start(argv, startOptions);
addTask('app/set up extra debug logging', () => {
reg.logger().info('app.start: doing regular boot');
const dir: string = Setting.value('profileDir');
await this.setupIntegrationTestUtils();
syncDebugLog.enabled = false;
bridge().setLogFilePath(Logger.globalLogger.logFilePath());
if (dir.endsWith('dev-desktop-2')) {
syncDebugLog.addTarget(TargetType.File, {
path: `${homedir()}/synclog.txt`,
});
syncDebugLog.enabled = true;
syncDebugLog.info(`Profile dir: ${dir}`);
}
});
await this.applySettingsSideEffects();
addTask('app/set up registry', () => {
reg.setDispatch(this.dispatch.bind(this));
reg.setShowErrorMessageBoxHandler((message: string) => { bridge().showErrorMessageBox(message); });
});
if (Setting.value('sync.upgradeState') === Setting.SYNC_UPGRADE_STATE_MUST_DO) {
reg.logger().info('app.start: doing upgradeSyncTarget action');
bridge().mainWindow().show();
return { action: 'upgradeSyncTarget' };
}
addTask('app/set up auto updater', () => {
this.setupAutoUpdaterService();
});
reg.logger().info('app.start: doing regular boot');
const dir: string = Setting.value('profileDir');
syncDebugLog.enabled = false;
if (dir.endsWith('dev-desktop-2')) {
syncDebugLog.addTarget(TargetType.File, {
path: `${homedir()}/synclog.txt`,
});
syncDebugLog.enabled = true;
syncDebugLog.info(`Profile dir: ${dir}`);
}
this.setupAutoUpdaterService();
AlarmService.setDriver(new AlarmServiceDriverNode({ appName: packageInfo.build.appId }));
AlarmService.setLogger(reg.logger());
reg.setDispatch(this.dispatch.bind(this));
reg.setShowErrorMessageBoxHandler((message: string) => { bridge().showErrorMessageBox(message); });
addTask('app/set up AlarmService', () => {
AlarmService.setDriver(new AlarmServiceDriverNode({ appName: packageInfo.build.appId }));
AlarmService.setLogger(reg.logger());
});
if (Setting.value('flagOpenDevTools')) {
bridge().openDevTools();
addTask('app/openDevTools', () => {
bridge().openDevTools();
});
}
this.protocolHandler_ = bridge().electronApp().getCustomProtocolHandler();
this.protocolHandler_.allowReadAccessToDirectory(__dirname); // App bundle directory
this.protocolHandler_.allowReadAccessToDirectory(Setting.value('cacheDir'));
this.protocolHandler_.allowReadAccessToDirectory(Setting.value('resourceDir'));
addTask('app/set up custom protocol handler', async () => {
this.protocolHandler_ = bridge().electronApp().getCustomProtocolHandler();
this.protocolHandler_.allowReadAccessToDirectory(__dirname); // App bundle directory
this.protocolHandler_.allowReadAccessToDirectory(Setting.value('cacheDir'));
this.protocolHandler_.allowReadAccessToDirectory(Setting.value('resourceDir'));
});
// this.protocolHandler_.allowReadAccessTo(Setting.value('tempDir'));
// For now, this doesn't seem necessary:
// this.protocolHandler_.allowReadAccessTo(Setting.value('profileDir'));
@@ -468,44 +475,52 @@ class Application extends BaseApplication {
// handler, and, as such, it may make sense to also limit permissions of
// allowed pages with a Content Security Policy.
PluginManager.instance().dispatch_ = this.dispatch.bind(this);
PluginManager.instance().setLogger(reg.logger());
PluginManager.instance().register(pluginClasses);
addTask('app/initialize PluginManager, redux, CommandService, and KeymapService', async () => {
PluginManager.instance().dispatch_ = this.dispatch.bind(this);
PluginManager.instance().setLogger(reg.logger());
PluginManager.instance().register(pluginClasses);
this.initRedux();
this.initRedux();
PerFolderSortOrderService.initialize();
initializeCommandService(this.store(), Setting.value('env') === 'dev');
initializeCommandService(this.store(), Setting.value('env') === 'dev');
const keymapService = KeymapService.instance();
// We only add the commands that appear in the menu because only
// those can have a shortcut associated with them.
keymapService.initialize(menuCommandNames());
const keymapService = KeymapService.instance();
// We only add the commands that appear in the menu because only
// those can have a shortcut associated with them.
keymapService.initialize(menuCommandNames());
try {
await keymapService.loadCustomKeymap(`${dir}/keymap-desktop.json`);
} catch (error) {
reg.logger().error(error);
}
// Since the settings need to be loaded before the store is
// created, it will never receive the SETTING_UPDATE_ALL even,
// which mean state.settings will not be initialised. So we
// manually call dispatchUpdateAll() to force an update.
Setting.dispatchUpdateAll();
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
await refreshFolders((action: any) => this.dispatch(action), '');
const tags = await Tag.allWithNotes();
this.dispatch({
type: 'TAG_UPDATE_ALL',
items: tags,
try {
await keymapService.loadCustomKeymap(`${Setting.value('profileDir')}/keymap-desktop.json`);
} catch (error) {
reg.logger().error(error);
}
});
await this.setupCustomCss();
addTask('app/initialize PerFolderSortOrderService', () => {
PerFolderSortOrderService.initialize();
});
addTask('app/dispatch initial settings', () => {
// Since the settings need to be loaded before the store is
// created, it will never receive the SETTING_UPDATE_ALL even,
// which mean state.settings will not be initialised. So we
// manually call dispatchUpdateAll() to force an update.
Setting.dispatchUpdateAll();
});
addTask('app/update folders and tags', async () => {
await refreshFolders((action) => this.dispatch(action), '');
const tags = await Tag.allWithNotes();
this.dispatch({
type: 'TAG_UPDATE_ALL',
items: tags,
});
});
addTask('app/set up custom CSS', async () => {
await this.setupCustomCss();
});
// const masterKeys = await MasterKey.all();
@@ -514,188 +529,237 @@ class Application extends BaseApplication {
// items: masterKeys,
// });
const getNotesParent = async () => {
let notesParent = parseNotesParent(Setting.value('notesParent'), Setting.value('activeFolderId'));
if (notesParent.type === 'Tag' && !(await Tag.load(notesParent.selectedItemId))) {
notesParent = {
type: 'Folder',
selectedItemId: Setting.value('activeFolderId'),
};
addTask('app/send initial selection to redux', async () => {
const getNotesParent = async () => {
let notesParent = parseNotesParent(Setting.value('notesParent'), Setting.value('activeFolderId'));
if (notesParent.type === 'Tag' && !(await Tag.load(notesParent.selectedItemId))) {
notesParent = {
type: 'Folder',
selectedItemId: Setting.value('activeFolderId'),
};
}
return notesParent;
};
const notesParent = await getNotesParent();
if (notesParent.type === 'SmartFilter') {
this.store().dispatch({
type: 'SMART_FILTER_SELECT',
id: notesParent.selectedItemId,
});
} else if (notesParent.type === 'Tag') {
this.store().dispatch({
type: 'TAG_SELECT',
id: notesParent.selectedItemId,
});
} else {
this.store().dispatch({
type: 'FOLDER_SELECT',
id: notesParent.selectedItemId,
});
}
return notesParent;
};
const notesParent = await getNotesParent();
this.store().dispatch({
type: 'FOLDER_SET_COLLAPSED_ALL',
ids: Setting.value('collapsedFolderIds'),
});
if (notesParent.type === 'SmartFilter') {
this.store().dispatch({
type: 'SMART_FILTER_SELECT',
id: notesParent.selectedItemId,
type: 'NOTE_DEVTOOLS_SET',
value: Setting.value('flagOpenDevTools'),
});
} else if (notesParent.type === 'Tag') {
this.store().dispatch({
type: 'TAG_SELECT',
id: notesParent.selectedItemId,
});
} else {
this.store().dispatch({
type: 'FOLDER_SELECT',
id: notesParent.selectedItemId,
});
}
this.store().dispatch({
type: 'FOLDER_SET_COLLAPSED_ALL',
ids: Setting.value('collapsedFolderIds'),
});
this.store().dispatch({
type: 'NOTE_DEVTOOLS_SET',
value: Setting.value('flagOpenDevTools'),
addTask('app/initializeUserFetcher', async () => {
initializeUserFetcher();
shim.setInterval(() => { void userFetcher(); }, 1000 * 60 * 60);
});
// Always disable on Mac for now - and disable too for the few apps that may have the flag enabled.
// At present, it only seems to work on Windows.
if (shim.isMac()) {
Setting.setValue('featureFlag.autoUpdaterServiceEnabled', false);
}
addTask('app/updateTray', () => this.updateTray());
// Note: Auto-update is a misnomer in the code.
// The code below only checks, if a new version is available.
// We only allow Windows and macOS users to automatically check for updates
if (!Setting.value('featureFlag.autoUpdaterServiceEnabled')) {
if (shim.isWindows() || shim.isMac()) {
const runAutoUpdateCheck = () => {
if (Setting.value('autoUpdateEnabled')) {
void checkForUpdates(true, bridge().mainWindow(), { includePreReleases: Setting.value('autoUpdate.includePreReleases') });
}
};
// Initial check on startup
shim.setTimeout(() => { runAutoUpdateCheck(); }, 5000);
// Then every x hours
shim.setInterval(() => { runAutoUpdateCheck(); }, 12 * 60 * 60 * 1000);
addTask('app/set main window state', () => {
if (Setting.value('startMinimized') && Setting.value('showTrayIcon')) {
bridge().mainWindow().hide();
} else {
bridge().mainWindow().show();
}
}
});
initializeUserFetcher();
shim.setInterval(() => { void userFetcher(); }, 1000 * 60 * 60);
addTask('app/start maintenance tasks', () => {
// Always disable on Mac for now - and disable too for the few apps that may have the flag enabled.
// At present, it only seems to work on Windows.
if (shim.isMac()) {
Setting.setValue('featureFlag.autoUpdaterServiceEnabled', false);
}
this.updateTray();
// Note: Auto-update is a misnomer in the code.
// The code below only checks, if a new version is available.
// We only allow Windows and macOS users to automatically check for updates
if (!Setting.value('featureFlag.autoUpdaterServiceEnabled')) {
if (shim.isWindows() || shim.isMac()) {
const runAutoUpdateCheck = () => {
if (Setting.value('autoUpdateEnabled')) {
void checkForUpdates(true, bridge().mainWindow(), { includePreReleases: Setting.value('autoUpdate.includePreReleases') });
}
};
shim.setTimeout(() => {
void AlarmService.garbageCollect();
}, 1000 * 60 * 60);
// Initial check on startup
shim.setTimeout(() => { runAutoUpdateCheck(); }, 5000);
// Then every x hours
shim.setInterval(() => { runAutoUpdateCheck(); }, 12 * 60 * 60 * 1000);
}
}
if (Setting.value('startMinimized') && Setting.value('showTrayIcon')) {
bridge().mainWindow().hide();
} else {
bridge().mainWindow().show();
}
shim.setTimeout(() => {
void AlarmService.garbageCollect();
}, 1000 * 60 * 60);
void ShareService.instance().maintenance();
void ShareService.instance().maintenance();
ResourceService.runInBackground();
ResourceService.runInBackground();
if (Setting.value('env') === 'dev') {
void AlarmService.updateAllNotifications();
} else {
// eslint-disable-next-line promise/prefer-await-to-then -- Old code before rule was applied
void reg.scheduleSync(1000).then(() => {
// Wait for the first sync before updating the notifications, since synchronisation
// might change the notifications.
if (Setting.value('env') === 'dev') {
void AlarmService.updateAllNotifications();
} else {
// eslint-disable-next-line promise/prefer-await-to-then -- Old code before rule was applied
void reg.scheduleSync(1000).then(() => {
// Wait for the first sync before updating the notifications, since synchronisation
// might change the notifications.
void AlarmService.updateAllNotifications();
void DecryptionWorker.instance().scheduleStart();
});
}
void DecryptionWorker.instance().scheduleStart();
});
}
const clipperLogger = new Logger();
clipperLogger.addTarget(TargetType.File, { path: `${Setting.value('profileDir')}/log-clipper.txt` });
clipperLogger.addTarget(TargetType.Console);
ClipperServer.instance().initialize(actionApi);
ClipperServer.instance().setEnabled(!Setting.value('altInstanceId'));
ClipperServer.instance().setLogger(clipperLogger);
ClipperServer.instance().setDispatch(this.store().dispatch);
if (ClipperServer.instance().enabled() && Setting.value('clipperServer.autoStart')) {
void ClipperServer.instance().start();
}
ExternalEditWatcher.instance().setLogger(reg.logger());
ExternalEditWatcher.instance().initialize(bridge, this.store().dispatch);
ResourceEditWatcher.instance().initialize(
reg.logger(),
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
(action: any) => { this.store().dispatch(action); },
(path: string) => bridge().openItem(path),
() => this.store().getState().windowId,
);
// Forwards the local event to the global event manager, so that it can
// be picked up by the plugin manager.
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
ResourceEditWatcher.instance().on('resourceChange', (event: any) => {
eventManager.emit(EventName.ResourceChange, event);
RevisionService.instance().runInBackground();
this.startRotatingLogMaintenance(Setting.value('profileDir'));
});
RevisionService.instance().runInBackground();
addTask('app/set up ClipperServer', () => {
const clipperLogger = new Logger();
clipperLogger.addTarget(TargetType.File, { path: `${Setting.value('profileDir')}/log-clipper.txt` });
clipperLogger.addTarget(TargetType.Console);
ClipperServer.instance().initialize(actionApi);
ClipperServer.instance().setEnabled(!Setting.value('altInstanceId'));
ClipperServer.instance().setLogger(clipperLogger);
ClipperServer.instance().setDispatch(this.store().dispatch);
if (ClipperServer.instance().enabled() && Setting.value('clipperServer.autoStart')) {
void ClipperServer.instance().start();
}
});
addTask('app/set up external edit watchers', () => {
ExternalEditWatcher.instance().setLogger(reg.logger());
ExternalEditWatcher.instance().initialize(bridge, this.store().dispatch);
ResourceEditWatcher.instance().initialize(
reg.logger(),
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
(action: any) => { this.store().dispatch(action); },
(path: string) => bridge().openItem(path),
() => this.store().getState().windowId,
);
// Forwards the local event to the global event manager, so that it can
// be picked up by the plugin manager.
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
ResourceEditWatcher.instance().on('resourceChange', (event: any) => {
eventManager.emit(EventName.ResourceChange, event);
});
});
// Make it available to the console window - useful to call revisionService.collectRevisions()
if (Setting.value('env') === 'dev') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
(window as any).joplin = {
revisionService: RevisionService.instance(),
migrationService: MigrationService.instance(),
decryptionWorker: DecryptionWorker.instance(),
commandService: CommandService.instance(),
pluginService: PluginService.instance(),
bridge: bridge(),
debug: new DebugService(reg.db()),
resourceService: ResourceService.instance(),
searchEngine: SearchEngine.instance(),
ocrService: () => this.ocrService_,
};
addTask('app/add debug variables', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
(window as any).joplin = {
revisionService: RevisionService.instance(),
migrationService: MigrationService.instance(),
decryptionWorker: DecryptionWorker.instance(),
commandService: CommandService.instance(),
pluginService: PluginService.instance(),
bridge: bridge(),
debug: new DebugService(reg.db()),
resourceService: ResourceService.instance(),
searchEngine: SearchEngine.instance(),
ocrService: () => this.ocrService_,
};
});
}
bridge().addEventListener('nativeThemeUpdated', this.bridge_nativeThemeUpdated);
bridge().setOnAllowedExtensionsChangeListener((newExtensions) => {
Setting.setValue('linking.extraAllowedExtensions', newExtensions);
addTask('app/listen for main process events', () => {
bridge().addEventListener('nativeThemeUpdated', this.bridge_nativeThemeUpdated);
bridge().setOnAllowedExtensionsChangeListener((newExtensions) => {
Setting.setValue('linking.extraAllowedExtensions', newExtensions);
});
ipcRenderer.on('window-focused', (_event, newWindowId) => {
const currentWindowId = this.store().getState().windowId;
if (newWindowId !== currentWindowId) {
this.dispatch({
type: 'WINDOW_FOCUS',
windowId: newWindowId,
lastWindowId: currentWindowId,
});
}
});
});
ipcRenderer.on('window-focused', (_event, newWindowId) => {
const currentWindowId = this.store().getState().windowId;
if (newWindowId !== currentWindowId) {
this.dispatch({
type: 'WINDOW_FOCUS',
windowId: newWindowId,
lastWindowId: currentWindowId,
});
}
addTask('app/initPluginService', () => this.initPluginService());
addTask('app/setupContextMenu', () => {
this.setupContextMenu();
});
await this.initPluginService();
this.setupContextMenu();
await SpellCheckerService.instance().initialize(new SpellCheckerServiceDriverNative());
this.startRotatingLogMaintenance(Setting.value('profileDir'));
await this.setupOcrService();
eventManager.on(EventName.OcrServiceResourcesProcessed, async () => {
await ResourceService.instance().indexNoteResources();
addTask('app/set up SpellCheckerService', async () => {
await SpellCheckerService.instance().initialize(new SpellCheckerServiceDriverNative());
});
eventManager.on(EventName.NoteResourceIndexed, async () => {
SearchEngine.instance().scheduleSyncTables();
addTask('app/listen for resource events', () => {
eventManager.on(EventName.OcrServiceResourcesProcessed, async () => {
await ResourceService.instance().indexNoteResources();
});
eventManager.on(EventName.NoteResourceIndexed, async () => {
SearchEngine.instance().scheduleSyncTables();
});
});
// Used by tests
ipcRenderer.send('startup-finished');
addTask('app/setupOcrService', () => this.setupOcrService());
return tasks;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
public async start(argv: string[], startOptions: StartOptions = null): Promise<any> {
const startupTask = perfLogger.taskStart('app/start');
// If running inside a package, the command line, instead of being "node.exe <path> <flags>" is "joplin.exe <flags>" so
// insert an extra argument so that they can be processed in a consistent way everywhere.
if (!bridge().electronIsDev()) argv.splice(1, 0, '.');
argv = await super.start(argv, startOptions);
await this.setupIntegrationTestUtils();
bridge().setLogFilePath(Logger.globalLogger.logFilePath());
await this.applySettingsSideEffects();
if (Setting.value('sync.upgradeState') === Setting.SYNC_UPGRADE_STATE_MUST_DO) {
reg.logger().info('app.start: doing upgradeSyncTarget action');
bridge().mainWindow().show();
startupTask.onEnd();
return { action: 'upgradeSyncTarget' };
}
const startupTasks = this.buildStartupTasks_();
for (const task of startupTasks) {
await perfLogger.track(task.label, async () => task.task());
}
// setTimeout(() => {
// void populateDatabase(reg.db(), {
@@ -749,6 +813,10 @@ class Application extends BaseApplication {
// await runIntegrationTests();
// Used by tests
ipcRenderer.send('startup-finished');
startupTask.onEnd();
return null;
}

View File

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

View File

@@ -8,14 +8,15 @@ const MenuItem = bridge().MenuItem;
import Resource, { resourceOcrStatusToString } from '@joplin/lib/models/Resource';
import BaseItem from '@joplin/lib/models/BaseItem';
import BaseModel, { ModelType } from '@joplin/lib/BaseModel';
import { NoteEntity, ResourceEntity, ResourceOcrStatus } from '@joplin/lib/services/database/types';
import { NoteEntity, ResourceEntity, ResourceOcrDriverId, ResourceOcrStatus } from '@joplin/lib/services/database/types';
import { TinyMceEditorEvents } from '../NoteBody/TinyMCE/utils/types';
import { itemIsReadOnlySync, ItemSlice } from '@joplin/lib/models/utils/readOnly';
import Setting from '@joplin/lib/models/Setting';
import ItemChange from '@joplin/lib/models/ItemChange';
import shim from '@joplin/lib/shim';
import shim, { MessageBoxType } from '@joplin/lib/shim';
import { openFileWithExternalEditor } from '@joplin/lib/services/ExternalEditWatcher/utils';
import CommandService from '@joplin/lib/services/CommandService';
import SyncTargetRegistry from '@joplin/lib/SyncTargetRegistry';
const fs = require('fs-extra');
const { writeFile } = require('fs-extra');
const { clipboard } = require('electron');
@@ -137,6 +138,40 @@ export function menuItems(dispatch: Function): ContextMenuItems {
},
isActive: (itemType: ContextMenuItemType, options: ContextMenuOptions) => !!options.textToCopy && itemType === ContextMenuItemType.Image && options.mime?.startsWith('image/svg'),
},
recognizeHandwrittenImage: {
label: _('Recognize handwritten image'),
onAction: async (options: ContextMenuOptions) => {
const syncTargetId = Setting.value('sync.target');
if (!SyncTargetRegistry.isJoplinServerOrCloud(syncTargetId)) {
await shim.showMessageBox(_('This feature is only available on Joplin Cloud and Joplin Server.'), { type: MessageBoxType.Error });
return;
}
if (!Setting.value('ocr.handwrittenTextDriverEnabled')) {
await shim.showMessageBox(_('This feature is disabled by default, you need to manually enable it by turning on the option to \'Enable handwritten transcription\'.'), { type: MessageBoxType.Error });
return;
}
const { resource } = await resourceInfo(options);
if (!['image/png', 'image/jpg', 'image/jpeg', 'image/bmp'].includes(resource.mime)) {
await shim.showMessageBox(_('This image type is not supported by the recognition system.'), { type: MessageBoxType.Error });
return;
}
await Resource.save({
id: resource.id,
ocr_status: ResourceOcrStatus.Todo,
ocr_driver_id: ResourceOcrDriverId.HandwrittenText,
ocr_details: '',
ocr_error: '',
ocr_text: '',
});
},
isActive: (itemType: ContextMenuItemType, options: ContextMenuOptions) => {
return itemType === ContextMenuItemType.Resource || (itemType === ContextMenuItemType.Image && options.resourceId);
},
},
revealInFolder: {
label: _('Reveal file in folder'),
onAction: async (options: ContextMenuOptions) => {

View File

@@ -8,6 +8,7 @@ import { focus } from '@joplin/lib/utils/focusHandler';
import Dialog from './Dialog';
import { ChangeEvent } from 'react';
import { formatDateTimeLocalToMs, isValidDate } from '@joplin/utils/time';
import lightTheme from '@joplin/lib/themes/light';
interface Props {
themeId: number;
@@ -117,6 +118,15 @@ export default class PromptDialog extends React.Component<Props, any> {
borderColor: theme.dividerColor,
};
// The button to change the date/time cannot be customized easily so we need to use the
// light theme for that particular component.
this.styles_.dateTimeInput = {
...this.styles_.input,
color: lightTheme.color,
backgroundColor: lightTheme.backgroundColor,
borderColor: lightTheme.dividerColor,
};
this.styles_.select = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
control: (provided: any) => {
@@ -256,7 +266,7 @@ export default class PromptDialog extends React.Component<Props, any> {
onChange={onChange}
type="datetime-local"
className='datetime-picker'
style={styles.input}
style={styles.dateTimeInput}
/>;
} else if (this.props.inputType === 'tags') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied

View File

@@ -27,7 +27,7 @@ const CollapseExpandAllButton = (props: CollapseExpandAllButtonProps) => {
const icon = props.allFoldersCollapsed ? 'far fa-caret-square-right' : 'far fa-caret-square-down';
const label = props.allFoldersCollapsed ? _('Expand all notebooks') : _('Collapse all notebooks');
return <button onClick={() => onToggleAllFolders(props.allFoldersCollapsed)} className='sidebar-header-button -collapseall'>
return <button onClick={() => onToggleAllFolders(props.allFoldersCollapsed)} className='sidebar-header-button -collapseall' title={label}>
<i
aria-label={label}
role='img'
@@ -39,9 +39,11 @@ const CollapseExpandAllButton = (props: CollapseExpandAllButtonProps) => {
const NewFolderButton = () => {
// To allow it to be accessed by accessibility tools, the new folder button
// is not included in the portion of the list with role='tree'.
return <button onClick={onAddFolderButtonClick} className='sidebar-header-button -newfolder'>
const label = _('New notebook');
return <button onClick={onAddFolderButtonClick} className='sidebar-header-button -newfolder' title={label}>
<i
aria-label={_('New notebook')}
aria-label={label}
role='img'
className='fas fa-plus'
/>

View File

@@ -31,6 +31,7 @@ import FileApiDriverLocal from '@joplin/lib/file-api-driver-local';
import * as React from 'react';
import nodeSqlite = require('sqlite3');
import initLib from '@joplin/lib/initLib';
import PerformanceLogger from '@joplin/lib/PerformanceLogger';
const pdfJs = require('pdfjs-dist');
const { isAppleSilicon } = require('is-apple-silicon');
require('@sentry/electron/renderer');
@@ -38,6 +39,8 @@ require('@sentry/electron/renderer');
// Allows components to use React as a global
window.React = React;
const perfLogger = PerformanceLogger.create();
const main = async () => {
// eslint-disable-next-line no-console
@@ -106,7 +109,7 @@ const main = async () => {
}
};
main().catch((error) => {
perfLogger.track('main', main).catch((error) => {
const env = bridge().env();
console.error(error);

View File

@@ -1,6 +1,6 @@
{
"name": "@joplin/app-desktop",
"version": "3.4.2",
"version": "3.4.4",
"description": "Joplin for Desktop",
"main": "main.bundle.js",
"private": true,
@@ -133,7 +133,7 @@
"7zip-bin": "5.2.0",
"@axe-core/playwright": "4.10.1",
"@electron/notarize": "2.5.0",
"@electron/rebuild": "3.7.1",
"@electron/rebuild": "3.7.2",
"@fortawesome/fontawesome-free": "5.15.4",
"@joeattardi/emoji-button": "4.6.4",
"@joplin/default-plugins": "~3.4",
@@ -145,12 +145,13 @@
"@playwright/test": "1.51.1",
"@sentry/electron": "4.24.0",
"@testing-library/react-hooks": "8.0.1",
"@types/jest": "29.5.12",
"@types/mustache": "4.2.5",
"@types/jest": "29.5.14",
"@types/mustache": "4.2.6",
"@types/node": "18.19.87",
"@types/react": "18.3.20",
"@types/react-dom": "18.3.6",
"@types/react-dom": "18.3.7",
"@types/react-redux": "7.1.33",
"@types/semver": "^7.7.0",
"@types/styled-components": "5.1.32",
"@types/tesseract.js": "2.0.0",
"async-mutex": "0.5.0",
@@ -162,7 +163,7 @@
"debounce": "1.2.1",
"electron": "35.5.1",
"electron-builder": "24.13.3",
"electron-updater": "6.6.0",
"electron-updater": "6.6.2",
"electron-window-state": "5.0.3",
"esbuild": "^0.25.3",
"formatcoords": "1.1.3",
@@ -202,9 +203,9 @@
"taboverride": "4.0.3",
"tesseract.js": "5.1.1",
"tinymce": "6.8.5",
"ts-jest": "29.1.5",
"ts-jest": "29.3.1",
"ts-node": "10.9.2",
"typescript": "5.4.5"
"typescript": "5.8.2"
},
"dependencies": {
"@electron/remote": "2.1.2",

View File

@@ -345,8 +345,8 @@ class DialogComponent extends React.PureComponent<Props, State> {
return {
id: result.commandName,
title: result.title,
parent_id: null,
fields: [],
parent_id: null as string,
fields: [] as string[],
type: BaseModel.TYPE_COMMAND,
};
});

View File

@@ -51,10 +51,8 @@
const modulePath = args && args.length ? args[0] : null;
if (!modulePath) throw new Error('No module path specified on `require` call');
// The sqlite3 is actually part of the lib package so we need to do
// something convoluted to get it working.
if (modulePath === 'sqlite3') {
return require('../../node_modules/@joplin/lib/node_modules/sqlite3/lib/sqlite3.js');
return require('sqlite3');
}
if (modulePath === 'fs-extra') {

View File

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

View File

@@ -89,8 +89,8 @@ android {
applicationId "net.cozic.joplin"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 2097774
versionName "3.4.1"
versionCode 2097776
versionName "3.4.3"
ndk {
abiFilters "armeabi-v7a", "x86", "arm64-v8a", "x86_64"
}

View File

@@ -6,6 +6,12 @@ import com.facebook.react.ReactActivityDelegate
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
import com.facebook.react.defaults.DefaultReactActivityDelegate
import android.os.Build
import android.os.Bundle
import android.view.View
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
class MainActivity : ReactActivity() {
/**
@@ -20,4 +26,25 @@ class MainActivity : ReactActivity() {
*/
override fun createReactActivityDelegate(): ReactActivityDelegate =
ReactActivityDelegateWrapper(this, BuildConfig.IS_NEW_ARCHITECTURE_ENABLED, DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled))
/**
* This is a workaround to fix the upstream issue https://github.com/facebook/react-native/issues/49759#issuecomment-2918934967
*/
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (Build.VERSION.SDK_INT >= 35) {
val rootView = findViewById<View>(android.R.id.content)
ViewCompat.setOnApplyWindowInsetsListener(rootView) { _, insets ->
val innerPadding = insets.getInsets(WindowInsetsCompat.Type.ime())
rootView.setPadding(
innerPadding.left,
innerPadding.top,
innerPadding.right,
innerPadding.bottom
)
insets
}
}
}
}

View File

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

View File

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

View File

@@ -1,13 +1,14 @@
import * as React from 'react';
import {
forwardRef, Ref, useEffect, useImperativeHandle, useRef, useState,
forwardRef, Ref, useCallback, useEffect, useImperativeHandle, useRef, useState,
} from 'react';
import { Props, WebViewControl } from './types';
import { View, ViewStyle } from 'react-native';
import makeSandboxedIframe from '@joplin/lib/utils/dom/makeSandboxedIframe';
import Logger from '@joplin/utils/Logger';
import useCss from './utils/useCss';
const logger = Logger.create('ExtendedWebView');
@@ -20,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 = `

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,46 +1,49 @@
import Setting from '@joplin/lib/models/Setting';
import shim from '@joplin/lib/shim';
import { themeStyle } from '@joplin/lib/theme';
import themeToCss from '@joplin/lib/services/style/themeToCss';
import EditLinkDialog from './EditLinkDialog';
import { defaultSearchState, SearchPanel } from './SearchPanel';
import ExtendedWebView from '../ExtendedWebView';
import { WebViewControl } from '../ExtendedWebView/types';
import * as React from 'react';
import { forwardRef, RefObject, useEffect, useImperativeHandle } from 'react';
import { Ref, RefObject, useEffect, useImperativeHandle } from 'react';
import { useMemo, useState, useCallback, useRef } from 'react';
import { LayoutChangeEvent, NativeSyntheticEvent, View, ViewStyle } from 'react-native';
import { LayoutChangeEvent, View, ViewStyle } from 'react-native';
import { editorFont } from '../global-style';
import { EditorControl as EditorBodyControl, ContentScriptData } from '@joplin/editor/types';
import { EditorControl, EditorSettings, SelectionRange, WebViewToEditorApi } from './types';
import { EditorControl, EditorSettings, EditorType } from './types';
import { _ } from '@joplin/lib/locale';
import { ChangeEvent, EditorEvent, EditorEventType, SelectionRangeChangeEvent, UndoRedoDepthChangeEvent } from '@joplin/editor/events';
import { EditorCommandType, EditorKeymap, EditorLanguageType, SearchState } from '@joplin/editor/types';
import SelectionFormatting, { defaultSelectionFormatting } from '@joplin/editor/SelectionFormatting';
import useCodeMirrorPlugins from './hooks/useCodeMirrorPlugins';
import RNToWebViewMessenger from '../../utils/ipc/RNToWebViewMessenger';
import { WebViewErrorEvent } from 'react-native-webview/lib/RNCWebViewNativeComponent';
import Logger from '@joplin/utils/Logger';
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
import useEditorCommandHandler from './hooks/useEditorCommandHandler';
import { OnMessageEvent } from '../ExtendedWebView/types';
import { join, dirname } from 'path';
import * as mimeUtils from '@joplin/lib/mime-utils';
import uuid from '@joplin/lib/uuid';
import EditorToolbar from '../EditorToolbar/EditorToolbar';
import { SelectionRange } from '../../contentScripts/markdownEditorBundle/types';
import MarkdownEditor from './MarkdownEditor';
import RichTextEditor from './RichTextEditor';
import { ResourceInfos } from '@joplin/renderer/types';
import CommandService from '@joplin/lib/services/CommandService';
import Resource from '@joplin/lib/models/Resource';
import { join } from 'path';
import uuid from '@joplin/lib/uuid';
import shim from '@joplin/lib/shim';
import { dirname } from '@joplin/utils/path';
import { toFileExtension } from '@joplin/lib/mime-utils';
import { MarkupLanguage } from '@joplin/renderer';
import WarningBanner from './WarningBanner';
type ChangeEventHandler = (event: ChangeEvent)=> void;
type UndoRedoDepthChangeHandler = (event: UndoRedoDepthChangeEvent)=> void;
type SelectionChangeEventHandler = (event: SelectionRangeChangeEvent)=> void;
type OnAttachCallback = (filePath?: string)=> Promise<void>;
const logger = Logger.create('NoteEditor');
interface Props {
ref: Ref<EditorControl>;
themeId: number;
initialText: string;
mode: EditorType;
markupLanguage: MarkupLanguage;
noteId: string;
noteHash: string;
globalSearch: string;
@@ -49,6 +52,7 @@ interface Props {
toolbarEnabled: boolean;
readOnly: boolean;
plugins: PluginStates;
noteResources: ResourceInfos;
onChange: ChangeEventHandler;
onSelectionChange: SelectionChangeEventHandler;
@@ -61,103 +65,6 @@ function fontFamilyFromSettings() {
return font ? `${font}, sans-serif` : 'sans-serif';
}
function useCss(themeId: number): string {
return useMemo(() => {
const theme = themeStyle(themeId);
const themeVariableCss = themeToCss(theme);
return `
${themeVariableCss}
:root {
background-color: ${theme.backgroundColor};
}
body {
margin: 0;
height: 100vh;
/* Prefer 100% -- 100vw shows an unnecessary horizontal scrollbar in Google Chrome (desktop). */
width: 100%;
box-sizing: border-box;
padding-left: 1px;
padding-right: 1px;
padding-bottom: 1px;
padding-top: 10px;
font-size: 13pt;
}
* {
scrollbar-width: thin;
scrollbar-color: rgba(100, 100, 100, 0.7) rgba(0, 0, 0, 0.1);
}
@supports selector(::-webkit-scrollbar) {
*::-webkit-scrollbar {
width: 7px;
height: 7px;
}
*::-webkit-scrollbar-corner {
background: none;
}
*::-webkit-scrollbar-track {
border: none;
}
*::-webkit-scrollbar-thumb {
background: rgba(100, 100, 100, 0.3);
border-radius: 5px;
}
*::-webkit-scrollbar-track:hover {
background: rgba(0, 0, 0, 0.1);
}
*::-webkit-scrollbar-thumb:hover {
background: rgba(100, 100, 100, 0.7);
}
* {
scrollbar-width: unset;
scrollbar-color: unset;
}
}
`;
}, [themeId]);
}
const themeStyleSheetClassName = 'note-editor-styles';
function useHtml(initialCss: string): string {
const cssRef = useRef(initialCss);
cssRef.current = initialCss;
return useMemo(() => `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>${_('Note editor')}</title>
<style>
/* For better scrolling on iOS (working scrollbar) we use external, rather than internal,
scrolling. */
.cm-scroller {
overflow: none;
}
</style>
<style class=${JSON.stringify(themeStyleSheetClassName)}>
${cssRef.current}
</style>
</head>
<body>
<div class="CodeMirror" style="height:100%;" autocapitalize="on"></div>
</body>
</html>
`, []);
}
function editorTheme(themeId: number) {
const fontSizeInPx = Setting.value('style.editor.fontSize');
@@ -167,6 +74,7 @@ function editorTheme(themeId: number) {
const estimatedFontSizeInEm = fontSizeInPx / 16;
return {
themeId,
...themeStyle(themeId),
// To allow accessibility font scaling, we also need to set the
@@ -181,54 +89,54 @@ function editorTheme(themeId: number) {
type OnSetVisibleCallback = (visible: boolean)=> void;
type OnSearchStateChangeCallback = (state: SearchState)=> void;
const useEditorControl = (
bodyControl: EditorBodyControl,
editorRef: RefObject<EditorBodyControl>,
webviewRef: RefObject<WebViewControl>,
setLinkDialogVisible: OnSetVisibleCallback,
setSearchState: OnSearchStateChangeCallback,
): EditorControl => {
return useMemo(() => {
const execEditorCommand = (command: EditorCommandType) => {
void bodyControl.execCommand(command);
void editorRef.current.execCommand(command);
};
const setSearchStateCallback = (state: SearchState) => {
bodyControl.setSearchState(state);
editorRef.current.setSearchState(state);
setSearchState(state);
};
const control: EditorControl = {
supportsCommand(command: EditorCommandType) {
return bodyControl.supportsCommand(command);
return editorRef.current.supportsCommand(command);
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
execCommand(command, ...args: any[]) {
return bodyControl.execCommand(command, ...args);
return editorRef.current.execCommand(command, ...args);
},
focus() {
void bodyControl.execCommand(EditorCommandType.Focus);
void editorRef.current.execCommand(EditorCommandType.Focus);
},
undo() {
bodyControl.undo();
editorRef.current.undo();
},
redo() {
bodyControl.redo();
editorRef.current.redo();
},
select(anchor: number, head: number) {
bodyControl.select(anchor, head);
editorRef.current.select(anchor, head);
},
setScrollPercent(fraction: number) {
bodyControl.setScrollPercent(fraction);
editorRef.current.setScrollPercent(fraction);
},
insertText(text: string) {
bodyControl.insertText(text);
editorRef.current.insertText(text);
},
updateBody(newBody: string) {
bodyControl.updateBody(newBody);
editorRef.current.updateBody(newBody);
},
updateSettings(newSettings: EditorSettings) {
bodyControl.updateSettings(newSettings);
editorRef.current.updateSettings(newSettings);
},
toggleBolded() {
@@ -276,7 +184,7 @@ const useEditorControl = (
execEditorCommand(EditorCommandType.IndentLess);
},
updateLink(label: string, url: string) {
bodyControl.updateLink(label, url);
editorRef.current.updateLink(label, url);
},
scrollSelectionIntoView() {
execEditorCommand(EditorCommandType.ScrollSelectionIntoView);
@@ -292,7 +200,7 @@ const useEditorControl = (
},
setContentScripts: async (plugins: ContentScriptData[]) => {
return bodyControl.setContentScripts(plugins);
return editorRef.current.setContentScripts(plugins);
},
setSearchState: setSearchStateCallback,
@@ -320,37 +228,25 @@ const useEditorControl = (
setSearchState: setSearchStateCallback,
},
onResourceDownloaded: (id: string) => {
editorRef.current.onResourceDownloaded(id);
},
};
return control;
}, [webviewRef, bodyControl, setLinkDialogVisible, setSearchState]);
}, [webviewRef, editorRef, setLinkDialogVisible, setSearchState]);
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
function NoteEditor(props: Props, ref: any) {
function NoteEditor(props: Props) {
const webviewRef = useRef<WebViewControl>(null);
const setInitialSelectionJs = props.initialSelection ? `
cm.select(${props.initialSelection.start}, ${props.initialSelection.end});
cm.execCommand('scrollSelectionIntoView');
` : '';
const jumpToHashJs = props.noteHash ? `
cm.jumpToHash(${JSON.stringify(props.noteHash)});
` : '';
const setInitialSearchJs = props.globalSearch ? `
cm.setSearchState(${JSON.stringify({
...defaultSearchState,
searchText: props.globalSearch,
})})
` : '';
const editorSettings: EditorSettings = useMemo(() => ({
themeId: props.themeId,
themeData: editorTheme(props.themeId),
markdownMarkEnabled: Setting.value('markdown.plugin.mark'),
katexEnabled: Setting.value('markdown.plugin.katex'),
spellcheckEnabled: Setting.value('editor.mobile.spellcheckEnabled'),
language: EditorLanguageType.Markdown,
language: props.markupLanguage === MarkupLanguage.Html ? EditorLanguageType.Html : EditorLanguageType.Markdown,
useExternalSearch: true,
readOnly: props.readOnly,
@@ -365,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;

View File

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

View File

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

View File

@@ -116,6 +116,7 @@ const useStyles = (theme: Theme) => {
backgroundColor: theme.backgroundColor3,
},
input: {
flexBasis: 0,
flexGrow: 1,
height: buttonSize,
backgroundColor: theme.backgroundColor4,
@@ -136,6 +137,13 @@ const useStyles = (theme: Theme) => {
justifyContent: 'center',
marginLeft: 10,
},
panelContainer: {
// Workaround for the editor disappearing when dismissing search on Android.
// See https://github.com/laurent22/joplin/issues/12781
//
// It may be possible to remove this line after upgrading to React Native's New Architecture.
borderColor: 'transparent',
},
});
}, [theme]);
};
@@ -171,6 +179,7 @@ export const SearchPanel = (props: SearchPanelProps) => {
returnKeyType='search'
blurOnSubmit={false}
onSubmitEditing={control.findNext}
selectTextOnFocus={true}
/>
);
};
@@ -192,7 +201,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}
@@ -366,7 +375,9 @@ export const SearchPanel = (props: SearchPanelProps) => {
return null;
}
return showingAdvanced ? advancedLayout : simpleLayout;
return <View style={styles.panelContainer}>
{showingAdvanced ? advancedLayout : simpleLayout}
</View>;
};
export default SearchPanel;

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import * as React from 'react';
import { PureComponent, ReactElement } from 'react';
import { connect } from 'react-redux';
import { View, Text, StyleSheet, TouchableOpacity, Image, ViewStyle, Platform } from 'react-native';
import { View, Text, StyleSheet, TouchableOpacity, ViewStyle, Platform } from 'react-native';
const Icon = require('react-native-vector-icons/Ionicons').default;
import BackButtonService from '../../services/BackButtonService';
import NavService from '@joplin/lib/services/NavService';
@@ -129,7 +129,10 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
flex: 0,
flexDirection: 'row',
alignItems: 'center',
padding: 10,
justifyContent: 'center',
minWidth: 40,
minHeight: 40,
borderWidth: 1,
borderColor: theme.colorBright2,
borderRadius: 4,
@@ -147,8 +150,9 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
height: 18,
},
saveButtonIcon: {
width: 18,
height: 18,
...theme.icon,
fontSize: 25,
color: theme.colorBright2,
},
contextMenuTrigger: {
fontSize: 30,
@@ -297,18 +301,18 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
) {
if (!show) return null;
const icon = disabled ? <Icon name="checkmark" style={styles.savedButtonIcon} /> : <Image style={styles.saveButtonIcon} source={require('./SaveIcon.png')} />;
return (
<TouchableOpacity
<IconButton
onPress={onPress}
disabled={disabled}
style={{ padding: 0 }}
accessibilityLabel={_('Save changes')}
accessibilityRole="button">
<View style={disabled ? styles.saveButtonDisabled : styles.saveButton}>{icon}</View>
</TouchableOpacity>
themeId={themeId}
description={_('Save changes')}
disabled={disabled}
contentWrapperStyle={disabled ? styles.saveButtonDisabled : styles.saveButton}
iconStyle={disabled ? styles.savedButtonIcon : styles.saveButtonIcon}
iconName={disabled ? 'ionicon checkmark' : 'material content-save'}
/>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,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');
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,32 @@
const TurndownService = require('@joplin/turndown');
const turndownPluginGfm = require('@joplin/turndown-plugin-gfm').gfm;
// Avoid using @joplin/lib/HtmlToMd here. HtmlToMd may cause several megabytes
// of additional JavaScript and supporting data to be included.
const convertHtmlToMarkdown = (html: string|HTMLElement) => {
const turndownOpts = {
headingStyle: 'atx',
codeBlockStyle: 'fenced',
preserveImageTagsWithSize: true,
preserveNestedTables: true,
preserveColorStyles: true,
bulletListMarker: '-',
emDelimiter: '*',
strongDelimiter: '**',
allowResourcePlaceholders: true,
// If soft-breaks are enabled, lines need to end with two or more spaces for
// trailing <br/>s to render. See
// https://github.com/laurent22/joplin/issues/8430
br: ' ',
};
const turndown = new TurndownService(turndownOpts);
turndown.use(turndownPluginGfm);
turndown.remove('script');
turndown.remove('style');
const md = turndown.turndown(html);
return md;
};
export default convertHtmlToMarkdown;

View File

@@ -0,0 +1,130 @@
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/types';
import '@joplin/editor/ProseMirror/styles';
import readFileToBase64 from '../../utils/readFileToBase64';
import { EditorLanguageType } from '@joplin/editor/types';
import convertHtmlToMarkdown from './convertHtmlToMarkdown';
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 htmlToMarkdown = (html: HTMLElement): string => {
html = postprocessHtml(html);
return convertHtmlToMarkdown(html);
};
export const initialize = async ({
settings,
initialText,
initialNoteId,
parentElementClassName,
}: EditorProps) => {
const messenger = new WebViewToRNMessenger<EditorProcessApi, MainProcessApi>('rich-text-editor', null);
const parentElement = document.getElementsByClassName(parentElementClassName)[0];
if (!parentElement) throw new Error('Parent element not found');
if (!(parentElement instanceof HTMLElement)) {
throw new Error('Parent node is not an element.');
}
const assetContainer = document.createElement('div');
assetContainer.id = 'joplin-container-pluginAssetsContainer';
document.body.appendChild(assetContainer);
const editor = await createEditor(parentElement, {
settings,
initialText,
initialNoteId,
onPasteFile: async (data) => {
const base64 = await readFileToBase64(data);
await messenger.remoteApi.onPasteFile(data.type, base64);
},
onLogMessage: (message: string) => {
void messenger.remoteApi.logMessage(message);
},
onEvent: (event) => {
void messenger.remoteApi.onEditorEvent(event);
},
}, {
renderMarkupToHtml: async (markup) => {
return await messenger.remoteApi.onRender({
markup,
language: settings.language === EditorLanguageType.Html ? MarkupLanguage.Html : MarkupLanguage.Markdown,
}, {
pluginAssetContainerSelector: `#${assetContainer.id}`,
splitted: true,
mapsToLine: true,
});
},
renderHtmlToMarkup: (node) => {
// By default, if `src` is specified on an image, the browser will try to load the image, even if it isn't added
// to the DOM. (A similar problem is described here: https://stackoverflow.com/q/62019538).
// Since :/resourceId isn't a valid image URI, this results in a large number of warnings. As a workaround,
// move the element to a temporary document before processing:
const dom = document.implementation.createHTMLDocument();
node = dom.importNode(node, true);
let html: HTMLElement;
if ((node instanceof HTMLElement)) {
html = node;
} else {
const container = document.createElement('div');
container.appendChild(html);
html = container;
}
if (settings.language === EditorLanguageType.Markdown) {
return htmlToMarkdown(wrapHtmlForMarkdownConversion(html));
} else {
return postprocessHtml(html).outerHTML;
}
},
});
messenger.setLocalInterface({
editor,
});
return editor;
};
export { default as setUpLogger } from '../../utils/setUpLogger';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -363,7 +363,6 @@
"${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/boost/boost_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/glog/glog_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/react-native-image-picker/RNImagePickerPrivacyInfo.bundle",
);
name = "[CP] Copy Pods Resources";
@@ -395,7 +394,6 @@
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/boost_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/glog_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNImagePickerPrivacyInfo.bundle",
);
runOnlyForDeploymentPostprocessing = 0;

View File

@@ -1408,7 +1408,7 @@ PODS:
- ReactCommon/turbomodule/core
- react-native-alarm-notification (3.4.0):
- React
- react-native-document-picker (10.1.2):
- react-native-document-picker (10.1.3):
- DoubleConversion
- glog
- hermes-engine
@@ -2285,7 +2285,7 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90
DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb
DoubleConversion: 76ab83afb40bddeeee456813d9c04f67f78771b5
EXAV: ae28256069c4cdde93d185c007d8f68d92902c2e
EXConstants: 98bcf0f22b820f9b28f9fee55ff2daededadd2f8
Expo: 4b1c6de7c441e1caa1918671ae0aa34d51f019a5
@@ -2298,7 +2298,7 @@ SPEC CHECKSUMS:
fast_float: 06eeec4fe712a76acc9376682e4808b05ce978b6
FBLazyVector: 84b955f7b4da8b895faf5946f73748267347c975
fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd
glog: 5683914934d5b6e4240e497e0f4a3b42d1854183
glog: c5d68082e772fa1c511173d6b30a9de2c05a69a2
hermes-engine: 314be5250afa5692b57b4dd1705959e1973a8ebe
JoplinCommonShareExtension: a8b60b02704d85a7305627912c0240e94af78db7
JoplinRNShareExtension: e158a4b53ee0aa9cd3037a16221dc8adbd6f7860
@@ -2335,7 +2335,7 @@ SPEC CHECKSUMS:
React-Mapbuffer: c3f4b608e4a59dd2f6a416ef4d47a14400194468
React-microtasksnativemodule: 054f34e9b82f02bd40f09cebd4083828b5b2beb6
react-native-alarm-notification: fd7c73a3dc15ce2d5bd9b28dfaa5aa2e25850c7b
react-native-document-picker: 3491100f1048571593b284b74b93ce99b14e20a2
react-native-document-picker: da39c5e4f279d39c0356dca157b98f9dc349e5bb
react-native-geolocation: ec15ffebc53790314885eb9e5f2132132fbc2600
react-native-get-random-values: d16467cf726c618e9c7a8c3c39c31faa2244bbba
react-native-image-picker: 99fbcec11cf4679170a7cfba4e4d9f598297448c

View File

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

View File

@@ -33,7 +33,7 @@
"@react-native-community/geolocation": "3.4.0",
"@react-native-community/netinfo": "11.4.1",
"@react-native-community/push-notification-ios": "1.11.0",
"@react-native-documents/picker": "10.1.2",
"@react-native-documents/picker": "10.1.3",
"assert-browserify": "2.0.0",
"buffer": "6.0.3",
"color": "3.2.1",
@@ -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",
@@ -103,16 +105,17 @@
"@sqlite.org/sqlite-wasm": "3.46.0-build2",
"@testing-library/react-native": "13.2.0",
"@types/fs-extra": "11.0.4",
"@types/jest": "29.5.12",
"@types/jest": "29.5.14",
"@types/node": "18.19.87",
"@types/react": "19.0.14",
"@types/react-redux": "7.1.33",
"@types/serviceworker": "0.0.132",
"@types/serviceworker": "0.0.133",
"@types/tar-stream": "3.1.3",
"babel-jest": "29.7.0",
"babel-loader": "9.1.3",
"babel-plugin-module-resolver": "4.1.0",
"babel-plugin-react-native-web": "0.19.12",
"babel-plugin-react-native-web": "0.20.0",
"esbuild": "0.25.3",
"fast-deep-equal": "3.1.3",
"fs-extra": "11.2.0",
"gulp": "4.0.2",
@@ -120,17 +123,17 @@
"jest-environment-jsdom": "29.7.0",
"jetifier": "2.0.0",
"js-draw": "1.30.0",
"jsdom": "25.0.1",
"jsdom": "26.0.0",
"nodemon": "3.1.10",
"punycode": "2.3.1",
"react-dom": "19.0.0",
"react-native-web": "0.20.0",
"react-refresh": "0.16.0",
"react-refresh": "0.17.0",
"react-test-renderer": "19.0.0",
"sharp": "0.33.5",
"sharp": "0.34.0",
"sqlite3": "5.1.6",
"timers-browserify": "2.0.12",
"ts-jest": "29.1.5",
"ts-jest": "29.3.1",
"ts-loader": "9.5.2",
"ts-node": "10.9.2",
"typescript": "5.8.3",

View File

@@ -110,7 +110,7 @@ import buildStartupTasks from './utils/buildStartupTasks';
import { SafeAreaProvider } from 'react-native-safe-area-context';
const logger = Logger.create('root');
const perfLogger = PerformanceLogger.create('root');
const perfLogger = PerformanceLogger.create();
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
let storeDispatch: any = function(_action: any) {};
@@ -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)}`;
@@ -593,7 +597,7 @@ class AppComponent extends React.Component<AppComponentProps, AppComponentState>
}
try {
await perfLogger.track('initialize', () => initialize(this.props.dispatch));
await perfLogger.track('root/initialize', () => initialize(this.props.dispatch));
} catch (error) {
alert(`Something went wrong while starting the application: ${error}`);
this.props.dispatch({
@@ -669,11 +673,11 @@ class AppComponent extends React.Component<AppComponentProps, AppComponentState>
);
onSystemColorSchemeChange(Appearance.getColorScheme());
this.quickActionShortcutListener_ = await perfLogger.track('setupQuickActions',
this.quickActionShortcutListener_ = await perfLogger.track('root/setupQuickActions',
() => setupQuickActions(this.props.dispatch),
);
await perfLogger.track('setupNotifications',
await perfLogger.track('root/setupNotifications',
() => setupNotifications(this.props.dispatch),
);

View File

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

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