You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-08-24 20:19:10 +02:00
Compare commits
85 Commits
android-v3
...
renovate/m
Author | SHA1 | Date | |
---|---|---|---|
|
7c3fb47b44 | ||
|
d3954e769f | ||
|
4d8a16bda7 | ||
|
f725d3895f | ||
|
0e19dce0d1 | ||
|
31c5058d5e | ||
|
4d760303bc | ||
|
23e63e5fec | ||
|
3880352f53 | ||
|
42a3c40702 | ||
|
8e585640e7 | ||
|
cd3fb4e7ad | ||
|
702b5b3c63 | ||
|
0ad9e8f112 | ||
|
a80406dcb7 | ||
|
ea8b6485d8 | ||
|
1a2ef78726 | ||
|
63d5ffc796 | ||
|
15918a57aa | ||
|
032e8b5596 | ||
|
ee091ede52 | ||
|
763e3f7479 | ||
|
0089c62493 | ||
|
20d6d56c02 | ||
|
8b999f8dc6 | ||
|
0067ac126d | ||
|
c6f47a9084 | ||
|
22817317f1 | ||
|
9ba1c0db4e | ||
|
70d6c1225c | ||
|
b1f013a8c2 | ||
|
8c66349907 | ||
|
86b4f713ee | ||
|
f50dc6a536 | ||
|
825ce51a3c | ||
|
c5b6f0bca1 | ||
|
86934d502e | ||
|
c63ad17f98 | ||
|
c746b5fdc2 | ||
|
949fb85755 | ||
|
0f94cb8c17 | ||
|
7ba61bb585 | ||
|
00e4657a39 | ||
|
cbdc98553a | ||
|
e3c2589a12 | ||
|
56b3cc3dc2 | ||
|
d59a09fd29 | ||
|
5a64222276 | ||
|
012297d52a | ||
|
5e70bce2c3 | ||
|
4c3eca1f18 | ||
|
c899f63a41 | ||
|
c838b86413 | ||
|
90d6d1747a | ||
|
6e8ba8a536 | ||
|
ffeb5f887a | ||
|
65bde86263 | ||
|
1c236ca73c | ||
|
2881280100 | ||
|
954b48b779 | ||
|
53e7b672b0 | ||
|
ceaaab77e8 | ||
|
c29bbe96f7 | ||
|
db323ac585 | ||
|
dc8e3242f3 | ||
|
9705941538 | ||
|
0cf9981ac7 | ||
|
b93ee3469b | ||
|
73e5bc74a5 | ||
|
6c761b3fb4 | ||
|
e13985a952 | ||
|
83f1fcc228 | ||
|
b03e370d2b | ||
|
4ddd5c4558 | ||
|
7746694dca | ||
|
f1ac95a1c7 | ||
|
78e9ced96c | ||
|
bba6ede569 | ||
|
7a26d4f336 | ||
|
04a976e459 | ||
|
ecfef1a9da | ||
|
e422a88bb0 | ||
|
74ef89d25b | ||
|
28b7251e16 | ||
|
9218c7df1f |
17
.env-sample
17
.env-sample
@@ -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
|
||||
# -----------------------------------------------------------------------------
|
||||
|
@@ -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
|
103
.eslintignore
103
.eslintignore
@@ -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
|
||||
|
@@ -23,6 +23,7 @@ module.exports = {
|
||||
'FileSystemCreateWritableOptions': 'readonly',
|
||||
'FileSystemHandle': 'readonly',
|
||||
'IDBTransactionMode': 'readonly',
|
||||
'FlatArray': 'readonly',
|
||||
'BigInt': 'readonly',
|
||||
'globalThis': 'readonly',
|
||||
|
||||
|
32
.github/scripts/run_ci.sh
vendored
32
.github/scripts/run_ci.sh
vendored
@@ -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..."
|
||||
|
||||
|
5
.github/workflows/github-actions-main.yml
vendored
5
.github/workflows/github-actions-main.yml
vendored
@@ -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
101
.gitignore
vendored
@@ -638,6 +638,7 @@ packages/app-mobile/components/ExtendedWebView/index.jest.js
|
||||
packages/app-mobile/components/ExtendedWebView/index.js
|
||||
packages/app-mobile/components/ExtendedWebView/index.web.js
|
||||
packages/app-mobile/components/ExtendedWebView/types.js
|
||||
packages/app-mobile/components/ExtendedWebView/utils/useCss.js
|
||||
packages/app-mobile/components/FolderPicker.js
|
||||
packages/app-mobile/components/Icon.js
|
||||
packages/app-mobile/components/IconButton.js
|
||||
@@ -646,42 +647,28 @@ packages/app-mobile/components/ModalDialog.js
|
||||
packages/app-mobile/components/NestableFlatList.js
|
||||
packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.test.js
|
||||
packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.js
|
||||
packages/app-mobile/components/NoteBodyViewer/bundledJs/Renderer.test.js
|
||||
packages/app-mobile/components/NoteBodyViewer/bundledJs/Renderer.js
|
||||
packages/app-mobile/components/NoteBodyViewer/bundledJs/noteBodyViewerBundle.js
|
||||
packages/app-mobile/components/NoteBodyViewer/bundledJs/types.js
|
||||
packages/app-mobile/components/NoteBodyViewer/bundledJs/utils/addPluginAssets.js
|
||||
packages/app-mobile/components/NoteBodyViewer/bundledJs/utils/makeResourceModel.js
|
||||
packages/app-mobile/components/NoteBodyViewer/hooks/useContentScripts.js
|
||||
packages/app-mobile/components/NoteBodyViewer/hooks/useEditPopup.test.js
|
||||
packages/app-mobile/components/NoteBodyViewer/hooks/useEditPopup.js
|
||||
packages/app-mobile/components/NoteBodyViewer/hooks/useOnMessage.js
|
||||
packages/app-mobile/components/NoteBodyViewer/hooks/useOnResourceLongPress.js
|
||||
packages/app-mobile/components/NoteBodyViewer/hooks/useRenderer.js
|
||||
packages/app-mobile/components/NoteBodyViewer/hooks/useRerenderHandler.js
|
||||
packages/app-mobile/components/NoteBodyViewer/hooks/useSource.js
|
||||
packages/app-mobile/components/NoteBodyViewer/types.js
|
||||
packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.js
|
||||
packages/app-mobile/components/NoteEditor/EditLinkDialog.js
|
||||
packages/app-mobile/components/NoteEditor/ImageEditor/ImageEditor.js
|
||||
packages/app-mobile/components/NoteEditor/ImageEditor/autosave.js
|
||||
packages/app-mobile/components/NoteEditor/ImageEditor/isEditableResource.js
|
||||
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/applyTemplateToEditor.js
|
||||
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/createJsDrawEditor.test.js
|
||||
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/createJsDrawEditor.js
|
||||
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/polyfills.js
|
||||
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/startAutosaveLoop.js
|
||||
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/types.js
|
||||
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/watchEditorForTemplateChanges.js
|
||||
packages/app-mobile/components/NoteEditor/ImageEditor/promptRestoreAutosave.js
|
||||
packages/app-mobile/components/NoteEditor/ImageEditor/utils/useEditorMessenger.js
|
||||
packages/app-mobile/components/NoteEditor/MarkdownEditor.js
|
||||
packages/app-mobile/components/NoteEditor/NoteEditor.test.js
|
||||
packages/app-mobile/components/NoteEditor/NoteEditor.js
|
||||
packages/app-mobile/components/NoteEditor/RichTextEditor.test.js
|
||||
packages/app-mobile/components/NoteEditor/RichTextEditor.js
|
||||
packages/app-mobile/components/NoteEditor/SearchPanel.js
|
||||
packages/app-mobile/components/NoteEditor/WarningBanner.js
|
||||
packages/app-mobile/components/NoteEditor/commandDeclarations.js
|
||||
packages/app-mobile/components/NoteEditor/hooks/useCodeMirrorPlugins.js
|
||||
packages/app-mobile/components/NoteEditor/hooks/useEditorCommandHandler.test.js
|
||||
packages/app-mobile/components/NoteEditor/hooks/useEditorCommandHandler.js
|
||||
packages/app-mobile/components/NoteEditor/testing/createTestEditorProps.js
|
||||
packages/app-mobile/components/NoteEditor/types.js
|
||||
packages/app-mobile/components/NoteItem.js
|
||||
packages/app-mobile/components/NoteList.js
|
||||
@@ -836,6 +823,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
|
||||
|
@@ -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>
|
||||
|
@@ -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 .
|
||||
|
@@ -5,7 +5,7 @@
|
||||
"version": "latest",
|
||||
"platforms": ["aarch64-darwin", "x86_64-darwin"],
|
||||
},
|
||||
"yarn": "latest",
|
||||
"yarn": "1.22.19",
|
||||
"vips.dev": {
|
||||
"platforms": ["aarch64-darwin"],
|
||||
},
|
||||
|
@@ -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}
|
||||
|
||||
|
24
package.json
24
package.json
@@ -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",
|
||||
|
@@ -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"
|
||||
}
|
||||
}
|
||||
|
@@ -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', () => {
|
||||
|
||||
|
@@ -0,0 +1,13 @@
|
||||
<p>A task list created by the TipTap editor:</p>
|
||||
<ul data-type="taskList">
|
||||
<li><label contenteditable="false"><input type="checkbox"><span></span></label>
|
||||
<div>
|
||||
<p>Testing...</p>
|
||||
</div>
|
||||
</li>
|
||||
<li><label contenteditable="false"><input type="checkbox"><span></span></label>
|
||||
<div>
|
||||
<p>testing</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
@@ -0,0 +1,5 @@
|
||||
A task list created by the TipTap editor:
|
||||
|
||||
- [ ] Testing...
|
||||
|
||||
- [ ] testing
|
26
packages/app-cli/tests/html_to_md/task_lists.html
Normal file
26
packages/app-cli/tests/html_to_md/task_lists.html
Normal file
@@ -0,0 +1,26 @@
|
||||
<p>List 1:</p>
|
||||
<ul>
|
||||
<li><label><input type="checkbox"/>This</label></li>
|
||||
<li><label><input type="checkbox" checked/>is a test.</label></li>
|
||||
</ul>
|
||||
<p>List 2:</p>
|
||||
<ul>
|
||||
<li>
|
||||
<input type="checkbox" id="checkbox-1"/><label for="checkbox-1">This</label>
|
||||
</li>
|
||||
<li>
|
||||
<input type="checkbox" checked id="checkbox-2"/><label for="checkbox-2">is another test.</label>
|
||||
</li>
|
||||
</ul>
|
||||
<p>List 3:</p>
|
||||
<ul>
|
||||
<li>
|
||||
<input type="checkbox" id="checkbox-a1"/><label for="checkbox-a1">This</label>
|
||||
</li>
|
||||
<li>
|
||||
<input type="checkbox" checked id="checkbox-a2"/><label for="checkbox-a2">is another test.</label>
|
||||
</li>
|
||||
<li>
|
||||
<input type="checkbox" checked id="checkbox-a3"/><label for="checkbox-a3"></label>
|
||||
</li>
|
||||
</ul>
|
15
packages/app-cli/tests/html_to_md/task_lists.md
Normal file
15
packages/app-cli/tests/html_to_md/task_lists.md
Normal file
@@ -0,0 +1,15 @@
|
||||
List 1:
|
||||
|
||||
- [ ] This
|
||||
- [x] is a test.
|
||||
|
||||
List 2:
|
||||
|
||||
- [ ] This
|
||||
- [x] is another test.
|
||||
|
||||
List 3:
|
||||
|
||||
- [ ] This
|
||||
- [x] is another test.
|
||||
- [x]
|
@@ -1,7 +1,7 @@
|
||||
<ul class="joplin-checklist">
|
||||
<ul class="joplin-checklist" data-is-checklist="1">
|
||||
<li>Not checked</li>
|
||||
<li class="checked">Checked!!
|
||||
<ul class="joplin-checklist">
|
||||
<ul class="joplin-checklist" data-is-checklist="1">
|
||||
<li class="checked">Indented, with <strong>bold</strong></li>
|
||||
<li>Indented, not checked</li>
|
||||
</ul>
|
||||
|
@@ -1,15 +1,15 @@
|
||||
<div class="not-loaded-resource not-loaded-image-resource resource-status-notDownloaded" data-resource-id="a1test2a1test2a1test2a1test22345" data-original-alt data-original-title="test" contenteditable="false"><img src="data:image/svg+xml;utf8,
|
||||
<span class="not-loaded-resource not-loaded-image-resource resource-status-notDownloaded" data-resource-id="a1test2a1test2a1test2a1test22345" data-original-alt data-original-title="test" contenteditable="false"><img src="data:image/svg+xml;utf8,
|
||||
		<svg width="1700" height="1536" xmlns="http://www.w3.org/2000/svg">
|
||||
		 <path d="M1280 1344c0-35-29-64-64-64s-64 29-64 64 29 64 64 64 64-29 64-64zm256 0c0-35-29-64-64-64s-64 29-64 64 29 64 64 64 64-29 64-64zm128-224v320c0 53-43 96-96 96H96c-53 0-96-43-96-96v-320c0-53 43-96 96-96h465l135 136c37 36 85 56 136 56s99-20 136-56l136-136h464c53 0 96 43 96 96zm-325-569c10 24 5 52-14 70l-448 448c-12 13-29 19-45 19s-33-6-45-19L339 621c-19-18-24-46-14-70 10-23 33-39 59-39h256V64c0-35 29-64 64-64h256c35 0 64 29 64 64v448h256c26 0 49 16 59 39z"/>
|
||||
		</svg>
|
||||
	"/></div>
|
||||
<div class="not-loaded-resource not-loaded-image-resource resource-status-notDownloaded" data-resource-id="a1test2a1test2a1test2a1test22346" data-original-alt="test" data-original-title contenteditable="false"><img src="data:image/svg+xml;utf8,
|
||||
	"/></span>
|
||||
<span class="not-loaded-resource not-loaded-image-resource resource-status-notDownloaded" data-resource-id="a1test2a1test2a1test2a1test22346" data-original-alt="test" data-original-title contenteditable="false"><img src="data:image/svg+xml;utf8,
|
||||
		<svg width="1700" height="1536" xmlns="http://www.w3.org/2000/svg">
|
||||
		 <path d="M1280 1344c0-35-29-64-64-64s-64 29-64 64 29 64 64 64 64-29 64-64zm256 0c0-35-29-64-64-64s-64 29-64 64 29 64 64 64 64-29 64-64zm128-224v320c0 53-43 96-96 96H96c-53 0-96-43-96-96v-320c0-53 43-96 96-96h465l135 136c37 36 85 56 136 56s99-20 136-56l136-136h464c53 0 96 43 96 96zm-325-569c10 24 5 52-14 70l-448 448c-12 13-29 19-45 19s-33-6-45-19L339 621c-19-18-24-46-14-70 10-23 33-39 59-39h256V64c0-35 29-64 64-64h256c35 0 64 29 64 64v448h256c26 0 49 16 59 39z"/>
|
||||
		</svg>
|
||||
	"/></div>
|
||||
<div class="not-loaded-resource not-loaded-image-resource resource-status-notDownloaded" data-resource-id="a1test2a1test2a1test2a1test22347" data-original-before=" " data-original-after=" class="jop-noMdConv"/" contenteditable="false"><img src="data:image/svg+xml;utf8,
|
||||
	"/></span>
|
||||
<span class="not-loaded-resource not-loaded-image-resource resource-status-notDownloaded" data-resource-id="a1test2a1test2a1test2a1test22347" data-original-before=" " data-original-after=" class="jop-noMdConv"/" contenteditable="false"><img src="data:image/svg+xml;utf8,
|
||||
		<svg width="1700" height="1536" xmlns="http://www.w3.org/2000/svg">
|
||||
		 <path d="M1280 1344c0-35-29-64-64-64s-64 29-64 64 29 64 64 64 64-29 64-64zm256 0c0-35-29-64-64-64s-64 29-64 64 29 64 64 64 64-29 64-64zm128-224v320c0 53-43 96-96 96H96c-53 0-96-43-96-96v-320c0-53 43-96 96-96h465l135 136c37 36 85 56 136 56s99-20 136-56l136-136h464c53 0 96 43 96 96zm-325-569c10 24 5 52-14 70l-448 448c-12 13-29 19-45 19s-33-6-45-19L339 621c-19-18-24-46-14-70 10-23 33-39 59-39h256V64c0-35 29-64 64-64h256c35 0 64 29 64 64v448h256c26 0 49 16 59 39z"/>
|
||||
		</svg>
|
||||
	"/></div>
|
||||
	"/></span>
|
@@ -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;
|
||||
}
|
||||
|
||||
|
@@ -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);
|
||||
};
|
||||
|
@@ -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) => {
|
||||
|
@@ -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
|
||||
|
@@ -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'
|
||||
/>
|
||||
|
@@ -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);
|
||||
|
||||
|
@@ -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",
|
||||
|
@@ -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,
|
||||
};
|
||||
});
|
||||
|
@@ -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') {
|
||||
|
3
packages/app-mobile/.gitignore
vendored
3
packages/app-mobile/.gitignore
vendored
@@ -67,7 +67,8 @@ yarn-error.log
|
||||
lib/csstojs/
|
||||
lib/rnInjectedJs/
|
||||
dist/
|
||||
components/**/*.bundle.js
|
||||
/**/*.bundle.js
|
||||
/**/*.bundle.css
|
||||
components/**/*.bundle.js.LICENSE.txt
|
||||
components/**/*.bundle.js.md5
|
||||
components/**/*.bundle.min.js
|
||||
|
@@ -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"
|
||||
}
|
||||
|
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,13 +1,14 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import {
|
||||
forwardRef, Ref, useEffect, useImperativeHandle, useMemo, useRef,
|
||||
forwardRef, Ref, useCallback, useEffect, useImperativeHandle, useMemo, useRef,
|
||||
} from 'react';
|
||||
|
||||
import { View } from 'react-native';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import { Props, WebViewControl } from './types';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import useCss from './utils/useCss';
|
||||
|
||||
const logger = Logger.create('ExtendedWebView');
|
||||
|
||||
@@ -18,11 +19,13 @@ const ExtendedWebView = (props: Props, ref: Ref<WebViewControl>) => {
|
||||
return new JSDOM(props.html, { runScripts: 'dangerously', pretendToBeVisual: true });
|
||||
}, [props.html]);
|
||||
|
||||
const injectJs = useCallback((js: string) => {
|
||||
return dom.window.eval(js);
|
||||
}, [dom]);
|
||||
|
||||
useImperativeHandle(ref, (): WebViewControl => {
|
||||
const result = {
|
||||
injectJS(js: string) {
|
||||
return dom.window.eval(js);
|
||||
},
|
||||
injectJS: injectJs,
|
||||
postMessage(message: unknown) {
|
||||
const messageEventContent = {
|
||||
data: message,
|
||||
@@ -36,33 +39,24 @@ const ExtendedWebView = (props: Props, ref: Ref<WebViewControl>) => {
|
||||
},
|
||||
};
|
||||
return result;
|
||||
}, [dom]);
|
||||
}, [dom, injectJs]);
|
||||
|
||||
const onMessageRef = useRef(props.onMessage);
|
||||
onMessageRef.current = props.onMessage;
|
||||
|
||||
const { injectedJs: cssInjectedJavaScript } = useCss(
|
||||
injectJs,
|
||||
props.css,
|
||||
);
|
||||
|
||||
// Don't re-load when injected JS changes. This should match the behavior of the native webview.
|
||||
const injectedJavaScriptRef = useRef(props.injectedJavaScript);
|
||||
injectedJavaScriptRef.current = props.injectedJavaScript;
|
||||
injectedJavaScriptRef.current = props.injectedJavaScript + cssInjectedJavaScript;
|
||||
|
||||
useEffect(() => {
|
||||
// JSDOM polyfills
|
||||
dom.window.eval(`
|
||||
// Prevents the CodeMirror error "getClientRects is undefined".
|
||||
// See https://github.com/jsdom/jsdom/issues/3002#issue-652790925
|
||||
document.createRange = () => {
|
||||
const range = new Range();
|
||||
range.getBoundingClientRect = () => {};
|
||||
range.getClientRects = () => {
|
||||
return {
|
||||
length: 0,
|
||||
item: () => null,
|
||||
[Symbol.iterator]: () => {},
|
||||
};
|
||||
};
|
||||
|
||||
return range;
|
||||
};
|
||||
window.scrollBy = (_amount) => { };
|
||||
`);
|
||||
|
||||
dom.window.eval(`
|
||||
@@ -80,7 +74,6 @@ const ExtendedWebView = (props: Props, ref: Ref<WebViewControl>) => {
|
||||
dom.window.eval(injectedJavaScriptRef.current);
|
||||
}, [dom]);
|
||||
|
||||
|
||||
const onLoadEndRef = useRef(props.onLoadEnd);
|
||||
onLoadEndRef.current = props.onLoadEnd;
|
||||
const onLoadStartRef = useRef(props.onLoadStart);
|
||||
|
@@ -12,6 +12,7 @@ import Setting from '@joplin/lib/models/Setting';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import { Props, WebViewControl } from './types';
|
||||
import useCss from './utils/useCss';
|
||||
|
||||
const logger = Logger.create('ExtendedWebView');
|
||||
|
||||
@@ -98,6 +99,9 @@ const ExtendedWebView = (props: Props, ref: Ref<WebViewControl>) => {
|
||||
}, 250);
|
||||
}, []);
|
||||
|
||||
const { injectedJs: cssInjectedJs } = useCss(webviewRef.current?.injectJavaScript, props.css);
|
||||
const injectedJavaScript = props.injectedJavaScript + cssInjectedJs;
|
||||
|
||||
// - `setSupportMultipleWindows` must be `true` for security reasons:
|
||||
// https://github.com/react-native-webview/react-native-webview/releases/tag/v11.0.0
|
||||
|
||||
@@ -131,7 +135,7 @@ const ExtendedWebView = (props: Props, ref: Ref<WebViewControl>) => {
|
||||
allowFileAccess={true}
|
||||
allowFileAccessFromFileURLs={props.allowFileAccessFromJs}
|
||||
webviewDebuggingEnabled={allowWebviewDebugging}
|
||||
injectedJavaScript={props.injectedJavaScript}
|
||||
injectedJavaScript={injectedJavaScript}
|
||||
onMessage={props.onMessage}
|
||||
onError={props.onError ?? onError}
|
||||
onLoadEnd={props.onLoadEnd}
|
||||
|
@@ -1,13 +1,14 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import {
|
||||
forwardRef, Ref, useEffect, useImperativeHandle, useRef, useState,
|
||||
forwardRef, Ref, useCallback, useEffect, useImperativeHandle, useRef, useState,
|
||||
} from 'react';
|
||||
import { Props, WebViewControl } from './types';
|
||||
|
||||
import { View, ViewStyle } from 'react-native';
|
||||
import makeSandboxedIframe from '@joplin/lib/utils/dom/makeSandboxedIframe';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import useCss from './utils/useCss';
|
||||
|
||||
const logger = Logger.create('ExtendedWebView');
|
||||
|
||||
@@ -20,24 +21,26 @@ const wrapperStyle: ViewStyle = { height: '100%', width: '100%', flex: 1 };
|
||||
const ExtendedWebView = (props: Props, ref: Ref<WebViewControl>) => {
|
||||
const iframeRef = useRef<HTMLIFrameElement|null>(null);
|
||||
|
||||
const injectJs = useCallback((js: string) => {
|
||||
if (!iframeRef.current) {
|
||||
logger.warn(`WebView(${props.webviewInstanceId}): Tried to inject JavaScript after the iframe has unloaded.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// react-native-webview doesn't seem to show a warning in the case where JavaScript
|
||||
// is injected before the first page loads.
|
||||
if (!iframeRef.current.contentWindow) {
|
||||
return;
|
||||
}
|
||||
|
||||
iframeRef.current.contentWindow.postMessage({
|
||||
injectJs: js,
|
||||
}, '*');
|
||||
}, [props.webviewInstanceId]);
|
||||
|
||||
useImperativeHandle(ref, (): WebViewControl => {
|
||||
return {
|
||||
injectJS(js: string) {
|
||||
if (!iframeRef.current) {
|
||||
logger.warn(`WebView(${props.webviewInstanceId}): Tried to inject JavaScript after the iframe has unloaded.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// react-native-webview doesn't seem to show a warning in the case where JavaScript
|
||||
// is injected before the first page loads.
|
||||
if (!iframeRef.current.contentWindow) {
|
||||
return;
|
||||
}
|
||||
|
||||
iframeRef.current.contentWindow.postMessage({
|
||||
injectJs: js,
|
||||
}, '*');
|
||||
},
|
||||
injectJS: injectJs,
|
||||
postMessage(message: unknown) {
|
||||
if (!iframeRef.current || !iframeRef.current.contentWindow) {
|
||||
logger.warn(`WebView(${props.webviewInstanceId}): Tried to post a message to an unloaded iframe.`);
|
||||
@@ -49,7 +52,7 @@ const ExtendedWebView = (props: Props, ref: Ref<WebViewControl>) => {
|
||||
}, '*');
|
||||
},
|
||||
};
|
||||
}, [props.webviewInstanceId]);
|
||||
}, [props.webviewInstanceId, injectJs]);
|
||||
|
||||
const [containerElement, setContainerElement] = useState<HTMLDivElement>();
|
||||
const containerRef = useRef(containerElement);
|
||||
@@ -62,9 +65,15 @@ const ExtendedWebView = (props: Props, ref: Ref<WebViewControl>) => {
|
||||
const onLoadStartRef = useRef(props.onLoadStart);
|
||||
onLoadStartRef.current = props.onLoadStart;
|
||||
|
||||
const { injectedJs: cssInjectedJs } = useCss(
|
||||
iframeRef.current ? injectJs : null,
|
||||
props.css,
|
||||
);
|
||||
const injectedJavaScript = props.injectedJavaScript + cssInjectedJs;
|
||||
|
||||
// Don't re-load when injected JS changes. This should match the behavior of the native webview.
|
||||
const injectedJavaScriptRef = useRef(props.injectedJavaScript);
|
||||
injectedJavaScriptRef.current = props.injectedJavaScript;
|
||||
const injectedJavaScriptRef = useRef(injectedJavaScript);
|
||||
injectedJavaScriptRef.current = injectedJavaScript;
|
||||
|
||||
useEffect(() => {
|
||||
const headHtml = `
|
||||
|
@@ -31,6 +31,7 @@ export interface Props {
|
||||
|
||||
// If HTML is still being loaded, [html] should be an empty string.
|
||||
html: string;
|
||||
css?: string;
|
||||
|
||||
// Initial javascript. Must evaluate to true.
|
||||
injectedJavaScript: string;
|
||||
|
@@ -0,0 +1,38 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
type OnInjectJs = (js: string)=> void;
|
||||
|
||||
const webViewCssClassName = 'extended-webview-css';
|
||||
|
||||
const applyCssJs = (css: string) => `
|
||||
(function() {
|
||||
const styleId = ${JSON.stringify(webViewCssClassName)};
|
||||
|
||||
const oldStyle = document.getElementById(styleId);
|
||||
if (oldStyle) {
|
||||
oldStyle.remove();
|
||||
}
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.setAttribute('id', styleId);
|
||||
|
||||
style.appendChild(document.createTextNode(${JSON.stringify(css)}));
|
||||
document.head.appendChild(style);
|
||||
})();
|
||||
|
||||
true;
|
||||
`;
|
||||
|
||||
const useCss = (injectJs: OnInjectJs|null, css: string) => {
|
||||
useEffect(() => {
|
||||
if (injectJs && css) {
|
||||
injectJs(applyCssJs(css));
|
||||
}
|
||||
}, [injectJs, css]);
|
||||
|
||||
return {
|
||||
injectedJs: css ? applyCssJs(css) : '',
|
||||
};
|
||||
};
|
||||
|
||||
export default useCss;
|
@@ -1,24 +1,20 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import useOnMessage, { HandleMessageCallback, OnMarkForDownloadCallback } from './hooks/useOnMessage';
|
||||
import { useRef, useCallback, useState, useMemo } from 'react';
|
||||
import { useRef, useCallback } from 'react';
|
||||
import { View, ViewStyle } from 'react-native';
|
||||
import ExtendedWebView from '../ExtendedWebView';
|
||||
import { WebViewControl } from '../ExtendedWebView/types';
|
||||
import useOnResourceLongPress from './hooks/useOnResourceLongPress';
|
||||
import useRenderer from './hooks/useRenderer';
|
||||
import { OnWebViewMessageHandler } from './types';
|
||||
import useRerenderHandler, { ResourceInfo } from './hooks/useRerenderHandler';
|
||||
import useSource from './hooks/useSource';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import uuid from '@joplin/lib/uuid';
|
||||
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
|
||||
import useContentScripts from './hooks/useContentScripts';
|
||||
import { MarkupLanguage } from '@joplin/renderer';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import { AppState } from '../../utils/types';
|
||||
import { connect } from 'react-redux';
|
||||
import useWebViewSetup from '../../contentScripts/rendererBundle/useWebViewSetup';
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
@@ -69,27 +65,14 @@ function NoteBodyViewer(props: Props) {
|
||||
onResourceLongPress,
|
||||
});
|
||||
|
||||
const [webViewLoaded, setWebViewLoaded] = useState(false);
|
||||
const [onWebViewMessage, setOnWebViewMessage] = useState<OnWebViewMessageHandler>(()=>()=>{});
|
||||
|
||||
|
||||
// The renderer can write to whichever temporary directory we choose. As such,
|
||||
// we use a subdirectory of the main temporary directory for security reasons.
|
||||
const tempDir = useMemo(() => {
|
||||
return `${Setting.value('tempDir')}/${uuid.createNano()}`;
|
||||
}, []);
|
||||
|
||||
const renderer = useRenderer({
|
||||
webViewLoaded,
|
||||
onScroll,
|
||||
const { api: renderer, pageSetup, webViewEventHandlers } = useWebViewSetup({
|
||||
webviewRef,
|
||||
onBodyScroll: onScroll,
|
||||
onPostMessage,
|
||||
setOnWebViewMessage,
|
||||
tempDir,
|
||||
pluginStates: props.pluginStates,
|
||||
themeId: props.themeId,
|
||||
});
|
||||
|
||||
const contentScripts = useContentScripts(props.pluginStates);
|
||||
|
||||
useRerenderHandler({
|
||||
renderer,
|
||||
fontSize: props.fontSize,
|
||||
@@ -102,16 +85,14 @@ function NoteBodyViewer(props: Props) {
|
||||
initialScroll: props.initialScroll,
|
||||
|
||||
paddingBottom: props.paddingBottom,
|
||||
|
||||
contentScripts,
|
||||
});
|
||||
|
||||
const onLoadEnd = useCallback(() => {
|
||||
setWebViewLoaded(true);
|
||||
webViewEventHandlers.onLoadEnd();
|
||||
if (props.onLoadEnd) props.onLoadEnd();
|
||||
}, [props.onLoadEnd]);
|
||||
}, [props.onLoadEnd, webViewEventHandlers]);
|
||||
|
||||
const { html, injectedJs } = useSource(tempDir, props.themeId);
|
||||
const { html, js } = useSource(pageSetup, props.themeId);
|
||||
|
||||
return (
|
||||
<View style={props.style}>
|
||||
@@ -121,10 +102,10 @@ function NoteBodyViewer(props: Props) {
|
||||
testID='NoteBodyViewer'
|
||||
html={html}
|
||||
allowFileAccessFromJs={true}
|
||||
injectedJavaScript={injectedJs}
|
||||
injectedJavaScript={js}
|
||||
mixedContentMode="always"
|
||||
onLoadEnd={onLoadEnd}
|
||||
onMessage={onWebViewMessage}
|
||||
onMessage={webViewEventHandlers.onMessage}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
@@ -1,239 +0,0 @@
|
||||
import { MarkupLanguage, MarkupToHtml } from '@joplin/renderer';
|
||||
import type { MarkupToHtmlConverter, RenderOptions, RenderResultPluginAsset, FsDriver as RendererFsDriver } from '@joplin/renderer/types';
|
||||
import makeResourceModel from './utils/makeResourceModel';
|
||||
import addPluginAssets from './utils/addPluginAssets';
|
||||
import { ExtraContentScriptSource } from './types';
|
||||
import { ExtraContentScript } from '@joplin/lib/services/plugins/utils/loadContentScripts';
|
||||
|
||||
export interface RendererSetupOptions {
|
||||
settings: {
|
||||
safeMode: boolean;
|
||||
tempDir: string;
|
||||
resourceDir: string;
|
||||
resourceDownloadMode: string;
|
||||
};
|
||||
// True if asset and resource files should be transferred to the WebView before rendering.
|
||||
// This must be true on web, where asset and resource files are virtual and can't be accessed
|
||||
// without transferring.
|
||||
useTransferredFiles: boolean;
|
||||
|
||||
fsDriver: RendererFsDriver;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
pluginOptions: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface RendererSettings {
|
||||
theme: string;
|
||||
onResourceLoaded: ()=> void;
|
||||
highlightedKeywords: string[];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
resources: Record<string, any>;
|
||||
codeTheme: string;
|
||||
noteHash: string;
|
||||
initialScroll: number;
|
||||
|
||||
createEditPopupSyntax: string;
|
||||
destroyEditPopupSyntax: string;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
pluginSettings: Record<string, any>;
|
||||
requestPluginSetting: (pluginId: string, settingKey: string)=> void;
|
||||
readAssetBlob: (assetPath: string)=> Promise<Blob>;
|
||||
}
|
||||
|
||||
export interface MarkupRecord {
|
||||
language: MarkupLanguage;
|
||||
markup: string;
|
||||
}
|
||||
|
||||
export default class Renderer {
|
||||
private markupToHtml: MarkupToHtmlConverter;
|
||||
private lastSettings: RendererSettings|null = null;
|
||||
private extraContentScripts: ExtraContentScript[] = [];
|
||||
private lastRenderMarkup: MarkupRecord|null = null;
|
||||
private resourcePathOverrides: Record<string, string> = Object.create(null);
|
||||
|
||||
public constructor(private setupOptions: RendererSetupOptions) {
|
||||
this.recreateMarkupToHtml();
|
||||
}
|
||||
|
||||
private recreateMarkupToHtml() {
|
||||
this.markupToHtml = new MarkupToHtml({
|
||||
extraRendererRules: this.extraContentScripts,
|
||||
fsDriver: this.setupOptions.fsDriver,
|
||||
isSafeMode: this.setupOptions.settings.safeMode,
|
||||
tempDir: this.setupOptions.settings.tempDir,
|
||||
ResourceModel: makeResourceModel(this.setupOptions.settings.resourceDir),
|
||||
pluginOptions: this.setupOptions.pluginOptions,
|
||||
});
|
||||
}
|
||||
|
||||
// Intended for web, where resources can't be linked to normally.
|
||||
public async setResourceFile(id: string, file: Blob) {
|
||||
this.resourcePathOverrides[id] = URL.createObjectURL(file);
|
||||
}
|
||||
|
||||
public getResourcePathOverride(resourceId: string) {
|
||||
if (Object.prototype.hasOwnProperty.call(this.resourcePathOverrides, resourceId)) {
|
||||
return this.resourcePathOverrides[resourceId];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public async setExtraContentScriptsAndRerender(
|
||||
extraContentScripts: ExtraContentScriptSource[],
|
||||
) {
|
||||
this.extraContentScripts = extraContentScripts.map(script => {
|
||||
const scriptModule = (eval(script.js))({
|
||||
pluginId: script.pluginId,
|
||||
contentScriptId: script.id,
|
||||
});
|
||||
|
||||
if (!scriptModule.plugin) {
|
||||
throw new Error(`
|
||||
Expected content script ${script.id} to export a function that returns an object with a "plugin" property.
|
||||
Found: ${scriptModule}, which has keys ${Object.keys(scriptModule)}.
|
||||
`);
|
||||
}
|
||||
|
||||
return {
|
||||
...script,
|
||||
module: scriptModule,
|
||||
};
|
||||
});
|
||||
this.recreateMarkupToHtml();
|
||||
|
||||
// If possible, rerenders with the last rendering settings. The goal
|
||||
// of this is to reduce the number of IPC calls between the viewer and
|
||||
// React Native. We want the first render to be as fast as possible.
|
||||
if (this.lastRenderMarkup) {
|
||||
await this.rerender(this.lastRenderMarkup, this.lastSettings);
|
||||
}
|
||||
}
|
||||
|
||||
public async rerender(markup: MarkupRecord, settings: RendererSettings) {
|
||||
this.lastSettings = settings;
|
||||
this.lastRenderMarkup = markup;
|
||||
|
||||
const options: RenderOptions = {
|
||||
onResourceLoaded: settings.onResourceLoaded,
|
||||
highlightedKeywords: settings.highlightedKeywords,
|
||||
resources: settings.resources,
|
||||
codeTheme: settings.codeTheme,
|
||||
postMessageSyntax: 'window.joplinPostMessage_',
|
||||
enableLongPress: true,
|
||||
|
||||
// Show an 'edit' popup over SVG images
|
||||
editPopupFiletypes: ['image/svg+xml'],
|
||||
createEditPopupSyntax: settings.createEditPopupSyntax,
|
||||
destroyEditPopupSyntax: settings.destroyEditPopupSyntax,
|
||||
itemIdToUrl: this.setupOptions.useTransferredFiles ? (id: string) => this.getResourcePathOverride(id) : undefined,
|
||||
|
||||
settingValue: (pluginId: string, settingName: string) => {
|
||||
const settingKey = `${pluginId}.${settingName}`;
|
||||
|
||||
if (!(settingKey in settings.pluginSettings)) {
|
||||
// This should make the setting available on future renders.
|
||||
settings.requestPluginSetting(pluginId, settingName);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return settings.pluginSettings[settingKey];
|
||||
},
|
||||
whiteBackgroundNoteRendering: markup.language === MarkupLanguage.Html,
|
||||
};
|
||||
|
||||
this.markupToHtml.clearCache(markup.language);
|
||||
|
||||
const contentContainer = document.getElementById('joplin-container-content');
|
||||
|
||||
let html = '';
|
||||
let pluginAssets: RenderResultPluginAsset[] = [];
|
||||
try {
|
||||
const result = await this.markupToHtml.render(
|
||||
markup.language,
|
||||
markup.markup,
|
||||
JSON.parse(settings.theme),
|
||||
options,
|
||||
);
|
||||
html = result.html;
|
||||
pluginAssets = result.pluginAssets;
|
||||
} catch (error) {
|
||||
if (!contentContainer) {
|
||||
alert(`Renderer error: ${error}`);
|
||||
} else {
|
||||
contentContainer.innerText = `
|
||||
Error: ${error}
|
||||
|
||||
${error.stack ?? ''}
|
||||
`;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
contentContainer.innerHTML = html;
|
||||
|
||||
// Adding plugin assets can be slow -- run it asynchronously.
|
||||
void (async () => {
|
||||
await addPluginAssets(pluginAssets, {
|
||||
inlineAssets: this.setupOptions.useTransferredFiles,
|
||||
readAssetBlob: settings.readAssetBlob,
|
||||
});
|
||||
|
||||
// Some plugins require this event to be dispatched just after being added.
|
||||
document.dispatchEvent(new Event('joplin-noteDidUpdate'));
|
||||
})();
|
||||
|
||||
this.afterRender(settings);
|
||||
}
|
||||
|
||||
private afterRender(renderSettings: RendererSettings) {
|
||||
const readyStateCheckInterval = setInterval(() => {
|
||||
if (document.readyState === 'complete') {
|
||||
clearInterval(readyStateCheckInterval);
|
||||
if (this.setupOptions.settings.resourceDownloadMode === 'manual') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
(window as any).webviewLib.setupResourceManualDownload();
|
||||
}
|
||||
|
||||
const hash = renderSettings.noteHash;
|
||||
const initialScroll = renderSettings.initialScroll;
|
||||
|
||||
// Don't scroll to a hash if we're given initial scroll (initial scroll
|
||||
// overrides scrolling to a hash).
|
||||
if ((initialScroll ?? null) !== null) {
|
||||
const scrollingElement = document.scrollingElement ?? document.documentElement;
|
||||
scrollingElement.scrollTop = initialScroll;
|
||||
} else if (hash) {
|
||||
// Gives it a bit of time before scrolling to the anchor
|
||||
// so that images are loaded.
|
||||
setTimeout(() => {
|
||||
const e = document.getElementById(hash);
|
||||
if (!e) {
|
||||
console.warn('Cannot find hash', hash);
|
||||
return;
|
||||
}
|
||||
e.scrollIntoView();
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
}, 10);
|
||||
}
|
||||
|
||||
public clearCache(markupLanguage: MarkupLanguage) {
|
||||
this.markupToHtml.clearCache(markupLanguage);
|
||||
}
|
||||
|
||||
private extraCssElements: Record<string, HTMLStyleElement> = {};
|
||||
public setExtraCss(key: string, css: string) {
|
||||
if (this.extraCssElements.hasOwnProperty(key)) {
|
||||
this.extraCssElements[key].remove();
|
||||
}
|
||||
|
||||
const extraCssElement = document.createElement('style');
|
||||
extraCssElement.appendChild(document.createTextNode(css));
|
||||
document.head.appendChild(extraCssElement);
|
||||
|
||||
this.extraCssElements[key] = extraCssElement;
|
||||
}
|
||||
}
|
@@ -1,67 +0,0 @@
|
||||
|
||||
import WebViewToRNMessenger from '../../../utils/ipc/WebViewToRNMessenger';
|
||||
import { NoteViewerLocalApi, NoteViewerRemoteApi, RendererWebViewOptions, WebViewLib } from './types';
|
||||
import Renderer from './Renderer';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
rendererWebViewOptions: RendererWebViewOptions;
|
||||
webviewLib: WebViewLib;
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
declare const webviewLib: WebViewLib;
|
||||
|
||||
const messenger = new WebViewToRNMessenger<NoteViewerLocalApi, NoteViewerRemoteApi>(
|
||||
'note-viewer',
|
||||
null,
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
(window as any).joplinPostMessage_ = (message: string, _args: any) => {
|
||||
return messenger.remoteApi.onPostMessage(message);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
(window as any).webviewApi = {
|
||||
postMessage: messenger.remoteApi.onPostPluginMessage,
|
||||
};
|
||||
|
||||
webviewLib.initialize({
|
||||
postMessage: (message: string) => {
|
||||
messenger.remoteApi.onPostMessage(message);
|
||||
},
|
||||
});
|
||||
// Share the webview library globally so that the renderer can access it.
|
||||
window.webviewLib = webviewLib;
|
||||
|
||||
window.webviewLib = webviewLib;
|
||||
|
||||
const renderer = new Renderer({
|
||||
...window.rendererWebViewOptions,
|
||||
fsDriver: messenger.remoteApi.fsDriver,
|
||||
});
|
||||
|
||||
messenger.setLocalInterface({
|
||||
renderer,
|
||||
jumpToHash: (hash: string) => {
|
||||
location.hash = `#${hash}`;
|
||||
},
|
||||
});
|
||||
|
||||
const lastScrollTop: number|null = null;
|
||||
const onMainContentScroll = () => {
|
||||
const newScrollTop = document.scrollingElement.scrollTop;
|
||||
if (lastScrollTop !== newScrollTop) {
|
||||
messenger.remoteApi.onScroll(newScrollTop);
|
||||
}
|
||||
};
|
||||
|
||||
// Listen for events on both scrollingElement and window
|
||||
// - On Android, scrollingElement.addEventListener('scroll', callback) doesn't call callback on
|
||||
// scroll. However, window.addEventListener('scroll', callback) does.
|
||||
// - iOS needs a listener to be added to scrollingElement -- events aren't received when
|
||||
// the listener is added to window with window.addEventListener('scroll', ...).
|
||||
document.scrollingElement?.addEventListener('scroll', onMainContentScroll);
|
||||
window.addEventListener('scroll', onMainContentScroll);
|
@@ -1,39 +0,0 @@
|
||||
import type { FsDriver as RendererFsDriver } from '@joplin/renderer/types';
|
||||
import Renderer from './Renderer';
|
||||
|
||||
export interface RendererWebViewOptions {
|
||||
settings: {
|
||||
safeMode: boolean;
|
||||
tempDir: string;
|
||||
resourceDir: string;
|
||||
resourceDownloadMode: string;
|
||||
};
|
||||
useTransferredFiles: boolean;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
pluginOptions: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface ExtraContentScriptSource {
|
||||
id: string;
|
||||
js: string;
|
||||
assetPath: string;
|
||||
pluginId: string;
|
||||
}
|
||||
|
||||
export interface NoteViewerLocalApi {
|
||||
renderer: Renderer;
|
||||
jumpToHash: (hash: string)=> void;
|
||||
}
|
||||
|
||||
export interface NoteViewerRemoteApi {
|
||||
onScroll(scrollTop: number): void;
|
||||
onPostMessage(message: string): void;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
onPostPluginMessage(contentScriptId: string, message: any): Promise<any>;
|
||||
fsDriver: RendererFsDriver;
|
||||
}
|
||||
|
||||
export interface WebViewLib {
|
||||
initialize(config: unknown): void;
|
||||
}
|
||||
|
@@ -1,86 +0,0 @@
|
||||
import { Dispatch, RefObject, SetStateAction, useEffect, useMemo, useRef } from 'react';
|
||||
import { WebViewControl } from '../../ExtendedWebView/types';
|
||||
import { OnScrollCallback, OnWebViewMessageHandler } from '../types';
|
||||
import RNToWebViewMessenger from '../../../utils/ipc/RNToWebViewMessenger';
|
||||
import { NoteViewerLocalApi, NoteViewerRemoteApi } from '../bundledJs/types';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import { WebViewMessageEvent } from 'react-native-webview';
|
||||
import PluginService from '@joplin/lib/services/plugins/PluginService';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
|
||||
const logger = Logger.create('useRenderer');
|
||||
|
||||
interface Props {
|
||||
webviewRef: RefObject<WebViewControl>;
|
||||
onScroll: OnScrollCallback;
|
||||
onPostMessage: (message: string)=> void;
|
||||
setOnWebViewMessage: Dispatch<SetStateAction<OnWebViewMessageHandler>>;
|
||||
webViewLoaded: boolean;
|
||||
|
||||
tempDir: string;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const onPostPluginMessage = async (contentScriptId: string, message: any) => {
|
||||
logger.debug(`Handling message from content script: ${contentScriptId}:`, message);
|
||||
|
||||
const pluginService = PluginService.instance();
|
||||
const pluginId = pluginService.pluginIdByContentScriptId(contentScriptId);
|
||||
if (!pluginId) {
|
||||
throw new Error(`Plugin not found for content script with ID ${contentScriptId}`);
|
||||
}
|
||||
|
||||
const plugin = pluginService.pluginById(pluginId);
|
||||
return plugin.emitContentScriptMessage(contentScriptId, message);
|
||||
};
|
||||
|
||||
const useRenderer = (props: Props) => {
|
||||
const onScrollRef = useRef(props.onScroll);
|
||||
onScrollRef.current = props.onScroll;
|
||||
|
||||
const onPostMessageRef = useRef(props.onPostMessage);
|
||||
onPostMessageRef.current = props.onPostMessage;
|
||||
|
||||
const messenger = useMemo(() => {
|
||||
const fsDriver = shim.fsDriver();
|
||||
const localApi = {
|
||||
onScroll: (fraction: number) => onScrollRef.current?.(fraction),
|
||||
onPostMessage: (message: string) => onPostMessageRef.current?.(message),
|
||||
onPostPluginMessage,
|
||||
fsDriver: {
|
||||
writeFile: async (path: string, content: string, encoding?: string) => {
|
||||
if (!await fsDriver.exists(props.tempDir)) {
|
||||
await fsDriver.mkdir(props.tempDir);
|
||||
}
|
||||
// To avoid giving the WebView access to the entire main tempDir,
|
||||
// we use props.tempDir (which should be different).
|
||||
path = fsDriver.resolveRelativePathWithinDir(props.tempDir, path);
|
||||
return await fsDriver.writeFile(path, content, encoding);
|
||||
},
|
||||
exists: fsDriver.exists,
|
||||
cacheCssToFile: fsDriver.cacheCssToFile,
|
||||
},
|
||||
};
|
||||
return new RNToWebViewMessenger<NoteViewerRemoteApi, NoteViewerLocalApi>(
|
||||
'note-viewer', props.webviewRef, localApi,
|
||||
);
|
||||
}, [props.webviewRef, props.tempDir]);
|
||||
|
||||
useEffect(() => {
|
||||
props.setOnWebViewMessage(() => (event: WebViewMessageEvent) => {
|
||||
messenger.onWebViewMessage(event);
|
||||
});
|
||||
}, [messenger, props.setOnWebViewMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.webViewLoaded) {
|
||||
messenger.onWebViewLoaded();
|
||||
}
|
||||
}, [messenger, props.webViewLoaded]);
|
||||
|
||||
return useMemo(() => {
|
||||
return messenger.remoteApi.renderer;
|
||||
}, [messenger]);
|
||||
};
|
||||
|
||||
export default useRenderer;
|
@@ -1,26 +1,20 @@
|
||||
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
|
||||
import usePrevious from '@joplin/lib/hooks/usePrevious';
|
||||
import { themeStyle } from '@joplin/lib/theme';
|
||||
import { MarkupLanguage } from '@joplin/renderer';
|
||||
import useEditPopup from './useEditPopup';
|
||||
import Renderer from '../bundledJs/Renderer';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import { ExtraContentScriptSource } from '../bundledJs/types';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import { ResourceEntity, ResourceLocalStateEntity } from '@joplin/lib/services/database/types';
|
||||
import { RendererControl, RenderOptions } from '../../../contentScripts/rendererBundle/types';
|
||||
import Resource from '@joplin/lib/models/Resource';
|
||||
import { ResourceEntity } from '@joplin/lib/services/database/types';
|
||||
import resolvePathWithinDir from '@joplin/lib/utils/resolvePathWithinDir';
|
||||
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
|
||||
|
||||
|
||||
export interface ResourceInfo {
|
||||
localState: unknown;
|
||||
localState: ResourceLocalStateEntity;
|
||||
item: ResourceEntity;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
renderer: Renderer;
|
||||
renderer: RendererControl;
|
||||
|
||||
noteBody: string;
|
||||
noteMarkupLanguage: MarkupLanguage;
|
||||
@@ -33,8 +27,6 @@ interface Props {
|
||||
initialScroll: number|undefined;
|
||||
|
||||
paddingBottom: number;
|
||||
|
||||
contentScripts: ExtraContentScriptSource[];
|
||||
}
|
||||
|
||||
const onlyCheckboxHasChangedHack = (previousBody: string, newBody: string) => {
|
||||
@@ -56,10 +48,35 @@ const onlyCheckboxHasChangedHack = (previousBody: string, newBody: string) => {
|
||||
|
||||
const logger = Logger.create('useRerenderHandler');
|
||||
|
||||
const useRerenderHandler = (props: Props) => {
|
||||
const { createEditPopupSyntax, destroyEditPopupSyntax, editPopupCss } = useEditPopup(props.themeId);
|
||||
const useResourceLoadCounter = (noteResources: Record<string, ResourceInfo>) => {
|
||||
const [lastResourceLoadCounter, setLastResourceLoadCounter] = useState(0);
|
||||
const [pluginSettingKeys, setPluginSettingKeys] = useState<Record<string, boolean>>({});
|
||||
const lastDownloadCount = useRef(-1);
|
||||
useEffect(() => {
|
||||
let downloadedCount = 0;
|
||||
for (const resource of Object.values(noteResources)) {
|
||||
if (resource.localState.fetch_status === Resource.FETCH_STATUS_DONE) {
|
||||
downloadedCount ++;
|
||||
}
|
||||
}
|
||||
|
||||
if (lastDownloadCount.current !== -1 && lastDownloadCount.current < downloadedCount) {
|
||||
setLastResourceLoadCounter(counter => counter + 1);
|
||||
}
|
||||
lastDownloadCount.current = downloadedCount;
|
||||
}, [noteResources]);
|
||||
|
||||
return lastResourceLoadCounter;
|
||||
};
|
||||
|
||||
const useRerenderHandler = (props: Props) => {
|
||||
const resourceDownloadRerenderCounter = useResourceLoadCounter(props.noteResources);
|
||||
useEffect(() => {
|
||||
// Whenever a resource state changes, for example when it goes from "not downloaded" to "downloaded", the "noteResources"
|
||||
// props changes, thus triggering a render. The **content** of this noteResources array however is not changed because
|
||||
// it doesn't contain info about the resource download state. Because of that, if we were to use the markupToHtml() cache
|
||||
// it wouldn't re-render at all.
|
||||
props.renderer.clearCache(props.noteMarkupLanguage);
|
||||
}, [resourceDownloadRerenderCounter, props.renderer, props.noteMarkupLanguage]);
|
||||
|
||||
// To address https://github.com/laurent22/joplin/issues/433
|
||||
//
|
||||
@@ -82,8 +99,8 @@ const useRerenderHandler = (props: Props) => {
|
||||
// below logic rely on this.
|
||||
const effectDependencies = [
|
||||
props.noteBody, props.noteMarkupLanguage, props.renderer, props.highlightedKeywords,
|
||||
props.noteHash, props.noteResources, props.themeId, props.paddingBottom, lastResourceLoadCounter,
|
||||
createEditPopupSyntax, destroyEditPopupSyntax, pluginSettingKeys, props.fontSize,
|
||||
props.noteHash, props.noteResources, props.themeId, props.paddingBottom, resourceDownloadRerenderCounter,
|
||||
props.fontSize,
|
||||
];
|
||||
const previousDeps = usePrevious(effectDependencies, []);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
@@ -99,125 +116,42 @@ const useRerenderHandler = (props: Props) => {
|
||||
const previousHash = usePrevious(props.noteHash, '');
|
||||
const hashChanged = previousHash !== props.noteHash;
|
||||
|
||||
useEffect(() => {
|
||||
// Whenever a resource state changes, for example when it goes from "not downloaded" to "downloaded", the "noteResources"
|
||||
// props changes, thus triggering a render. The **content** of this noteResources array however is not changed because
|
||||
// it doesn't contain info about the resource download state. Because of that, if we were to use the markupToHtml() cache
|
||||
// it wouldn't re-render at all.
|
||||
props.renderer.clearCache(props.noteMarkupLanguage);
|
||||
}, [lastResourceLoadCounter, props.renderer, props.noteMarkupLanguage]);
|
||||
|
||||
useEffect(() => {
|
||||
void props.renderer.setExtraContentScriptsAndRerender(props.contentScripts);
|
||||
}, [props.contentScripts, props.renderer]);
|
||||
|
||||
useAsyncEffect(async event => {
|
||||
useAsyncEffect(async (event) => {
|
||||
if (onlyNoteBodyHasChanged && onlyCheckboxesHaveChanged) {
|
||||
logger.info('Only a checkbox has changed - not updating HTML');
|
||||
return;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const pluginSettings: Record<string, any> = { };
|
||||
for (const key in pluginSettingKeys) {
|
||||
pluginSettings[key] = Setting.value(`plugin-${key}`);
|
||||
}
|
||||
let newPluginSettingKeys = pluginSettingKeys;
|
||||
|
||||
// On web, resources are virtual files and thus need to be transferred to the WebView.
|
||||
if (shim.mobilePlatform() === 'web') {
|
||||
for (const [resourceId, resource] of Object.entries(props.noteResources)) {
|
||||
try {
|
||||
await props.renderer.setResourceFile(
|
||||
resourceId,
|
||||
await shim.fsDriver().fileAtPath(Resource.fullPath(resource.item)),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// This can happen if a resource hasn't been downloaded yet
|
||||
logger.warn('Error: Resource file not found (ENOENT)', Resource.fullPath(resource.item), 'for ID', resource.item.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const theme = themeStyle(props.themeId);
|
||||
const config = {
|
||||
// We .stringify the theme to avoid a JSON serialization error involving
|
||||
// the color package.
|
||||
theme: JSON.stringify({
|
||||
const config: RenderOptions = {
|
||||
themeId: props.themeId,
|
||||
themeOverrides: {
|
||||
bodyPaddingTop: '0.8em',
|
||||
bodyPaddingBottom: props.paddingBottom,
|
||||
...theme,
|
||||
|
||||
noteViewerFontSize: props.fontSize,
|
||||
}),
|
||||
codeTheme: theme.codeThemeCss,
|
||||
|
||||
onResourceLoaded: () => {
|
||||
// Force a rerender when a resource loads
|
||||
setLastResourceLoadCounter(lastResourceLoadCounter + 1);
|
||||
},
|
||||
highlightedKeywords: props.highlightedKeywords,
|
||||
resources: props.noteResources,
|
||||
pluginAssetContainerSelector: '#joplin-container-pluginAssetsContainer',
|
||||
|
||||
// If the hash changed, we don't set initial scroll -- we want to scroll to the hash
|
||||
// instead.
|
||||
initialScroll: (previousHash && hashChanged) ? undefined : props.initialScroll,
|
||||
noteHash: props.noteHash,
|
||||
|
||||
pluginSettings,
|
||||
requestPluginSetting: (pluginId: string, settingKey: string) => {
|
||||
// Don't trigger additional renders
|
||||
if (event.cancelled) return;
|
||||
|
||||
const key = `${pluginId}.${settingKey}`;
|
||||
logger.debug(`Request plugin setting: plugin-${key}`);
|
||||
|
||||
if (!(key in newPluginSettingKeys)) {
|
||||
newPluginSettingKeys = { ...newPluginSettingKeys, [`${pluginId}.${settingKey}`]: true };
|
||||
setPluginSettingKeys(newPluginSettingKeys);
|
||||
}
|
||||
},
|
||||
readAssetBlob: (assetPath: string) => {
|
||||
// Built-in assets are in resourceDir, external plugin assets are in cacheDir.
|
||||
const assetsDirs = [Setting.value('resourceDir'), Setting.value('cacheDir')];
|
||||
|
||||
let resolvedPath = null;
|
||||
for (const assetDir of assetsDirs) {
|
||||
resolvedPath ??= resolvePathWithinDir(assetDir, assetPath);
|
||||
if (resolvedPath) break;
|
||||
}
|
||||
|
||||
if (!resolvedPath) {
|
||||
throw new Error(`Failed to load asset at ${assetPath} -- not in any of the allowed asset directories: ${assetsDirs.join(',')}.`);
|
||||
}
|
||||
return shim.fsDriver().fileAtPath(resolvedPath);
|
||||
},
|
||||
|
||||
createEditPopupSyntax,
|
||||
destroyEditPopupSyntax,
|
||||
};
|
||||
|
||||
try {
|
||||
logger.debug('Starting render...');
|
||||
|
||||
await props.renderer.rerender({
|
||||
await props.renderer.rerenderToBody({
|
||||
language: props.noteMarkupLanguage,
|
||||
markup: props.noteBody,
|
||||
}, config);
|
||||
}, config, event);
|
||||
|
||||
logger.debug('Render complete.');
|
||||
} catch (error) {
|
||||
logger.error('Render failed:', error);
|
||||
}
|
||||
}, effectDependencies);
|
||||
|
||||
useEffect(() => {
|
||||
props.renderer.setExtraCss('edit-popup', editPopupCss);
|
||||
}, [editPopupCss, props.renderer]);
|
||||
};
|
||||
|
||||
export default useRerenderHandler;
|
||||
|
@@ -1,49 +1,15 @@
|
||||
import { useMemo } from 'react';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { RendererWebViewOptions } from '../bundledJs/types';
|
||||
import { themeStyle } from '../../global-style';
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
const useSource = (tempDirPath: string, themeId: number) => {
|
||||
const injectedJs = useMemo(() => {
|
||||
const subValues = Setting.subValues('markdown.plugin', Setting.toPlainObject());
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const pluginOptions: any = {};
|
||||
for (const n in subValues) {
|
||||
pluginOptions[n] = { enabled: subValues[n] };
|
||||
}
|
||||
|
||||
const rendererWebViewOptions: RendererWebViewOptions = {
|
||||
settings: {
|
||||
safeMode: Setting.value('isSafeMode'),
|
||||
tempDir: tempDirPath,
|
||||
resourceDir: Setting.value('resourceDir'),
|
||||
resourceDownloadMode: Setting.value('sync.resourceDownloadMode'),
|
||||
},
|
||||
// Web needs files to be transferred manually, since image SRCs can't reference
|
||||
// the Origin Private File System.
|
||||
useTransferredFiles: Platform.OS === 'web',
|
||||
pluginOptions,
|
||||
};
|
||||
|
||||
return `
|
||||
window.rendererWebViewOptions = ${JSON.stringify(rendererWebViewOptions)};
|
||||
|
||||
if (!window.injectedJsLoaded) {
|
||||
window.injectedJsLoaded = true;
|
||||
|
||||
${shim.injectedJs('webviewLib')}
|
||||
${shim.injectedJs('noteBodyViewerBundle')}
|
||||
}
|
||||
`;
|
||||
}, [tempDirPath]);
|
||||
import { PageSetupSources } from '../../../contentScripts/types';
|
||||
|
||||
const useSource = (rendererSource: PageSetupSources, themeId: number) => {
|
||||
const [paddingLeft, paddingRight] = useMemo(() => {
|
||||
const theme = themeStyle(themeId);
|
||||
return [theme.marginLeft, theme.marginRight];
|
||||
}, [themeId]);
|
||||
|
||||
const rendererBaseCss = rendererSource.css;
|
||||
const html = useMemo(() => {
|
||||
// iOS doesn't automatically adjust the WebView's font size to match users'
|
||||
// accessibility settings. To do this, we need to tell it to match the system font.
|
||||
@@ -75,6 +41,7 @@ const useSource = (tempDirPath: string, themeId: number) => {
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
${defaultCss}
|
||||
${rendererBaseCss}
|
||||
${shim.mobilePlatform() === 'ios' ? iOSSpecificCss : ''}
|
||||
</style>
|
||||
</head>
|
||||
@@ -84,9 +51,9 @@ const useSource = (tempDirPath: string, themeId: number) => {
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}, [paddingLeft, paddingRight]);
|
||||
}, [paddingLeft, paddingRight, rendererBaseCss]);
|
||||
|
||||
return { html, injectedJs };
|
||||
return { html, js: rendererSource.js };
|
||||
};
|
||||
|
||||
export default useSource;
|
||||
|
@@ -1,91 +0,0 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
|
||||
// This contains the CodeMirror instance, which needs to be built into a bundle
|
||||
// using `yarn buildInjectedJs`. This bundle is then loaded from
|
||||
// NoteEditor.tsx into the webview.
|
||||
//
|
||||
// In general, since this file is harder to debug due to the intermediate built
|
||||
// step, it's better to keep it as light as possible - it should just be a light
|
||||
// wrapper to access CodeMirror functionalities. Anything else should be done
|
||||
// from NoteEditor.tsx.
|
||||
|
||||
import { EditorSettings } from '@joplin/editor/types';
|
||||
import createEditor from '@joplin/editor/CodeMirror/createEditor';
|
||||
import CodeMirrorControl from '@joplin/editor/CodeMirror/CodeMirrorControl';
|
||||
import WebViewToRNMessenger from '../../../utils/ipc/WebViewToRNMessenger';
|
||||
import { WebViewToEditorApi } from '../types';
|
||||
import { focus } from '@joplin/lib/utils/focusHandler';
|
||||
import Logger, { TargetType } from '@joplin/utils/Logger';
|
||||
|
||||
let loggerCreated = false;
|
||||
export const setUpLogger = () => {
|
||||
if (!loggerCreated) {
|
||||
const logger = new Logger();
|
||||
logger.addTarget(TargetType.Console);
|
||||
logger.setLevel(Logger.LEVEL_WARN);
|
||||
Logger.initializeGlobalLogger(logger);
|
||||
loggerCreated = true;
|
||||
}
|
||||
};
|
||||
|
||||
export const initCodeMirror = (
|
||||
parentElement: HTMLElement,
|
||||
initialText: string,
|
||||
initialNoteId: string,
|
||||
settings: EditorSettings,
|
||||
): CodeMirrorControl => {
|
||||
const messenger = new WebViewToRNMessenger<CodeMirrorControl, WebViewToEditorApi>('editor', null);
|
||||
|
||||
const control = createEditor(parentElement, {
|
||||
initialText,
|
||||
initialNoteId,
|
||||
settings,
|
||||
|
||||
onPasteFile: async (data) => {
|
||||
const reader = new FileReader();
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
reader.onload = async () => {
|
||||
const dataUrl = reader.result as string;
|
||||
const base64 = dataUrl.replace(/^data:.*;base64,/, '');
|
||||
await messenger.remoteApi.onPasteFile(data.type, base64);
|
||||
resolve();
|
||||
};
|
||||
reader.onerror = () => reject(new Error('Failed to load file.'));
|
||||
|
||||
reader.readAsDataURL(data);
|
||||
});
|
||||
},
|
||||
|
||||
onLogMessage: message => {
|
||||
void messenger.remoteApi.logMessage(message);
|
||||
},
|
||||
onEvent: (event): void => {
|
||||
void messenger.remoteApi.onEditorEvent(event);
|
||||
},
|
||||
});
|
||||
|
||||
// Works around https://github.com/laurent22/joplin/issues/10047 by handling
|
||||
// the text/uri-list MIME type when pasting, rather than sending the paste event
|
||||
// to CodeMirror.
|
||||
//
|
||||
// TODO: Remove this workaround when the issue has been fixed upstream.
|
||||
control.on('paste', (_editor, event: ClipboardEvent) => {
|
||||
const clipboardData = event.clipboardData;
|
||||
if (clipboardData.types.length === 1 && clipboardData.types[0] === 'text/uri-list') {
|
||||
event.preventDefault();
|
||||
control.insertText(clipboardData.getData('text/uri-list'));
|
||||
}
|
||||
});
|
||||
|
||||
// Note: Just adding an onclick listener seems sufficient to focus the editor when its background
|
||||
// is tapped.
|
||||
parentElement.addEventListener('click', (event) => {
|
||||
const activeElement = document.querySelector(':focus');
|
||||
if (!parentElement.contains(activeElement) && event.target === parentElement) {
|
||||
focus('initial editor focus', control);
|
||||
}
|
||||
});
|
||||
|
||||
messenger.setLocalInterface(control);
|
||||
return control;
|
||||
};
|
@@ -1,60 +0,0 @@
|
||||
<!--
|
||||
Open this file in a web browser to more easily debug the CodeMirror editor.
|
||||
Messages will show up in the console when posted.
|
||||
-->
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>
|
||||
<meta charset="utf-8"/>
|
||||
<title>CodeMirror test</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="CodeMirror"></div>
|
||||
<script>
|
||||
// Override the default postMessage — codeMirrorBundle expects
|
||||
// this to be present.
|
||||
window.ReactNativeWebView = {
|
||||
postMessage: message => {
|
||||
console.log('postMessage:', message);
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<script src="./CodeMirror.bundle.js"></script>
|
||||
<script>
|
||||
const parent = document.querySelector('.CodeMirror');
|
||||
const initialText = 'Testing...';
|
||||
|
||||
const settings = {
|
||||
themeData: {
|
||||
fontSize: 12, // px
|
||||
fontFamily: 'serif',
|
||||
backgroundColor: 'black',
|
||||
color: 'white',
|
||||
backgroundColor2: '#330',
|
||||
color2: '#ff0',
|
||||
backgroundColor3: '#404',
|
||||
color3: '#f0f',
|
||||
backgroundColor4: '#555',
|
||||
color4: '#0ff',
|
||||
appearance: 'dark',
|
||||
},
|
||||
themeId: 0,
|
||||
spellcheckEnabled: true,
|
||||
language: 'markdown',
|
||||
katexEnabled: true,
|
||||
useExternalSearch: false,
|
||||
readOnly: false,
|
||||
|
||||
keymap: 'default',
|
||||
|
||||
automatchBraces: false,
|
||||
ignoreModifiers: false,
|
||||
|
||||
indentWithTabs: false,
|
||||
};
|
||||
|
||||
window.cm = codeMirrorBundle.initCodeMirror(parent, initialText, settings);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
@@ -1,20 +1,13 @@
|
||||
import * as React from 'react';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import { themeStyle } from '@joplin/lib/theme';
|
||||
import { Theme } from '@joplin/lib/themes/type';
|
||||
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Platform } from 'react-native';
|
||||
import { useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||
import ExtendedWebView from '../../ExtendedWebView';
|
||||
import { OnMessageEvent, WebViewControl } from '../../ExtendedWebView/types';
|
||||
import { clearAutosave } from './autosave';
|
||||
import { LocalizedStrings } from './js-draw/types';
|
||||
import { clearAutosave, writeAutosave } from './autosave';
|
||||
import { DialogContext } from '../../DialogManager';
|
||||
import useEditorMessenger from './utils/useEditorMessenger';
|
||||
import BackButtonService from '../../../services/BackButtonService';
|
||||
|
||||
import useWebViewSetup, { ImageEditorControl } from '../../../contentScripts/imageEditorBundle/useWebViewSetup';
|
||||
|
||||
const logger = Logger.create('ImageEditor');
|
||||
|
||||
@@ -28,69 +21,15 @@ interface Props {
|
||||
onExit: OnCancelCallback;
|
||||
}
|
||||
|
||||
const useCss = (editorTheme: Theme) => {
|
||||
return useMemo(() => {
|
||||
// Ensure we have contrast between the background and selection. Some themes
|
||||
// have the same backgroundColor and selectionColor2. (E.g. Aritim Dark)
|
||||
let selectionBackgroundColor = editorTheme.selectedColor2;
|
||||
if (selectionBackgroundColor === editorTheme.backgroundColor) {
|
||||
selectionBackgroundColor = editorTheme.selectedColor;
|
||||
}
|
||||
|
||||
return `
|
||||
:root .imageEditorContainer {
|
||||
--background-color-1: ${editorTheme.backgroundColor};
|
||||
--foreground-color-1: ${editorTheme.color};
|
||||
--background-color-2: ${editorTheme.backgroundColor3};
|
||||
--foreground-color-2: ${editorTheme.color3};
|
||||
--background-color-3: ${editorTheme.raisedBackgroundColor};
|
||||
--foreground-color-3: ${editorTheme.raisedColor};
|
||||
|
||||
--selection-background-color: ${editorTheme.backgroundColorHover3};
|
||||
--selection-foreground-color: ${editorTheme.color3};
|
||||
--primary-action-foreground-color: ${editorTheme.color4};
|
||||
|
||||
--primary-shadow-color: ${editorTheme.colorFaded};
|
||||
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body, html {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Hide the scrollbar. See scrollbar accessibility concerns
|
||||
(https://developer.mozilla.org/en-US/docs/Web/CSS/scrollbar-width#accessibility_concerns)
|
||||
for why this isn't done in js-draw itself. */
|
||||
.toolbar-tool-row::-webkit-scrollbar {
|
||||
display: none;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
/* Hide the save/close icons on small screens. This isn't done in the upstream
|
||||
js-draw repository partially because it isn't as well localized as Joplin
|
||||
(icons can be used to suggest the meaning of a button when a translation is
|
||||
unavailable). */
|
||||
.toolbar-edge-toolbar:not(.one-row) .toolwidget-tag--save .toolbar-icon,
|
||||
.toolbar-edge-toolbar:not(.one-row) .toolwidget-tag--exit .toolbar-icon {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
}, [editorTheme]);
|
||||
};
|
||||
|
||||
const ImageEditor = (props: Props) => {
|
||||
const editorTheme: Theme = themeStyle(props.themeId);
|
||||
const webviewRef = useRef<WebViewControl|null>(null);
|
||||
const webViewRef = useRef<WebViewControl|null>(null);
|
||||
const [imageChanged, setImageChanged] = useState(false);
|
||||
|
||||
const editorControlRef = useRef<ImageEditorControl|null>(null);
|
||||
const dialogs = useContext(DialogContext);
|
||||
|
||||
const onRequestCloseEditor = useCallback((promptIfUnsaved: boolean) => {
|
||||
const onRequestCloseEditor = useCallback((promptIfUnsaved = true) => {
|
||||
const discardChangesAndClose = async () => {
|
||||
await clearAutosave();
|
||||
props.onExit();
|
||||
@@ -98,7 +37,7 @@ const ImageEditor = (props: Props) => {
|
||||
|
||||
if (!imageChanged || !promptIfUnsaved) {
|
||||
void discardChangesAndClose();
|
||||
return true;
|
||||
return;
|
||||
}
|
||||
|
||||
dialogs.prompt(
|
||||
@@ -113,13 +52,12 @@ const ImageEditor = (props: Props) => {
|
||||
onPress: () => {
|
||||
// saveDrawing calls props.onSave(...) which may close the
|
||||
// editor.
|
||||
webviewRef.current.injectJS('window.editorControl.saveThenExit()');
|
||||
void editorControlRef.current.saveThenExit();
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
return true;
|
||||
}, [webviewRef, dialogs, props.onExit, imageChanged]);
|
||||
}, [dialogs, props.onExit, imageChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
const hardwareBackPressListener = () => {
|
||||
@@ -133,9 +71,18 @@ const ImageEditor = (props: Props) => {
|
||||
};
|
||||
}, [onRequestCloseEditor]);
|
||||
|
||||
const css = useCss(editorTheme);
|
||||
const [html, setHtml] = useState('');
|
||||
const { pageSetup, api: editorControl, webViewEventHandlers } = useWebViewSetup({
|
||||
webViewRef,
|
||||
themeId: props.themeId,
|
||||
onSetImageChanged: setImageChanged,
|
||||
onAutoSave: writeAutosave,
|
||||
onSave: props.onSave,
|
||||
onRequestCloseEditor,
|
||||
resourceFilename: props.resourceFilename,
|
||||
});
|
||||
editorControlRef.current = editorControl;
|
||||
|
||||
const [html, setHtml] = useState('');
|
||||
useEffect(() => {
|
||||
setHtml(`
|
||||
<!DOCTYPE html>
|
||||
@@ -144,8 +91,8 @@ const ImageEditor = (props: Props) => {
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no"/>
|
||||
|
||||
<style id='main-style'>
|
||||
${css}
|
||||
<style>
|
||||
${pageSetup.css}
|
||||
</style>
|
||||
</head>
|
||||
<body></body>
|
||||
@@ -160,112 +107,12 @@ const ImageEditor = (props: Props) => {
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// A set of localization overrides (Joplin is better localized than js-draw).
|
||||
// All localizable strings (some unused?) can be found at
|
||||
// https://github.com/personalizedrefrigerator/js-draw/blob/main/.github/ISSUE_TEMPLATE/translation-js-draw-new.yml
|
||||
const localizedStrings: LocalizedStrings = useMemo(() => ({
|
||||
save: _('Save'),
|
||||
close: _('Close'),
|
||||
undo: _('Undo'),
|
||||
redo: _('Redo'),
|
||||
}), []);
|
||||
|
||||
const appInfo = useMemo(() => {
|
||||
return {
|
||||
name: 'Joplin',
|
||||
description: `v${shim.appVersion()}`,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const injectedJavaScript = useMemo(() => `
|
||||
window.onerror = (message, source, lineno) => {
|
||||
window.ReactNativeWebView.postMessage(
|
||||
"error: " + message + " in file://" + source + ", line " + lineno,
|
||||
);
|
||||
};
|
||||
|
||||
window.onunhandledrejection = (error) => {
|
||||
window.ReactNativeWebView.postMessage(
|
||||
"error: " + error.reason,
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
if (window.editorControl === undefined) {
|
||||
${shim.injectedJs('svgEditorBundle')}
|
||||
|
||||
window.editorControl = svgEditorBundle.createJsDrawEditor(
|
||||
svgEditorBundle.createMessenger().remoteApi,
|
||||
${JSON.stringify(Setting.value('imageeditor.jsdrawToolbar'))},
|
||||
${JSON.stringify(Setting.value('locale'))},
|
||||
${JSON.stringify(localizedStrings)},
|
||||
${JSON.stringify({
|
||||
appInfo,
|
||||
...(shim.mobilePlatform() === 'web' ? {
|
||||
// Use the browser-default clipboard API on web.
|
||||
clipboardApi: null,
|
||||
} : {}),
|
||||
})},
|
||||
);
|
||||
}
|
||||
} catch(e) {
|
||||
window.ReactNativeWebView.postMessage(
|
||||
'error: ' + e.message + ': ' + JSON.stringify(e)
|
||||
);
|
||||
}
|
||||
true;
|
||||
`, [localizedStrings, appInfo]);
|
||||
|
||||
useEffect(() => {
|
||||
webviewRef.current?.injectJS(`
|
||||
document.querySelector('#main-style').textContent = ${JSON.stringify(css)};
|
||||
|
||||
if (window.editorControl) {
|
||||
window.editorControl.onThemeUpdate();
|
||||
}
|
||||
`);
|
||||
}, [css]);
|
||||
|
||||
const onReadyToLoadData = useCallback(async () => {
|
||||
const getInitialInjectedData = async () => {
|
||||
// On mobile, it's faster to load the image within the WebView with an XMLHttpRequest.
|
||||
// In this case, the image is loaded elsewhere.
|
||||
if (Platform.OS !== 'web') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// On web, however, this doesn't work, so the image needs to be loaded here.
|
||||
if (!props.resourceFilename) {
|
||||
return '';
|
||||
}
|
||||
return await shim.fsDriver().readFile(props.resourceFilename, 'utf-8');
|
||||
};
|
||||
// It can take some time for initialSVGData to be transferred to the WebView.
|
||||
// Thus, do so after the main content has been loaded.
|
||||
webviewRef.current.injectJS(`(async () => {
|
||||
if (window.editorControl) {
|
||||
const initialSVGPath = ${JSON.stringify(props.resourceFilename)};
|
||||
const initialTemplateData = ${JSON.stringify(Setting.value('imageeditor.imageTemplate'))};
|
||||
const initialData = ${JSON.stringify(await getInitialInjectedData())};
|
||||
|
||||
editorControl.loadImageOrTemplate(initialSVGPath, initialTemplateData, initialData);
|
||||
}
|
||||
})();`);
|
||||
}, [webviewRef, props.resourceFilename]);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const onError = useCallback((event: any) => {
|
||||
logger.error('ImageEditor: WebView error: ', event);
|
||||
}, []);
|
||||
|
||||
const messenger = useEditorMessenger({
|
||||
webviewRef,
|
||||
setImageChanged,
|
||||
onReadyToLoadData,
|
||||
onSave: props.onSave,
|
||||
onRequestCloseEditor,
|
||||
});
|
||||
|
||||
const onWebViewMessage = webViewEventHandlers.onMessage;
|
||||
const onMessage = useCallback((event: OnMessageEvent) => {
|
||||
const data = event.nativeEvent.data;
|
||||
if (typeof data === 'string' && data.startsWith('error:')) {
|
||||
@@ -273,18 +120,18 @@ const ImageEditor = (props: Props) => {
|
||||
return;
|
||||
}
|
||||
|
||||
messenger.onWebViewMessage(event);
|
||||
}, [messenger]);
|
||||
onWebViewMessage(event);
|
||||
}, [onWebViewMessage]);
|
||||
|
||||
return (
|
||||
<ExtendedWebView
|
||||
html={html}
|
||||
injectedJavaScript={injectedJavaScript}
|
||||
injectedJavaScript={pageSetup.js}
|
||||
allowFileAccessFromJs={true}
|
||||
onMessage={onMessage}
|
||||
onLoadEnd={messenger.onWebViewLoaded}
|
||||
onLoadEnd={webViewEventHandlers.onLoadEnd}
|
||||
onError={onError}
|
||||
ref={webviewRef}
|
||||
ref={webViewRef}
|
||||
webviewInstanceId={'image-editor-js-draw'}
|
||||
/>
|
||||
);
|
||||
|
@@ -1,11 +0,0 @@
|
||||
// .replaceChildren is not supported in Chromium 83, which is the default for Android 11
|
||||
// (unless auto-updated from the Google Play store).
|
||||
HTMLElement.prototype.replaceChildren ??= function(this: HTMLElement, ...nodes: Node[]) {
|
||||
while (this.children.length) {
|
||||
this.children[0].remove();
|
||||
}
|
||||
|
||||
for (const node of nodes) {
|
||||
this.appendChild(node);
|
||||
}
|
||||
};
|
193
packages/app-mobile/components/NoteEditor/MarkdownEditor.tsx
Normal file
193
packages/app-mobile/components/NoteEditor/MarkdownEditor.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import { themeStyle } from '@joplin/lib/theme';
|
||||
import themeToCss from '@joplin/lib/services/style/themeToCss';
|
||||
import ExtendedWebView from '../ExtendedWebView';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import { NativeSyntheticEvent } from 'react-native';
|
||||
|
||||
import { EditorProps } from './types';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import useCodeMirrorPlugins from './hooks/useCodeMirrorPlugins';
|
||||
import { WebViewErrorEvent } from 'react-native-webview/lib/RNCWebViewNativeComponent';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import { OnMessageEvent } from '../ExtendedWebView/types';
|
||||
import useWebViewSetup from '../../contentScripts/markdownEditorBundle/useWebViewSetup';
|
||||
|
||||
const logger = Logger.create('MarkdownEditor');
|
||||
|
||||
function useCss(themeId: number): string {
|
||||
return useMemo(() => {
|
||||
const theme = themeStyle(themeId);
|
||||
const themeVariableCss = themeToCss(theme);
|
||||
return `
|
||||
${themeVariableCss}
|
||||
|
||||
:root {
|
||||
background-color: ${theme.backgroundColor};
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100vh;
|
||||
/* Prefer 100% -- 100vw shows an unnecessary horizontal scrollbar in Google Chrome (desktop). */
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
padding-left: 1px;
|
||||
padding-right: 1px;
|
||||
padding-bottom: 1px;
|
||||
padding-top: 10px;
|
||||
|
||||
font-size: 13pt;
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(100, 100, 100, 0.7) rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
@supports selector(::-webkit-scrollbar) {
|
||||
*::-webkit-scrollbar {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-corner {
|
||||
background: none;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
border: none;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: rgba(100, 100, 100, 0.3);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track:hover {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(100, 100, 100, 0.7);
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: unset;
|
||||
scrollbar-color: unset;
|
||||
}
|
||||
}
|
||||
`;
|
||||
}, [themeId]);
|
||||
}
|
||||
|
||||
function useHtml(): string {
|
||||
return useMemo(() => `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||||
<title>${_('Note editor')}</title>
|
||||
<style>
|
||||
/* For better scrolling on iOS (working scrollbar) we use external, rather than internal,
|
||||
scrolling. */
|
||||
.cm-scroller {
|
||||
overflow: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="CodeMirror" style="height:100%;" autocapitalize="on"></div>
|
||||
</body>
|
||||
</html>
|
||||
`, []);
|
||||
}
|
||||
|
||||
const MarkdownEditor: React.FC<EditorProps> = props => {
|
||||
const webviewRef = props.webviewRef;
|
||||
|
||||
const editorWebViewSetup = useWebViewSetup({
|
||||
initialSelection: props.initialSelection,
|
||||
noteHash: props.noteHash,
|
||||
globalSearch: props.globalSearch,
|
||||
onEditorEvent: props.onEditorEvent,
|
||||
onAttachFile: props.onAttach,
|
||||
editorOptions: {
|
||||
parentElementClassName: 'CodeMirror',
|
||||
initialText: props.initialText,
|
||||
initialNoteId: props.noteId,
|
||||
settings: props.editorSettings,
|
||||
},
|
||||
webviewRef,
|
||||
});
|
||||
|
||||
props.editorRef.current = editorWebViewSetup.api.editor;
|
||||
|
||||
const injectedJavaScript = `
|
||||
window.onerror = (message, source, lineno) => {
|
||||
console.error(message);
|
||||
window.ReactNativeWebView.postMessage(
|
||||
"error: " + message + " in file://" + source + ", line " + lineno
|
||||
);
|
||||
};
|
||||
window.onunhandledrejection = (event) => {
|
||||
window.ReactNativeWebView.postMessage(
|
||||
"error: Unhandled promise rejection: " + event
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
${editorWebViewSetup.pageSetup.js}
|
||||
} catch (e) {
|
||||
console.error('Setup error: ', e);
|
||||
window.ReactNativeWebView.postMessage("error:" + e.message + ": " + JSON.stringify(e))
|
||||
}
|
||||
|
||||
true;
|
||||
`;
|
||||
|
||||
const css = useCss(props.themeId);
|
||||
const html = useHtml();
|
||||
|
||||
const codeMirrorPlugins = useCodeMirrorPlugins(props.plugins);
|
||||
useEffect(() => {
|
||||
void editorWebViewSetup.api.editor.setContentScripts(codeMirrorPlugins);
|
||||
}, [codeMirrorPlugins, editorWebViewSetup]);
|
||||
|
||||
const onMessage = useCallback((event: OnMessageEvent) => {
|
||||
const data = event.nativeEvent.data;
|
||||
|
||||
if (typeof data === 'string' && data.indexOf('error:') === 0) {
|
||||
logger.error('CodeMirror error', data);
|
||||
return;
|
||||
}
|
||||
|
||||
editorWebViewSetup.webViewEventHandlers.onMessage(event);
|
||||
}, [editorWebViewSetup]);
|
||||
|
||||
const onError = useCallback((event: NativeSyntheticEvent<WebViewErrorEvent>) => {
|
||||
logger.error(`Load error: Code ${event.nativeEvent.code}: ${event.nativeEvent.description}`);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ExtendedWebView
|
||||
ref={webviewRef}
|
||||
webviewInstanceId='MarkdownEditor'
|
||||
testID='MarkdownEditor'
|
||||
scrollEnabled={true}
|
||||
html={html}
|
||||
injectedJavaScript={injectedJavaScript}
|
||||
css={css}
|
||||
hasPluginScripts={codeMirrorPlugins.length > 0}
|
||||
onMessage={onMessage}
|
||||
onLoadEnd={editorWebViewSetup.webViewEventHandlers.onLoadEnd}
|
||||
onError={onError}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarkdownEditor;
|
@@ -15,10 +15,31 @@ import mockCommandRuntimes from '../EditorToolbar/testing/mockCommandRuntimes';
|
||||
import setupGlobalStore from '../../utils/testing/setupGlobalStore';
|
||||
import { Store } from 'redux';
|
||||
import { AppState } from '../../utils/types';
|
||||
import { MarkupLanguage } from '@joplin/renderer';
|
||||
import { EditorType } from './types';
|
||||
|
||||
let store: Store<AppState>;
|
||||
let registeredRuntime: RegisteredRuntime;
|
||||
|
||||
const defaultEditorProps = {
|
||||
themeId: Setting.THEME_ARITIM_DARK,
|
||||
markupLanguage: MarkupLanguage.Markdown,
|
||||
initialText: 'Testing...',
|
||||
globalSearch: '',
|
||||
noteId: '',
|
||||
noteHash: '',
|
||||
style: {},
|
||||
toolbarEnabled: true,
|
||||
readOnly: false,
|
||||
onChange: ()=>{},
|
||||
onSelectionChange: ()=>{},
|
||||
onUndoRedoDepthChange: ()=>{},
|
||||
onAttach: async ()=>{},
|
||||
noteResources: {},
|
||||
plugins: {},
|
||||
mode: EditorType.Markdown,
|
||||
};
|
||||
|
||||
describe('NoteEditor', () => {
|
||||
beforeAll(() => {
|
||||
// This allows the NoteEditor test to register editor commands without errors.
|
||||
@@ -45,19 +66,8 @@ describe('NoteEditor', () => {
|
||||
const wrappedNoteEditor = render(
|
||||
<TestProviderStack store={store}>
|
||||
<NoteEditor
|
||||
themeId={Setting.THEME_ARITIM_DARK}
|
||||
initialText='Testing...'
|
||||
globalSearch=''
|
||||
noteId=''
|
||||
noteHash=''
|
||||
style={{}}
|
||||
toolbarEnabled={true}
|
||||
readOnly={false}
|
||||
onChange={()=>{}}
|
||||
onSelectionChange={()=>{}}
|
||||
onUndoRedoDepthChange={()=>{}}
|
||||
onAttach={async ()=>{}}
|
||||
plugins={{}}
|
||||
ref={undefined}
|
||||
{...defaultEditorProps}
|
||||
/>
|
||||
</TestProviderStack>,
|
||||
);
|
||||
@@ -99,4 +109,27 @@ describe('NoteEditor', () => {
|
||||
|
||||
wrappedNoteEditor.unmount();
|
||||
});
|
||||
|
||||
it('should show a warning banner the first time the Rich Text Editor is used', () => {
|
||||
const wrappedNoteEditor = render(
|
||||
<TestProviderStack store={store}>
|
||||
<NoteEditor
|
||||
ref={undefined}
|
||||
{...defaultEditorProps}
|
||||
mode={EditorType.RichText}
|
||||
/>
|
||||
</TestProviderStack>,
|
||||
);
|
||||
|
||||
const warningBannerQuery = /This Rich Text editor has a number of limitations.*/;
|
||||
const warning = screen.getByText(warningBannerQuery);
|
||||
expect(warning).toBeVisible();
|
||||
|
||||
// Pressing dismiss should dismiss the warning
|
||||
const dismissButton = screen.getByHintText('Hides warning');
|
||||
fireEvent.press(dismissButton);
|
||||
expect(screen.queryByText(warningBannerQuery)).toBeNull();
|
||||
|
||||
wrappedNoteEditor.unmount();
|
||||
});
|
||||
});
|
||||
|
@@ -1,46 +1,49 @@
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import { themeStyle } from '@joplin/lib/theme';
|
||||
import themeToCss from '@joplin/lib/services/style/themeToCss';
|
||||
import EditLinkDialog from './EditLinkDialog';
|
||||
import { defaultSearchState, SearchPanel } from './SearchPanel';
|
||||
import ExtendedWebView from '../ExtendedWebView';
|
||||
import { WebViewControl } from '../ExtendedWebView/types';
|
||||
|
||||
import * as React from 'react';
|
||||
import { forwardRef, RefObject, useEffect, useImperativeHandle } from 'react';
|
||||
import { Ref, RefObject, useEffect, useImperativeHandle } from 'react';
|
||||
import { useMemo, useState, useCallback, useRef } from 'react';
|
||||
import { LayoutChangeEvent, NativeSyntheticEvent, View, ViewStyle } from 'react-native';
|
||||
import { LayoutChangeEvent, View, ViewStyle } from 'react-native';
|
||||
import { editorFont } from '../global-style';
|
||||
|
||||
import { EditorControl as EditorBodyControl, ContentScriptData } from '@joplin/editor/types';
|
||||
import { EditorControl, EditorSettings, SelectionRange, WebViewToEditorApi } from './types';
|
||||
import { EditorControl, EditorSettings, EditorType } from './types';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { ChangeEvent, EditorEvent, EditorEventType, SelectionRangeChangeEvent, UndoRedoDepthChangeEvent } from '@joplin/editor/events';
|
||||
import { EditorCommandType, EditorKeymap, EditorLanguageType, SearchState } from '@joplin/editor/types';
|
||||
import SelectionFormatting, { defaultSelectionFormatting } from '@joplin/editor/SelectionFormatting';
|
||||
import useCodeMirrorPlugins from './hooks/useCodeMirrorPlugins';
|
||||
import RNToWebViewMessenger from '../../utils/ipc/RNToWebViewMessenger';
|
||||
import { WebViewErrorEvent } from 'react-native-webview/lib/RNCWebViewNativeComponent';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
|
||||
import useEditorCommandHandler from './hooks/useEditorCommandHandler';
|
||||
import { OnMessageEvent } from '../ExtendedWebView/types';
|
||||
import { join, dirname } from 'path';
|
||||
import * as mimeUtils from '@joplin/lib/mime-utils';
|
||||
import uuid from '@joplin/lib/uuid';
|
||||
import EditorToolbar from '../EditorToolbar/EditorToolbar';
|
||||
import { SelectionRange } from '../../contentScripts/markdownEditorBundle/types';
|
||||
import MarkdownEditor from './MarkdownEditor';
|
||||
import RichTextEditor from './RichTextEditor';
|
||||
import { ResourceInfos } from '@joplin/renderer/types';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import Resource from '@joplin/lib/models/Resource';
|
||||
import { join } from 'path';
|
||||
import uuid from '@joplin/lib/uuid';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import { dirname } from '@joplin/utils/path';
|
||||
import { toFileExtension } from '@joplin/lib/mime-utils';
|
||||
import { MarkupLanguage } from '@joplin/renderer';
|
||||
import WarningBanner from './WarningBanner';
|
||||
|
||||
type ChangeEventHandler = (event: ChangeEvent)=> void;
|
||||
type UndoRedoDepthChangeHandler = (event: UndoRedoDepthChangeEvent)=> void;
|
||||
type SelectionChangeEventHandler = (event: SelectionRangeChangeEvent)=> void;
|
||||
type OnAttachCallback = (filePath?: string)=> Promise<void>;
|
||||
|
||||
const logger = Logger.create('NoteEditor');
|
||||
|
||||
interface Props {
|
||||
ref: Ref<EditorControl>;
|
||||
themeId: number;
|
||||
initialText: string;
|
||||
mode: EditorType;
|
||||
markupLanguage: MarkupLanguage;
|
||||
noteId: string;
|
||||
noteHash: string;
|
||||
globalSearch: string;
|
||||
@@ -49,6 +52,7 @@ interface Props {
|
||||
toolbarEnabled: boolean;
|
||||
readOnly: boolean;
|
||||
plugins: PluginStates;
|
||||
noteResources: ResourceInfos;
|
||||
|
||||
onChange: ChangeEventHandler;
|
||||
onSelectionChange: SelectionChangeEventHandler;
|
||||
@@ -61,103 +65,6 @@ function fontFamilyFromSettings() {
|
||||
return font ? `${font}, sans-serif` : 'sans-serif';
|
||||
}
|
||||
|
||||
function useCss(themeId: number): string {
|
||||
return useMemo(() => {
|
||||
const theme = themeStyle(themeId);
|
||||
const themeVariableCss = themeToCss(theme);
|
||||
return `
|
||||
${themeVariableCss}
|
||||
|
||||
:root {
|
||||
background-color: ${theme.backgroundColor};
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100vh;
|
||||
/* Prefer 100% -- 100vw shows an unnecessary horizontal scrollbar in Google Chrome (desktop). */
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
padding-left: 1px;
|
||||
padding-right: 1px;
|
||||
padding-bottom: 1px;
|
||||
padding-top: 10px;
|
||||
|
||||
font-size: 13pt;
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(100, 100, 100, 0.7) rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
@supports selector(::-webkit-scrollbar) {
|
||||
*::-webkit-scrollbar {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-corner {
|
||||
background: none;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
border: none;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: rgba(100, 100, 100, 0.3);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track:hover {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(100, 100, 100, 0.7);
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: unset;
|
||||
scrollbar-color: unset;
|
||||
}
|
||||
}
|
||||
`;
|
||||
}, [themeId]);
|
||||
}
|
||||
|
||||
const themeStyleSheetClassName = 'note-editor-styles';
|
||||
function useHtml(initialCss: string): string {
|
||||
const cssRef = useRef(initialCss);
|
||||
cssRef.current = initialCss;
|
||||
|
||||
return useMemo(() => `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||||
<title>${_('Note editor')}</title>
|
||||
<style>
|
||||
/* For better scrolling on iOS (working scrollbar) we use external, rather than internal,
|
||||
scrolling. */
|
||||
.cm-scroller {
|
||||
overflow: none;
|
||||
}
|
||||
</style>
|
||||
<style class=${JSON.stringify(themeStyleSheetClassName)}>
|
||||
${cssRef.current}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="CodeMirror" style="height:100%;" autocapitalize="on"></div>
|
||||
</body>
|
||||
</html>
|
||||
`, []);
|
||||
}
|
||||
|
||||
function editorTheme(themeId: number) {
|
||||
const fontSizeInPx = Setting.value('style.editor.fontSize');
|
||||
|
||||
@@ -167,6 +74,7 @@ function editorTheme(themeId: number) {
|
||||
const estimatedFontSizeInEm = fontSizeInPx / 16;
|
||||
|
||||
return {
|
||||
themeId,
|
||||
...themeStyle(themeId),
|
||||
|
||||
// To allow accessibility font scaling, we also need to set the
|
||||
@@ -181,54 +89,54 @@ function editorTheme(themeId: number) {
|
||||
type OnSetVisibleCallback = (visible: boolean)=> void;
|
||||
type OnSearchStateChangeCallback = (state: SearchState)=> void;
|
||||
const useEditorControl = (
|
||||
bodyControl: EditorBodyControl,
|
||||
editorRef: RefObject<EditorBodyControl>,
|
||||
webviewRef: RefObject<WebViewControl>,
|
||||
setLinkDialogVisible: OnSetVisibleCallback,
|
||||
setSearchState: OnSearchStateChangeCallback,
|
||||
): EditorControl => {
|
||||
return useMemo(() => {
|
||||
const execEditorCommand = (command: EditorCommandType) => {
|
||||
void bodyControl.execCommand(command);
|
||||
void editorRef.current.execCommand(command);
|
||||
};
|
||||
|
||||
const setSearchStateCallback = (state: SearchState) => {
|
||||
bodyControl.setSearchState(state);
|
||||
editorRef.current.setSearchState(state);
|
||||
setSearchState(state);
|
||||
};
|
||||
|
||||
const control: EditorControl = {
|
||||
supportsCommand(command: EditorCommandType) {
|
||||
return bodyControl.supportsCommand(command);
|
||||
return editorRef.current.supportsCommand(command);
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
execCommand(command, ...args: any[]) {
|
||||
return bodyControl.execCommand(command, ...args);
|
||||
return editorRef.current.execCommand(command, ...args);
|
||||
},
|
||||
|
||||
focus() {
|
||||
void bodyControl.execCommand(EditorCommandType.Focus);
|
||||
void editorRef.current.execCommand(EditorCommandType.Focus);
|
||||
},
|
||||
|
||||
undo() {
|
||||
bodyControl.undo();
|
||||
editorRef.current.undo();
|
||||
},
|
||||
redo() {
|
||||
bodyControl.redo();
|
||||
editorRef.current.redo();
|
||||
},
|
||||
select(anchor: number, head: number) {
|
||||
bodyControl.select(anchor, head);
|
||||
editorRef.current.select(anchor, head);
|
||||
},
|
||||
setScrollPercent(fraction: number) {
|
||||
bodyControl.setScrollPercent(fraction);
|
||||
editorRef.current.setScrollPercent(fraction);
|
||||
},
|
||||
insertText(text: string) {
|
||||
bodyControl.insertText(text);
|
||||
editorRef.current.insertText(text);
|
||||
},
|
||||
updateBody(newBody: string) {
|
||||
bodyControl.updateBody(newBody);
|
||||
editorRef.current.updateBody(newBody);
|
||||
},
|
||||
updateSettings(newSettings: EditorSettings) {
|
||||
bodyControl.updateSettings(newSettings);
|
||||
editorRef.current.updateSettings(newSettings);
|
||||
},
|
||||
|
||||
toggleBolded() {
|
||||
@@ -276,7 +184,7 @@ const useEditorControl = (
|
||||
execEditorCommand(EditorCommandType.IndentLess);
|
||||
},
|
||||
updateLink(label: string, url: string) {
|
||||
bodyControl.updateLink(label, url);
|
||||
editorRef.current.updateLink(label, url);
|
||||
},
|
||||
scrollSelectionIntoView() {
|
||||
execEditorCommand(EditorCommandType.ScrollSelectionIntoView);
|
||||
@@ -292,7 +200,7 @@ const useEditorControl = (
|
||||
},
|
||||
|
||||
setContentScripts: async (plugins: ContentScriptData[]) => {
|
||||
return bodyControl.setContentScripts(plugins);
|
||||
return editorRef.current.setContentScripts(plugins);
|
||||
},
|
||||
|
||||
setSearchState: setSearchStateCallback,
|
||||
@@ -320,37 +228,25 @@ const useEditorControl = (
|
||||
|
||||
setSearchState: setSearchStateCallback,
|
||||
},
|
||||
|
||||
onResourceDownloaded: (id: string) => {
|
||||
editorRef.current.onResourceDownloaded(id);
|
||||
},
|
||||
};
|
||||
|
||||
return control;
|
||||
}, [webviewRef, bodyControl, setLinkDialogVisible, setSearchState]);
|
||||
}, [webviewRef, editorRef, setLinkDialogVisible, setSearchState]);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
function NoteEditor(props: Props, ref: any) {
|
||||
function NoteEditor(props: Props) {
|
||||
const webviewRef = useRef<WebViewControl>(null);
|
||||
|
||||
const setInitialSelectionJs = props.initialSelection ? `
|
||||
cm.select(${props.initialSelection.start}, ${props.initialSelection.end});
|
||||
cm.execCommand('scrollSelectionIntoView');
|
||||
` : '';
|
||||
const jumpToHashJs = props.noteHash ? `
|
||||
cm.jumpToHash(${JSON.stringify(props.noteHash)});
|
||||
` : '';
|
||||
const setInitialSearchJs = props.globalSearch ? `
|
||||
cm.setSearchState(${JSON.stringify({
|
||||
...defaultSearchState,
|
||||
searchText: props.globalSearch,
|
||||
})})
|
||||
` : '';
|
||||
|
||||
const editorSettings: EditorSettings = useMemo(() => ({
|
||||
themeId: props.themeId,
|
||||
themeData: editorTheme(props.themeId),
|
||||
markdownMarkEnabled: Setting.value('markdown.plugin.mark'),
|
||||
katexEnabled: Setting.value('markdown.plugin.katex'),
|
||||
spellcheckEnabled: Setting.value('editor.mobile.spellcheckEnabled'),
|
||||
language: EditorLanguageType.Markdown,
|
||||
language: props.markupLanguage === MarkupLanguage.Html ? EditorLanguageType.Html : EditorLanguageType.Markdown,
|
||||
useExternalSearch: true,
|
||||
readOnly: props.readOnly,
|
||||
|
||||
@@ -365,208 +261,83 @@ function NoteEditor(props: Props, ref: any) {
|
||||
indentWithTabs: true,
|
||||
|
||||
editorLabel: _('Markdown editor'),
|
||||
}), [props.themeId, props.readOnly]);
|
||||
}), [props.themeId, props.readOnly, props.markupLanguage]);
|
||||
|
||||
const injectedJavaScript = `
|
||||
window.onerror = (message, source, lineno) => {
|
||||
window.ReactNativeWebView.postMessage(
|
||||
"error: " + message + " in file://" + source + ", line " + lineno
|
||||
);
|
||||
};
|
||||
window.onunhandledrejection = (event) => {
|
||||
window.ReactNativeWebView.postMessage(
|
||||
"error: Unhandled promise rejection: " + event
|
||||
);
|
||||
};
|
||||
|
||||
if (!window.cm) {
|
||||
// This variable is not used within this script
|
||||
// but is called using "injectJavaScript" from
|
||||
// the wrapper component.
|
||||
window.cm = null;
|
||||
|
||||
try {
|
||||
${shim.injectedJs('codeMirrorBundle')};
|
||||
codeMirrorBundle.setUpLogger();
|
||||
|
||||
const parentElement = document.getElementsByClassName('CodeMirror')[0];
|
||||
// On Android, injectJavaScript is run twice -- once before the parent element exists.
|
||||
// To avoid logging unnecessary errors to the console, skip setup in this case:
|
||||
if (parentElement) {
|
||||
const initialText = ${JSON.stringify(props.initialText)};
|
||||
const settings = ${JSON.stringify(editorSettings)};
|
||||
|
||||
window.cm = codeMirrorBundle.initCodeMirror(
|
||||
parentElement,
|
||||
initialText,
|
||||
${JSON.stringify(props.noteId)},
|
||||
settings
|
||||
);
|
||||
|
||||
${jumpToHashJs}
|
||||
// Set the initial selection after jumping to the header -- the initial selection,
|
||||
// if specified, should take precedence.
|
||||
${setInitialSelectionJs}
|
||||
${setInitialSearchJs}
|
||||
|
||||
window.onresize = () => {
|
||||
cm.execCommand('scrollSelectionIntoView');
|
||||
};
|
||||
} else {
|
||||
console.warn('No parent element for the editor found. This may mean that the editor HTML is still loading.');
|
||||
}
|
||||
} catch (e) {
|
||||
window.ReactNativeWebView.postMessage("error:" + e.message + ": " + JSON.stringify(e))
|
||||
}
|
||||
}
|
||||
true;
|
||||
`;
|
||||
|
||||
const css = useCss(props.themeId);
|
||||
|
||||
useEffect(() => {
|
||||
if (webviewRef.current) {
|
||||
webviewRef.current.injectJS(`
|
||||
const styleClass = ${JSON.stringify(themeStyleSheetClassName)};
|
||||
for (const oldStyle of [...document.getElementsByClassName(styleClass)]) {
|
||||
oldStyle.remove();
|
||||
}
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.classList.add(styleClass);
|
||||
|
||||
style.appendChild(document.createTextNode(${JSON.stringify(css)}));
|
||||
document.head.appendChild(style);
|
||||
`);
|
||||
}
|
||||
}, [css]);
|
||||
|
||||
// Scroll to the new hash, if it changes.
|
||||
const isFirstScrollRef = useRef(true);
|
||||
useEffect(() => {
|
||||
// The first "jump to header" is handled during editor setup and shouldn't
|
||||
// be handled a second time:
|
||||
if (isFirstScrollRef.current) {
|
||||
isFirstScrollRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (jumpToHashJs && webviewRef.current) {
|
||||
webviewRef.current.injectJS(jumpToHashJs);
|
||||
}
|
||||
}, [jumpToHashJs]);
|
||||
|
||||
const html = useHtml(css);
|
||||
const [selectionState, setSelectionState] = useState<SelectionFormatting>(defaultSelectionFormatting);
|
||||
const [linkDialogVisible, setLinkDialogVisible] = useState(false);
|
||||
const [searchState, setSearchState] = useState(defaultSearchState);
|
||||
|
||||
const onEditorEvent = useRef((_event: EditorEvent) => {});
|
||||
const editorControlRef = useRef<EditorControl|null>(null);
|
||||
const onEditorEvent = (event: EditorEvent) => {
|
||||
let exhaustivenessCheck: never;
|
||||
switch (event.kind) {
|
||||
case EditorEventType.Change:
|
||||
props.onChange(event);
|
||||
break;
|
||||
case EditorEventType.UndoRedoDepthChange:
|
||||
props.onUndoRedoDepthChange(event);
|
||||
break;
|
||||
case EditorEventType.SelectionRangeChange:
|
||||
props.onSelectionChange(event);
|
||||
break;
|
||||
case EditorEventType.SelectionFormattingChange:
|
||||
setSelectionState(event.formatting);
|
||||
break;
|
||||
case EditorEventType.EditLink:
|
||||
editorControl.showLinkDialog();
|
||||
break;
|
||||
case EditorEventType.FollowLink:
|
||||
void CommandService.instance().execute('openItem', event.link);
|
||||
break;
|
||||
case EditorEventType.UpdateSearchDialog:
|
||||
setSearchState(event.searchState);
|
||||
|
||||
const onAttachRef = useRef(props.onAttach);
|
||||
onAttachRef.current = props.onAttach;
|
||||
|
||||
const editorMessenger = useMemo(() => {
|
||||
const localApi: WebViewToEditorApi = {
|
||||
async onEditorEvent(event) {
|
||||
onEditorEvent.current(event);
|
||||
},
|
||||
async logMessage(message) {
|
||||
logger.debug('CodeMirror:', message);
|
||||
},
|
||||
async onPasteFile(type, data) {
|
||||
const tempFilePath = join(Setting.value('tempDir'), `paste.${uuid.createNano()}.${mimeUtils.toFileExtension(type)}`);
|
||||
await shim.fsDriver().mkdir(dirname(tempFilePath));
|
||||
try {
|
||||
await shim.fsDriver().writeFile(tempFilePath, data, 'base64');
|
||||
await onAttachRef.current(tempFilePath);
|
||||
} finally {
|
||||
await shim.fsDriver().remove(tempFilePath);
|
||||
}
|
||||
},
|
||||
};
|
||||
const messenger = new RNToWebViewMessenger<WebViewToEditorApi, EditorBodyControl>(
|
||||
'editor', webviewRef, localApi,
|
||||
);
|
||||
return messenger;
|
||||
}, []);
|
||||
if (event.searchState.dialogVisible) {
|
||||
editorControl.searchControl.showSearch();
|
||||
} else {
|
||||
editorControl.searchControl.hideSearch();
|
||||
}
|
||||
break;
|
||||
case EditorEventType.Scroll:
|
||||
// Not handled
|
||||
break;
|
||||
default:
|
||||
exhaustivenessCheck = event;
|
||||
return exhaustivenessCheck;
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
const editorRef = useRef<EditorBodyControl|null>(null);
|
||||
const editorControl = useEditorControl(
|
||||
editorMessenger.remoteApi, webviewRef, setLinkDialogVisible, setSearchState,
|
||||
editorRef, webviewRef, setLinkDialogVisible, setSearchState,
|
||||
);
|
||||
editorControlRef.current = editorControl;
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
editorControl.updateSettings(editorSettings);
|
||||
}, [editorSettings, editorControl]);
|
||||
|
||||
const lastNoteResources = useRef<ResourceInfos>(props.noteResources);
|
||||
useEffect(() => {
|
||||
const isDownloaded = (resourceInfos: ResourceInfos, resourceId: string) => {
|
||||
return resourceInfos[resourceId]?.localState?.fetch_status === Resource.FETCH_STATUS_DONE;
|
||||
};
|
||||
for (const key in props.noteResources) {
|
||||
const wasDownloaded = isDownloaded(lastNoteResources.current, key);
|
||||
if (!wasDownloaded && isDownloaded(props.noteResources, key)) {
|
||||
editorControl.onResourceDownloaded(key);
|
||||
}
|
||||
}
|
||||
}, [props.noteResources, editorControl]);
|
||||
|
||||
useEditorCommandHandler(editorControl);
|
||||
|
||||
useImperativeHandle(ref, () => {
|
||||
useImperativeHandle(props.ref, () => {
|
||||
return editorControl;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
onEditorEvent.current = (event: EditorEvent) => {
|
||||
let exhaustivenessCheck: never;
|
||||
switch (event.kind) {
|
||||
case EditorEventType.Change:
|
||||
props.onChange(event);
|
||||
break;
|
||||
case EditorEventType.UndoRedoDepthChange:
|
||||
props.onUndoRedoDepthChange(event);
|
||||
break;
|
||||
case EditorEventType.SelectionRangeChange:
|
||||
props.onSelectionChange(event);
|
||||
break;
|
||||
case EditorEventType.SelectionFormattingChange:
|
||||
setSelectionState(event.formatting);
|
||||
break;
|
||||
case EditorEventType.EditLink:
|
||||
editorControl.showLinkDialog();
|
||||
break;
|
||||
case EditorEventType.UpdateSearchDialog:
|
||||
setSearchState(event.searchState);
|
||||
|
||||
if (event.searchState.dialogVisible) {
|
||||
editorControl.searchControl.showSearch();
|
||||
} else {
|
||||
editorControl.searchControl.hideSearch();
|
||||
}
|
||||
break;
|
||||
case EditorEventType.Scroll:
|
||||
// Not handled
|
||||
break;
|
||||
default:
|
||||
exhaustivenessCheck = event;
|
||||
return exhaustivenessCheck;
|
||||
}
|
||||
return;
|
||||
};
|
||||
}, [props.onChange, props.onUndoRedoDepthChange, props.onSelectionChange, editorControl]);
|
||||
|
||||
const codeMirrorPlugins = useCodeMirrorPlugins(props.plugins);
|
||||
useEffect(() => {
|
||||
void editorControl.setContentScripts(codeMirrorPlugins);
|
||||
}, [codeMirrorPlugins, editorControl]);
|
||||
|
||||
const onLoadEnd = useCallback(() => {
|
||||
editorMessenger.onWebViewLoaded();
|
||||
}, [editorMessenger]);
|
||||
|
||||
const onMessage = useCallback((event: OnMessageEvent) => {
|
||||
const data = event.nativeEvent.data;
|
||||
|
||||
if (typeof data === 'string' && data.indexOf('error:') === 0) {
|
||||
logger.error('CodeMirror error', data);
|
||||
return;
|
||||
}
|
||||
|
||||
editorMessenger.onWebViewMessage(event);
|
||||
}, [editorMessenger]);
|
||||
|
||||
const onError = useCallback((event: NativeSyntheticEvent<WebViewErrorEvent>) => {
|
||||
logger.error(`Load error: Code ${event.nativeEvent.code}: ${event.nativeEvent.description}`);
|
||||
}, []);
|
||||
|
||||
const [hasSpaceForToolbar, setHasSpaceForToolbar] = useState(true);
|
||||
const toolbarEnabled = props.toolbarEnabled && hasSpaceForToolbar;
|
||||
|
||||
@@ -580,12 +351,24 @@ function NoteEditor(props: Props, ref: any) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onAttach = useCallback(async (type: string, base64: string) => {
|
||||
const tempFilePath = join(Setting.value('tempDir'), `paste.${uuid.createNano()}.${toFileExtension(type)}`);
|
||||
await shim.fsDriver().mkdir(dirname(tempFilePath));
|
||||
try {
|
||||
await shim.fsDriver().writeFile(tempFilePath, base64, 'base64');
|
||||
await props.onAttach(tempFilePath);
|
||||
} finally {
|
||||
await shim.fsDriver().remove(tempFilePath);
|
||||
}
|
||||
}, [props.onAttach]);
|
||||
|
||||
const toolbarEditorState = useMemo(() => ({
|
||||
selectionState,
|
||||
searchVisible: searchState.dialogVisible,
|
||||
}), [selectionState, searchState.dialogVisible]);
|
||||
|
||||
const toolbar = <EditorToolbar editorState={toolbarEditorState} />;
|
||||
const EditorComponent = props.mode === EditorType.Markdown ? MarkdownEditor : RichTextEditor;
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -607,20 +390,25 @@ function NoteEditor(props: Props, ref: any) {
|
||||
flexShrink: 0,
|
||||
minHeight: '30%',
|
||||
}}>
|
||||
<ExtendedWebView
|
||||
webviewInstanceId='NoteEditor'
|
||||
testID='NoteEditor'
|
||||
scrollEnabled={true}
|
||||
ref={webviewRef}
|
||||
html={html}
|
||||
injectedJavaScript={injectedJavaScript}
|
||||
hasPluginScripts={codeMirrorPlugins.length > 0}
|
||||
onMessage={onMessage}
|
||||
onLoadEnd={onLoadEnd}
|
||||
onError={onError}
|
||||
<EditorComponent
|
||||
editorRef={editorRef}
|
||||
webviewRef={webviewRef}
|
||||
themeId={props.themeId}
|
||||
noteId={props.noteId}
|
||||
noteHash={props.noteHash}
|
||||
initialText={props.initialText}
|
||||
initialSelection={props.initialSelection}
|
||||
editorSettings={editorSettings}
|
||||
globalSearch={props.globalSearch}
|
||||
onEditorEvent={onEditorEvent}
|
||||
noteResources={props.noteResources}
|
||||
plugins={props.plugins}
|
||||
onAttach={onAttach}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<WarningBanner editorType={props.mode}/>
|
||||
|
||||
<SearchPanel
|
||||
editorSettings={editorSettings}
|
||||
searchControl={editorControl.searchControl}
|
||||
@@ -632,4 +420,4 @@ function NoteEditor(props: Props, ref: any) {
|
||||
);
|
||||
}
|
||||
|
||||
export default forwardRef(NoteEditor);
|
||||
export default NoteEditor;
|
||||
|
@@ -0,0 +1,357 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { describe, it, beforeEach } from '@jest/globals';
|
||||
import { render, waitFor } from '../../utils/testing/testingLibrary';
|
||||
|
||||
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { createNoteAndResource, resourceFetcher, setupDatabaseAndSynchronizer, supportDir, switchClient, synchronizerStart } from '@joplin/lib/testing/test-utils';
|
||||
import getWebViewWindowById from '../../utils/testing/getWebViewWindowById';
|
||||
import TestProviderStack from '../testing/TestProviderStack';
|
||||
import createMockReduxStore from '../../utils/testing/createMockReduxStore';
|
||||
import RichTextEditor from './RichTextEditor';
|
||||
import createTestEditorProps from './testing/createTestEditorProps';
|
||||
import { EditorEvent, EditorEventType } from '@joplin/editor/events';
|
||||
import { RefObject, useCallback, useMemo } from 'react';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import Resource from '@joplin/lib/models/Resource';
|
||||
import { ResourceInfos } from '@joplin/renderer/types';
|
||||
import { EditorControl, EditorLanguageType } from '@joplin/editor/types';
|
||||
import attachedResources from '@joplin/lib/utils/attachedResources';
|
||||
import { MarkupLanguage } from '@joplin/renderer';
|
||||
import { NoteEntity } from '@joplin/lib/services/database/types';
|
||||
import { EditorSettings } from './types';
|
||||
import { pregQuote } from '@joplin/lib/string-utils';
|
||||
|
||||
|
||||
interface WrapperProps {
|
||||
ref?: RefObject<EditorControl>;
|
||||
noteResources?: ResourceInfos;
|
||||
onBodyChange: (newBody: string)=> void;
|
||||
onLinkClick?: (link: string)=> void;
|
||||
note?: NoteEntity;
|
||||
noteBody: string;
|
||||
}
|
||||
|
||||
const defaultEditorProps = createTestEditorProps();
|
||||
const testStore = createMockReduxStore();
|
||||
const WrappedEditor: React.FC<WrapperProps> = (
|
||||
{
|
||||
noteBody,
|
||||
note,
|
||||
onBodyChange,
|
||||
onLinkClick,
|
||||
noteResources,
|
||||
ref,
|
||||
}: WrapperProps,
|
||||
) => {
|
||||
const onEvent = useCallback((event: EditorEvent) => {
|
||||
if (event.kind === EditorEventType.Change) {
|
||||
onBodyChange(event.value);
|
||||
} else if (event.kind === EditorEventType.FollowLink) {
|
||||
if (!onLinkClick) {
|
||||
throw new Error('No mock function for onLinkClick registered.');
|
||||
}
|
||||
|
||||
onLinkClick(event.link);
|
||||
}
|
||||
}, [onBodyChange, onLinkClick]);
|
||||
|
||||
const editorSettings = useMemo((): EditorSettings => {
|
||||
const isHtml = note?.markup_language === MarkupLanguage.Html;
|
||||
return {
|
||||
...defaultEditorProps.editorSettings,
|
||||
language: isHtml ? EditorLanguageType.Html : EditorLanguageType.Markdown,
|
||||
};
|
||||
}, [note]);
|
||||
|
||||
return <TestProviderStack store={testStore}>
|
||||
<RichTextEditor
|
||||
{...defaultEditorProps}
|
||||
editorSettings={editorSettings}
|
||||
onEditorEvent={onEvent}
|
||||
initialText={noteBody}
|
||||
noteId={note?.id ?? defaultEditorProps.noteId}
|
||||
noteResources={noteResources ?? defaultEditorProps.noteResources}
|
||||
editorRef={ref ?? defaultEditorProps.editorRef}
|
||||
/>
|
||||
</TestProviderStack>;
|
||||
};
|
||||
|
||||
const getEditorWindow = async () => {
|
||||
return await getWebViewWindowById('RichTextEditor');
|
||||
};
|
||||
|
||||
type EditorWindow = Window&typeof globalThis;
|
||||
const getEditorControl = (window: EditorWindow) => {
|
||||
if ('joplinRichTextEditor_' in window) {
|
||||
return window.joplinRichTextEditor_ as EditorControl;
|
||||
}
|
||||
throw new Error('No editor control found. Is the editor loaded?');
|
||||
};
|
||||
|
||||
const mockTyping = (window: EditorWindow, text: string) => {
|
||||
const document = window.document;
|
||||
const editor = document.querySelector('div[contenteditable]');
|
||||
|
||||
for (const character of text.split('')) {
|
||||
editor.dispatchEvent(new window.KeyboardEvent('keydown', { key: character }));
|
||||
const paragraphs = editor.querySelectorAll('p');
|
||||
(paragraphs[paragraphs.length - 1] ?? editor).appendChild(document.createTextNode(character));
|
||||
editor.dispatchEvent(new window.KeyboardEvent('keyup', { key: character }));
|
||||
}
|
||||
};
|
||||
|
||||
const mockSelectionMovement = (window: EditorWindow, position: number) => {
|
||||
getEditorControl(window).select(position, position);
|
||||
};
|
||||
|
||||
const findElement = async function<ElementType extends Element = Element>(selector: string) {
|
||||
const window = await getEditorWindow();
|
||||
return await waitFor(() => {
|
||||
const element = window.document.querySelector<ElementType>(selector);
|
||||
expect(element).toBeTruthy();
|
||||
return element;
|
||||
}, {
|
||||
onTimeout: (error) => {
|
||||
return new Error(`Failed to find element from selector ${selector}. DOM: ${window?.document?.body?.innerHTML}. \n\nFull error: ${error}`);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const createRemoteResourceAndNote = async (remoteClientId: number) => {
|
||||
await setupDatabaseAndSynchronizer(remoteClientId);
|
||||
await switchClient(remoteClientId);
|
||||
|
||||
let note = await Note.save({ title: 'Note 1', parent_id: '' });
|
||||
note = await shim.attachFileToNote(note, `${supportDir}/photo.jpg`);
|
||||
|
||||
const allResources = await Resource.all();
|
||||
expect(allResources.length).toBe(1);
|
||||
const resourceId = allResources[0].id;
|
||||
|
||||
await synchronizerStart();
|
||||
await switchClient(0);
|
||||
await synchronizerStart();
|
||||
|
||||
|
||||
return { noteId: note.id, resourceId };
|
||||
};
|
||||
|
||||
describe('RichTextEditor', () => {
|
||||
beforeEach(async () => {
|
||||
await setupDatabaseAndSynchronizer(0);
|
||||
await switchClient(0);
|
||||
Setting.setValue('editor.codeView', false);
|
||||
});
|
||||
|
||||
it('should render basic markdown', async () => {
|
||||
render(<WrappedEditor
|
||||
noteBody={'### Test\n\nParagraph `test`'}
|
||||
onBodyChange={jest.fn()}
|
||||
/>);
|
||||
|
||||
const dom = (await getEditorWindow()).document;
|
||||
expect((await findElement('h3')).textContent).toBe('Test');
|
||||
expect(dom.querySelector('p').textContent).toBe('Paragraph test');
|
||||
expect(dom.querySelector('p code').textContent).toBe('test');
|
||||
});
|
||||
|
||||
it('should dispatch events when the editor content changes', async () => {
|
||||
let body = '**bold** normal';
|
||||
render(<WrappedEditor
|
||||
noteBody={body}
|
||||
onBodyChange={newBody => { body = newBody; }}
|
||||
/>);
|
||||
|
||||
const window = await getEditorWindow();
|
||||
mockTyping(window, ' test');
|
||||
|
||||
await waitFor(async () => {
|
||||
expect(body.trim()).toBe('**bold** normal test');
|
||||
});
|
||||
});
|
||||
|
||||
it('should render clickable checkboxes', async () => {
|
||||
let body = '- [ ] Test\n- [x] Another test';
|
||||
render(<WrappedEditor
|
||||
noteBody={body}
|
||||
onBodyChange={newBody => { body = newBody; }}
|
||||
/>);
|
||||
|
||||
const firstCheckbox = await findElement<HTMLInputElement>('input[type=checkbox]');
|
||||
const dom = (await getEditorWindow()).document;
|
||||
const getCheckboxLabel = (checkbox: HTMLElement) => {
|
||||
const labelledByAttr = checkbox.getAttribute('aria-labelledby');
|
||||
const label = dom.getElementById(labelledByAttr);
|
||||
return label;
|
||||
};
|
||||
|
||||
// Should have the correct labels
|
||||
expect(firstCheckbox.getAttribute('aria-labelledby')).toBeTruthy();
|
||||
expect(getCheckboxLabel(firstCheckbox).textContent).toBe('Test');
|
||||
|
||||
// Should be correctly checked/unchecked
|
||||
expect(firstCheckbox.checked).toBe(false);
|
||||
|
||||
// Clicking a checkbox should toggle it
|
||||
firstCheckbox.click();
|
||||
|
||||
await waitFor(async () => {
|
||||
// At present, lists are saved as non-tight lists:
|
||||
expect(body.trim()).toBe('- [x] Test\n \n- [x] Another test');
|
||||
});
|
||||
});
|
||||
|
||||
it('should reload resource placeholders when the corresponding item downloads', async () => {
|
||||
Setting.setValue('sync.resourceDownloadMode', 'manual');
|
||||
const { noteId, resourceId } = await createRemoteResourceAndNote(1);
|
||||
|
||||
const note = await Note.load(noteId);
|
||||
const localResource = await Resource.load(resourceId);
|
||||
let localState = await Resource.localState(localResource);
|
||||
expect(localState.fetch_status).toBe(Resource.FETCH_STATUS_IDLE);
|
||||
|
||||
const editorRef = React.createRef<EditorControl>();
|
||||
const component = render(
|
||||
<WrappedEditor
|
||||
noteBody={note.body}
|
||||
noteResources={{ [localResource.id]: { localState, item: localResource } }}
|
||||
onBodyChange={jest.fn()}
|
||||
ref={editorRef}
|
||||
/>,
|
||||
);
|
||||
|
||||
// The resource placeholder should have rendered
|
||||
const placeholder = await findElement(`span[data-resource-id=${JSON.stringify(localResource.id)}]`);
|
||||
expect([...placeholder.classList]).toContain('not-loaded-resource');
|
||||
|
||||
await resourceFetcher().markForDownload([localResource.id]);
|
||||
|
||||
await waitFor(async () => {
|
||||
localState = await Resource.localState(localResource.id);
|
||||
expect(localState).toMatchObject({ fetch_status: Resource.FETCH_STATUS_DONE });
|
||||
});
|
||||
|
||||
component.rerender(
|
||||
<WrappedEditor
|
||||
noteBody={note.body}
|
||||
noteResources={{ [localResource.id]: { localState, item: localResource } }}
|
||||
onBodyChange={jest.fn()}
|
||||
ref={editorRef}
|
||||
/>,
|
||||
);
|
||||
editorRef.current.onResourceDownloaded(localResource.id);
|
||||
|
||||
expect(
|
||||
await findElement(`img[data-resource-id=${JSON.stringify(localResource.id)}]`),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render clickable internal note links', async () => {
|
||||
const linkTarget = await Note.save({ title: 'test' });
|
||||
const body = `[link](:/${linkTarget.id})`;
|
||||
const onLinkClick = jest.fn();
|
||||
render(<WrappedEditor
|
||||
noteBody={body}
|
||||
onBodyChange={jest.fn()}
|
||||
onLinkClick={onLinkClick}
|
||||
/>);
|
||||
|
||||
const window = await getEditorWindow();
|
||||
|
||||
const link = await findElement<HTMLAnchorElement>('a[href]');
|
||||
expect(link.href).toBe(`:/${linkTarget.id}`);
|
||||
mockSelectionMovement(window, 2);
|
||||
|
||||
const tooltipButton = await findElement<HTMLButtonElement>('.link-tooltip:not(.-hidden) > button');
|
||||
tooltipButton.click();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onLinkClick).toHaveBeenCalledWith(`:/${linkTarget.id}`);
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
MarkupLanguage.Markdown, MarkupLanguage.Html,
|
||||
])('should preserve image attachments on edit (case %#)', async (markupLanguage) => {
|
||||
const { note, resource } = await createNoteAndResource({ markupLanguage });
|
||||
let body = note.body;
|
||||
|
||||
const resources = await attachedResources(body);
|
||||
render(<WrappedEditor
|
||||
noteBody={note.body}
|
||||
note={note}
|
||||
onBodyChange={newBody => { body = newBody; }}
|
||||
noteResources={resources}
|
||||
/>);
|
||||
|
||||
const renderedImage = await findElement<HTMLImageElement>(`img[data-resource-id=${JSON.stringify(resource.id)}]`);
|
||||
expect(renderedImage).toBeTruthy();
|
||||
|
||||
const window = await getEditorWindow();
|
||||
mockTyping(window, ' test');
|
||||
|
||||
// The rendered image should still have the correct ALT and source
|
||||
await waitFor(async () => {
|
||||
const editorContent = body.trim();
|
||||
if (markupLanguage === MarkupLanguage.Html) {
|
||||
expect(editorContent).toMatch(
|
||||
new RegExp(`^<p><img src=":/${pregQuote(resource.id)}" alt="${pregQuote(renderedImage.alt)}"[^>]*> test</p>$`),
|
||||
);
|
||||
} else {
|
||||
expect(editorContent).toBe(` test`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ useValidSyntax: false },
|
||||
{ useValidSyntax: true },
|
||||
])('should preserve inline math on edit (%j)', async ({ useValidSyntax }) => {
|
||||
const macros = '\\def\\<{\\langle} \\def\\>{\\rangle}';
|
||||
let inlineMath = '| \\< u, v \\> |^2 \\leq \\< u, u \\>\\< v, v \\>';
|
||||
// The \\< escapes are invalid without the above custom macro definitions.
|
||||
// It should be possible for the editor to preserve invalid math syntax.
|
||||
if (useValidSyntax) {
|
||||
inlineMath = macros + inlineMath;
|
||||
}
|
||||
|
||||
let body = `Inline math: $${inlineMath}$...`;
|
||||
|
||||
render(<WrappedEditor
|
||||
noteBody={body}
|
||||
onBodyChange={newBody => { body = newBody; }}
|
||||
/>);
|
||||
|
||||
const renderedInlineMath = await findElement<HTMLElement>('span.joplin-editable');
|
||||
expect(renderedInlineMath).toBeTruthy();
|
||||
|
||||
const window = await getEditorWindow();
|
||||
mockTyping(window, ' testing');
|
||||
|
||||
await waitFor(async () => {
|
||||
expect(body.trim()).toBe(`Inline math: $${inlineMath}$... testing`);
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve block math on edit', async () => {
|
||||
let body = 'Test:\n\n$$3^2 + 4^2 = \\sqrt{625}$$\n\nTest.';
|
||||
|
||||
render(<WrappedEditor
|
||||
noteBody={body}
|
||||
onBodyChange={newBody => { body = newBody; }}
|
||||
/>);
|
||||
|
||||
const renderedInlineMath = await findElement<HTMLElement>('div.joplin-editable');
|
||||
expect(renderedInlineMath).toBeTruthy();
|
||||
|
||||
const window = await getEditorWindow();
|
||||
mockTyping(window, ' testing');
|
||||
|
||||
await waitFor(async () => {
|
||||
expect(body.trim()).toBe('Test:\n\n$$\n3^2 + 4^2 = \\sqrt{625}\n$$\n\nTest. testing');
|
||||
});
|
||||
});
|
||||
});
|
155
packages/app-mobile/components/NoteEditor/RichTextEditor.tsx
Normal file
155
packages/app-mobile/components/NoteEditor/RichTextEditor.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import { themeStyle } from '@joplin/lib/theme';
|
||||
import themeToCss from '@joplin/lib/services/style/themeToCss';
|
||||
import ExtendedWebView from '../ExtendedWebView';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useMemo, useCallback, useRef } from 'react';
|
||||
import { NativeSyntheticEvent } from 'react-native';
|
||||
|
||||
import { EditorProps } from './types';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { WebViewErrorEvent } from 'react-native-webview/lib/RNCWebViewNativeComponent';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import { OnMessageEvent } from '../ExtendedWebView/types';
|
||||
import useWebViewSetup from '../../contentScripts/richTextEditorBundle/useWebViewSetup';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import shim from '@joplin/lib/shim';
|
||||
|
||||
const logger = Logger.create('RichTextEditor');
|
||||
|
||||
function useCss(themeId: number, editorCss: string): string {
|
||||
return useMemo(() => {
|
||||
const theme = themeStyle(themeId);
|
||||
const themeVariableCss = themeToCss(theme);
|
||||
return `
|
||||
${themeVariableCss}
|
||||
${editorCss}
|
||||
|
||||
:root {
|
||||
background-color: ${theme.backgroundColor};
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100vh;
|
||||
/* Prefer 100% -- 100vw shows an unnecessary horizontal scrollbar in Google Chrome (desktop). */
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
padding-left: 4px;
|
||||
padding-right: 4px;
|
||||
padding-bottom: 1px;
|
||||
padding-top: 10px;
|
||||
|
||||
font-size: 13pt;
|
||||
font-family: ${JSON.stringify(theme.fontFamily)}, sans-serif;
|
||||
}
|
||||
`;
|
||||
}, [themeId, editorCss]);
|
||||
}
|
||||
|
||||
function useHtml(initialCss: string): string {
|
||||
const cssRef = useRef(initialCss);
|
||||
cssRef.current = initialCss;
|
||||
|
||||
return useMemo(() => `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||||
<title>${_('Note editor')}</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="RichTextEditor" style="height:100%;" autocapitalize="on"></div>
|
||||
</body>
|
||||
</html>
|
||||
`, []);
|
||||
}
|
||||
|
||||
const onPostMessage = async (message: string) => {
|
||||
try {
|
||||
await CommandService.instance().execute('openItem', message);
|
||||
} catch (error) {
|
||||
void shim.showErrorDialog(`postMessage failed: ${message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const RichTextEditor: React.FC<EditorProps> = props => {
|
||||
const webviewRef = props.webviewRef;
|
||||
|
||||
const editorWebViewSetup = useWebViewSetup({
|
||||
parentElementClassName: 'RichTextEditor',
|
||||
onEditorEvent: props.onEditorEvent,
|
||||
initialText: props.initialText,
|
||||
noteId: props.noteId,
|
||||
settings: props.editorSettings,
|
||||
webviewRef,
|
||||
themeId: props.themeId,
|
||||
pluginStates: props.plugins,
|
||||
noteResources: props.noteResources,
|
||||
onPostMessage: onPostMessage,
|
||||
onAttachFile: props.onAttach,
|
||||
});
|
||||
|
||||
props.editorRef.current = editorWebViewSetup.api;
|
||||
|
||||
const injectedJavaScript = `
|
||||
window.onerror = (message, source, lineno) => {
|
||||
console.error(message);
|
||||
window.ReactNativeWebView.postMessage(
|
||||
"error: " + message + " in file://" + source + ", line " + lineno
|
||||
);
|
||||
};
|
||||
window.onunhandledrejection = (event) => {
|
||||
window.ReactNativeWebView.postMessage(
|
||||
"error: Unhandled promise rejection: " + event
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
${editorWebViewSetup.pageSetup.js}
|
||||
} catch (e) {
|
||||
console.error('Setup error: ', e);
|
||||
window.ReactNativeWebView.postMessage("error:" + e.message + ": " + JSON.stringify(e))
|
||||
}
|
||||
|
||||
true;
|
||||
`;
|
||||
|
||||
const css = useCss(props.themeId, editorWebViewSetup.pageSetup.css);
|
||||
const html = useHtml(css);
|
||||
|
||||
const onMessage = useCallback((event: OnMessageEvent) => {
|
||||
const data = event.nativeEvent.data;
|
||||
|
||||
if (typeof data === 'string' && data.indexOf('error:') === 0) {
|
||||
logger.error('Rich Text Editor error', data);
|
||||
return;
|
||||
}
|
||||
|
||||
editorWebViewSetup.webViewEventHandlers.onMessage(event);
|
||||
}, [editorWebViewSetup]);
|
||||
|
||||
const onError = useCallback((event: NativeSyntheticEvent<WebViewErrorEvent>) => {
|
||||
logger.error(`Load error: Code ${event.nativeEvent.code}: ${event.nativeEvent.description}`);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ExtendedWebView
|
||||
ref={webviewRef}
|
||||
webviewInstanceId='RichTextEditor'
|
||||
testID='RichTextEditor'
|
||||
scrollEnabled={true}
|
||||
html={html}
|
||||
injectedJavaScript={injectedJavaScript}
|
||||
css={css}
|
||||
hasPluginScripts={false}
|
||||
onMessage={onMessage}
|
||||
onLoadEnd={editorWebViewSetup.webViewEventHandlers.onLoadEnd}
|
||||
onError={onError}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default RichTextEditor;
|
@@ -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;
|
||||
|
47
packages/app-mobile/components/NoteEditor/WarningBanner.tsx
Normal file
47
packages/app-mobile/components/NoteEditor/WarningBanner.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import * as React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import onRichTextReadMoreLinkClick from '@joplin/lib/components/shared/NoteEditor/WarningBanner/onRichTextReadMoreLinkClick';
|
||||
import onRichTextDismissLinkClick from '@joplin/lib/components/shared/NoteEditor/WarningBanner/onRichTextDismissLinkClick';
|
||||
import { AppState } from '../../utils/types';
|
||||
import { EditorType } from './types';
|
||||
import { Banner } from 'react-native-paper';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
interface Props {
|
||||
editorType: EditorType;
|
||||
richTextBannerDismissed: boolean;
|
||||
}
|
||||
|
||||
const WarningBanner: React.FC<Props> = props => {
|
||||
const actions = useMemo(() => [
|
||||
{
|
||||
label: _('Read more'),
|
||||
onPress: onRichTextReadMoreLinkClick,
|
||||
},
|
||||
{
|
||||
label: _('Dismiss'),
|
||||
accessibilityHint: _('Hides warning'),
|
||||
onPress: onRichTextDismissLinkClick,
|
||||
},
|
||||
], []);
|
||||
|
||||
if (props.editorType !== EditorType.RichText || props.richTextBannerDismissed) return null;
|
||||
return (
|
||||
<Banner
|
||||
icon='alert-outline'
|
||||
actions={actions}
|
||||
// Avoid hiding with react-native-paper's "visible" prop to avoid potential accessibility issues
|
||||
// related to how react-native-paper hides the banner.
|
||||
visible={true}
|
||||
>
|
||||
{_('This Rich Text editor has a number of limitations and it is recommended to be aware of them before using it.')}
|
||||
</Banner>
|
||||
);
|
||||
};
|
||||
|
||||
export default connect((state: AppState) => {
|
||||
return {
|
||||
richTextBannerDismissed: state.settings.richTextBannerDismissed,
|
||||
};
|
||||
})(WarningBanner);
|
@@ -2,11 +2,22 @@ import { EditorCommandType } from '@joplin/editor/types';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { CommandDeclaration } from '@joplin/lib/services/CommandService';
|
||||
|
||||
export const enabledCondition = (_commandName: string) => {
|
||||
const markdownEditorOnlyCommands = [
|
||||
EditorCommandType.DuplicateLine,
|
||||
EditorCommandType.SortSelectedLines,
|
||||
EditorCommandType.SwapLineUp,
|
||||
EditorCommandType.SwapLineDown,
|
||||
].map(command => `editor.${command}`);
|
||||
|
||||
export const enabledCondition = (commandName: string) => {
|
||||
const output = [
|
||||
'!noteIsReadOnly',
|
||||
];
|
||||
|
||||
if (markdownEditorOnlyCommands.includes(commandName)) {
|
||||
output.push('!richTextEditorVisible');
|
||||
}
|
||||
|
||||
return output.filter(c => !!c).join(' && ');
|
||||
};
|
||||
|
||||
|
@@ -0,0 +1,24 @@
|
||||
import * as React from 'react';
|
||||
import createEditorSettings from '@joplin/editor/testing/createEditorSettings';
|
||||
import { EditorProps } from '../types';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
|
||||
const defaultEditorSettings = { ...createEditorSettings(Setting.THEME_LIGHT), themeId: Setting.THEME_LIGHT };
|
||||
const defaultWrapperProps: EditorProps = {
|
||||
noteResources: {},
|
||||
webviewRef: React.createRef(),
|
||||
editorRef: React.createRef(),
|
||||
themeId: Setting.THEME_LIGHT,
|
||||
noteHash: '',
|
||||
noteId: '',
|
||||
initialText: '',
|
||||
editorSettings: defaultEditorSettings,
|
||||
initialSelection: { start: 0, end: 0 },
|
||||
globalSearch: '',
|
||||
plugins: {},
|
||||
onAttach: () => Promise.resolve(),
|
||||
onEditorEvent: () => {},
|
||||
};
|
||||
|
||||
const createTestEditorProps = () => ({ ...defaultWrapperProps });
|
||||
export default createTestEditorProps;
|
@@ -1,7 +1,12 @@
|
||||
// Types related to the NoteEditor
|
||||
|
||||
import { EditorEvent } from '@joplin/editor/events';
|
||||
import { EditorControl as EditorBodyControl, EditorSettings as EditorBodySettings, SearchState } from '@joplin/editor/types';
|
||||
import { RefObject } from 'react';
|
||||
import { WebViewControl } from '../ExtendedWebView/types';
|
||||
import { SelectionRange } from '../../contentScripts/markdownEditorBundle/types';
|
||||
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
|
||||
import { EditorEvent } from '@joplin/editor/events';
|
||||
import { ResourceInfos } from '@joplin/renderer/types';
|
||||
|
||||
export interface SearchControl {
|
||||
findNext(): void;
|
||||
@@ -45,17 +50,27 @@ export interface EditorControl extends EditorBodyControl {
|
||||
searchControl: SearchControl;
|
||||
}
|
||||
|
||||
export interface EditorSettings extends EditorBodySettings {
|
||||
export type EditorSettings = EditorBodySettings;
|
||||
|
||||
type OnAttachCallback = (mime: string, base64: string)=> Promise<void>;
|
||||
export interface EditorProps {
|
||||
noteResources: ResourceInfos;
|
||||
editorRef: RefObject<EditorBodyControl>;
|
||||
webviewRef: RefObject<WebViewControl>;
|
||||
themeId: number;
|
||||
noteId: string;
|
||||
noteHash: string;
|
||||
initialText: string;
|
||||
initialSelection: SelectionRange;
|
||||
editorSettings: EditorSettings;
|
||||
globalSearch: string;
|
||||
plugins: PluginStates;
|
||||
|
||||
onAttach: OnAttachCallback;
|
||||
onEditorEvent: (event: EditorEvent)=> void;
|
||||
}
|
||||
|
||||
export interface SelectionRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface WebViewToEditorApi {
|
||||
onEditorEvent(event: EditorEvent): Promise<void>;
|
||||
logMessage(message: string): Promise<void>;
|
||||
onPasteFile(type: string, dataBase64: string): Promise<void>;
|
||||
export enum EditorType {
|
||||
Markdown = 'markdown',
|
||||
RichText = 'rich-text',
|
||||
}
|
||||
|
@@ -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'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
@@ -46,8 +46,8 @@ const getNoteViewerDom = async () => {
|
||||
return await getWebViewDomById('NoteBodyViewer');
|
||||
};
|
||||
|
||||
const getNoteEditorControl = async () => {
|
||||
const noteEditor = await getWebViewWindowById('NoteEditor');
|
||||
const getMarkdownEditorControl = async () => {
|
||||
const noteEditor = await getWebViewWindowById('MarkdownEditor');
|
||||
const getEditorControl = () => {
|
||||
if ('cm' in noteEditor.window && noteEditor.window.cm) {
|
||||
return noteEditor.window.cm as CodeMirrorControl;
|
||||
@@ -213,7 +213,7 @@ describe('screens/Note', () => {
|
||||
|
||||
const noteScreen = render(<WrappedNoteScreen />);
|
||||
await openEditor();
|
||||
const editor = await getNoteEditorControl();
|
||||
const editor = await getMarkdownEditorControl();
|
||||
await act(async () => {
|
||||
editor.select(defaultBody.length, defaultBody.length);
|
||||
editor.insertText(' Testing!!!');
|
||||
|
@@ -43,7 +43,6 @@ import { ChangeEvent as EditorChangeEvent, SelectionRangeChangeEvent, UndoRedoDe
|
||||
import { join } from 'path';
|
||||
import { Dispatch } from 'redux';
|
||||
import { RefObject, useContext } from 'react';
|
||||
import { SelectionRange } from '../../NoteEditor/types';
|
||||
import { getNoteCallbackUrl } from '@joplin/lib/callbackUrlUtils';
|
||||
import { AppState } from '../../../utils/types';
|
||||
import restoreItems from '@joplin/lib/services/trash/restoreItems';
|
||||
@@ -57,7 +56,7 @@ import getImageDimensions from '../../../utils/image/getImageDimensions';
|
||||
import resizeImage from '../../../utils/image/resizeImage';
|
||||
import { CameraResult } from '../../CameraView/types';
|
||||
import { DialogContext, DialogControl } from '../../DialogManager';
|
||||
import { CommandRuntimeProps, EditorMode, PickerResponse } from './types';
|
||||
import { CommandRuntimeProps, NoteViewerMode, PickerResponse } from './types';
|
||||
import commands from './commands';
|
||||
import { AttachFileAction, AttachFileOptions } from './commands/attachFile';
|
||||
import PluginService from '@joplin/lib/services/plugins/PluginService';
|
||||
@@ -72,6 +71,8 @@ import ShareNoteDialog from '../ShareNoteDialog';
|
||||
import stateToWhenClauseContext from '../../../services/commands/stateToWhenClauseContext';
|
||||
import { defaultWindowId } from '@joplin/lib/reducer';
|
||||
import useVisiblePluginEditorViewIds from '@joplin/lib/hooks/plugins/useVisiblePluginEditorViewIds';
|
||||
import { SelectionRange } from '../../../contentScripts/markdownEditorBundle/types';
|
||||
import { EditorType } from '../../NoteEditor/types';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const emptyArray: any[] = [];
|
||||
@@ -95,6 +96,7 @@ interface Props extends BaseProps {
|
||||
navigation: NoteNavigation;
|
||||
dispatch: Dispatch;
|
||||
noteId: string;
|
||||
editorType: EditorType;
|
||||
useEditorBeta: boolean;
|
||||
plugins: PluginStates;
|
||||
themeId: number;
|
||||
@@ -119,7 +121,7 @@ interface ComponentProps extends Props {
|
||||
|
||||
interface State {
|
||||
note: NoteEntity;
|
||||
mode: EditorMode;
|
||||
mode: NoteViewerMode;
|
||||
readOnly: boolean;
|
||||
folder: FolderEntity|null;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
@@ -637,6 +639,13 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
});
|
||||
}
|
||||
|
||||
if (prevState.mode !== this.state.mode) {
|
||||
this.props.dispatch({
|
||||
type: 'NOTE_EDITOR_VISIBLE_CHANGE',
|
||||
visible: this.state.mode === 'edit' && !this.state.showCamera && !this.state.showImageEditor,
|
||||
});
|
||||
}
|
||||
|
||||
if (prevProps.noteId && this.props.noteId && prevProps.noteId !== this.props.noteId) {
|
||||
// Easier to just go back, then go to the note since
|
||||
// the Note screen doesn't handle reloading a different note
|
||||
@@ -684,6 +693,11 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
|
||||
this.commandRegistration_?.deregister();
|
||||
this.commandRegistration_ = null;
|
||||
|
||||
this.props.dispatch({
|
||||
type: 'SET_NOTE_EDITOR_VISIBLE',
|
||||
visible: false,
|
||||
});
|
||||
}
|
||||
|
||||
private title_changeText(text: string) {
|
||||
@@ -1227,10 +1241,11 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
const isSaved = note && note.id;
|
||||
const readOnly = this.state.readOnly;
|
||||
const isDeleted = !!this.state.note.deleted_time;
|
||||
const isCodeView = this.props.editorType === EditorType.Markdown;
|
||||
|
||||
const pluginCommands = pluginUtils.commandNamesFromViews(this.props.plugins, 'noteToolbar');
|
||||
|
||||
const cacheKey = md5([isTodo, isSaved, pluginCommands.join(','), readOnly].join('_'));
|
||||
const cacheKey = md5([isTodo, isSaved, pluginCommands.join(','), readOnly, this.state.mode, isCodeView].join('_'));
|
||||
if (!this.menuOptionsCache_) this.menuOptionsCache_ = {};
|
||||
|
||||
if (this.menuOptionsCache_[cacheKey]) return this.menuOptionsCache_[cacheKey];
|
||||
@@ -1347,6 +1362,16 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
},
|
||||
});
|
||||
|
||||
if (this.state.mode === 'edit') {
|
||||
const newCodeView = !isCodeView;
|
||||
output.push({
|
||||
title: newCodeView ? _('Edit as Markdown') : _('Edit as Rich Text'),
|
||||
onPress: () => {
|
||||
Setting.setValue('editor.codeView', newCodeView);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (isDeleted) {
|
||||
output.push({
|
||||
title: _('Restore'),
|
||||
@@ -1635,11 +1660,13 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
noteHash={this.props.noteHash}
|
||||
initialText={note.body}
|
||||
initialSelection={this.selection}
|
||||
markupLanguage={this.state.note.markup_language}
|
||||
globalSearch={this.props.searchQuery}
|
||||
onChange={this.onMarkdownEditorTextChange}
|
||||
onSelectionChange={this.onMarkdownEditorSelectionChange}
|
||||
onUndoRedoDepthChange={this.onUndoRedoDepthChange}
|
||||
onAttach={this.onAttach}
|
||||
noteResources={this.state.noteResources}
|
||||
readOnly={this.state.readOnly}
|
||||
plugins={this.props.plugins}
|
||||
style={{
|
||||
@@ -1649,6 +1676,7 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
paddingLeft: 0,
|
||||
paddingRight: 0,
|
||||
}}
|
||||
mode={this.props.editorType}
|
||||
/>;
|
||||
}
|
||||
}
|
||||
@@ -1798,6 +1826,8 @@ const NoteScreen = connect((state: AppState) => {
|
||||
pluginHtmlContents: state.pluginService.pluginHtmlContents,
|
||||
editorNoteReloadTimeRequest: state.editorNoteReloadTimeRequest,
|
||||
|
||||
editorType: state.settings['editor.codeView'] ? EditorType.Markdown : EditorType.RichText,
|
||||
|
||||
// What we call "beta editor" in this component is actually the (now
|
||||
// default) CodeMirror editor. That should be refactored to make it less
|
||||
// confusing.
|
||||
|
@@ -7,15 +7,15 @@ export interface PickerResponse {
|
||||
fileName?: string;
|
||||
}
|
||||
|
||||
export type EditorMode = 'view'|'edit';
|
||||
export type NoteViewerMode = 'view'|'edit';
|
||||
|
||||
export interface CommandRuntimeProps {
|
||||
attachFile(pickerResponse: PickerResponse, fileType: string): Promise<ResourceEntity|null>;
|
||||
hideKeyboard(): void;
|
||||
insertText(text: string): void;
|
||||
|
||||
getMode(): EditorMode;
|
||||
setMode(mode: EditorMode): void;
|
||||
getMode(): NoteViewerMode;
|
||||
setMode(mode: NoteViewerMode): void;
|
||||
setCameraVisible(visible: boolean): void;
|
||||
setTagDialogVisible(visible: boolean): void;
|
||||
setAudioRecorderVisible(visible: boolean): void;
|
||||
|
@@ -9,18 +9,18 @@ window.ResizeObserver = class { public observe() { } } as any;
|
||||
import { describe, it, expect, jest } from '@jest/globals';
|
||||
import { Color4, EditorImage, EditorSettings, Path, pathToRenderable, StrokeComponent } from 'js-draw';
|
||||
import { RenderingMode } from 'js-draw';
|
||||
import createJsDrawEditor from './createJsDrawEditor';
|
||||
import { createJsDrawEditor } from './index';
|
||||
import { BackgroundComponent } from 'js-draw';
|
||||
import { BackgroundComponentBackgroundType } from 'js-draw';
|
||||
import { ImageEditorCallbacks } from './types';
|
||||
import { MainProcessApi } from './types';
|
||||
import applyTemplateToEditor from './applyTemplateToEditor';
|
||||
|
||||
|
||||
const createEditorWithCallbacks = (callbacks: Partial<ImageEditorCallbacks>) => {
|
||||
const createEditorWithCallbacks = (callbacks: Partial<MainProcessApi>) => {
|
||||
const toolbarState = '';
|
||||
const locale = 'en';
|
||||
|
||||
const allCallbacks: ImageEditorCallbacks = {
|
||||
const allCallbacks: MainProcessApi = {
|
||||
save: () => {},
|
||||
saveThenClose: ()=> {},
|
||||
closeEditor: ()=> {},
|
||||
@@ -49,7 +49,7 @@ const createEditorWithCallbacks = (callbacks: Partial<ImageEditorCallbacks>) =>
|
||||
return createJsDrawEditor(allCallbacks, toolbarState, locale, localizations, editorOptions);
|
||||
};
|
||||
|
||||
describe('createJsDrawEditor', () => {
|
||||
describe('imageEditor/contentScript/index', () => {
|
||||
it('should trigger autosave callback every few minutes', async () => {
|
||||
let calledAutosaveCount = 0;
|
||||
|
@@ -1,13 +1,12 @@
|
||||
|
||||
import '../../utils/polyfills';
|
||||
import { Editor, AbstractToolbar, EditorEventType, EditorSettings, getLocalizationTable, adjustEditorThemeForContrast, BaseWidget } from 'js-draw';
|
||||
import { MaterialIconProvider } from '@js-draw/material-icons';
|
||||
import 'js-draw/bundledStyles';
|
||||
import applyTemplateToEditor from './applyTemplateToEditor';
|
||||
import watchEditorForTemplateChanges from './watchEditorForTemplateChanges';
|
||||
import { ImageEditorCallbacks, ImageEditorControl, LocalizedStrings } from './types';
|
||||
import { MainProcessApi, LocalizedStrings, EditorProcessApi } from './types';
|
||||
import startAutosaveLoop from './startAutosaveLoop';
|
||||
import WebViewToRNMessenger from '../../../../utils/ipc/WebViewToRNMessenger';
|
||||
import './polyfills';
|
||||
import WebViewToRNMessenger from '../../../utils/ipc/WebViewToRNMessenger';
|
||||
|
||||
|
||||
const restoreToolbarState = (toolbar: AbstractToolbar, state: string) => {
|
||||
@@ -21,15 +20,8 @@ const restoreToolbarState = (toolbar: AbstractToolbar, state: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const createMessenger = () => {
|
||||
const messenger = new WebViewToRNMessenger<ImageEditorControl, ImageEditorCallbacks>(
|
||||
'image-editor', {},
|
||||
);
|
||||
return messenger;
|
||||
};
|
||||
|
||||
export const createJsDrawEditor = (
|
||||
callbacks: ImageEditorCallbacks,
|
||||
callbacks: MainProcessApi,
|
||||
initialToolbarState: string,
|
||||
locale: string,
|
||||
defaultLocalizations: LocalizedStrings,
|
||||
@@ -177,6 +169,9 @@ export const createJsDrawEditor = (
|
||||
});
|
||||
};
|
||||
|
||||
const themeStyles = document.createElement('style');
|
||||
parentElement.appendChild(themeStyles);
|
||||
|
||||
const editorControl = {
|
||||
editor,
|
||||
loadImageOrTemplate: async (resourceUrl: string, templateData: string, svgData: string|undefined) => {
|
||||
@@ -200,7 +195,11 @@ export const createJsDrawEditor = (
|
||||
void startAutosaveLoop(editor, callbacks.save);
|
||||
watchEditorForTemplateChanges(editor, templateData, callbacks.updateEditorTemplate);
|
||||
},
|
||||
onThemeUpdate: () => {
|
||||
onThemeUpdate: (css: string|null) => {
|
||||
if (css) {
|
||||
themeStyles.textContent = css;
|
||||
}
|
||||
|
||||
// Slightly adjusts the given editor's theme colors. This ensures that the colors chosen for
|
||||
// the editor have proper contrast.
|
||||
adjustEditorThemeForContrast(editor);
|
||||
@@ -211,12 +210,24 @@ export const createJsDrawEditor = (
|
||||
},
|
||||
};
|
||||
|
||||
editorControl.onThemeUpdate();
|
||||
editorControl.onThemeUpdate(null);
|
||||
|
||||
callbacks.onLoadedEditor();
|
||||
|
||||
return editorControl;
|
||||
};
|
||||
|
||||
type EditorControl = ReturnType<typeof createJsDrawEditor>;
|
||||
export const createMessenger = (getEditor: ()=> EditorControl) => {
|
||||
const messenger = new WebViewToRNMessenger<EditorProcessApi, MainProcessApi>(
|
||||
'image-editor', {
|
||||
onThemeUpdate: async (css: string) => {
|
||||
getEditor().onThemeUpdate(css);
|
||||
},
|
||||
saveThenExit: () => getEditor().saveThenExit(),
|
||||
},
|
||||
);
|
||||
return messenger;
|
||||
};
|
||||
|
||||
export default createJsDrawEditor;
|
@@ -3,7 +3,7 @@ export type SaveDrawingCallback = (svgData: string, isAutosave: boolean)=> void;
|
||||
export type UpdateEditorTemplateCallback = (newTemplate: string)=> void;
|
||||
export type UpdateToolbarCallback = (toolbarData: string)=> void;
|
||||
|
||||
export interface ImageEditorCallbacks {
|
||||
export interface MainProcessApi {
|
||||
onLoadedEditor: ()=> void;
|
||||
|
||||
save: SaveDrawingCallback;
|
||||
@@ -18,7 +18,10 @@ export interface ImageEditorCallbacks {
|
||||
readClipboardText: ()=> Promise<string>;
|
||||
}
|
||||
|
||||
export interface ImageEditorControl {}
|
||||
export interface EditorProcessApi {
|
||||
saveThenExit(): Promise<void>;
|
||||
onThemeUpdate(newCss: string): Promise<void>;
|
||||
}
|
||||
|
||||
// Overrides translations in js-draw -- as of the time of this writing,
|
||||
// Joplin has many common strings localized better than js-draw.
|
@@ -0,0 +1,200 @@
|
||||
import * as React from 'react';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import { themeStyle } from '@joplin/lib/theme';
|
||||
import { Theme } from '@joplin/lib/themes/type';
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { Platform } from 'react-native';
|
||||
import useEditorMessenger from './utils/useEditorMessenger';
|
||||
import { WebViewControl } from '../../components/ExtendedWebView/types';
|
||||
import { LocalizedStrings } from './contentScript/types';
|
||||
import { SetUpResult } from '../types';
|
||||
|
||||
|
||||
type OnSaveCallback = (svgData: string)=> Promise<void>;
|
||||
type OnCancelCallback = ()=> void;
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
resourceFilename: string|null;
|
||||
onSave: OnSaveCallback;
|
||||
onAutoSave: OnSaveCallback;
|
||||
onRequestCloseEditor: OnCancelCallback;
|
||||
onSetImageChanged: (changed: boolean)=> void;
|
||||
webViewRef: React.RefObject<WebViewControl>;
|
||||
}
|
||||
|
||||
export interface ImageEditorControl {
|
||||
saveThenExit(): Promise<void>;
|
||||
}
|
||||
|
||||
const useCss = (editorTheme: Theme) => {
|
||||
return useMemo(() => {
|
||||
// Ensure we have contrast between the background and selection. Some themes
|
||||
// have the same backgroundColor and selectionColor2. (E.g. Aritim Dark)
|
||||
let selectionBackgroundColor = editorTheme.selectedColor2;
|
||||
if (selectionBackgroundColor === editorTheme.backgroundColor) {
|
||||
selectionBackgroundColor = editorTheme.selectedColor;
|
||||
}
|
||||
|
||||
return `
|
||||
:root .imageEditorContainer {
|
||||
--background-color-1: ${editorTheme.backgroundColor};
|
||||
--foreground-color-1: ${editorTheme.color};
|
||||
--background-color-2: ${editorTheme.backgroundColor3};
|
||||
--foreground-color-2: ${editorTheme.color3};
|
||||
--background-color-3: ${editorTheme.raisedBackgroundColor};
|
||||
--foreground-color-3: ${editorTheme.raisedColor};
|
||||
|
||||
--selection-background-color: ${editorTheme.backgroundColorHover3};
|
||||
--selection-foreground-color: ${editorTheme.color3};
|
||||
--primary-action-foreground-color: ${editorTheme.color4};
|
||||
|
||||
--primary-shadow-color: ${editorTheme.colorFaded};
|
||||
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body, html {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Hide the scrollbar. See scrollbar accessibility concerns
|
||||
(https://developer.mozilla.org/en-US/docs/Web/CSS/scrollbar-width#accessibility_concerns)
|
||||
for why this isn't done in js-draw itself. */
|
||||
.toolbar-tool-row::-webkit-scrollbar {
|
||||
display: none;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
/* Hide the save/close icons on small screens. This isn't done in the upstream
|
||||
js-draw repository partially because it isn't as well localized as Joplin
|
||||
(icons can be used to suggest the meaning of a button when a translation is
|
||||
unavailable). */
|
||||
.toolbar-edge-toolbar:not(.one-row) .toolwidget-tag--save .toolbar-icon,
|
||||
.toolbar-edge-toolbar:not(.one-row) .toolwidget-tag--exit .toolbar-icon {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
}, [editorTheme]);
|
||||
};
|
||||
|
||||
const useWebViewSetup = ({
|
||||
webViewRef,
|
||||
themeId,
|
||||
resourceFilename,
|
||||
onSetImageChanged,
|
||||
onSave,
|
||||
onAutoSave,
|
||||
onRequestCloseEditor,
|
||||
}: Props): SetUpResult<ImageEditorControl> => {
|
||||
const editorTheme: Theme = themeStyle(themeId);
|
||||
|
||||
// A set of localization overrides (Joplin is better localized than js-draw).
|
||||
// All localizable strings (some unused?) can be found at
|
||||
// https://github.com/personalizedrefrigerator/js-draw/blob/main/.github/ISSUE_TEMPLATE/translation-js-draw-new.yml
|
||||
const localizedStrings: LocalizedStrings = useMemo(() => ({
|
||||
save: _('Save'),
|
||||
close: _('Close'),
|
||||
undo: _('Undo'),
|
||||
redo: _('Redo'),
|
||||
}), []);
|
||||
|
||||
const appInfo = useMemo(() => {
|
||||
return {
|
||||
name: 'Joplin',
|
||||
description: `v${shim.appVersion()}`,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const injectedJavaScript = useMemo(() => `
|
||||
if (window.imageEditorControl === undefined) {
|
||||
${shim.injectedJs('imageEditorBundle')}
|
||||
|
||||
const messenger = imageEditorBundle.createMessenger(() => window.imageEditorControl);
|
||||
window.imageEditorControl = imageEditorBundle.createJsDrawEditor(
|
||||
messenger.remoteApi,
|
||||
${JSON.stringify(Setting.value('imageeditor.jsdrawToolbar'))},
|
||||
${JSON.stringify(Setting.value('locale'))},
|
||||
${JSON.stringify(localizedStrings)},
|
||||
${JSON.stringify({
|
||||
appInfo,
|
||||
...(shim.mobilePlatform() === 'web' ? {
|
||||
// Use the browser-default clipboard API on web.
|
||||
clipboardApi: null,
|
||||
} : {}),
|
||||
})},
|
||||
);
|
||||
}
|
||||
`, [localizedStrings, appInfo]);
|
||||
|
||||
const onReadyToLoadData = useCallback(async () => {
|
||||
const getInitialInjectedData = async () => {
|
||||
// On mobile, it's faster to load the image within the WebView with an XMLHttpRequest.
|
||||
// In this case, the image is loaded elsewhere.
|
||||
if (Platform.OS !== 'web') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// On web, however, this doesn't work, so the image needs to be loaded here.
|
||||
if (!resourceFilename) {
|
||||
return '';
|
||||
}
|
||||
return await shim.fsDriver().readFile(resourceFilename, 'utf-8');
|
||||
};
|
||||
// It can take some time for initialSVGData to be transferred to the WebView.
|
||||
// Thus, do so after the main content has been loaded.
|
||||
webViewRef.current.injectJS(`(async () => {
|
||||
if (window.imageEditorControl) {
|
||||
const initialSVGPath = ${JSON.stringify(resourceFilename)};
|
||||
const initialTemplateData = ${JSON.stringify(Setting.value('imageeditor.imageTemplate'))};
|
||||
const initialData = ${JSON.stringify(await getInitialInjectedData())};
|
||||
|
||||
imageEditorControl.loadImageOrTemplate(initialSVGPath, initialTemplateData, initialData);
|
||||
}
|
||||
})();`);
|
||||
}, [webViewRef, resourceFilename]);
|
||||
|
||||
const messenger = useEditorMessenger({
|
||||
webViewRef,
|
||||
setImageChanged: onSetImageChanged,
|
||||
onReadyToLoadData,
|
||||
onSave,
|
||||
onAutoSave,
|
||||
onRequestCloseEditor,
|
||||
});
|
||||
const messengerRef = useRef(messenger);
|
||||
messengerRef.current = messenger;
|
||||
|
||||
const css = useCss(editorTheme);
|
||||
useEffect(() => {
|
||||
void messengerRef.current.remoteApi.onThemeUpdate(css);
|
||||
}, [css]);
|
||||
|
||||
const editorControl = useMemo((): ImageEditorControl => {
|
||||
return {
|
||||
saveThenExit: () => messenger.remoteApi.saveThenExit(),
|
||||
};
|
||||
}, [messenger]);
|
||||
|
||||
return useMemo(() => {
|
||||
return {
|
||||
pageSetup: {
|
||||
js: injectedJavaScript,
|
||||
css,
|
||||
},
|
||||
api: editorControl,
|
||||
webViewEventHandlers: {
|
||||
onLoadEnd: messenger.onWebViewLoaded,
|
||||
onMessage: messenger.onWebViewMessage,
|
||||
},
|
||||
};
|
||||
}, [editorControl, messenger, injectedJavaScript, css]);
|
||||
};
|
||||
|
||||
export default useWebViewSetup;
|
@@ -1,25 +1,30 @@
|
||||
import { RefObject, useMemo } from 'react';
|
||||
import { WebViewControl } from '../../../ExtendedWebView/types';
|
||||
import { ImageEditorCallbacks, ImageEditorControl } from '../js-draw/types';
|
||||
import { RefObject, useMemo, useRef } from 'react';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import RNToWebViewMessenger from '../../../../utils/ipc/RNToWebViewMessenger';
|
||||
import { writeAutosave } from '../autosave';
|
||||
import Clipboard from '@react-native-clipboard/clipboard';
|
||||
import { MainProcessApi, EditorProcessApi } from '../contentScript/types';
|
||||
import { WebViewControl } from '../../../components/ExtendedWebView/types';
|
||||
import RNToWebViewMessenger from '../../../utils/ipc/RNToWebViewMessenger';
|
||||
|
||||
interface Props {
|
||||
webviewRef: RefObject<WebViewControl>;
|
||||
webViewRef: RefObject<WebViewControl>;
|
||||
setImageChanged(changed: boolean): void;
|
||||
|
||||
onReadyToLoadData(): void;
|
||||
onSave(data: string): void;
|
||||
onAutoSave(data: string): void;
|
||||
onRequestCloseEditor(promptIfUnsaved: boolean): void;
|
||||
}
|
||||
|
||||
const useEditorMessenger = ({
|
||||
webviewRef, setImageChanged, onReadyToLoadData, onRequestCloseEditor, onSave,
|
||||
webViewRef: webviewRef, setImageChanged, onReadyToLoadData, onRequestCloseEditor, onSave, onAutoSave,
|
||||
}: Props) => {
|
||||
const events = { onRequestCloseEditor, onSave, onAutoSave, onReadyToLoadData };
|
||||
// Use a ref to avoid unnecessary rerenders
|
||||
const eventsRef = useRef(events);
|
||||
eventsRef.current = events;
|
||||
|
||||
return useMemo(() => {
|
||||
const localApi: ImageEditorCallbacks = {
|
||||
const localApi: MainProcessApi = {
|
||||
updateEditorTemplate: newTemplate => {
|
||||
Setting.setValue('imageeditor.imageTemplate', newTemplate);
|
||||
},
|
||||
@@ -30,21 +35,21 @@ const useEditorMessenger = ({
|
||||
setImageChanged(hasChanges);
|
||||
},
|
||||
onLoadedEditor: () => {
|
||||
onReadyToLoadData();
|
||||
eventsRef.current.onReadyToLoadData();
|
||||
},
|
||||
saveThenClose: svgData => {
|
||||
onSave(svgData);
|
||||
onRequestCloseEditor(false);
|
||||
eventsRef.current.onSave(svgData);
|
||||
eventsRef.current.onRequestCloseEditor(false);
|
||||
},
|
||||
save: (svgData, isAutosave) => {
|
||||
if (isAutosave) {
|
||||
return writeAutosave(svgData);
|
||||
return eventsRef.current.onAutoSave(svgData);
|
||||
} else {
|
||||
return onSave(svgData);
|
||||
return eventsRef.current.onSave(svgData);
|
||||
}
|
||||
},
|
||||
closeEditor: promptIfUnsaved => {
|
||||
onRequestCloseEditor(promptIfUnsaved);
|
||||
eventsRef.current.onRequestCloseEditor(promptIfUnsaved);
|
||||
},
|
||||
writeClipboardText: async text => {
|
||||
Clipboard.setString(text);
|
||||
@@ -53,11 +58,11 @@ const useEditorMessenger = ({
|
||||
return Clipboard.getString();
|
||||
},
|
||||
};
|
||||
const messenger = new RNToWebViewMessenger<ImageEditorCallbacks, ImageEditorControl>(
|
||||
const messenger = new RNToWebViewMessenger<MainProcessApi, EditorProcessApi>(
|
||||
'image-editor', webviewRef, localApi,
|
||||
);
|
||||
return messenger;
|
||||
}, [webviewRef, setImageChanged, onReadyToLoadData, onRequestCloseEditor, onSave]);
|
||||
}, [webviewRef, setImageChanged]);
|
||||
};
|
||||
|
||||
export default useEditorMessenger;
|
@@ -0,0 +1,66 @@
|
||||
import { createEditor } from '@joplin/editor/CodeMirror';
|
||||
import { focus } from '@joplin/lib/utils/focusHandler';
|
||||
import WebViewToRNMessenger from '../../utils/ipc/WebViewToRNMessenger';
|
||||
import { EditorProcessApi, EditorProps, MainProcessApi } from './types';
|
||||
import readFileToBase64 from '../utils/readFileToBase64';
|
||||
|
||||
export { default as setUpLogger } from '../utils/setUpLogger';
|
||||
|
||||
export const initializeEditor = ({
|
||||
parentElementClassName,
|
||||
initialText,
|
||||
initialNoteId,
|
||||
settings,
|
||||
}: EditorProps) => {
|
||||
const messenger = new WebViewToRNMessenger<EditorProcessApi, MainProcessApi>('markdownEditor', null);
|
||||
|
||||
const parentElement = document.getElementsByClassName(parentElementClassName)[0] as HTMLElement;
|
||||
if (!parentElement) {
|
||||
throw new Error(`Unable to find parent element for editor (class name: ${JSON.stringify(parentElementClassName)})`);
|
||||
}
|
||||
|
||||
const control = createEditor(parentElement, {
|
||||
initialText,
|
||||
initialNoteId,
|
||||
settings,
|
||||
|
||||
onPasteFile: async (data) => {
|
||||
const base64 = await readFileToBase64(data);
|
||||
await messenger.remoteApi.onPasteFile(data.type, base64);
|
||||
},
|
||||
|
||||
onLogMessage: message => {
|
||||
void messenger.remoteApi.logMessage(message);
|
||||
},
|
||||
onEvent: (event): void => {
|
||||
void messenger.remoteApi.onEditorEvent(event);
|
||||
},
|
||||
});
|
||||
|
||||
// Works around https://github.com/laurent22/joplin/issues/10047 by handling
|
||||
// the text/uri-list MIME type when pasting, rather than sending the paste event
|
||||
// to CodeMirror.
|
||||
//
|
||||
// TODO: Remove this workaround when the issue has been fixed upstream.
|
||||
control.on('paste', (_editor, event: ClipboardEvent) => {
|
||||
const clipboardData = event.clipboardData;
|
||||
if (clipboardData.types.length === 1 && clipboardData.types[0] === 'text/uri-list') {
|
||||
event.preventDefault();
|
||||
control.insertText(clipboardData.getData('text/uri-list'));
|
||||
}
|
||||
});
|
||||
|
||||
// Note: Just adding an onclick listener seems sufficient to focus the editor when its background
|
||||
// is tapped.
|
||||
parentElement.addEventListener('click', (event) => {
|
||||
const activeElement = document.querySelector(':focus');
|
||||
if (!parentElement.contains(activeElement) && event.target === parentElement) {
|
||||
focus('initial editor focus', control);
|
||||
}
|
||||
});
|
||||
|
||||
messenger.setLocalInterface({
|
||||
editor: control,
|
||||
});
|
||||
return control;
|
||||
};
|
@@ -0,0 +1,24 @@
|
||||
import { EditorEvent } from '@joplin/editor/events';
|
||||
import { EditorControl, EditorSettings } from '@joplin/editor/types';
|
||||
|
||||
export interface EditorProcessApi {
|
||||
editor: EditorControl;
|
||||
}
|
||||
|
||||
export interface SelectionRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface EditorProps {
|
||||
parentElementClassName: string;
|
||||
initialText: string;
|
||||
initialNoteId: string;
|
||||
settings: EditorSettings;
|
||||
}
|
||||
|
||||
export interface MainProcessApi {
|
||||
onEditorEvent(event: EditorEvent): Promise<void>;
|
||||
logMessage(message: string): Promise<void>;
|
||||
onPasteFile(type: string, dataBase64: string): Promise<void>;
|
||||
}
|
@@ -0,0 +1,139 @@
|
||||
import shim from '@joplin/lib/shim';
|
||||
import { EditorProcessApi, EditorProps as EditorOptions, SelectionRange, MainProcessApi } from './types';
|
||||
import { SetUpResult } from '../types';
|
||||
import { SearchState } from '@joplin/editor/types';
|
||||
import { RefObject, useEffect, useMemo, useRef } from 'react';
|
||||
import { OnMessageEvent, WebViewControl } from '../../components/ExtendedWebView/types';
|
||||
import { EditorEvent } from '@joplin/editor/events';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import RNToWebViewMessenger from '../../utils/ipc/RNToWebViewMessenger';
|
||||
|
||||
const logger = Logger.create('markdownEditor');
|
||||
|
||||
interface Props {
|
||||
editorOptions: EditorOptions;
|
||||
initialSelection: SelectionRange;
|
||||
noteHash: string;
|
||||
globalSearch: string;
|
||||
onEditorEvent: (event: EditorEvent)=> void;
|
||||
onAttachFile: (mime: string, base64: string)=> void;
|
||||
|
||||
webviewRef: RefObject<WebViewControl>;
|
||||
}
|
||||
|
||||
const defaultSearchState: SearchState = {
|
||||
useRegex: false,
|
||||
caseSensitive: false,
|
||||
|
||||
searchText: '',
|
||||
replaceText: '',
|
||||
dialogVisible: false,
|
||||
};
|
||||
|
||||
const useWebViewSetup = ({
|
||||
editorOptions, initialSelection, noteHash, globalSearch, webviewRef, onEditorEvent, onAttachFile,
|
||||
}: Props): SetUpResult<EditorProcessApi> => {
|
||||
const setInitialSelectionJs = initialSelection ? `
|
||||
cm.select(${initialSelection.start}, ${initialSelection.end});
|
||||
cm.execCommand('scrollSelectionIntoView');
|
||||
` : '';
|
||||
const jumpToHashJs = noteHash ? `
|
||||
cm.jumpToHash(${JSON.stringify(noteHash)});
|
||||
` : '';
|
||||
const setInitialSearchJs = globalSearch ? `
|
||||
cm.setSearchState(${JSON.stringify({
|
||||
...defaultSearchState,
|
||||
searchText: globalSearch,
|
||||
})})
|
||||
` : '';
|
||||
|
||||
const injectedJavaScript = useMemo(() => `
|
||||
if (!window.cm) {
|
||||
${shim.injectedJs('markdownEditorBundle')};
|
||||
markdownEditorBundle.setUpLogger();
|
||||
|
||||
window.cm = markdownEditorBundle.initializeEditor(
|
||||
${JSON.stringify(editorOptions)}
|
||||
);
|
||||
|
||||
${jumpToHashJs}
|
||||
// Set the initial selection after jumping to the header -- the initial selection,
|
||||
// if specified, should take precedence.
|
||||
${setInitialSelectionJs}
|
||||
${setInitialSearchJs}
|
||||
|
||||
window.onresize = () => {
|
||||
cm.execCommand('scrollSelectionIntoView');
|
||||
};
|
||||
}
|
||||
`, [jumpToHashJs, setInitialSearchJs, setInitialSelectionJs, editorOptions]);
|
||||
|
||||
// Scroll to the new hash, if it changes.
|
||||
const isFirstScrollRef = useRef(true);
|
||||
useEffect(() => {
|
||||
// The first "jump to header" is handled during editor setup and shouldn't
|
||||
// be handled a second time:
|
||||
if (isFirstScrollRef.current) {
|
||||
isFirstScrollRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (jumpToHashJs && webviewRef.current) {
|
||||
webviewRef.current.injectJS(jumpToHashJs);
|
||||
}
|
||||
}, [jumpToHashJs, webviewRef]);
|
||||
|
||||
const onEditorEventRef = useRef(onEditorEvent);
|
||||
onEditorEventRef.current = onEditorEvent;
|
||||
|
||||
const onAttachRef = useRef(onAttachFile);
|
||||
onAttachRef.current = onAttachFile;
|
||||
|
||||
const editorMessenger = useMemo(() => {
|
||||
const localApi: MainProcessApi = {
|
||||
async onEditorEvent(event) {
|
||||
onEditorEventRef.current(event);
|
||||
},
|
||||
async logMessage(message) {
|
||||
logger.debug('CodeMirror:', message);
|
||||
},
|
||||
async onPasteFile(type, data) {
|
||||
onAttachRef.current(type, data);
|
||||
},
|
||||
};
|
||||
const messenger = new RNToWebViewMessenger<MainProcessApi, EditorProcessApi>(
|
||||
'markdownEditor', webviewRef, localApi,
|
||||
);
|
||||
return messenger;
|
||||
}, [webviewRef]);
|
||||
|
||||
const webViewEventHandlers = useMemo(() => {
|
||||
return {
|
||||
onLoadEnd: () => {
|
||||
editorMessenger.onWebViewLoaded();
|
||||
},
|
||||
onMessage: (event: OnMessageEvent) => {
|
||||
editorMessenger.onWebViewMessage(event);
|
||||
},
|
||||
};
|
||||
}, [editorMessenger]);
|
||||
|
||||
const api = useMemo(() => {
|
||||
return editorMessenger.remoteApi;
|
||||
}, [editorMessenger]);
|
||||
|
||||
const editorSettings = editorOptions.settings;
|
||||
useEffect(() => {
|
||||
api.editor.updateSettings(editorSettings);
|
||||
}, [api, editorSettings]);
|
||||
|
||||
return useMemo(() => ({
|
||||
pageSetup: {
|
||||
js: injectedJavaScript,
|
||||
css: '',
|
||||
},
|
||||
api,
|
||||
webViewEventHandlers,
|
||||
}), [injectedJavaScript, api, webViewEventHandlers]);
|
||||
};
|
||||
|
||||
export default useWebViewSetup;
|
@@ -1,24 +1,25 @@
|
||||
/** @jest-environment jsdom */
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import Renderer, { RendererSettings, RendererSetupOptions } from './Renderer';
|
||||
import Renderer, { RenderSettings, RendererSetupOptions } from './Renderer';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import { MarkupLanguage } from '@joplin/renderer';
|
||||
|
||||
const defaultRendererSettings: RendererSettings = {
|
||||
const defaultRendererSettings: RenderSettings = {
|
||||
theme: JSON.stringify({ cacheKey: 'test' }),
|
||||
onResourceLoaded: ()=>{},
|
||||
highlightedKeywords: [],
|
||||
resources: {},
|
||||
codeTheme: 'atom-one-light.css',
|
||||
noteHash: '',
|
||||
initialScroll: 0,
|
||||
readAssetBlob: async (_path: string)=>new Blob(),
|
||||
readAssetBlob: async (_path: string) => new Blob(),
|
||||
|
||||
createEditPopupSyntax: '',
|
||||
destroyEditPopupSyntax: '',
|
||||
pluginAssetContainerSelector: '#asset-container',
|
||||
splitted: false,
|
||||
|
||||
pluginSettings: {},
|
||||
requestPluginSetting: ()=>{},
|
||||
requestPluginSetting: () => { },
|
||||
};
|
||||
|
||||
const makeRenderer = (options: Partial<RendererSetupOptions>) => {
|
||||
@@ -47,25 +48,25 @@ describe('Renderer', () => {
|
||||
document.body.appendChild(contentContainer);
|
||||
|
||||
const pluginAssetsContainer = document.createElement('div');
|
||||
pluginAssetsContainer.id = 'joplin-container-pluginAssetsContainer';
|
||||
pluginAssetsContainer.id = 'asset-container';
|
||||
document.body.appendChild(pluginAssetsContainer);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.querySelector('#joplin-container-content')?.remove();
|
||||
document.querySelector('#joplin-container-pluginAssetsContainer')?.remove();
|
||||
document.querySelector('#asset-container')?.remove();
|
||||
});
|
||||
|
||||
test('should support rendering markdown', async () => {
|
||||
const renderer = makeRenderer({});
|
||||
await renderer.rerender(
|
||||
await renderer.rerenderToBody(
|
||||
{ language: MarkupLanguage.Markdown, markup: '**test**' },
|
||||
defaultRendererSettings,
|
||||
);
|
||||
|
||||
expect(getRenderedContent().innerHTML.trim()).toBe('<p><strong>test</strong></p>');
|
||||
|
||||
await renderer.rerender(
|
||||
await renderer.rerenderToBody(
|
||||
{ language: MarkupLanguage.Markdown, markup: '*test*' },
|
||||
defaultRendererSettings,
|
||||
);
|
||||
@@ -92,7 +93,7 @@ describe('Renderer', () => {
|
||||
pluginId: 'com.example.test-plugin',
|
||||
},
|
||||
]);
|
||||
await renderer.rerender(
|
||||
await renderer.rerenderToBody(
|
||||
{ language: MarkupLanguage.Markdown, markup: '```\ntest\n```' },
|
||||
defaultRendererSettings,
|
||||
);
|
||||
@@ -100,7 +101,7 @@ describe('Renderer', () => {
|
||||
|
||||
// Should support removing plugin scripts
|
||||
await renderer.setExtraContentScriptsAndRerender([]);
|
||||
await renderer.rerender(
|
||||
await renderer.rerenderToBody(
|
||||
{ language: MarkupLanguage.Markdown, markup: '```\ntest\n```' },
|
||||
defaultRendererSettings,
|
||||
);
|
||||
@@ -113,14 +114,14 @@ describe('Renderer', () => {
|
||||
|
||||
const requestPluginSetting = jest.fn();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const rerender = (pluginSettings: Record<string, any>) => {
|
||||
return renderer.rerender(
|
||||
const rerenderToBody = (pluginSettings: Record<string, any>) => {
|
||||
return renderer.rerenderToBody(
|
||||
{ language: MarkupLanguage.Markdown, markup: '```\ntest\n```' },
|
||||
{ ...defaultRendererSettings, pluginSettings, requestPluginSetting },
|
||||
);
|
||||
};
|
||||
|
||||
await rerender({});
|
||||
await rerenderToBody({});
|
||||
expect(requestPluginSetting).toHaveBeenCalledTimes(0);
|
||||
|
||||
const pluginId = 'com.example.test-plugin';
|
||||
@@ -146,7 +147,7 @@ describe('Renderer', () => {
|
||||
|
||||
// Should call .requestPluginSetting for missing settings
|
||||
expect(requestPluginSetting).toHaveBeenCalledTimes(1);
|
||||
await rerender({});
|
||||
await rerenderToBody({ someOtherSetting: 1 });
|
||||
expect(requestPluginSetting).toHaveBeenCalledTimes(2);
|
||||
expect(requestPluginSetting).toHaveBeenLastCalledWith('com.example.test-plugin', 'setting');
|
||||
|
||||
@@ -154,11 +155,11 @@ describe('Renderer', () => {
|
||||
expect(getRenderedContent().querySelector('#setting-value').innerHTML).toBe('Setting value: undefined');
|
||||
|
||||
// Should expect only namespaced plugin settings
|
||||
await rerender({ 'setting': 'test' });
|
||||
await rerenderToBody({ 'setting': 'test' });
|
||||
expect(requestPluginSetting).toHaveBeenCalledTimes(3);
|
||||
|
||||
// Should not request plugin settings when all settings are present.
|
||||
await rerender({ [`${pluginId}.setting`]: 'test' });
|
||||
await rerenderToBody({ [`${pluginId}.setting`]: 'test' });
|
||||
expect(requestPluginSetting).toHaveBeenCalledTimes(3);
|
||||
expect(getRenderedContent().querySelector('#setting-value').innerHTML).toBe('Setting value: test');
|
||||
});
|
@@ -0,0 +1,202 @@
|
||||
import { MarkupLanguage, MarkupToHtml } from '@joplin/renderer';
|
||||
import type { MarkupToHtmlConverter, RenderOptions, FsDriver as RendererFsDriver, ResourceInfos } from '@joplin/renderer/types';
|
||||
import makeResourceModel from './utils/makeResourceModel';
|
||||
import addPluginAssets from './utils/addPluginAssets';
|
||||
import { ExtraContentScriptSource, ForwardedJoplinSettings, MarkupRecord } from '../types';
|
||||
import { ExtraContentScript } from '@joplin/lib/services/plugins/utils/loadContentScripts';
|
||||
import { PluginOptions } from '@joplin/renderer/MarkupToHtml';
|
||||
import afterFullPageRender from './utils/afterFullPageRender';
|
||||
|
||||
export interface RendererSetupOptions {
|
||||
settings: ForwardedJoplinSettings;
|
||||
useTransferredFiles: boolean;
|
||||
pluginOptions: PluginOptions;
|
||||
fsDriver: RendererFsDriver;
|
||||
}
|
||||
|
||||
export interface RenderSettings {
|
||||
theme: string;
|
||||
highlightedKeywords: string[];
|
||||
resources: ResourceInfos;
|
||||
codeTheme: string;
|
||||
noteHash: string;
|
||||
initialScroll: number;
|
||||
// If [null], plugin assets are not added to the document
|
||||
pluginAssetContainerSelector: string|null;
|
||||
|
||||
splitted?: boolean; // Move CSS into a separate output
|
||||
mapsToLine?: boolean; // Sourcemaps
|
||||
|
||||
createEditPopupSyntax: string;
|
||||
destroyEditPopupSyntax: string;
|
||||
|
||||
pluginSettings: Record<string, unknown>;
|
||||
requestPluginSetting: (pluginId: string, settingKey: string)=> void;
|
||||
readAssetBlob: (assetPath: string)=> Promise<Blob>;
|
||||
}
|
||||
|
||||
export interface RendererOutput {
|
||||
getOutputElement: ()=> HTMLElement;
|
||||
afterRender: (setupOptions: RendererSetupOptions, renderSettings: RenderSettings)=> void;
|
||||
}
|
||||
|
||||
export default class Renderer {
|
||||
private markupToHtml_: MarkupToHtmlConverter;
|
||||
private lastBodyRenderSettings_: RenderSettings|null = null;
|
||||
private extraContentScripts_: ExtraContentScript[] = [];
|
||||
private lastBodyMarkup_: MarkupRecord|null = null;
|
||||
private lastPluginSettingsCacheKey_: string|null = null;
|
||||
private resourcePathOverrides_: Record<string, string> = Object.create(null);
|
||||
|
||||
public constructor(private setupOptions_: RendererSetupOptions) {
|
||||
this.recreateMarkupToHtml_();
|
||||
}
|
||||
|
||||
private recreateMarkupToHtml_() {
|
||||
this.markupToHtml_ = new MarkupToHtml({
|
||||
extraRendererRules: this.extraContentScripts_,
|
||||
fsDriver: this.setupOptions_.fsDriver,
|
||||
isSafeMode: this.setupOptions_.settings.safeMode,
|
||||
tempDir: this.setupOptions_.settings.tempDir,
|
||||
ResourceModel: makeResourceModel(this.setupOptions_.settings.resourceDir),
|
||||
pluginOptions: this.setupOptions_.pluginOptions,
|
||||
});
|
||||
}
|
||||
|
||||
// Intended for web, where resources can't be linked to normally.
|
||||
public async setResourceFile(id: string, file: Blob) {
|
||||
this.resourcePathOverrides_[id] = URL.createObjectURL(file);
|
||||
}
|
||||
|
||||
public getResourcePathOverride(resourceId: string) {
|
||||
if (Object.prototype.hasOwnProperty.call(this.resourcePathOverrides_, resourceId)) {
|
||||
return this.resourcePathOverrides_[resourceId];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public async setExtraContentScriptsAndRerender(
|
||||
extraContentScripts: ExtraContentScriptSource[],
|
||||
) {
|
||||
this.extraContentScripts_ = extraContentScripts.map(script => {
|
||||
const scriptModule = ((0, eval)(script.js))({
|
||||
pluginId: script.pluginId,
|
||||
contentScriptId: script.id,
|
||||
});
|
||||
|
||||
if (!scriptModule.plugin) {
|
||||
throw new Error(`
|
||||
Expected content script ${script.id} to export a function that returns an object with a "plugin" property.
|
||||
Found: ${scriptModule}, which has keys ${Object.keys(scriptModule)}.
|
||||
`);
|
||||
}
|
||||
|
||||
return {
|
||||
...script,
|
||||
module: scriptModule,
|
||||
};
|
||||
});
|
||||
this.recreateMarkupToHtml_();
|
||||
|
||||
// If possible, rerenders with the last rendering settings. The goal
|
||||
// of this is to reduce the number of IPC calls between the viewer and
|
||||
// React Native. We want the first render to be as fast as possible.
|
||||
if (this.lastBodyMarkup_) {
|
||||
await this.rerenderToBody(this.lastBodyMarkup_, this.lastBodyRenderSettings_);
|
||||
}
|
||||
}
|
||||
|
||||
public async render(markup: MarkupRecord, settings: RenderSettings) {
|
||||
const options: RenderOptions = {
|
||||
highlightedKeywords: settings.highlightedKeywords,
|
||||
resources: settings.resources,
|
||||
codeTheme: settings.codeTheme,
|
||||
postMessageSyntax: 'window.joplinPostMessage_',
|
||||
enableLongPress: true,
|
||||
|
||||
// Show an 'edit' popup over SVG images
|
||||
editPopupFiletypes: ['image/svg+xml'],
|
||||
createEditPopupSyntax: settings.createEditPopupSyntax,
|
||||
destroyEditPopupSyntax: settings.destroyEditPopupSyntax,
|
||||
itemIdToUrl: this.setupOptions_.useTransferredFiles ? (id: string) => this.getResourcePathOverride(id) : undefined,
|
||||
|
||||
settingValue: (pluginId: string, settingName: string) => {
|
||||
const settingKey = `${pluginId}.${settingName}`;
|
||||
|
||||
if (!(settingKey in settings.pluginSettings)) {
|
||||
// This should make the setting available on future renders.
|
||||
settings.requestPluginSetting(pluginId, settingName);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return settings.pluginSettings[settingKey];
|
||||
},
|
||||
splitted: settings.splitted,
|
||||
mapsToLine: settings.mapsToLine,
|
||||
whiteBackgroundNoteRendering: markup.language === MarkupLanguage.Html,
|
||||
};
|
||||
|
||||
const pluginSettingsCacheKey = JSON.stringify(settings.pluginSettings);
|
||||
if (pluginSettingsCacheKey !== this.lastPluginSettingsCacheKey_) {
|
||||
this.lastPluginSettingsCacheKey_ = pluginSettingsCacheKey;
|
||||
this.markupToHtml_.clearCache(markup.language);
|
||||
}
|
||||
|
||||
const result = await this.markupToHtml_.render(
|
||||
markup.language,
|
||||
markup.markup,
|
||||
JSON.parse(settings.theme),
|
||||
options,
|
||||
);
|
||||
|
||||
// Adding plugin assets can be slow -- run it asynchronously.
|
||||
if (settings.pluginAssetContainerSelector) {
|
||||
void (async () => {
|
||||
await addPluginAssets(result.pluginAssets, {
|
||||
inlineAssets: this.setupOptions_.useTransferredFiles,
|
||||
readAssetBlob: settings.readAssetBlob,
|
||||
container: document.querySelector(settings.pluginAssetContainerSelector),
|
||||
});
|
||||
|
||||
// Some plugins require this event to be dispatched just after being added.
|
||||
document.dispatchEvent(new Event('joplin-noteDidUpdate'));
|
||||
})();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async rerenderToBody(markup: MarkupRecord, settings: RenderSettings) {
|
||||
this.lastBodyMarkup_ = markup;
|
||||
this.lastBodyRenderSettings_ = settings;
|
||||
|
||||
const contentContainer = document.getElementById('joplin-container-content') ?? document.body;
|
||||
|
||||
let html = '';
|
||||
try {
|
||||
const result = await this.render(markup, settings);
|
||||
html = result.html;
|
||||
} catch (error) {
|
||||
if (!contentContainer) {
|
||||
alert(`Renderer error: ${error}`);
|
||||
} else {
|
||||
contentContainer.textContent = `
|
||||
Error: ${error}
|
||||
|
||||
${error.stack ?? ''}
|
||||
`;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (contentContainer) {
|
||||
contentContainer.innerHTML = html;
|
||||
}
|
||||
|
||||
afterFullPageRender(this.setupOptions_, settings);
|
||||
}
|
||||
|
||||
public clearCache(markupLanguage: MarkupLanguage) {
|
||||
this.markupToHtml_.clearCache(markupLanguage);
|
||||
}
|
||||
}
|
@@ -0,0 +1,79 @@
|
||||
import Renderer from './Renderer';
|
||||
import WebViewToRNMessenger from '../../../utils/ipc/WebViewToRNMessenger';
|
||||
import { RendererProcessApi, MainProcessApi, RendererWebViewOptions } from '../types';
|
||||
|
||||
interface WebViewLib {
|
||||
initialize(config: unknown): void;
|
||||
}
|
||||
|
||||
interface WebViewApi {
|
||||
postMessage: (contentScriptId: string, args: unknown)=> void;
|
||||
}
|
||||
|
||||
interface ExtendedWindow extends Window {
|
||||
webviewLib: WebViewLib;
|
||||
webviewApi: WebViewApi;
|
||||
joplinPostMessage_: (message: string, args: unknown)=> void;
|
||||
}
|
||||
|
||||
declare const window: ExtendedWindow;
|
||||
declare const webviewLib: WebViewLib;
|
||||
|
||||
const initializeMessenger = (options: RendererWebViewOptions) => {
|
||||
const messenger = new WebViewToRNMessenger<RendererProcessApi, MainProcessApi>(
|
||||
'renderer',
|
||||
null,
|
||||
);
|
||||
|
||||
window.joplinPostMessage_ = (message: string, _args: unknown) => {
|
||||
return messenger.remoteApi.onPostMessage(message);
|
||||
};
|
||||
|
||||
window.webviewApi = {
|
||||
postMessage: messenger.remoteApi.onPostPluginMessage,
|
||||
};
|
||||
|
||||
webviewLib.initialize({
|
||||
postMessage: (message: string) => {
|
||||
messenger.remoteApi.onPostMessage(message);
|
||||
},
|
||||
});
|
||||
// Share the webview library globally so that the renderer can access it.
|
||||
window.webviewLib = webviewLib;
|
||||
|
||||
const renderer = new Renderer({
|
||||
...options,
|
||||
fsDriver: messenger.remoteApi.fsDriver,
|
||||
});
|
||||
|
||||
messenger.setLocalInterface({
|
||||
renderer,
|
||||
jumpToHash: (hash: string) => {
|
||||
location.hash = `#${hash}`;
|
||||
},
|
||||
});
|
||||
|
||||
return { messenger };
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export -- This is a bundle entrypoint
|
||||
export const initialize = (options: RendererWebViewOptions) => {
|
||||
const { messenger } = initializeMessenger(options);
|
||||
|
||||
const lastScrollTop: number|null = null;
|
||||
const onMainContentScroll = () => {
|
||||
const newScrollTop = document.scrollingElement.scrollTop;
|
||||
if (lastScrollTop !== newScrollTop) {
|
||||
messenger.remoteApi.onScroll(newScrollTop);
|
||||
}
|
||||
};
|
||||
|
||||
// Listen for events on both scrollingElement and window
|
||||
// - On Android, scrollingElement.addEventListener('scroll', callback) doesn't call callback on
|
||||
// scroll. However, window.addEventListener('scroll', callback) does.
|
||||
// - iOS needs a listener to be added to scrollingElement -- events aren't received when
|
||||
// the listener is added to window with window.addEventListener('scroll', ...).
|
||||
document.scrollingElement?.addEventListener('scroll', onMainContentScroll);
|
||||
window.addEventListener('scroll', onMainContentScroll);
|
||||
};
|
||||
|
@@ -0,0 +1,5 @@
|
||||
export interface WebViewLib {
|
||||
initialize(config: unknown): void;
|
||||
setupResourceManualDownload(): void;
|
||||
}
|
||||
|
@@ -39,6 +39,7 @@ const rewriteInternalAssetLinks = async (asset: RenderResultPluginAsset, content
|
||||
|
||||
interface Options {
|
||||
inlineAssets: boolean;
|
||||
container: HTMLElement;
|
||||
readAssetBlob?(path: string): Promise<Blob>;
|
||||
}
|
||||
|
||||
@@ -47,7 +48,7 @@ interface Options {
|
||||
const addPluginAssets = async (assets: RenderResultPluginAsset[], options: Options) => {
|
||||
if (!assets) return;
|
||||
|
||||
const pluginAssetsContainer = document.getElementById('joplin-container-pluginAssetsContainer');
|
||||
const pluginAssetsContainer = options.container;
|
||||
|
||||
const prepareAssetBlobUrls = () => {
|
||||
for (const asset of assets) {
|
@@ -0,0 +1,45 @@
|
||||
import { RenderSettings, RendererSetupOptions } from '../Renderer';
|
||||
import { WebViewLib } from '../types';
|
||||
|
||||
interface ExtendedWindow extends Window {
|
||||
webviewLib: WebViewLib;
|
||||
}
|
||||
|
||||
declare const window: ExtendedWindow;
|
||||
|
||||
const afterFullPageRender = (
|
||||
setupOptions: RendererSetupOptions,
|
||||
renderSettings: RenderSettings,
|
||||
) => {
|
||||
const readyStateCheckInterval = setInterval(() => {
|
||||
if (document.readyState === 'complete') {
|
||||
clearInterval(readyStateCheckInterval);
|
||||
if (setupOptions.settings.resourceDownloadMode === 'manual') {
|
||||
window.webviewLib.setupResourceManualDownload();
|
||||
}
|
||||
|
||||
const hash = renderSettings.noteHash;
|
||||
const initialScroll = renderSettings.initialScroll;
|
||||
|
||||
// Don't scroll to a hash if we're given initial scroll (initial scroll
|
||||
// overrides scrolling to a hash).
|
||||
if ((initialScroll ?? null) !== null) {
|
||||
const scrollingElement = document.scrollingElement ?? document.documentElement;
|
||||
scrollingElement.scrollTop = initialScroll;
|
||||
} else if (hash) {
|
||||
// Gives it a bit of time before scrolling to the anchor
|
||||
// so that images are loaded.
|
||||
setTimeout(() => {
|
||||
const e = document.getElementById(hash);
|
||||
if (!e) {
|
||||
console.warn('Cannot find hash', hash);
|
||||
return;
|
||||
}
|
||||
e.scrollIntoView();
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
}, 10);
|
||||
};
|
||||
|
||||
export default afterFullPageRender;
|
73
packages/app-mobile/contentScripts/rendererBundle/types.ts
Normal file
73
packages/app-mobile/contentScripts/rendererBundle/types.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { 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;
|
||||
}
|
@@ -0,0 +1,277 @@
|
||||
import { RefObject, useEffect, useMemo, useRef } from 'react';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { Platform } from 'react-native';
|
||||
import { SetUpResult } from '../types';
|
||||
import { themeStyle } from '../../components/global-style';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import { WebViewControl } from '../../components/ExtendedWebView/types';
|
||||
import { MainProcessApi, OnScrollCallback, RendererControl, RendererProcessApi, RendererWebViewOptions, RenderOptions } from './types';
|
||||
import PluginService from '@joplin/lib/services/plugins/PluginService';
|
||||
import RNToWebViewMessenger from '../../utils/ipc/RNToWebViewMessenger';
|
||||
import useEditPopup from './utils/useEditPopup';
|
||||
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
|
||||
import { RenderSettings } from './contentScript/Renderer';
|
||||
import resolvePathWithinDir from '@joplin/lib/utils/resolvePathWithinDir';
|
||||
import Resource from '@joplin/lib/models/Resource';
|
||||
import { ResourceInfos } from '@joplin/renderer/types';
|
||||
import useContentScripts from './utils/useContentScripts';
|
||||
import uuid from '@joplin/lib/uuid';
|
||||
|
||||
const logger = Logger.create('renderer/useWebViewSetup');
|
||||
|
||||
interface Props {
|
||||
webviewRef: RefObject<WebViewControl>;
|
||||
onBodyScroll: OnScrollCallback|null;
|
||||
onPostMessage: (message: string)=> void;
|
||||
pluginStates: PluginStates;
|
||||
|
||||
themeId: number;
|
||||
}
|
||||
|
||||
const useSource = (tempDirPath: string) => {
|
||||
const injectedJs = useMemo(() => {
|
||||
const subValues = Setting.subValues('markdown.plugin', Setting.toPlainObject());
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const pluginOptions: any = {};
|
||||
for (const n in subValues) {
|
||||
pluginOptions[n] = { enabled: subValues[n] };
|
||||
}
|
||||
|
||||
const rendererWebViewStaticOptions: RendererWebViewOptions = {
|
||||
settings: {
|
||||
safeMode: Setting.value('isSafeMode'),
|
||||
tempDir: tempDirPath,
|
||||
resourceDir: Setting.value('resourceDir'),
|
||||
resourceDownloadMode: Setting.value('sync.resourceDownloadMode'),
|
||||
},
|
||||
// Web needs files to be transferred manually, since image SRCs can't reference
|
||||
// the Origin Private File System.
|
||||
useTransferredFiles: Platform.OS === 'web',
|
||||
pluginOptions,
|
||||
};
|
||||
|
||||
return `
|
||||
if (!window.rendererJsLoaded) {
|
||||
window.rendererJsLoaded = true;
|
||||
|
||||
${shim.injectedJs('webviewLib')}
|
||||
${shim.injectedJs('rendererBundle')}
|
||||
|
||||
rendererBundle.initialize(${JSON.stringify(rendererWebViewStaticOptions)});
|
||||
}
|
||||
`;
|
||||
}, [tempDirPath]);
|
||||
|
||||
return { css: '', injectedJs };
|
||||
};
|
||||
|
||||
const onPostPluginMessage = async (contentScriptId: string, message: unknown) => {
|
||||
logger.debug(`Handling message from content script: ${contentScriptId}:`, message);
|
||||
|
||||
const pluginService = PluginService.instance();
|
||||
const pluginId = pluginService.pluginIdByContentScriptId(contentScriptId);
|
||||
if (!pluginId) {
|
||||
throw new Error(`Plugin not found for content script with ID ${contentScriptId}`);
|
||||
}
|
||||
|
||||
const plugin = pluginService.pluginById(pluginId);
|
||||
return plugin.emitContentScriptMessage(contentScriptId, message);
|
||||
};
|
||||
|
||||
type UseMessengerProps = Props & { tempDirPath: string };
|
||||
|
||||
const useMessenger = (props: UseMessengerProps) => {
|
||||
const onScrollRef = useRef(props.onBodyScroll);
|
||||
onScrollRef.current = props.onBodyScroll;
|
||||
|
||||
const onPostMessageRef = useRef(props.onPostMessage);
|
||||
onPostMessageRef.current = props.onPostMessage;
|
||||
|
||||
const messenger = useMemo(() => {
|
||||
const fsDriver = shim.fsDriver();
|
||||
const localApi = {
|
||||
onScroll: (fraction: number) => onScrollRef.current?.(fraction),
|
||||
onPostMessage: (message: string) => onPostMessageRef.current?.(message),
|
||||
onPostPluginMessage,
|
||||
fsDriver: {
|
||||
writeFile: async (path: string, content: string, encoding?: string) => {
|
||||
if (!await fsDriver.exists(props.tempDirPath)) {
|
||||
await fsDriver.mkdir(props.tempDirPath);
|
||||
}
|
||||
// To avoid giving the WebView access to the entire main tempDir,
|
||||
// we use props.tempDir (which should be different).
|
||||
path = fsDriver.resolveRelativePathWithinDir(props.tempDirPath, path);
|
||||
return await fsDriver.writeFile(path, content, encoding);
|
||||
},
|
||||
exists: fsDriver.exists,
|
||||
cacheCssToFile: fsDriver.cacheCssToFile,
|
||||
},
|
||||
};
|
||||
return new RNToWebViewMessenger<MainProcessApi, RendererProcessApi>(
|
||||
'renderer', props.webviewRef, localApi,
|
||||
);
|
||||
}, [props.webviewRef, props.tempDirPath]);
|
||||
|
||||
return messenger;
|
||||
};
|
||||
|
||||
const useTempDirPath = () => {
|
||||
// The renderer can write to whichever temporary directory is chosen here. As such,
|
||||
// use a subdirectory of the main temporary directory for security reasons.
|
||||
const tempDirPath = useMemo(() => {
|
||||
return `${Setting.value('tempDir')}/${uuid.createNano()}`;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
void (async () => {
|
||||
if (await shim.fsDriver().exists(tempDirPath)) {
|
||||
await shim.fsDriver().remove(tempDirPath);
|
||||
}
|
||||
})();
|
||||
};
|
||||
}, [tempDirPath]);
|
||||
|
||||
return tempDirPath;
|
||||
};
|
||||
|
||||
const useWebViewSetup = (props: Props): SetUpResult<RendererControl> => {
|
||||
const tempDirPath = useTempDirPath();
|
||||
const { css, injectedJs } = useSource(tempDirPath);
|
||||
const { editPopupCss, createEditPopupSyntax, destroyEditPopupSyntax } = useEditPopup(props.themeId);
|
||||
|
||||
const messenger = useMessenger({ ...props, tempDirPath });
|
||||
const pluginSettingKeysRef = useRef(new Set<string>());
|
||||
|
||||
const contentScripts = useContentScripts(props.pluginStates);
|
||||
useEffect(() => {
|
||||
void messenger.remoteApi.renderer.setExtraContentScriptsAndRerender(contentScripts);
|
||||
}, [messenger, contentScripts]);
|
||||
|
||||
const rendererControl = useMemo((): RendererControl => {
|
||||
const renderer = messenger.remoteApi.renderer;
|
||||
|
||||
const transferResources = async (resources: ResourceInfos) => {
|
||||
// On web, resources are virtual files and thus need to be transferred to the WebView.
|
||||
if (shim.mobilePlatform() === 'web') {
|
||||
for (const [resourceId, resource] of Object.entries(resources)) {
|
||||
try {
|
||||
await renderer.setResourceFile(
|
||||
resourceId,
|
||||
await shim.fsDriver().fileAtPath(Resource.fullPath(resource.item)),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// This can happen if a resource hasn't been downloaded yet
|
||||
logger.warn('Error: Resource file not found (ENOENT)', Resource.fullPath(resource.item), 'for ID', resource.item.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const prepareRenderer = async (options: RenderOptions) => {
|
||||
const theme = themeStyle(options.themeId);
|
||||
|
||||
const loadPluginSettings = () => {
|
||||
const output: Record<string, unknown> = Object.create(null);
|
||||
for (const key of pluginSettingKeysRef.current) {
|
||||
output[key] = Setting.value(`plugin-${key}`);
|
||||
}
|
||||
return output;
|
||||
};
|
||||
|
||||
let settingsChanged = false;
|
||||
const settings: RenderSettings = {
|
||||
...options,
|
||||
codeTheme: theme.codeThemeCss,
|
||||
// We .stringify the theme to avoid a JSON serialization error involving
|
||||
// the color package.
|
||||
theme: JSON.stringify({
|
||||
...theme,
|
||||
...options.themeOverrides,
|
||||
}),
|
||||
createEditPopupSyntax,
|
||||
destroyEditPopupSyntax,
|
||||
pluginSettings: loadPluginSettings(),
|
||||
requestPluginSetting: (pluginId: string, settingKey: string) => {
|
||||
const key = `${pluginId}.${settingKey}`;
|
||||
if (!pluginSettingKeysRef.current.has(key)) {
|
||||
pluginSettingKeysRef.current.add(key);
|
||||
settingsChanged = true;
|
||||
}
|
||||
},
|
||||
readAssetBlob: (assetPath: string): Promise<Blob> => {
|
||||
// Built-in assets are in resourceDir, external plugin assets are in cacheDir.
|
||||
const assetsDirs = [Setting.value('resourceDir'), Setting.value('cacheDir')];
|
||||
|
||||
let resolvedPath = null;
|
||||
for (const assetDir of assetsDirs) {
|
||||
resolvedPath ??= resolvePathWithinDir(assetDir, assetPath);
|
||||
if (resolvedPath) break;
|
||||
}
|
||||
|
||||
if (!resolvedPath) {
|
||||
throw new Error(`Failed to load asset at ${assetPath} -- not in any of the allowed asset directories: ${assetsDirs.join(',')}.`);
|
||||
}
|
||||
return shim.fsDriver().fileAtPath(resolvedPath);
|
||||
},
|
||||
};
|
||||
|
||||
await transferResources(options.resources);
|
||||
|
||||
return {
|
||||
settings,
|
||||
getSettingsChanged() {
|
||||
return settingsChanged;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
rerenderToBody: async (markup, options, cancelEvent) => {
|
||||
const { settings, getSettingsChanged } = await prepareRenderer(options);
|
||||
if (cancelEvent?.cancelled) return null;
|
||||
|
||||
const output = await renderer.rerenderToBody(markup, settings);
|
||||
if (cancelEvent?.cancelled) return null;
|
||||
|
||||
if (getSettingsChanged()) {
|
||||
return await renderer.rerenderToBody(markup, settings);
|
||||
}
|
||||
return output;
|
||||
},
|
||||
render: async (markup, options) => {
|
||||
const { settings, getSettingsChanged } = await prepareRenderer(options);
|
||||
const output = await renderer.render(markup, settings);
|
||||
|
||||
if (getSettingsChanged()) {
|
||||
return await renderer.render(markup, settings);
|
||||
}
|
||||
return output;
|
||||
},
|
||||
clearCache: async markupLanguage => {
|
||||
await renderer.clearCache(markupLanguage);
|
||||
},
|
||||
};
|
||||
}, [createEditPopupSyntax, destroyEditPopupSyntax, messenger]);
|
||||
|
||||
return useMemo(() => {
|
||||
return {
|
||||
api: rendererControl,
|
||||
pageSetup: {
|
||||
css: `${css} ${editPopupCss}`,
|
||||
js: injectedJs,
|
||||
},
|
||||
webViewEventHandlers: {
|
||||
onLoadEnd: messenger.onWebViewLoaded,
|
||||
onMessage: messenger.onWebViewMessage,
|
||||
},
|
||||
};
|
||||
}, [css, injectedJs, messenger, editPopupCss, rendererControl]);
|
||||
};
|
||||
|
||||
export default useWebViewSetup;
|
@@ -4,8 +4,8 @@ import { ContentScriptType } from '@joplin/lib/services/plugins/api/types';
|
||||
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import { useRef, useState } from 'react';
|
||||
import { ExtraContentScriptSource } from '../bundledJs/types';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import { ExtraContentScriptSource } from '../types';
|
||||
|
||||
const logger = Logger.create('NoteBodyViewer/hooks/useContentScripts');
|
||||
|
@@ -0,0 +1,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;
|
@@ -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';
|
||||
|
@@ -0,0 +1,33 @@
|
||||
import { EditorEvent } from '@joplin/editor/events';
|
||||
import { EditorControl, EditorSettings } from '@joplin/editor/types';
|
||||
import { MarkupRecord, RendererControl } from '../rendererBundle/types';
|
||||
import { RenderResult } from '@joplin/renderer/types';
|
||||
|
||||
export interface EditorProps {
|
||||
initialText: string;
|
||||
initialNoteId: string;
|
||||
parentElementClassName: string;
|
||||
settings: EditorSettings;
|
||||
}
|
||||
|
||||
export interface EditorProcessApi {
|
||||
editor: EditorControl;
|
||||
}
|
||||
|
||||
type RenderOptionsSlice = {
|
||||
pluginAssetContainerSelector: string;
|
||||
splitted: boolean;
|
||||
mapsToLine: true;
|
||||
};
|
||||
|
||||
export interface MainProcessApi {
|
||||
onEditorEvent(event: EditorEvent): Promise<void>;
|
||||
logMessage(message: string): Promise<void>;
|
||||
onRender(markup: MarkupRecord, options: RenderOptionsSlice): Promise<RenderResult>;
|
||||
onPasteFile(type: string, base64: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface RichTextEditorControl {
|
||||
editor: EditorControl;
|
||||
renderer: RendererControl;
|
||||
}
|
@@ -0,0 +1,166 @@
|
||||
import { RefObject, useEffect, useMemo, useRef } from 'react';
|
||||
import { WebViewControl } from '../../components/ExtendedWebView/types';
|
||||
import { SetUpResult } from '../types';
|
||||
import { EditorControl, EditorSettings } from '@joplin/editor/types';
|
||||
import RNToWebViewMessenger from '../../utils/ipc/RNToWebViewMessenger';
|
||||
import { EditorProcessApi, EditorProps, MainProcessApi } from './types';
|
||||
import useRendererSetup from '../rendererBundle/useWebViewSetup';
|
||||
import { EditorEvent } from '@joplin/editor/events';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
|
||||
import { RendererControl, RenderOptions } from '../rendererBundle/types';
|
||||
import { ResourceInfos } from '@joplin/renderer/types';
|
||||
|
||||
const logger = Logger.create('useWebViewSetup');
|
||||
|
||||
interface Props {
|
||||
initialText: string;
|
||||
noteId: string;
|
||||
settings: EditorSettings;
|
||||
parentElementClassName: string;
|
||||
themeId: number;
|
||||
pluginStates: PluginStates;
|
||||
noteResources: ResourceInfos;
|
||||
onAttachFile: (mime: string, base64: string)=> void;
|
||||
|
||||
onPostMessage: (message: string)=> void;
|
||||
onEditorEvent: (event: EditorEvent)=> void;
|
||||
webviewRef: RefObject<WebViewControl>;
|
||||
}
|
||||
|
||||
type UseMessengerProps = Props & { renderer: SetUpResult<RendererControl> };
|
||||
|
||||
const useMessenger = (props: UseMessengerProps) => {
|
||||
const onEditorEventRef = useRef(props.onEditorEvent);
|
||||
onEditorEventRef.current = props.onEditorEvent;
|
||||
const rendererRef = useRef(props.renderer);
|
||||
rendererRef.current = props.renderer;
|
||||
const onAttachRef = useRef(props.onAttachFile);
|
||||
onAttachRef.current = props.onAttachFile;
|
||||
|
||||
const markupRenderingSettings = useRef<RenderOptions>(null);
|
||||
markupRenderingSettings.current = {
|
||||
themeId: props.themeId,
|
||||
highlightedKeywords: [],
|
||||
resources: props.noteResources,
|
||||
themeOverrides: {},
|
||||
noteHash: '',
|
||||
initialScroll: 0,
|
||||
pluginAssetContainerSelector: null,
|
||||
};
|
||||
|
||||
return useMemo(() => {
|
||||
const api: MainProcessApi = {
|
||||
onEditorEvent: (event: EditorEvent) => {
|
||||
onEditorEventRef.current(event);
|
||||
return Promise.resolve();
|
||||
},
|
||||
logMessage: (message: string) => {
|
||||
logger.info(message);
|
||||
return Promise.resolve();
|
||||
},
|
||||
onRender: async (markup, options) => {
|
||||
const renderResult = await rendererRef.current.api.render(
|
||||
markup,
|
||||
{
|
||||
...markupRenderingSettings.current,
|
||||
splitted: options.splitted,
|
||||
pluginAssetContainerSelector: options.pluginAssetContainerSelector,
|
||||
mapsToLine: options.mapsToLine,
|
||||
},
|
||||
);
|
||||
return renderResult;
|
||||
},
|
||||
onPasteFile: async (type: string, base64: string) => {
|
||||
onAttachRef.current(type, base64);
|
||||
},
|
||||
};
|
||||
|
||||
const messenger = new RNToWebViewMessenger<MainProcessApi, EditorProcessApi>(
|
||||
'rich-text-editor',
|
||||
props.webviewRef,
|
||||
api,
|
||||
);
|
||||
return messenger;
|
||||
}, [props.webviewRef]);
|
||||
};
|
||||
|
||||
type UseSourceProps = Props & { renderer: SetUpResult<RendererControl> };
|
||||
|
||||
const useSource = (props: UseSourceProps) => {
|
||||
const propsRef = useRef(props);
|
||||
propsRef.current = props;
|
||||
|
||||
const rendererJs = props.renderer.pageSetup.js;
|
||||
const rendererCss = props.renderer.pageSetup.css;
|
||||
|
||||
return useMemo(() => {
|
||||
const editorOptions: EditorProps = {
|
||||
parentElementClassName: propsRef.current.parentElementClassName,
|
||||
initialText: propsRef.current.initialText,
|
||||
initialNoteId: propsRef.current.noteId,
|
||||
settings: propsRef.current.settings,
|
||||
};
|
||||
|
||||
return {
|
||||
css: `
|
||||
${shim.injectedCss('richTextEditorBundle')}
|
||||
${rendererCss}
|
||||
|
||||
/* Increase the size of the editor to make it easier to focus the editor. */
|
||||
.prosemirror-editor {
|
||||
min-height: 75vh;
|
||||
}
|
||||
`,
|
||||
js: `
|
||||
${rendererJs}
|
||||
|
||||
if (!window.richTextEditorCreated) {
|
||||
window.richTextEditorCreated = true;
|
||||
${shim.injectedJs('richTextEditorBundle')}
|
||||
richTextEditorBundle.setUpLogger();
|
||||
richTextEditorBundle.initialize(${JSON.stringify(editorOptions)}).then(function(editor) {
|
||||
/* For testing */
|
||||
window.joplinRichTextEditor_ = editor;
|
||||
});
|
||||
}
|
||||
`,
|
||||
};
|
||||
}, [rendererJs, rendererCss]);
|
||||
};
|
||||
|
||||
const useWebViewSetup = (props: Props): SetUpResult<EditorControl> => {
|
||||
const renderer = useRendererSetup({
|
||||
webviewRef: props.webviewRef,
|
||||
onBodyScroll: null,
|
||||
onPostMessage: props.onPostMessage,
|
||||
pluginStates: props.pluginStates,
|
||||
themeId: props.themeId,
|
||||
});
|
||||
const messenger = useMessenger({ ...props, renderer });
|
||||
const pageSetup = useSource({ ...props, renderer });
|
||||
|
||||
useEffect(() => {
|
||||
void messenger.remoteApi.editor.updateSettings(props.settings);
|
||||
}, [props.settings, messenger]);
|
||||
|
||||
return useMemo(() => {
|
||||
return {
|
||||
api: messenger.remoteApi.editor,
|
||||
pageSetup: pageSetup,
|
||||
webViewEventHandlers: {
|
||||
onLoadEnd: () => {
|
||||
messenger.onWebViewLoaded();
|
||||
renderer.webViewEventHandlers.onLoadEnd();
|
||||
},
|
||||
onMessage: (event) => {
|
||||
messenger.onWebViewMessage(event);
|
||||
renderer.webViewEventHandlers.onMessage(event);
|
||||
},
|
||||
},
|
||||
};
|
||||
}, [messenger, pageSetup, renderer.webViewEventHandlers]);
|
||||
};
|
||||
|
||||
export default useWebViewSetup;
|
17
packages/app-mobile/contentScripts/types.ts
Normal file
17
packages/app-mobile/contentScripts/types.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { OnMessageEvent } from '../components/ExtendedWebView/types';
|
||||
|
||||
interface WebViewEventHandlers {
|
||||
onLoadEnd: ()=> void;
|
||||
onMessage: (event: OnMessageEvent)=> void;
|
||||
}
|
||||
|
||||
export interface PageSetupSources {
|
||||
css: string;
|
||||
js: string;
|
||||
}
|
||||
|
||||
export interface SetUpResult<Api> {
|
||||
api: Api;
|
||||
pageSetup: PageSetupSources;
|
||||
webViewEventHandlers: WebViewEventHandlers;
|
||||
}
|
27
packages/app-mobile/contentScripts/utils/polyfills.ts
Normal file
27
packages/app-mobile/contentScripts/utils/polyfills.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// .replaceChildren is not supported in Chromium 83, which is the default for Android 11
|
||||
// (unless auto-updated from the Google Play store).
|
||||
HTMLElement.prototype.replaceChildren ??= function(this: HTMLElement, ...nodes: Node[]) {
|
||||
while (this.children.length) {
|
||||
this.children[0].remove();
|
||||
}
|
||||
|
||||
for (const node of nodes) {
|
||||
this.appendChild(node);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Array.prototype.flat ??= function<A, D extends number = 1>(this: A, depthParam?: D): FlatArray<A, D>[] {
|
||||
if (!Array.isArray(this)) throw new Error('Not an array');
|
||||
const depth = depthParam ?? 1;
|
||||
|
||||
const result = [] as FlatArray<A, D>[];
|
||||
for (let i = 0; i < this.length; i++) {
|
||||
if (Array.isArray(this[i]) && depth > 0) {
|
||||
result.push(...this[i].flat(depth - 1));
|
||||
} else {
|
||||
result.push(this[i]);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
15
packages/app-mobile/contentScripts/utils/readFileToBase64.ts
Normal file
15
packages/app-mobile/contentScripts/utils/readFileToBase64.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
const readFileToBase64 = (file: Blob) => {
|
||||
const reader = new FileReader();
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
reader.onload = async () => {
|
||||
const dataUrl = reader.result as string;
|
||||
const base64 = dataUrl.replace(/^data:.*;base64,/, '');
|
||||
resolve(base64);
|
||||
};
|
||||
reader.onerror = () => reject(new Error('Failed to load file.'));
|
||||
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
};
|
||||
|
||||
export default readFileToBase64;
|
14
packages/app-mobile/contentScripts/utils/setUpLogger.ts
Normal file
14
packages/app-mobile/contentScripts/utils/setUpLogger.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import Logger, { TargetType } from '@joplin/utils/Logger';
|
||||
|
||||
let loggerCreated = false;
|
||||
const setUpLogger = () => {
|
||||
if (!loggerCreated) {
|
||||
const logger = new Logger();
|
||||
logger.addTarget(TargetType.Console);
|
||||
logger.setLevel(Logger.LEVEL_WARN);
|
||||
Logger.initializeGlobalLogger(logger);
|
||||
loggerCreated = true;
|
||||
}
|
||||
};
|
||||
|
||||
export default setUpLogger;
|
@@ -27,22 +27,14 @@ utils.registerGulpTasks(gulp, tasks);
|
||||
|
||||
gulp.task('buildInjectedJs', gulp.series(
|
||||
'beforeBundle',
|
||||
'buildCodeMirrorEditor',
|
||||
'buildJsDrawEditor',
|
||||
'buildPluginBackgroundScript',
|
||||
'buildNoteViewerBundle',
|
||||
'buildBundledJs',
|
||||
'copyWebviewLib',
|
||||
));
|
||||
|
||||
gulp.task('watchInjectedJs', gulp.series(
|
||||
'beforeBundle',
|
||||
'copyWebviewLib',
|
||||
gulp.parallel(
|
||||
'watchCodeMirrorEditor',
|
||||
'watchJsDrawEditor',
|
||||
'watchPluginBackgroundScript',
|
||||
'watchNoteViewerBundle',
|
||||
),
|
||||
'watchBundledJs',
|
||||
));
|
||||
|
||||
gulp.task('build', gulp.series(
|
||||
|
@@ -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;
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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",
|
||||
|
@@ -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),
|
||||
);
|
||||
|
||||
|
@@ -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
Reference in New Issue
Block a user