You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-08-24 20:19:10 +02:00
Compare commits
115 Commits
android-v3
...
dev
Author | SHA1 | Date | |
---|---|---|---|
|
227e41b69a | ||
|
a616e26a0f | ||
|
ba0e7e2226 | ||
|
b5a4ba554d | ||
|
9037da8f2d | ||
|
6998606ec9 | ||
|
66d52c90a3 | ||
|
f6fb1f7fbf | ||
|
3aac6043da | ||
|
ae170e0aa0 | ||
|
371f027a24 | ||
|
37422f316e | ||
|
a9f284ae45 | ||
|
fd2f69cc73 | ||
|
c4eab3c79c | ||
|
a0b9c6376e | ||
|
e2fc056369 | ||
|
453b4705b1 | ||
|
4128061e40 | ||
|
432b0ca870 | ||
|
c484cd2e48 | ||
|
58f0725c6b | ||
|
bf8fbec0cd | ||
|
f1d452f130 | ||
|
26012cd7d5 | ||
|
a414241541 | ||
|
0f13bf9d51 | ||
|
c142c5c5c0 | ||
|
af5c0135dc | ||
|
8a811b9e78 | ||
|
602484f143 | ||
|
dc84db1657 | ||
|
f5882ecfcc | ||
|
30000c34ec | ||
|
6e3df1bd90 | ||
|
67196ac0b2 | ||
|
69646b5522 | ||
|
9147afce9a | ||
|
c92701c52f | ||
|
ab3e9d1a3e | ||
|
f9cab8843b | ||
|
c36289c024 | ||
|
60b6db8cd4 | ||
|
bbd8f6f40e | ||
|
34b7f4e1f8 | ||
|
06b681d897 | ||
|
f02a94bef5 | ||
|
ae6b57c5a5 | ||
|
88ab916008 | ||
|
97b0ffc263 | ||
|
ff8848d138 | ||
|
2b686e6318 | ||
|
b913d18882 | ||
|
a2c9a01722 | ||
|
000d23c20f | ||
|
9e9f2f2930 | ||
|
c5a1a759c7 | ||
|
0b6a1c75ba | ||
|
53a0f8ddbc | ||
|
67eabb5038 | ||
|
983fced410 | ||
|
4f5bbc1132 | ||
|
2f10235ecb | ||
|
cfa7d6cb31 | ||
|
f5d62a50fe | ||
|
b52f5435aa | ||
|
bfd5bfc004 | ||
|
82965fe991 | ||
|
b2c162c25b | ||
|
022e76fe8d | ||
|
4b2d1895fd | ||
|
534507a31f | ||
|
5b4a300c81 | ||
|
1de0a59313 | ||
|
f4dff92d2e | ||
|
a5d37a0dca | ||
|
75ef418b39 | ||
|
6bd702ae24 | ||
|
9ea1808766 | ||
|
59f8dd36a6 | ||
|
ea1d2e4878 | ||
|
46ab00bfe4 | ||
|
07465dd349 | ||
|
a288ffe338 | ||
|
dba62386b6 | ||
|
6704ab0d13 | ||
|
0312f2213d | ||
|
2ac0b66ef6 | ||
|
639b261ee4 | ||
|
82bc819a21 | ||
|
72f8ebe4ff | ||
|
8c8a38e704 | ||
|
358134038c | ||
|
1f4b32a241 | ||
|
2a216f1e61 | ||
|
3f75d770f7 | ||
|
b6d32831c6 | ||
|
788033cb5f | ||
|
4e685ec687 | ||
|
c60b703b9c | ||
|
f23e10a975 | ||
|
b9a71c0c3d | ||
|
f525c4179f | ||
|
1dd0ec619f | ||
|
d2ee5411d0 | ||
|
a2472cb3b7 | ||
|
ca8415f74a | ||
|
853b792367 | ||
|
56d477f1c1 | ||
|
020ba10c56 | ||
|
be09873c58 | ||
|
4d8a16bda7 | ||
|
f725d3895f | ||
|
0e19dce0d1 | ||
|
31c5058d5e |
@@ -10,13 +10,16 @@ QUEUE_TTL=900000
|
||||
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
|
||||
HTR_CLI_DOCKER_IMAGE=joplin/htr-cli:latest
|
||||
# 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
|
||||
|
||||
FILE_STORAGE_MAINTENANCE_INTERVAL=3600000
|
||||
FILE_STORAGE_TTL=604800000 # one week
|
||||
|
||||
# =============================================================================
|
||||
# Queue driver
|
||||
|
@@ -98,6 +98,7 @@ packages/app-cli/app/app.js
|
||||
packages/app-cli/app/base-command.js
|
||||
packages/app-cli/app/command-apidoc.js
|
||||
packages/app-cli/app/command-attach.js
|
||||
packages/app-cli/app/command-batch.js
|
||||
packages/app-cli/app/command-cat.js
|
||||
packages/app-cli/app/command-config.js
|
||||
packages/app-cli/app/command-cp.js
|
||||
@@ -135,6 +136,7 @@ packages/app-cli/app/gui/StatusBarWidget.js
|
||||
packages/app-cli/app/services/plugins/PluginRunner.js
|
||||
packages/app-cli/app/setupCommand.js
|
||||
packages/app-cli/app/utils/initializeCommandService.js
|
||||
packages/app-cli/app/utils/iterateStdin.js
|
||||
packages/app-cli/app/utils/shimInitCli.js
|
||||
packages/app-cli/app/utils/testUtils.js
|
||||
packages/app-cli/tests/HtmlToMd.js
|
||||
@@ -157,6 +159,8 @@ packages/app-desktop/app.reducer.js
|
||||
packages/app-desktop/app.js
|
||||
packages/app-desktop/bridge.js
|
||||
packages/app-desktop/checkForUpdates.js
|
||||
packages/app-desktop/commands/convertNoteToMarkdown.test.js
|
||||
packages/app-desktop/commands/convertNoteToMarkdown.js
|
||||
packages/app-desktop/commands/copyDevCommand.js
|
||||
packages/app-desktop/commands/copyToClipboard.js
|
||||
packages/app-desktop/commands/editProfileConfig.js
|
||||
@@ -197,6 +201,7 @@ packages/app-desktop/gui/ConfigScreen/controls/ToggleAdvancedSettingsButton.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/plugins/SearchPlugins.js
|
||||
packages/app-desktop/gui/ConversionNotification/ConversionNotification.js
|
||||
packages/app-desktop/gui/Dialog.js
|
||||
packages/app-desktop/gui/DialogButtonRow.js
|
||||
packages/app-desktop/gui/DialogButtonRow/useKeyboardHandler.js
|
||||
@@ -298,6 +303,7 @@ packages/app-desktop/gui/NoteEditor/utils/clipboardUtils.test.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/clipboardUtils.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/contextMenu.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/contextMenuUtils.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/getResourceBaseUrl.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/getWindowCommandPriority.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/index.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/markupRenderOptions.js
|
||||
@@ -692,7 +698,6 @@ 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
|
||||
@@ -739,6 +744,7 @@ packages/app-mobile/components/getResponsiveValue.js
|
||||
packages/app-mobile/components/global-style.js
|
||||
packages/app-mobile/components/plugins/PluginNotification.js
|
||||
packages/app-mobile/components/plugins/PluginRunner.js
|
||||
packages/app-mobile/components/plugins/PluginRunnerWebView.test.js
|
||||
packages/app-mobile/components/plugins/PluginRunnerWebView.js
|
||||
packages/app-mobile/components/plugins/backgroundPage/initializeDialogWebView.js
|
||||
packages/app-mobile/components/plugins/backgroundPage/initializePluginBackgroundIframe.js
|
||||
@@ -861,6 +867,7 @@ 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/markdownEditorBundle/utils/useCodeMirrorPlugins.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
|
||||
@@ -997,15 +1004,38 @@ packages/editor/CodeMirror/editorCommands/sortSelectedLines.test.js
|
||||
packages/editor/CodeMirror/editorCommands/sortSelectedLines.js
|
||||
packages/editor/CodeMirror/editorCommands/supportsCommand.js
|
||||
packages/editor/CodeMirror/extensions/biDirectionalTextExtension.js
|
||||
packages/editor/CodeMirror/extensions/ctrlClickActionExtension.js
|
||||
packages/editor/CodeMirror/extensions/ctrlClickCheckboxExtension.js
|
||||
packages/editor/CodeMirror/extensions/keyUpHandlerExtension.js
|
||||
packages/editor/CodeMirror/extensions/links/ctrlClickLinksExtension.js
|
||||
packages/editor/CodeMirror/extensions/links/followLinkTooltipExtension.test.js
|
||||
packages/editor/CodeMirror/extensions/links/followLinkTooltipExtension.js
|
||||
packages/editor/CodeMirror/extensions/links/referenceLinksStateField.js
|
||||
packages/editor/CodeMirror/extensions/links/utils/findLineMatchingLink.test.js
|
||||
packages/editor/CodeMirror/extensions/links/utils/findLineMatchingLink.js
|
||||
packages/editor/CodeMirror/extensions/links/utils/getUrlAtPosition.js
|
||||
packages/editor/CodeMirror/extensions/links/utils/openLink.js
|
||||
packages/editor/CodeMirror/extensions/markdownDecorationExtension.test.js
|
||||
packages/editor/CodeMirror/extensions/markdownDecorationExtension.js
|
||||
packages/editor/CodeMirror/extensions/markdownHighlightExtension.test.js
|
||||
packages/editor/CodeMirror/extensions/markdownHighlightExtension.js
|
||||
packages/editor/CodeMirror/extensions/markdownMathExtension.test.js
|
||||
packages/editor/CodeMirror/extensions/markdownMathExtension.js
|
||||
packages/editor/CodeMirror/extensions/modifierKeyCssExtension.js
|
||||
packages/editor/CodeMirror/extensions/overwriteModeExtension.test.js
|
||||
packages/editor/CodeMirror/extensions/overwriteModeExtension.js
|
||||
packages/editor/CodeMirror/extensions/rendering/addFormattingClasses.js
|
||||
packages/editor/CodeMirror/extensions/rendering/renderBlockImages.test.js
|
||||
packages/editor/CodeMirror/extensions/rendering/renderBlockImages.js
|
||||
packages/editor/CodeMirror/extensions/rendering/renderingExtension.js
|
||||
packages/editor/CodeMirror/extensions/rendering/replaceBulletLists.js
|
||||
packages/editor/CodeMirror/extensions/rendering/replaceCheckboxes.js
|
||||
packages/editor/CodeMirror/extensions/rendering/replaceDividers.js
|
||||
packages/editor/CodeMirror/extensions/rendering/replaceFormatCharacters.js
|
||||
packages/editor/CodeMirror/extensions/rendering/types.js
|
||||
packages/editor/CodeMirror/extensions/rendering/utils/makeBlockReplaceExtension.js
|
||||
packages/editor/CodeMirror/extensions/rendering/utils/makeInlineReplaceExtension.js
|
||||
packages/editor/CodeMirror/extensions/rendering/utils/nodeIntersectsSelection.js
|
||||
packages/editor/CodeMirror/extensions/searchExtension.js
|
||||
packages/editor/CodeMirror/extensions/selectedNoteIdExtension.js
|
||||
packages/editor/CodeMirror/getScrollFraction.js
|
||||
@@ -1046,16 +1076,23 @@ packages/editor/CodeMirror/utils/isInSyntaxNode.js
|
||||
packages/editor/CodeMirror/utils/markdown/codeBlockLanguages/allLanguages.js
|
||||
packages/editor/CodeMirror/utils/markdown/codeBlockLanguages/defaultLanguage.js
|
||||
packages/editor/CodeMirror/utils/markdown/codeBlockLanguages/lookUpLanguage.js
|
||||
packages/editor/CodeMirror/utils/markdown/getCheckboxAtPosition.js
|
||||
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/markdown/toggleCheckboxAt.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/detailsPlugin.test.js
|
||||
packages/editor/ProseMirror/plugins/detailsPlugin.js
|
||||
packages/editor/ProseMirror/plugins/inputRulesPlugin.js
|
||||
packages/editor/ProseMirror/plugins/joplinEditablePlugin.js
|
||||
packages/editor/ProseMirror/plugins/joplinEditablePlugin/createEditorDialog.js
|
||||
packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.test.js
|
||||
packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.js
|
||||
packages/editor/ProseMirror/plugins/joplinEditablePlugin/postProcessRenderedHtml.js
|
||||
packages/editor/ProseMirror/plugins/joplinEditorApiPlugin.js
|
||||
packages/editor/ProseMirror/plugins/keymapPlugin.js
|
||||
packages/editor/ProseMirror/plugins/linkTooltipPlugin.test.js
|
||||
@@ -1071,9 +1108,15 @@ 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/dom/createButton.js
|
||||
packages/editor/ProseMirror/utils/dom/createTextArea.js
|
||||
packages/editor/ProseMirror/utils/dom/createTextNode.js
|
||||
packages/editor/ProseMirror/utils/dom/createUniqueId.js
|
||||
packages/editor/ProseMirror/utils/extractSelectedLinesTo.test.js
|
||||
packages/editor/ProseMirror/utils/extractSelectedLinesTo.js
|
||||
packages/editor/ProseMirror/utils/forEachHeading.js
|
||||
packages/editor/ProseMirror/utils/jumpToHash.js
|
||||
packages/editor/ProseMirror/utils/makeLinksClickableInElement.js
|
||||
packages/editor/ProseMirror/utils/preprocessEditorInput.test.js
|
||||
packages/editor/ProseMirror/utils/preprocessEditorInput.js
|
||||
packages/editor/ProseMirror/utils/sanitizeHtml.js
|
||||
@@ -1150,6 +1193,8 @@ packages/lib/array.js
|
||||
packages/lib/callbackUrlUtils.test.js
|
||||
packages/lib/callbackUrlUtils.js
|
||||
packages/lib/clipperUtils.js
|
||||
packages/lib/commands/convertHtmlToMarkdown.test.js
|
||||
packages/lib/commands/convertHtmlToMarkdown.js
|
||||
packages/lib/commands/deleteNote.js
|
||||
packages/lib/commands/historyBackward.js
|
||||
packages/lib/commands/historyForward.js
|
||||
@@ -1584,6 +1629,7 @@ packages/lib/shim-init-node.js
|
||||
packages/lib/shim.js
|
||||
packages/lib/string-utils.test.js
|
||||
packages/lib/string-utils.js
|
||||
packages/lib/testing/plugins/createTestPlugin.js
|
||||
packages/lib/testing/share/makeMockShareInvitation.js
|
||||
packages/lib/testing/share/mockShareService.js
|
||||
packages/lib/testing/syncTargetUtils.js
|
||||
@@ -1728,12 +1774,14 @@ packages/tools/fuzzer/Client.js
|
||||
packages/tools/fuzzer/ClientPool.js
|
||||
packages/tools/fuzzer/Server.js
|
||||
packages/tools/fuzzer/constants.js
|
||||
packages/tools/fuzzer/model/FolderRecord.js
|
||||
packages/tools/fuzzer/sync-fuzzer.js
|
||||
packages/tools/fuzzer/types.js
|
||||
packages/tools/fuzzer/utils/SeededRandom.js
|
||||
packages/tools/fuzzer/utils/getNumberProperty.js
|
||||
packages/tools/fuzzer/utils/getProperty.js
|
||||
packages/tools/fuzzer/utils/getStringProperty.js
|
||||
packages/tools/fuzzer/utils/openDebugSession.js
|
||||
packages/tools/fuzzer/utils/retryWithCount.js
|
||||
packages/tools/generate-database-types.js
|
||||
packages/tools/generate-images.js
|
||||
|
52
.gitignore
vendored
52
.gitignore
vendored
@@ -71,6 +71,7 @@ packages/app-cli/app/app.js
|
||||
packages/app-cli/app/base-command.js
|
||||
packages/app-cli/app/command-apidoc.js
|
||||
packages/app-cli/app/command-attach.js
|
||||
packages/app-cli/app/command-batch.js
|
||||
packages/app-cli/app/command-cat.js
|
||||
packages/app-cli/app/command-config.js
|
||||
packages/app-cli/app/command-cp.js
|
||||
@@ -108,6 +109,7 @@ packages/app-cli/app/gui/StatusBarWidget.js
|
||||
packages/app-cli/app/services/plugins/PluginRunner.js
|
||||
packages/app-cli/app/setupCommand.js
|
||||
packages/app-cli/app/utils/initializeCommandService.js
|
||||
packages/app-cli/app/utils/iterateStdin.js
|
||||
packages/app-cli/app/utils/shimInitCli.js
|
||||
packages/app-cli/app/utils/testUtils.js
|
||||
packages/app-cli/tests/HtmlToMd.js
|
||||
@@ -130,6 +132,8 @@ packages/app-desktop/app.reducer.js
|
||||
packages/app-desktop/app.js
|
||||
packages/app-desktop/bridge.js
|
||||
packages/app-desktop/checkForUpdates.js
|
||||
packages/app-desktop/commands/convertNoteToMarkdown.test.js
|
||||
packages/app-desktop/commands/convertNoteToMarkdown.js
|
||||
packages/app-desktop/commands/copyDevCommand.js
|
||||
packages/app-desktop/commands/copyToClipboard.js
|
||||
packages/app-desktop/commands/editProfileConfig.js
|
||||
@@ -170,6 +174,7 @@ packages/app-desktop/gui/ConfigScreen/controls/ToggleAdvancedSettingsButton.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/plugins/SearchPlugins.js
|
||||
packages/app-desktop/gui/ConversionNotification/ConversionNotification.js
|
||||
packages/app-desktop/gui/Dialog.js
|
||||
packages/app-desktop/gui/DialogButtonRow.js
|
||||
packages/app-desktop/gui/DialogButtonRow/useKeyboardHandler.js
|
||||
@@ -271,6 +276,7 @@ packages/app-desktop/gui/NoteEditor/utils/clipboardUtils.test.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/clipboardUtils.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/contextMenu.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/contextMenuUtils.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/getResourceBaseUrl.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/getWindowCommandPriority.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/index.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/markupRenderOptions.js
|
||||
@@ -665,7 +671,6 @@ 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
|
||||
@@ -712,6 +717,7 @@ packages/app-mobile/components/getResponsiveValue.js
|
||||
packages/app-mobile/components/global-style.js
|
||||
packages/app-mobile/components/plugins/PluginNotification.js
|
||||
packages/app-mobile/components/plugins/PluginRunner.js
|
||||
packages/app-mobile/components/plugins/PluginRunnerWebView.test.js
|
||||
packages/app-mobile/components/plugins/PluginRunnerWebView.js
|
||||
packages/app-mobile/components/plugins/backgroundPage/initializeDialogWebView.js
|
||||
packages/app-mobile/components/plugins/backgroundPage/initializePluginBackgroundIframe.js
|
||||
@@ -834,6 +840,7 @@ 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/markdownEditorBundle/utils/useCodeMirrorPlugins.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
|
||||
@@ -970,15 +977,38 @@ packages/editor/CodeMirror/editorCommands/sortSelectedLines.test.js
|
||||
packages/editor/CodeMirror/editorCommands/sortSelectedLines.js
|
||||
packages/editor/CodeMirror/editorCommands/supportsCommand.js
|
||||
packages/editor/CodeMirror/extensions/biDirectionalTextExtension.js
|
||||
packages/editor/CodeMirror/extensions/ctrlClickActionExtension.js
|
||||
packages/editor/CodeMirror/extensions/ctrlClickCheckboxExtension.js
|
||||
packages/editor/CodeMirror/extensions/keyUpHandlerExtension.js
|
||||
packages/editor/CodeMirror/extensions/links/ctrlClickLinksExtension.js
|
||||
packages/editor/CodeMirror/extensions/links/followLinkTooltipExtension.test.js
|
||||
packages/editor/CodeMirror/extensions/links/followLinkTooltipExtension.js
|
||||
packages/editor/CodeMirror/extensions/links/referenceLinksStateField.js
|
||||
packages/editor/CodeMirror/extensions/links/utils/findLineMatchingLink.test.js
|
||||
packages/editor/CodeMirror/extensions/links/utils/findLineMatchingLink.js
|
||||
packages/editor/CodeMirror/extensions/links/utils/getUrlAtPosition.js
|
||||
packages/editor/CodeMirror/extensions/links/utils/openLink.js
|
||||
packages/editor/CodeMirror/extensions/markdownDecorationExtension.test.js
|
||||
packages/editor/CodeMirror/extensions/markdownDecorationExtension.js
|
||||
packages/editor/CodeMirror/extensions/markdownHighlightExtension.test.js
|
||||
packages/editor/CodeMirror/extensions/markdownHighlightExtension.js
|
||||
packages/editor/CodeMirror/extensions/markdownMathExtension.test.js
|
||||
packages/editor/CodeMirror/extensions/markdownMathExtension.js
|
||||
packages/editor/CodeMirror/extensions/modifierKeyCssExtension.js
|
||||
packages/editor/CodeMirror/extensions/overwriteModeExtension.test.js
|
||||
packages/editor/CodeMirror/extensions/overwriteModeExtension.js
|
||||
packages/editor/CodeMirror/extensions/rendering/addFormattingClasses.js
|
||||
packages/editor/CodeMirror/extensions/rendering/renderBlockImages.test.js
|
||||
packages/editor/CodeMirror/extensions/rendering/renderBlockImages.js
|
||||
packages/editor/CodeMirror/extensions/rendering/renderingExtension.js
|
||||
packages/editor/CodeMirror/extensions/rendering/replaceBulletLists.js
|
||||
packages/editor/CodeMirror/extensions/rendering/replaceCheckboxes.js
|
||||
packages/editor/CodeMirror/extensions/rendering/replaceDividers.js
|
||||
packages/editor/CodeMirror/extensions/rendering/replaceFormatCharacters.js
|
||||
packages/editor/CodeMirror/extensions/rendering/types.js
|
||||
packages/editor/CodeMirror/extensions/rendering/utils/makeBlockReplaceExtension.js
|
||||
packages/editor/CodeMirror/extensions/rendering/utils/makeInlineReplaceExtension.js
|
||||
packages/editor/CodeMirror/extensions/rendering/utils/nodeIntersectsSelection.js
|
||||
packages/editor/CodeMirror/extensions/searchExtension.js
|
||||
packages/editor/CodeMirror/extensions/selectedNoteIdExtension.js
|
||||
packages/editor/CodeMirror/getScrollFraction.js
|
||||
@@ -1019,16 +1049,23 @@ packages/editor/CodeMirror/utils/isInSyntaxNode.js
|
||||
packages/editor/CodeMirror/utils/markdown/codeBlockLanguages/allLanguages.js
|
||||
packages/editor/CodeMirror/utils/markdown/codeBlockLanguages/defaultLanguage.js
|
||||
packages/editor/CodeMirror/utils/markdown/codeBlockLanguages/lookUpLanguage.js
|
||||
packages/editor/CodeMirror/utils/markdown/getCheckboxAtPosition.js
|
||||
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/markdown/toggleCheckboxAt.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/detailsPlugin.test.js
|
||||
packages/editor/ProseMirror/plugins/detailsPlugin.js
|
||||
packages/editor/ProseMirror/plugins/inputRulesPlugin.js
|
||||
packages/editor/ProseMirror/plugins/joplinEditablePlugin.js
|
||||
packages/editor/ProseMirror/plugins/joplinEditablePlugin/createEditorDialog.js
|
||||
packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.test.js
|
||||
packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.js
|
||||
packages/editor/ProseMirror/plugins/joplinEditablePlugin/postProcessRenderedHtml.js
|
||||
packages/editor/ProseMirror/plugins/joplinEditorApiPlugin.js
|
||||
packages/editor/ProseMirror/plugins/keymapPlugin.js
|
||||
packages/editor/ProseMirror/plugins/linkTooltipPlugin.test.js
|
||||
@@ -1044,9 +1081,15 @@ 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/dom/createButton.js
|
||||
packages/editor/ProseMirror/utils/dom/createTextArea.js
|
||||
packages/editor/ProseMirror/utils/dom/createTextNode.js
|
||||
packages/editor/ProseMirror/utils/dom/createUniqueId.js
|
||||
packages/editor/ProseMirror/utils/extractSelectedLinesTo.test.js
|
||||
packages/editor/ProseMirror/utils/extractSelectedLinesTo.js
|
||||
packages/editor/ProseMirror/utils/forEachHeading.js
|
||||
packages/editor/ProseMirror/utils/jumpToHash.js
|
||||
packages/editor/ProseMirror/utils/makeLinksClickableInElement.js
|
||||
packages/editor/ProseMirror/utils/preprocessEditorInput.test.js
|
||||
packages/editor/ProseMirror/utils/preprocessEditorInput.js
|
||||
packages/editor/ProseMirror/utils/sanitizeHtml.js
|
||||
@@ -1123,6 +1166,8 @@ packages/lib/array.js
|
||||
packages/lib/callbackUrlUtils.test.js
|
||||
packages/lib/callbackUrlUtils.js
|
||||
packages/lib/clipperUtils.js
|
||||
packages/lib/commands/convertHtmlToMarkdown.test.js
|
||||
packages/lib/commands/convertHtmlToMarkdown.js
|
||||
packages/lib/commands/deleteNote.js
|
||||
packages/lib/commands/historyBackward.js
|
||||
packages/lib/commands/historyForward.js
|
||||
@@ -1557,6 +1602,7 @@ packages/lib/shim-init-node.js
|
||||
packages/lib/shim.js
|
||||
packages/lib/string-utils.test.js
|
||||
packages/lib/string-utils.js
|
||||
packages/lib/testing/plugins/createTestPlugin.js
|
||||
packages/lib/testing/share/makeMockShareInvitation.js
|
||||
packages/lib/testing/share/mockShareService.js
|
||||
packages/lib/testing/syncTargetUtils.js
|
||||
@@ -1701,12 +1747,14 @@ packages/tools/fuzzer/Client.js
|
||||
packages/tools/fuzzer/ClientPool.js
|
||||
packages/tools/fuzzer/Server.js
|
||||
packages/tools/fuzzer/constants.js
|
||||
packages/tools/fuzzer/model/FolderRecord.js
|
||||
packages/tools/fuzzer/sync-fuzzer.js
|
||||
packages/tools/fuzzer/types.js
|
||||
packages/tools/fuzzer/utils/SeededRandom.js
|
||||
packages/tools/fuzzer/utils/getNumberProperty.js
|
||||
packages/tools/fuzzer/utils/getProperty.js
|
||||
packages/tools/fuzzer/utils/getStringProperty.js
|
||||
packages/tools/fuzzer/utils/openDebugSession.js
|
||||
packages/tools/fuzzer/utils/retryWithCount.js
|
||||
packages/tools/generate-database-types.js
|
||||
packages/tools/generate-images.js
|
||||
|
BIN
Assets/WebsiteAssets/images/sponsors/DamanGame.png
Normal file
BIN
Assets/WebsiteAssets/images/sponsors/DamanGame.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 12 KiB |
BIN
Assets/WebsiteAssets/images/sponsors/EssayShark.png
Normal file
BIN
Assets/WebsiteAssets/images/sponsors/EssayShark.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 12 KiB |
215
Assets/WebsiteAssets/locales/hr_HR.po
Normal file
215
Assets/WebsiteAssets/locales/hr_HR.po
Normal file
@@ -0,0 +1,215 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: \n"
|
||||
"POT-Creation-Date: \n"
|
||||
"PO-Revision-Date: \n"
|
||||
"Last-Translator: Milo Ivir <mail@mivirtype.de>\n"
|
||||
"Language-Team: \n"
|
||||
"Language: hr_HR\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"X-Generator: Poedit 3.6\n"
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/partials/plan.mustache:13
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/partials/plan.mustache:9
|
||||
msgid "/month"
|
||||
msgstr "/mjesec"
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/partials/plan.mustache:19
|
||||
msgid "/year"
|
||||
msgstr "/godina"
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/plans.mustache:8
|
||||
msgid ""
|
||||
"<a href=\"https://joplincloud.com\">Joplin Cloud</a> allows you to "
|
||||
"synchronise your notes across devices. It also lets you publish notes, and "
|
||||
"collaborate on notebooks with your friends, family or colleagues."
|
||||
msgstr ""
|
||||
"<a href=\"https://joplincloud.com\">Joplin Cloud</a> omogućuje "
|
||||
"sinkronizaciju bilješki na različitim uređajima. Omogućuje i objavljivanje "
|
||||
"bilješki i suradnju na bilježnicama s prijateljima, obitelji ili kolegama."
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:205
|
||||
msgid "<span class=\"frame-bg frame-bg-yellow-lg\">Customise</span> it"
|
||||
msgstr "<span class=\"frame-bg frame-bg-yellow-lg\">Prilagodi</span> uslugu"
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:104
|
||||
msgid "<span class=\"frame-bg frame-bg-yellow\">Multimedia</span> notes"
|
||||
msgstr "<span class=\"frame-bg frame-bg-yellow\">Multimedijske</span> bilješke"
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:256
|
||||
msgid "100% <span class=\"frame-bg frame-bg-yellow-lg\">your data</span>"
|
||||
msgstr "100 % <span class=\"frame-bg frame-bg-yellow-lg\">tvoji podaci</span>"
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:298
|
||||
msgid "A <span class=\"frame-bg frame-bg-yellow-lg\">French</span> Alternative"
|
||||
msgstr ""
|
||||
"<span class=\"frame-bg frame-bg-yellow-lg\">Francuska</span> alternativa"
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:236
|
||||
msgid ""
|
||||
"Access your notes from your computer, phone or tablet by synchronising with "
|
||||
"various services, including Joplin Cloud, Dropbox and OneDrive. The app is "
|
||||
"available on Windows, macOS, Linux, Android and iOS. A terminal app is also "
|
||||
"available!"
|
||||
msgstr ""
|
||||
"Pristupi svojim bilješkama s računala, mobitela ili tableta sinkronizacijom "
|
||||
"s raznim uslugama, uključujući Joplin Cloud, Dropbox i OneDrive. Program je "
|
||||
"dostupan za Windows, macOS, Linux, Android i iOS sustave. Dostupan je i "
|
||||
"program za terminal!"
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/plans.mustache:49
|
||||
msgid ""
|
||||
"Already have a Joplin Cloud account? <a href=\"https://"
|
||||
"joplincloud.com\">Login now</a>"
|
||||
msgstr ""
|
||||
"Već imaš Joplin Cloud račun? <a href=\"https://joplincloud.com\">Prijavi se "
|
||||
"sada</a>"
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:208
|
||||
msgid ""
|
||||
"Customise the app with plugins, custom themes and multiple text editors "
|
||||
"(Rich Text or Markdown). Or create your own scripts and plugins using the "
|
||||
"Extension API."
|
||||
msgstr ""
|
||||
"Prilagodi program pomoću dodataka, prilagođenih tema i uređivača teksta "
|
||||
"(formatirani tekst ili Markdown). Ili izradi vlastita skripta i dodatke "
|
||||
"pomoću API-ja za proširenja."
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:242
|
||||
msgid "Download it now"
|
||||
msgstr "Preuzmi sada"
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:112
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:63
|
||||
msgid "Download the app"
|
||||
msgstr "Preuzmi program"
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:213
|
||||
msgid "Find out more"
|
||||
msgstr "Saznaj više"
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:54
|
||||
msgid "Free your <span class=\"frame-bg frame-bg-blue\">notes</span>"
|
||||
msgstr "Oslobodi svoje <span class=\"frame-bg frame-bg-blue\">bilješke</span>"
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:175
|
||||
msgid "Get the clipper"
|
||||
msgstr "Nabavi Clipper"
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:107
|
||||
msgid ""
|
||||
"Images, videos, PDFs and audio files are supported. Create math expressions "
|
||||
"and diagrams directly from the app. Take photos with the mobile app and save "
|
||||
"them to a note."
|
||||
msgstr ""
|
||||
"Podržane su slike, videozapisi, PDF-ovi i audio datoteke. Stvori matematičke "
|
||||
"izraze i dijagrame izravno iz programa. Snimaj fotografije s programom za "
|
||||
"mobitel i spremi ih u bilješku."
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:327
|
||||
msgid "In the <span class=\"frame-bg frame-bg-yellow\">Press</span>"
|
||||
msgstr "<span class=\"frame-bg frame-bg-yellow\">Recenzije</span>"
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/plans.mustache:5
|
||||
msgid "Joplin Cloud <span class=\"frame-bg frame-bg-yellow\">plans</span>"
|
||||
msgstr "Joplin Cloud <span class=\"frame-bg frame-bg-yellow\">tarife</span>"
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:301
|
||||
msgid ""
|
||||
"Joplin Cloud is based in France. This means your data is protected by strict "
|
||||
"European Union privacy laws. In addition, Joplin Cloud implements strong end-"
|
||||
"to-end encryption so that not even us can have access to your data."
|
||||
msgstr ""
|
||||
"Joplin Cloud ima sjedište u Francuskoj. To znači da su tvoji podaci "
|
||||
"zaštićeni strogim zakonima o privatnosti Europske unije. Osim toga, Joplin "
|
||||
"Cloud implementira snažno sveobuhvatno šifriranje (end-to-end encryption) "
|
||||
"tako da čak ni mi ne možemo pristupiti tvojim podacima."
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:57
|
||||
msgid ""
|
||||
"Joplin is an open source note-taking app. Capture your thoughts and securely "
|
||||
"access them from any device."
|
||||
msgstr ""
|
||||
"Joplin je program za bilješke otvorenog koda. Zabilježi svoje misli i "
|
||||
"sigurno im pristupi s bilo kojeg uređaja."
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:262
|
||||
msgid "More about E2EE"
|
||||
msgstr "Više o E2EE"
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:391
|
||||
msgid "Our <span class=\"frame-bg frame-bg-blue-lg\">sponsors</span>"
|
||||
msgstr "Naši <span class=\"frame-bg frame-bg-blue-lg\">sponzori</span>"
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/plans.mustache:23
|
||||
msgid "Pay Monthly"
|
||||
msgstr "Plaćaj mjesečno"
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/plans.mustache:30
|
||||
msgid "Pay Yearly"
|
||||
msgstr "Plaćaj godišnje"
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:167
|
||||
msgid ""
|
||||
"Save <span class=\"frame-bg frame-bg-blue\">web pages</span> <br>as notes"
|
||||
msgstr ""
|
||||
"Spremaj <span class=\"frame-bg frame-bg-blue\">web stranice</span> <br>kao "
|
||||
"bilješke"
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:65
|
||||
msgid "Sign up with Joplin Cloud"
|
||||
msgstr "Registriraj se na Joplin Cloud"
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:394
|
||||
msgid "Thank you for your support!"
|
||||
msgstr "Hvala ti na podršci!"
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:257
|
||||
msgid ""
|
||||
"The app is open source and your notes are saved to an open format, so you'll "
|
||||
"always have access to them. Uses End-To-End Encryption (E2EE) to secure your "
|
||||
"notes and ensure no-one but yourself can access them."
|
||||
msgstr ""
|
||||
"Program je otvorenog koda i tvoje se bilješke spremaju u otvorenom formatu, "
|
||||
"tako da ćeš im uvijek moći pristupiti. Program koristi sveobuhvatno "
|
||||
"šifriranje – engl. End-To-End Encryption (E2EE) – kako bi zaštitila tvoje "
|
||||
"bilješke i osigurala da im nitko osim tebe ne može pristupiti."
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:144
|
||||
msgid "Try it now"
|
||||
msgstr "Isprobaj sada"
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:170
|
||||
msgid ""
|
||||
"Use the web clipper extension, available on Chrome and Firefox, to save web "
|
||||
"pages or take screenshots as notes."
|
||||
msgstr ""
|
||||
"Koristi proširenje Web Clipper, dostupno za Chrome i Firefox, za spremanje "
|
||||
"web stranica ili snimanje ekrana kao bilješku."
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:138
|
||||
msgid ""
|
||||
"With Joplin Cloud, share your notes with your friends, family or colleagues "
|
||||
"and collaborate on them."
|
||||
msgstr ""
|
||||
"Joplin Cloud ti omogućuje da dijeliš bilješke s prijateljima, obitelji ili "
|
||||
"kolegama te da na njima surađujete."
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:137
|
||||
msgid "Work <span class=\"frame-bg frame-bg-yellow\">together</span>"
|
||||
msgstr "<span class=\"frame-bg frame-bg-yellow\">Surađuj</span> s drugima"
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:141
|
||||
msgid ""
|
||||
"You can also publish a note to the internet and share the URL with others."
|
||||
msgstr "Bilješke možeš objaviti i na internetu te dijeliti URL s drugima."
|
||||
|
||||
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:233
|
||||
msgid ""
|
||||
"Your notes, <span class=\"frame-bg frame-bg-blue-lg\">everywhere</span> you "
|
||||
"are"
|
||||
msgstr ""
|
||||
"Tvoje bilješke, <span class=\"frame-bg frame-bg-blue-lg\">gdje god</span> se "
|
||||
"nalaziš"
|
@@ -31,7 +31,7 @@ Please see the [donation page](https://github.com/laurent22/joplin/blob/dev/read
|
||||
# Sponsors
|
||||
|
||||
<!-- SPONSORS-ORG -->
|
||||
<a href="https://seirei.ne.jp"><img title="Serei Network" width="256" src="https://joplinapp.org/images/sponsors/SeireiNetwork.png"/></a> <a href="https://www.hosting.de/nextcloud/?mtm_campaign=managed-nextcloud&mtm_kwd=joplinapp&mtm_source=joplinapp-webseite&mtm_medium=banner"><img title="Hosting.de" width="256" src="https://joplinapp.org/images/sponsors/HostingDe.png"/></a> <a href="https://citricsheep.com"><img title="Citric Sheep" width="256" src="https://joplinapp.org/images/sponsors/CitricSheep.png"/></a> <a href="https://sorted.travel/?utm_source=joplinapp"><img title="Sorted Travel" width="256" src="https://joplinapp.org/images/sponsors/SortedTravel.png"/></a> <a href="https://celebian.com"><img title="Celebian" width="256" src="https://joplinapp.org/images/sponsors/Celebian.png"/></a> <a href="https://bestkru.com"><img title="BestKru" width="256" src="https://joplinapp.org/images/sponsors/BestKru.png"/></a> <a href="https://www.socialfollowers.uk/buy-tiktok-followers/"><img title="Social Followers" width="256" src="https://joplinapp.org/images/sponsors/SocialFollowers.png"/></a> <a href="https://stormlikes.com/"><img title="Stormlikes" width="256" src="https://joplinapp.org/images/sponsors/Stormlikes.png"/></a> <a href="https://route4me.com"><img title="Route4Me" width="256" src="https://joplinapp.org/images/sponsors/Route4Me.png"/></a> <a href="https://topagency.webflow.io"><img title="WebDesignAgency" width="256" src="https://joplinapp.org/images/sponsors/WebDesignAgency.png" alt="topagency"/></a> <a href="https://realgambling.ca/"><img title="RealGambling.ca" width="256" src="https://joplinapp.org/images/sponsors/RealGambling.png" alt="RealGambling.ca"/></a> <a href="https://essaypro.com/"><img title="write an essay online with EssayPro" width="256" src="https://joplinapp.org/images/sponsors/EssayPro.png" alt="write an essay online with EssayPro"/></a> <a href="https://www.slotozilla.com/nz/no-deposit-bonus"><img title="casino without making any upfront cost" width="256" src="https://joplinapp.org/images/sponsors/Slotozilla.png" alt="casino without making any upfront cost"/></a> <a href="https://essaywriter.pro"><img title="write my essay services by EssayWriter" width="256" src="https://joplinapp.org/images/sponsors/EssayWriterPro.png" alt="write my essay services by EssayWriter"/></a> <a href="https://essayservice.com"><img title="quick and reliable service to write my paper for me" width="256" src="https://joplinapp.org/images/sponsors/EssayService.png" alt="quick and reliable service to write my paper for me"/></a> <a href="https://writepaper.com/"><img title="best service to write my paper for me" width="256" src="https://joplinapp.org/images/sponsors/WritePaper.png" alt="best service to write my paper for me"/></a> <a href="https://paperwriter.com/"><img title="high-quality paper writing service PaperWriter" width="256" src="https://joplinapp.org/images/sponsors/PaperWriter.png" alt="high-quality paper writing service PaperWriter"/></a> <a href="https://homeworkguy.org/someone-to-take-my-online-class"><img title="someone to take my online class" width="256" src="https://joplinapp.org/images/sponsors/HomeworkGuy.png" alt="someone to take my online class"/></a> <a href="https://www.bestetf.net/"><img title="BestETF" width="256" src="https://joplinapp.org/images/sponsors/BestEtf.png" alt="BestETF"/></a> <a href="https://freespinny.io/free-spins-no-deposit/"><img title="Freespinny.io Free Spins Bonus site" width="256" src="https://joplinapp.org/images/sponsors/Freespinny.png" alt="Freespinny.io Free Spins Bonus site"/></a>
|
||||
<a href="https://seirei.ne.jp"><img title="Serei Network" width="256" src="https://joplinapp.org/images/sponsors/SeireiNetwork.png"/></a> <a href="https://citricsheep.com"><img title="Citric Sheep" width="256" src="https://joplinapp.org/images/sponsors/CitricSheep.png"/></a> <a href="https://sorted.travel/?utm_source=joplinapp"><img title="Sorted Travel" width="256" src="https://joplinapp.org/images/sponsors/SortedTravel.png"/></a> <a href="https://celebian.com"><img title="Celebian" width="256" src="https://joplinapp.org/images/sponsors/Celebian.png"/></a> <a href="https://bestkru.com"><img title="BestKru" width="256" src="https://joplinapp.org/images/sponsors/BestKru.png"/></a> <a href="https://www.socialfollowers.uk/buy-tiktok-followers/"><img title="Social Followers" width="256" src="https://joplinapp.org/images/sponsors/SocialFollowers.png"/></a> <a href="https://stormlikes.com/"><img title="Stormlikes" width="256" src="https://joplinapp.org/images/sponsors/Stormlikes.png"/></a> <a href="https://route4me.com"><img title="Route4Me" width="256" src="https://joplinapp.org/images/sponsors/Route4Me.png"/></a> <a href="https://topagency.webflow.io"><img title="WebDesignAgency" width="256" src="https://joplinapp.org/images/sponsors/WebDesignAgency.png" alt="topagency"/></a> <a href="https://www.slotozilla.com/nz/no-deposit-bonus"><img title="casino without making any upfront cost" width="256" src="https://joplinapp.org/images/sponsors/Slotozilla.png" alt="casino without making any upfront cost"/></a> <a href="https://essayservice.com"><img title="quick and reliable service to write my paper for me" width="256" src="https://joplinapp.org/images/sponsors/EssayService.png" alt="quick and reliable service to write my paper for me"/></a> <a href="https://writepaper.com/"><img title="best service to write my paper for me" width="256" src="https://joplinapp.org/images/sponsors/WritePaper.png" alt="best service to write my paper for me"/></a> <a href="https://paperwriter.com/"><img title="high-quality paper writing service PaperWriter" width="256" src="https://joplinapp.org/images/sponsors/PaperWriter.png" alt="high-quality paper writing service PaperWriter"/></a> <a href="https://www.bestetf.net/"><img title="BestETF" width="256" src="https://joplinapp.org/images/sponsors/BestEtf.png" alt="BestETF"/></a> <a href="https://freespinny.io/free-spins-no-deposit/"><img title="Freespinny.io Free Spins Bonus site" width="256" src="https://joplinapp.org/images/sponsors/Freespinny.png" alt="Freespinny.io Free Spins Bonus site"/></a> <a href="https://damangameplay.in"><img title="Daman Game" width="256" src="https://joplinapp.org/images/sponsors/DamanGame.png" alt="Daman Game"/></a> <a href="https://essayshark.com"><img title="EssayShark - essay writers for hire" width="256" src="https://joplinapp.org/images/sponsors/EssayShark.png" alt="EssayShark - essay writers for hire"/></a>
|
||||
<!-- SPONSORS-ORG -->
|
||||
|
||||
* * *
|
||||
@@ -40,9 +40,8 @@ Please see the [donation page](https://github.com/laurent22/joplin/blob/dev/read
|
||||
| | | | |
|
||||
| :---: | :---: | :---: | :---: |
|
||||
| <img width="50" src="https://avatars2.githubusercontent.com/u/97193607?s=96&v=4"/></br>[Akhil-CM](https://github.com/Akhil-CM) | <img width="50" src="https://avatars2.githubusercontent.com/u/552452?s=96&v=4"/></br>[andypiper](https://github.com/andypiper) | <img width="50" src="https://avatars2.githubusercontent.com/u/215668?s=96&v=4"/></br>[avanderberg](https://github.com/avanderberg) | <img width="50" src="https://avatars2.githubusercontent.com/u/67130?s=96&v=4"/></br>[chr15m](https://github.com/chr15m) |
|
||||
| <img width="50" src="https://avatars2.githubusercontent.com/u/1177810?s=96&v=4"/></br>[felixstorm](https://github.com/felixstorm) | <img width="50" src="https://avatars2.githubusercontent.com/u/8030470?s=96&v=4"/></br>[Galliver7](https://github.com/Galliver7) | <img width="50" src="https://avatars2.githubusercontent.com/u/64712218?s=96&v=4"/></br>[Hegghammer](https://github.com/Hegghammer) | <img width="50" src="https://avatars2.githubusercontent.com/u/11947658?s=96&v=4"/></br>[KentBrockman](https://github.com/KentBrockman) |
|
||||
| <img width="50" src="https://avatars2.githubusercontent.com/u/42319182?s=96&v=4"/></br>[marcdw1289](https://github.com/marcdw1289) | <img width="50" src="https://avatars2.githubusercontent.com/u/1788010?s=96&v=4"/></br>[maxtruxa](https://github.com/maxtruxa) | <img width="50" src="https://avatars2.githubusercontent.com/u/327998?s=96&v=4"/></br>[sif](https://github.com/sif) | <img width="50" src="https://avatars2.githubusercontent.com/u/765564?s=96&v=4"/></br>[taskcruncher](https://github.com/taskcruncher) |
|
||||
| <img width="50" src="https://avatars2.githubusercontent.com/u/668977?s=96&v=4"/></br>[ugoertz](https://github.com/ugoertz) | | | |
|
||||
| <img width="50" src="https://avatars2.githubusercontent.com/u/1177810?s=96&v=4"/></br>[felixstorm](https://github.com/felixstorm) | <img width="50" src="https://avatars2.githubusercontent.com/u/11947658?s=96&v=4"/></br>[KentBrockman](https://github.com/KentBrockman) | <img width="50" src="https://avatars2.githubusercontent.com/u/42319182?s=96&v=4"/></br>[marcdw1289](https://github.com/marcdw1289) | <img width="50" src="https://avatars2.githubusercontent.com/u/668977?s=96&v=4"/></br>[ugoertz](https://github.com/ugoertz) |
|
||||
| | | | |
|
||||
<!-- SPONSORS-GITHUB -->
|
||||
|
||||
# Community
|
||||
|
@@ -16,13 +16,13 @@
|
||||
"version": "",
|
||||
"platforms": ["aarch64-darwin", "x86_64-darwin"],
|
||||
},
|
||||
"python": "3.13.2",
|
||||
"python": "3.13.3",
|
||||
"bat": "latest",
|
||||
"electron": {
|
||||
"version": "latest",
|
||||
"excluded_platforms": ["aarch64-darwin", "x86_64-darwin"],
|
||||
},
|
||||
"git": "2.47.2",
|
||||
"git": "2.48.1",
|
||||
},
|
||||
"shell": {
|
||||
"init_hook": [
|
||||
|
13
fastlane/metadata/android/hr/full_description.txt
Normal file
13
fastlane/metadata/android/hr/full_description.txt
Normal file
@@ -0,0 +1,13 @@
|
||||
<strong>Joplin</strong> je besplatan program otvorenog koda za bilješke i popis zadataka koji može obraditi veliki broj bilješki organizirane u bilježnice. Bilješke se mogu pretraživati, kopirati, označavati i mijenjati izravno iz programa ili iz vlastitog uređivača teksta.
|
||||
|
||||
Bilješke su u <a href="https://joplinapp.org/help/apps/markdown">Markdown formatu</a>.
|
||||
|
||||
Iz Evernotea izvezene bilješke <a href="https://joplinapp.org/help/apps/import_export">mogu se uvesti</a> u Joplin, uključujući formatirani sadržaj (koji se pretvara u Markdown), resurse (slike, privitke itd.) i potpune metapodatke (geografski podaci mjesta, vrijeme aktualiziranja, vrijeme stvaranja itd.). Mogu se uvesti i obične Markdown datoteke.
|
||||
|
||||
Joplin radi ponajprije s lokalnim podacima (offline first), što znači da uvijek imaš sve svoje podatke na mobitelu ili računalu. To osigurava da su tvoje bilješke uvijek dostupne, bez obzira je li imaš internetsku vezu ili ne.</p>
|
||||
|
||||
Bilješke se mogu sigurno <a href="https://joplinapp.org/help/apps/sync">sinkronizirati</a> pomoću <a href="https://joplinapp.org/help/apps/sync/e2ee">sveobuhvatnog šifriranja</a> s raznim uslugama u oblaku, uključujući Nextcloud, Dropbox, OneDrive i <a href="https://joplinapp.org/plans/">Joplin Cloud</a>.
|
||||
|
||||
Pretraživanje cijelog teksta dostupno je na svim platformama za brzo pronalaženje potrebnih informacija. Program se može prilagoditi pomoću dodataka i tema, a možeš stvoriti i vlastite.
|
||||
|
||||
Program je dostupan za Windows, Linux, macOS, Android i iOS sustave. <a href="https://joplinapp.org/help/apps/clipper">Web Clipper</a>, za spremanje web stranica i snimaka ekrana iz tvog preglednika, je također dostupan za <a href="https://addons.mozilla.org/firefox/addon/joplin-web-clipper/">Firefox</a> i <a href="https://chrome.google.com/webstore/detail/joplin-web-clipper/alofnhikmmkdbbbgpnglcpdollgjjfek">Chrome</a>.
|
1
fastlane/metadata/android/hr/short_description.txt
Normal file
1
fastlane/metadata/android/hr/short_description.txt
Normal file
@@ -0,0 +1 @@
|
||||
Program za bilješke i popis zadataka sa sinkronizacijom između Linuxa, macOS-a, Windowsa i mobitela
|
@@ -86,7 +86,7 @@
|
||||
"gulp": "4.0.2",
|
||||
"husky": "9.1.7",
|
||||
"lerna": "3.22.1",
|
||||
"lint-staged": "15.5.1",
|
||||
"lint-staged": "15.5.2",
|
||||
"madge": "8.0.0",
|
||||
"npm-package-json-lint": "8.0.0",
|
||||
"typescript": "5.8.2"
|
||||
|
@@ -380,6 +380,13 @@ class AppGui {
|
||||
this.widget('noteList').toggleShowIds();
|
||||
}
|
||||
|
||||
toggleFolderCollapse() {
|
||||
const folderList = this.widget('folderList');
|
||||
if (folderList && folderList.toggleFolderCollapse) {
|
||||
folderList.toggleFolderCollapse();
|
||||
}
|
||||
}
|
||||
|
||||
widget(name) {
|
||||
if (name === 'root') return this.rootWidget_;
|
||||
return this.rootWidget_.childByName(name);
|
||||
@@ -506,6 +513,8 @@ class AppGui {
|
||||
this.toggleNoteMetadata();
|
||||
} else if (cmd === 'toggle_ids') {
|
||||
this.toggleFolderIds();
|
||||
} else if (cmd === 'toggle_folder_collapse') {
|
||||
this.toggleFolderCollapse();
|
||||
} else if (cmd === 'enter_command_line_mode') {
|
||||
const cmd = await this.widget('statusBar').prompt();
|
||||
if (!cmd) return;
|
||||
|
@@ -9,7 +9,6 @@ import Tag from '@joplin/lib/models/Tag';
|
||||
import Setting, { Env } from '@joplin/lib/models/Setting';
|
||||
import { reg } from '@joplin/lib/registry.js';
|
||||
import { dirname, fileExtension } from '@joplin/lib/path-utils';
|
||||
import { splitCommandString } from '@joplin/utils';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { pathExists, readFile, readdirSync } from 'fs-extra';
|
||||
import RevisionService from '@joplin/lib/services/RevisionService';
|
||||
@@ -19,7 +18,6 @@ import { FolderEntity, NoteEntity } from '@joplin/lib/services/database/types';
|
||||
import initializeCommandService from './utils/initializeCommandService';
|
||||
const { cliUtils } = require('./cli-utils.js');
|
||||
const Cache = require('@joplin/lib/Cache');
|
||||
const { splitCommandBatch } = require('@joplin/lib/string-utils');
|
||||
|
||||
class Application extends BaseApplication {
|
||||
|
||||
@@ -222,6 +220,7 @@ class Application extends BaseApplication {
|
||||
return { ...this.commandMetadata_ };
|
||||
}
|
||||
|
||||
|
||||
public hasGui() {
|
||||
return this.gui() && !this.gui().isDummy();
|
||||
}
|
||||
@@ -332,6 +331,7 @@ class Application extends BaseApplication {
|
||||
{ keys: ['mb'], type: 'prompt', command: 'mkbook ""', cursorPosition: -2 },
|
||||
{ keys: ['yn'], type: 'prompt', command: 'cp $n ""', cursorPosition: -2 },
|
||||
{ keys: ['dn'], type: 'prompt', command: 'mv $n ""', cursorPosition: -2 },
|
||||
{ keys: ['z'], type: 'function', command: 'toggle_folder_collapse' },
|
||||
];
|
||||
|
||||
// Filter the keymap item by command so that items in keymap.json can override
|
||||
@@ -381,22 +381,6 @@ class Application extends BaseApplication {
|
||||
return output;
|
||||
}
|
||||
|
||||
public async commandList(argv: string[]) {
|
||||
if (argv.length && argv[0] === 'batch') {
|
||||
const commands = [];
|
||||
const commandLines = splitCommandBatch(await readFile(argv[1], 'utf-8'));
|
||||
|
||||
for (const commandLine of commandLines) {
|
||||
if (!commandLine.trim()) continue;
|
||||
const splitted = splitCommandString(commandLine.trim());
|
||||
commands.push(splitted);
|
||||
}
|
||||
return commands;
|
||||
} else {
|
||||
return [argv];
|
||||
}
|
||||
}
|
||||
|
||||
// We need this special case here because by the time the `version` command
|
||||
// runs, the keychain has already been setup.
|
||||
public checkIfKeychainEnabled(argv: string[]) {
|
||||
@@ -433,15 +417,10 @@ class Application extends BaseApplication {
|
||||
if (argv.length) {
|
||||
this.gui_ = this.dummyGui();
|
||||
|
||||
this.currentFolder_ = await Folder.load(Setting.value('activeFolderId'));
|
||||
|
||||
await this.applySettingsSideEffects();
|
||||
|
||||
await this.refreshCurrentFolder();
|
||||
try {
|
||||
const commands = await this.commandList(argv);
|
||||
for (const command of commands) {
|
||||
await this.execCommand(command);
|
||||
}
|
||||
await this.execCommand(argv);
|
||||
} catch (error) {
|
||||
if (this.showStackTraces_) {
|
||||
console.error(error);
|
||||
|
@@ -1,19 +0,0 @@
|
||||
const BaseCommand = require('./base-command').default;
|
||||
const { _ } = require('@joplin/lib/locale');
|
||||
|
||||
class Command extends BaseCommand {
|
||||
usage() {
|
||||
return 'batch <file-path>';
|
||||
}
|
||||
|
||||
description() {
|
||||
return _('Runs the commands contained in the text file. There should be one command per line.');
|
||||
}
|
||||
|
||||
async action() {
|
||||
// Implementation is in app.js::commandList()
|
||||
throw new Error('No implemented');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Command;
|
79
packages/app-cli/app/command-batch.ts
Normal file
79
packages/app-cli/app/command-batch.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { splitCommandBatch } from '@joplin/lib/string-utils';
|
||||
import BaseCommand from './base-command';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { splitCommandString } from '@joplin/utils';
|
||||
import iterateStdin from './utils/iterateStdin';
|
||||
import { readFile } from 'fs-extra';
|
||||
import app from './app';
|
||||
|
||||
interface Options {
|
||||
'file-path': string;
|
||||
options: {
|
||||
'continue-on-failure': boolean;
|
||||
};
|
||||
}
|
||||
|
||||
class Command extends BaseCommand {
|
||||
public usage() {
|
||||
return 'batch <file-path>';
|
||||
}
|
||||
|
||||
public options() {
|
||||
return [
|
||||
// These are present mostly for testing purposes
|
||||
['--continue-on-failure', 'Continue running commands when one command in the batch fails.'],
|
||||
];
|
||||
}
|
||||
|
||||
public description() {
|
||||
return _('Runs the commands contained in the text file. There should be one command per line.');
|
||||
}
|
||||
|
||||
private streamCommands_ = async function*(filePath: string) {
|
||||
const processLines = function*(lines: string) {
|
||||
const commandLines = splitCommandBatch(lines);
|
||||
|
||||
for (const command of commandLines) {
|
||||
if (!command.trim()) continue;
|
||||
yield splitCommandString(command.trim());
|
||||
}
|
||||
};
|
||||
|
||||
if (filePath === '-') { // stdin
|
||||
// Iterating over standard input conflicts with the CLI app's GUI.
|
||||
if (app().hasGui()) {
|
||||
throw new Error(_('Reading commands from standard input is only available in CLI mode.'));
|
||||
}
|
||||
|
||||
for await (const lines of iterateStdin('command> ')) {
|
||||
yield* processLines(lines);
|
||||
}
|
||||
} else {
|
||||
const data = await readFile(filePath, 'utf-8');
|
||||
yield* processLines(data);
|
||||
}
|
||||
};
|
||||
|
||||
public async action(options: Options) {
|
||||
let lastError;
|
||||
for await (const command of this.streamCommands_(options['file-path'])) {
|
||||
try {
|
||||
await app().refreshCurrentFolder();
|
||||
await app().execCommand(command);
|
||||
} catch (error) {
|
||||
if (options.options['continue-on-failure']) {
|
||||
app().stdout(error.message);
|
||||
lastError = error;
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (lastError) {
|
||||
throw lastError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Command;
|
@@ -6,6 +6,7 @@ import app from './app';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { ImportOptions } from '@joplin/lib/services/interop/types';
|
||||
import { unique } from '@joplin/lib/array';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
|
||||
class Command extends BaseCommand {
|
||||
public override usage() {
|
||||
@@ -32,14 +33,16 @@ class Command extends BaseCommand {
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
public override async action(args: any) {
|
||||
const folder = await app().loadItem(BaseModel.TYPE_FOLDER, args.notebook);
|
||||
let destinationFolder = await app().loadItem(BaseModel.TYPE_FOLDER, args.notebook);
|
||||
|
||||
if (args.notebook && !folder) throw new Error(_('Cannot find "%s".', args.notebook));
|
||||
if (args.notebook && !destinationFolder) throw new Error(_('Cannot find "%s".', args.notebook));
|
||||
|
||||
if (!destinationFolder) destinationFolder = await Folder.defaultFolder();
|
||||
|
||||
const importOptions: ImportOptions = {};
|
||||
importOptions.path = args.path;
|
||||
importOptions.format = args.options.format ? args.options.format : 'auto';
|
||||
importOptions.destinationFolderId = folder ? folder.id : null;
|
||||
importOptions.destinationFolderId = destinationFolder ? destinationFolder.id : null;
|
||||
|
||||
let lastProgress = '';
|
||||
|
||||
|
@@ -14,17 +14,25 @@ class Command extends BaseCommand {
|
||||
return `${_('Start, stop or check the API server. To specify on which port it should run, set the api.port config variable. Commands are (%s).', ['start', 'stop', 'status'].join('|'))} This is an experimental feature - use at your own risks! It is recommended that the server runs off its own separate profile so that no two CLI instances access that profile at the same time. Use --profile to specify the profile path.`;
|
||||
}
|
||||
|
||||
options() {
|
||||
return [
|
||||
['--exit-early', 'Allow the command to exit while the server is still running. The server will still stop when the app exits. Valid only for the `start` subcommand.'],
|
||||
['--quiet', 'Log less information to the console. More verbose logs will still be available through log-clipper.txt.'],
|
||||
];
|
||||
}
|
||||
|
||||
async action(args) {
|
||||
const command = args.command;
|
||||
|
||||
const ClipperServer = require('@joplin/lib/ClipperServer').default;
|
||||
ClipperServer.instance().initialize();
|
||||
const stdoutFn = (...s) => this.stdout(s.join(' '));
|
||||
const ignoreOutputFn = ()=>{};
|
||||
const clipperLogger = new Logger();
|
||||
clipperLogger.addTarget('file', { path: `${Setting.value('profileDir')}/log-clipper.txt` });
|
||||
clipperLogger.addTarget('console', { console: {
|
||||
info: stdoutFn,
|
||||
warn: stdoutFn,
|
||||
info: args.options.quiet ? ignoreOutputFn : stdoutFn,
|
||||
warn: args.options.quiet ? ignoreOutputFn : stdoutFn,
|
||||
error: stdoutFn,
|
||||
} });
|
||||
ClipperServer.instance().setDispatch(() => {});
|
||||
@@ -38,7 +46,11 @@ class Command extends BaseCommand {
|
||||
this.stdout(_('Server is already running on port %d', runningOnPort));
|
||||
} else {
|
||||
await shim.fsDriver().writeFile(pidPath, process.pid.toString(), 'utf-8');
|
||||
await ClipperServer.instance().start(); // Never exit
|
||||
const promise = ClipperServer.instance().start();
|
||||
|
||||
if (!args.options['exit-early']) {
|
||||
await promise; // Never exit
|
||||
}
|
||||
}
|
||||
} else if (command === 'status') {
|
||||
this.stdout(runningOnPort ? _('Server is running on port %d', runningOnPort) : _('Server is not running.'));
|
||||
|
@@ -149,6 +149,7 @@ class Command extends BaseCommand {
|
||||
waiting: invitation.status === ShareUserStatus.Waiting,
|
||||
rejected: invitation.status === ShareUserStatus.Rejected,
|
||||
folderId: invitation.share.folder_id,
|
||||
canWrite: !!invitation.can_write,
|
||||
fromUser: {
|
||||
email: invitation.share.user?.email,
|
||||
},
|
||||
|
@@ -2,6 +2,7 @@ import BaseCommand from './base-command';
|
||||
import app from './app';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import BaseModel from '@joplin/lib/BaseModel';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
|
||||
class Command extends BaseCommand {
|
||||
public override usage() {
|
||||
@@ -20,6 +21,18 @@ class Command extends BaseCommand {
|
||||
public override async action(args: any) {
|
||||
const folder = await app().loadItem(BaseModel.TYPE_FOLDER, args['notebook']);
|
||||
if (!folder) throw new Error(_('Cannot find "%s".', args['notebook']));
|
||||
|
||||
// Auto-expand parent folders in GUI if present
|
||||
if (app().gui() && app().gui().widget && app().gui().widget('folderList')) {
|
||||
const folderListWidget = app().gui().widget('folderList');
|
||||
if (folderListWidget.expandToFolder) {
|
||||
// Get all folders to pass to expandToFolder
|
||||
const folders = await Folder.all();
|
||||
folderListWidget.folders = folders; // Ensure widget has current folders
|
||||
folderListWidget.expandToFolder(folder.id);
|
||||
}
|
||||
}
|
||||
|
||||
app().switchCurrentFolder(folder);
|
||||
}
|
||||
}
|
||||
|
@@ -4,11 +4,14 @@ import BaseModel from '@joplin/lib/BaseModel';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { FolderEntity } from '@joplin/lib/services/database/types';
|
||||
import { getDisplayParentId, getTrashFolderId } from '@joplin/lib/services/trash';
|
||||
import {
|
||||
getDisplayParentId,
|
||||
getTrashFolderId,
|
||||
} from '@joplin/lib/services/trash';
|
||||
const ListWidget = require('tkwidgets/ListWidget.js');
|
||||
|
||||
export default class FolderListWidget extends ListWidget {
|
||||
|
||||
export default class FolderListWidget extends ListWidget {
|
||||
private folders_: FolderEntity[] = [];
|
||||
|
||||
public constructor() {
|
||||
@@ -31,7 +34,18 @@ export default class FolderListWidget extends ListWidget {
|
||||
if (item === '-') {
|
||||
output.push('-'.repeat(this.innerWidth));
|
||||
} else if (item.type_ === Folder.modelType()) {
|
||||
output.push(' '.repeat(this.folderDepth(this.folders, item.id)));
|
||||
const depth = this.folderDepth(this.folders, item.id);
|
||||
output.push(' '.repeat(depth));
|
||||
|
||||
// Add collapse/expand indicator
|
||||
const hasChildren = this.folderHasChildren_(this.folders, item.id);
|
||||
if (hasChildren) {
|
||||
const collapsedFolders = Setting.value('collapsedFolderIds');
|
||||
const isCollapsed = collapsedFolders.includes(item.id);
|
||||
output.push(isCollapsed ? '[+] ' : '[-] ');
|
||||
} else {
|
||||
output.push(' '); // Space for alignment
|
||||
}
|
||||
|
||||
if (this.showIds) {
|
||||
output.push(Folder.shortId(item.id));
|
||||
@@ -65,7 +79,10 @@ export default class FolderListWidget extends ListWidget {
|
||||
let output = 0;
|
||||
while (true) {
|
||||
const folder = BaseModel.byId(folders, folderId);
|
||||
const folderParentId = getDisplayParentId(folder, folders.find(f => f.id === folder.parent_id));
|
||||
const folderParentId = getDisplayParentId(
|
||||
folder,
|
||||
folders.find((f) => f.id === folder.parent_id),
|
||||
);
|
||||
if (!folder || !folderParentId) return output;
|
||||
output++;
|
||||
folderId = folderParentId;
|
||||
@@ -153,7 +170,10 @@ export default class FolderListWidget extends ListWidget {
|
||||
public folderHasChildren_(folders: FolderEntity[], folderId: string) {
|
||||
for (let i = 0; i < folders.length; i++) {
|
||||
const folder = folders[i];
|
||||
const folderParentId = getDisplayParentId(folder, folders.find(f => f.id === folder.parent_id));
|
||||
const folderParentId = getDisplayParentId(
|
||||
folder,
|
||||
folders.find((f) => f.id === folder.parent_id),
|
||||
);
|
||||
if (folderParentId === folderId) return true;
|
||||
}
|
||||
return false;
|
||||
@@ -161,7 +181,12 @@ export default class FolderListWidget extends ListWidget {
|
||||
|
||||
public render() {
|
||||
if (this.updateItems_) {
|
||||
this.logger().debug('Rebuilding items...', this.notesParentType, this.selectedJoplinItemId, this.selectedSearchId);
|
||||
this.logger().debug(
|
||||
'Rebuilding items...',
|
||||
this.notesParentType,
|
||||
this.selectedJoplinItemId,
|
||||
this.selectedSearchId,
|
||||
);
|
||||
const wasSelectedItemId = this.selectedJoplinItemId;
|
||||
const previousParentType = this.notesParentType;
|
||||
|
||||
@@ -170,12 +195,20 @@ export default class FolderListWidget extends ListWidget {
|
||||
const orderFolders = (parentId: string) => {
|
||||
for (let i = 0; i < this.folders.length; i++) {
|
||||
const f = this.folders[i];
|
||||
const originalParent = this.folders_.find(f => f.id === f.parent_id);
|
||||
const originalParent = this.folders_.find(
|
||||
(f) => f.id === f.parent_id,
|
||||
);
|
||||
|
||||
const folderParentId = getDisplayParentId(f, originalParent); // f.parent_id ? f.parent_id : '';
|
||||
if (folderParentId === parentId) {
|
||||
newItems.push(f);
|
||||
if (this.folderHasChildren_(this.folders, f.id)) orderFolders(f.id);
|
||||
// Only recurse into children if the folder is not collapsed
|
||||
if (this.folderHasChildren_(this.folders, f.id)) {
|
||||
const collapsedFolders = Setting.value('collapsedFolderIds');
|
||||
if (!collapsedFolders.includes(f.id)) {
|
||||
orderFolders(f.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -221,4 +254,53 @@ export default class FolderListWidget extends ListWidget {
|
||||
const index = this.itemIndexByKey('id', itemId);
|
||||
this.currentIndex = index >= 0 ? index : 0;
|
||||
}
|
||||
|
||||
public toggleFolderCollapse() {
|
||||
const item = this.currentItem;
|
||||
if (item && item.type_ === Folder.modelType() && this.folderHasChildren_(this.folders, item.id)) {
|
||||
const collapsedFolders = Setting.value('collapsedFolderIds');
|
||||
const isCollapsed = collapsedFolders.includes(item.id);
|
||||
if (isCollapsed) {
|
||||
const newCollapsed = collapsedFolders.filter((id: string) => id !== item.id);
|
||||
Setting.setValue('collapsedFolderIds', newCollapsed);
|
||||
} else {
|
||||
Setting.setValue('collapsedFolderIds', [...collapsedFolders, item.id]);
|
||||
}
|
||||
this.updateItems_ = true;
|
||||
this.invalidate();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
public expandToFolder(folderId: string) {
|
||||
// Find all parent folders and expand them
|
||||
const parentsToExpand: string[] = [];
|
||||
let currentId = folderId;
|
||||
|
||||
while (currentId) {
|
||||
const folder = BaseModel.byId(this.folders, currentId);
|
||||
if (!folder) break;
|
||||
|
||||
const parentId = getDisplayParentId(
|
||||
folder,
|
||||
this.folders.find((f) => f.id === folder.parent_id),
|
||||
);
|
||||
if (parentId) {
|
||||
parentsToExpand.unshift(parentId);
|
||||
currentId = parentId;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Expand all parent folders
|
||||
const collapsedFolders = Setting.value('collapsedFolderIds');
|
||||
const newCollapsed = collapsedFolders.filter((id: string) => !parentsToExpand.includes(id));
|
||||
Setting.setValue('collapsedFolderIds', newCollapsed);
|
||||
|
||||
this.updateItems_ = true;
|
||||
this.invalidate();
|
||||
}
|
||||
}
|
||||
|
54
packages/app-cli/app/utils/iterateStdin.ts
Normal file
54
packages/app-cli/app/utils/iterateStdin.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { createInterface } from 'readline/promises';
|
||||
|
||||
const iterateStdin = async function*(prompt: string) {
|
||||
let nextLineListeners: (()=> void)[] = [];
|
||||
const dispatchAllListeners = () => {
|
||||
const listeners = nextLineListeners;
|
||||
nextLineListeners = [];
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
};
|
||||
|
||||
const rl = createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
rl.setPrompt(prompt);
|
||||
|
||||
let buffer: string[] = [];
|
||||
rl.on('line', (line) => {
|
||||
buffer.push(line);
|
||||
dispatchAllListeners();
|
||||
});
|
||||
|
||||
let done = false;
|
||||
rl.on('close', () => {
|
||||
done = true;
|
||||
dispatchAllListeners();
|
||||
});
|
||||
|
||||
const readNextLines = () => {
|
||||
return new Promise<string|null>(resolve => {
|
||||
if (done) {
|
||||
resolve(null);
|
||||
} else if (buffer.length > 0) {
|
||||
resolve(buffer.join('\n'));
|
||||
buffer = [];
|
||||
} else {
|
||||
nextLineListeners.push(() => {
|
||||
resolve(buffer.join('\n'));
|
||||
buffer = [];
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
while (!done) {
|
||||
rl.prompt();
|
||||
const lines = await readNextLines();
|
||||
yield lines;
|
||||
}
|
||||
};
|
||||
|
||||
export default iterateStdin;
|
@@ -9,7 +9,7 @@ const shimInitCli = (options: ShimInitOptions) => {
|
||||
|
||||
shim.showMessageBox = async (message: string, options: ShowMessageBoxOptions) => {
|
||||
const gui = app()?.gui();
|
||||
let answers = options.buttons ?? [_('Ok'), _('Cancel')];
|
||||
let answers = options.buttons ?? [_('OK'), _('Cancel')];
|
||||
|
||||
if (options.type === 'error' || options.type === 'info') {
|
||||
answers = [];
|
||||
|
@@ -57,7 +57,7 @@
|
||||
"proper-lockfile": "4.1.2",
|
||||
"redux": "4.2.1",
|
||||
"server-destroy": "1.0.1",
|
||||
"sharp": "0.34.0",
|
||||
"sharp": "0.34.2",
|
||||
"sprintf-js": "1.1.3",
|
||||
"sqlite3": "5.1.6",
|
||||
"string-padding": "1.0.2",
|
||||
@@ -73,7 +73,7 @@
|
||||
"@joplin/tools": "~3.4",
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/node": "18.19.87",
|
||||
"@types/node": "18.19.103",
|
||||
"@types/proper-lockfile": "^4.1.2",
|
||||
"gulp": "4.0.2",
|
||||
"jest": "29.7.0",
|
||||
|
@@ -8,8 +8,8 @@ import { urlDecode } from '@joplin/lib/string-utils';
|
||||
import * as Sentry from '@sentry/electron/main';
|
||||
import { homedir } from 'os';
|
||||
import { msleep } from '@joplin/utils/time';
|
||||
import { pathExists, pathExistsSync, writeFileSync } from 'fs-extra';
|
||||
import { extname, normalize } from 'path';
|
||||
import { pathExists, pathExistsSync, writeFileSync, ensureDirSync } from 'fs-extra';
|
||||
import { extname, normalize, join } from 'path';
|
||||
import isSafeToOpen from './utils/isSafeToOpen';
|
||||
import { closeSync, openSync, readSync, statSync } from 'fs';
|
||||
import { KB } from '@joplin/utils/bytes';
|
||||
@@ -67,6 +67,30 @@ export class Bridge {
|
||||
this.logFilePath_ = v;
|
||||
}
|
||||
|
||||
private getCrashDumpDirectory(): string {
|
||||
try {
|
||||
const platformName = shim.platformName();
|
||||
switch (platformName) {
|
||||
case 'win32':
|
||||
// Windows: Use %LOCALAPPDATA%\CrashDumps
|
||||
return join(process.env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local'), 'CrashDumps');
|
||||
case 'darwin':
|
||||
// macOS: Use ~/Library/Logs/DiagnosticReports
|
||||
return join(homedir(), 'Library', 'Logs', 'DiagnosticReports');
|
||||
case 'linux':
|
||||
// Linux: Use XDG_STATE_HOME (for logs) or fallback to ~/.local/state
|
||||
return join(process.env.XDG_STATE_HOME || join(homedir(), '.local', 'state'), 'joplin');
|
||||
default:
|
||||
// For unknown platforms, default to the home directory
|
||||
return homedir();
|
||||
}
|
||||
} catch (error) {
|
||||
// If we can't get the platform name, fallback to the home directory
|
||||
return homedir();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private sentryInit() {
|
||||
const getLogLines = () => {
|
||||
try {
|
||||
@@ -109,7 +133,10 @@ export class Bridge {
|
||||
log: logAttachment ? logAttachment.data.trim().split('\n') : [],
|
||||
};
|
||||
|
||||
writeFileSync(`${homedir()}/joplin_crash_dump_${date}.json`, JSON.stringify(errorEventWithLog, null, '\t'), 'utf-8');
|
||||
const crashDumpDir = this.getCrashDumpDirectory();
|
||||
ensureDirSync(crashDumpDir);
|
||||
const crashDumpPath = join(crashDumpDir, `joplin_crash_dump_${date}.json`);
|
||||
writeFileSync(crashDumpPath, JSON.stringify(errorEventWithLog, null, '\t'), 'utf-8');
|
||||
} catch (error) {
|
||||
// Ignore the error since we can't handle it here
|
||||
}
|
||||
|
96
packages/app-desktop/commands/convertNoteToMarkdown.test.ts
Normal file
96
packages/app-desktop/commands/convertNoteToMarkdown.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import * as convertHtmlToMarkdown from './convertNoteToMarkdown';
|
||||
import { AppState, createAppDefaultState } from '../app.reducer';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import { MarkupLanguage } from '@joplin/renderer';
|
||||
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
import { NoteEntity } from '@joplin/lib/services/database/types';
|
||||
|
||||
describe('convertNoteToMarkdown', () => {
|
||||
let state: AppState = undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
state = createAppDefaultState({});
|
||||
await setupDatabaseAndSynchronizer(1);
|
||||
await switchClient(1);
|
||||
});
|
||||
|
||||
it('should set the original note to be trashed', async () => {
|
||||
const folder = await Folder.save({ title: 'test_folder' });
|
||||
const htmlNote = await Note.save({ title: 'test', body: '<p>Hello</p>', parent_id: folder.id, markup_language: MarkupLanguage.Html });
|
||||
state.selectedNoteIds = [htmlNote.id];
|
||||
|
||||
await convertHtmlToMarkdown.runtime().execute({ state, dispatch: () => {} });
|
||||
|
||||
const refreshedNote = await Note.load(htmlNote.id);
|
||||
|
||||
expect(htmlNote.deleted_time).toBe(0);
|
||||
expect(refreshedNote.deleted_time).not.toBe(0);
|
||||
});
|
||||
|
||||
it('should recreate a new note that is a clone of the original', async () => {
|
||||
let noteConvertedToMarkdownId = '';
|
||||
const dispatchFn = jest.fn()
|
||||
.mockImplementationOnce(() => {})
|
||||
.mockImplementationOnce(action => {
|
||||
noteConvertedToMarkdownId = action.id;
|
||||
});
|
||||
|
||||
const folder = await Folder.save({ title: 'test_folder' });
|
||||
const htmlNoteProperties = {
|
||||
title: 'test',
|
||||
body: '<p>Hello</p>',
|
||||
parent_id: folder.id,
|
||||
markup_language: MarkupLanguage.Html,
|
||||
author: 'test-author',
|
||||
is_todo: 1,
|
||||
todo_completed: 1,
|
||||
};
|
||||
const htmlNote = await Note.save(htmlNoteProperties);
|
||||
state.selectedNoteIds = [htmlNote.id];
|
||||
|
||||
await convertHtmlToMarkdown.runtime().execute({ state, dispatch: dispatchFn });
|
||||
|
||||
expect(dispatchFn).toHaveBeenCalledTimes(2);
|
||||
expect(noteConvertedToMarkdownId).not.toBe('');
|
||||
|
||||
const markdownNote = await Note.load(noteConvertedToMarkdownId);
|
||||
|
||||
const fields: (keyof NoteEntity)[] = ['parent_id', 'title', 'author', 'is_todo', 'todo_completed'];
|
||||
for (const field of fields) {
|
||||
expect(htmlNote[field]).toEqual(markdownNote[field]);
|
||||
}
|
||||
});
|
||||
|
||||
it('should generate action to trigger notification', async () => {
|
||||
let originalHtmlNoteId = '';
|
||||
let actionType = '';
|
||||
const dispatchFn = jest.fn()
|
||||
.mockImplementationOnce(action => {
|
||||
originalHtmlNoteId = action.value;
|
||||
actionType = action.type;
|
||||
})
|
||||
.mockImplementationOnce(() => {});
|
||||
|
||||
const folder = await Folder.save({ title: 'test_folder' });
|
||||
const htmlNoteProperties = {
|
||||
title: 'test',
|
||||
body: '<p>Hello</p>',
|
||||
parent_id: folder.id,
|
||||
markup_language: MarkupLanguage.Html,
|
||||
author: 'test-author',
|
||||
is_todo: 1,
|
||||
todo_completed: 1,
|
||||
};
|
||||
const htmlNote = await Note.save(htmlNoteProperties);
|
||||
state.selectedNoteIds = [htmlNote.id];
|
||||
|
||||
await convertHtmlToMarkdown.runtime().execute({ state, dispatch: dispatchFn });
|
||||
|
||||
expect(dispatchFn).toHaveBeenCalledTimes(2);
|
||||
|
||||
expect(originalHtmlNoteId).toBe(htmlNote.id);
|
||||
expect(actionType).toBe('NOTE_HTML_TO_MARKDOWN_DONE');
|
||||
});
|
||||
|
||||
});
|
52
packages/app-desktop/commands/convertNoteToMarkdown.ts
Normal file
52
packages/app-desktop/commands/convertNoteToMarkdown.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import { stateUtils } from '@joplin/lib/reducer';
|
||||
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
|
||||
import { MarkupLanguage } from '@joplin/renderer';
|
||||
import { runtime as convertHtmlToMarkdown } from '@joplin/lib/commands/convertHtmlToMarkdown';
|
||||
import bridge from '../services/bridge';
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'convertNoteToMarkdown',
|
||||
label: () => _('Convert note to Markdown'),
|
||||
};
|
||||
|
||||
export const runtime = (): CommandRuntime => {
|
||||
return {
|
||||
execute: async (context: CommandContext, noteId: string = null) => {
|
||||
noteId = noteId || stateUtils.selectedNoteId(context.state);
|
||||
|
||||
const note = await Note.load(noteId);
|
||||
|
||||
if (!note) return;
|
||||
|
||||
try {
|
||||
const markdownBody = await convertHtmlToMarkdown().execute(context, note.body);
|
||||
|
||||
const newNote = await Note.duplicate(note.id);
|
||||
|
||||
newNote.body = markdownBody;
|
||||
newNote.markup_language = MarkupLanguage.Markdown;
|
||||
|
||||
await Note.save(newNote);
|
||||
|
||||
await Note.delete(note.id, { toTrash: true });
|
||||
|
||||
context.dispatch({
|
||||
type: 'NOTE_HTML_TO_MARKDOWN_DONE',
|
||||
value: note.id,
|
||||
});
|
||||
|
||||
context.dispatch({
|
||||
type: 'NOTE_SELECT',
|
||||
id: newNote.id,
|
||||
});
|
||||
} catch (error) {
|
||||
bridge().showErrorMessageBox(_('Could not convert note to Markdown: %s', error.message));
|
||||
}
|
||||
|
||||
|
||||
},
|
||||
enabledCondition: 'oneNoteSelected && noteIsHtml && !noteIsReadOnly',
|
||||
};
|
||||
};
|
@@ -1,4 +1,5 @@
|
||||
// AUTO-GENERATED using `gulp buildScriptIndexes`
|
||||
import * as convertNoteToMarkdown from './convertNoteToMarkdown';
|
||||
import * as copyDevCommand from './copyDevCommand';
|
||||
import * as copyToClipboard from './copyToClipboard';
|
||||
import * as editProfileConfig from './editProfileConfig';
|
||||
@@ -24,6 +25,7 @@ import * as toggleSafeMode from './toggleSafeMode';
|
||||
import * as toggleTabMovesFocus from './toggleTabMovesFocus';
|
||||
|
||||
const index: any[] = [
|
||||
convertNoteToMarkdown,
|
||||
copyDevCommand,
|
||||
copyToClipboard,
|
||||
editProfileConfig,
|
||||
|
@@ -0,0 +1,28 @@
|
||||
import * as React from 'react';
|
||||
import { useContext, useEffect } from 'react';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { Dispatch } from 'redux';
|
||||
import { PopupNotificationContext } from '../PopupNotification/PopupNotificationProvider';
|
||||
import { NotificationType } from '../PopupNotification/types';
|
||||
|
||||
interface Props {
|
||||
noteId: string;
|
||||
dispatch: Dispatch;
|
||||
}
|
||||
|
||||
export default (props: Props) => {
|
||||
const popupManager = useContext(PopupNotificationContext);
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.noteId || props.noteId === '') return;
|
||||
|
||||
props.dispatch({ type: 'NOTE_HTML_TO_MARKDOWN_DONE', value: '' });
|
||||
|
||||
const notification = popupManager.createPopup(() => (
|
||||
<div>{_('The note has been converted to Markdown and the original note has been moved to the trash')}</div>
|
||||
), { type: NotificationType.Success });
|
||||
notification.scheduleDismiss();
|
||||
}, [props.dispatch, popupManager, props.noteId]);
|
||||
|
||||
return <div style={{ display: 'none' }}/>;
|
||||
};
|
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { useMemo, useRef, useState, useCallback } from 'react';
|
||||
|
||||
interface Props {
|
||||
width: number;
|
||||
@@ -9,40 +9,62 @@ interface Props {
|
||||
const fontSizeCache_: Record<string, number> = {};
|
||||
|
||||
export default (props: Props) => {
|
||||
const containerRef = useRef(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [containerReady, setContainerReady] = useState(false);
|
||||
|
||||
const refCallback = useCallback((el: HTMLDivElement | null) => {
|
||||
if (el && !containerRef.current) {
|
||||
containerRef.current = el;
|
||||
requestAnimationFrame(() => {
|
||||
setContainerReady(true);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fontSize = useMemo(() => {
|
||||
if (!containerReady) return props.height;
|
||||
if (!containerReady || !containerRef.current) {
|
||||
return Math.min(props.height * 0.7, 14);
|
||||
}
|
||||
|
||||
const cacheKey = [props.width, props.height, props.emoji].join('-');
|
||||
if (fontSizeCache_[cacheKey]) {
|
||||
return fontSizeCache_[cacheKey];
|
||||
}
|
||||
|
||||
// Set the emoji font size so that it fits within the specified width
|
||||
// and height. In fact, currently it only looks at the height.
|
||||
|
||||
let spanFontSize = props.height;
|
||||
|
||||
const span = document.createElement('span');
|
||||
span.innerText = props.emoji;
|
||||
span.style.fontSize = `${spanFontSize}px`;
|
||||
span.style.visibility = 'hidden';
|
||||
span.style.position = 'absolute';
|
||||
span.style.whiteSpace = 'nowrap';
|
||||
containerRef.current.appendChild(span);
|
||||
|
||||
let rect = span.getBoundingClientRect();
|
||||
|
||||
while (rect.height > props.height) {
|
||||
spanFontSize -= .5;
|
||||
while ((rect.height > props.height || rect.width > props.width) && spanFontSize > 1) {
|
||||
spanFontSize -= 0.5;
|
||||
span.style.fontSize = `${spanFontSize}px`;
|
||||
rect = span.getBoundingClientRect();
|
||||
}
|
||||
|
||||
span.remove();
|
||||
|
||||
fontSizeCache_[cacheKey] = spanFontSize;
|
||||
return spanFontSize;
|
||||
}, [props.width, props.height, props.emoji, containerReady, containerRef]);
|
||||
}, [props.width, props.height, props.emoji, containerReady]);
|
||||
|
||||
return <div className="emoji-box" ref={el => { containerRef.current = el; setContainerReady(true); }} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: props.width, height: props.height, fontSize }}>{props.emoji}</div>;
|
||||
return <div
|
||||
ref={refCallback}
|
||||
style={{
|
||||
width: props.width,
|
||||
height: props.height,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
fontSize,
|
||||
}}
|
||||
>
|
||||
{props.emoji}
|
||||
</div>;
|
||||
};
|
||||
|
@@ -38,12 +38,14 @@ import restart from '../services/restart';
|
||||
import { connect } from 'react-redux';
|
||||
import { NoteListColumns } from '@joplin/lib/services/plugins/api/noteListType';
|
||||
import validateColumns from './NoteListHeader/utils/validateColumns';
|
||||
import ConversionNotification from './ConversionNotification/ConversionNotification';
|
||||
import TrashNotification from './TrashNotification/TrashNotification';
|
||||
import UpdateNotification from './UpdateNotification/UpdateNotification';
|
||||
import NoteEditor from './NoteEditor/NoteEditor';
|
||||
import PluginNotification from './PluginNotification/PluginNotification';
|
||||
import { Toast } from '@joplin/lib/services/plugins/api/types';
|
||||
import PluginService from '@joplin/lib/services/plugins/PluginService';
|
||||
import { Dispatch } from 'redux';
|
||||
|
||||
const ipcRenderer = require('electron').ipcRenderer;
|
||||
|
||||
@@ -84,6 +86,7 @@ interface Props {
|
||||
showInvalidJoplinCloudCredential: boolean;
|
||||
toast: Toast;
|
||||
shouldSwitchToAppleSiliconVersion: boolean;
|
||||
noteHtmlToMarkdownDone: string;
|
||||
}
|
||||
|
||||
interface ShareFolderDialogOptions {
|
||||
@@ -797,6 +800,10 @@ class MainScreenComponent extends React.Component<Props, State> {
|
||||
|
||||
return (
|
||||
<div style={style}>
|
||||
<ConversionNotification
|
||||
noteId={this.props.noteHtmlToMarkdownDone}
|
||||
dispatch={this.props.dispatch as Dispatch}
|
||||
/>
|
||||
<TrashNotification
|
||||
lastDeletion={this.props.lastDeletion}
|
||||
lastDeletionNotificationTime={this.props.lastDeletionNotificationTime}
|
||||
@@ -853,6 +860,7 @@ const mapStateToProps = (state: AppState) => {
|
||||
showInvalidJoplinCloudCredential: state.settings['sync.target'] === 10 && state.mustAuthenticate,
|
||||
toast: state.toast,
|
||||
shouldSwitchToAppleSiliconVersion: shim.isAppleSilicon() && process.arch !== 'arm64',
|
||||
noteHtmlToMarkdownDone: state.noteHtmlToMarkdownDone,
|
||||
};
|
||||
};
|
||||
|
||||
|
@@ -803,6 +803,7 @@ function useMenu(props: Props) {
|
||||
menuItemDic.toggleNoteList,
|
||||
menuItemDic.toggleVisiblePanes,
|
||||
menuItemDic.toggleEditorPlugin,
|
||||
menuItemDic.toggleEditors,
|
||||
{
|
||||
label: _('Layout button sequence'),
|
||||
submenu: layoutButtonSequenceMenuItems,
|
||||
@@ -906,6 +907,7 @@ function useMenu(props: Props) {
|
||||
separator(),
|
||||
menuItemDic.setTags,
|
||||
menuItemDic.showShareNoteDialog,
|
||||
menuItemDic.convertNoteToMarkdown,
|
||||
separator(),
|
||||
menuItemDic.showNoteProperties,
|
||||
menuItemDic.showNoteContentProperties,
|
||||
|
@@ -340,6 +340,8 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
|
||||
props.setShowLocalSearch(event.searchState.dialogVisible);
|
||||
}
|
||||
lastSearchState.current = event.searchState;
|
||||
} else if (event.kind === EditorEventType.FollowLink) {
|
||||
void CommandService.instance().execute('openItem', event.link);
|
||||
}
|
||||
}, [editor_scroll, codeMirror_change, props.setLocalSearch, props.setShowLocalSearch]);
|
||||
|
||||
@@ -362,6 +364,8 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
|
||||
readOnly: props.disabled,
|
||||
markdownMarkEnabled: Setting.value('markdown.plugin.mark'),
|
||||
katexEnabled: Setting.value('markdown.plugin.katex'),
|
||||
inlineRenderingEnabled: Setting.value('editor.inlineRendering'),
|
||||
imageRenderingEnabled: Setting.value('editor.imageRendering'),
|
||||
themeData: {
|
||||
...styles.globalTheme,
|
||||
marginLeft: 0,
|
||||
@@ -410,6 +414,7 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
|
||||
onSelectPastBeginning={onSelectPastBeginning}
|
||||
externalSearch={props.searchMarkers}
|
||||
useLocalSearch={props.useLocalSearch}
|
||||
onLocalize={_}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
@@ -15,6 +15,10 @@ import useEditorSearch from '../utils/useEditorSearchExtension';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import { SearchMarkers } from '../../../utils/useSearchMarkers';
|
||||
import localisation from './utils/localisation';
|
||||
import Resource from '@joplin/lib/models/Resource';
|
||||
import { parseResourceUrl } from '@joplin/lib/urlUtils';
|
||||
import { resourceFilename } from '@joplin/lib/models/utils/resourceUtils';
|
||||
import getResourceBaseUrl from '../../../utils/getResourceBaseUrl';
|
||||
|
||||
interface Props extends EditorProps {
|
||||
style: React.CSSProperties;
|
||||
@@ -104,7 +108,16 @@ const Editor = (props: Props, ref: ForwardedRef<CodeMirrorControl>) => {
|
||||
onLogMessage: message => onLogMessageRef.current(message),
|
||||
};
|
||||
|
||||
const editor = createEditor(editorContainerRef.current, editorProps);
|
||||
const editor = createEditor(editorContainerRef.current, {
|
||||
...editorProps,
|
||||
resolveImageSrc: async src => {
|
||||
const url = parseResourceUrl(src);
|
||||
if (!url.itemId) return null;
|
||||
const item = await Resource.load(url.itemId);
|
||||
if (!item) return null;
|
||||
return `${getResourceBaseUrl()}/${resourceFilename(item)}`;
|
||||
},
|
||||
});
|
||||
editor.addStyles({
|
||||
'.cm-scroller': { overflow: 'auto' },
|
||||
'&.CodeMirror': {
|
||||
|
@@ -5,6 +5,7 @@ import shim from '@joplin/lib/shim';
|
||||
|
||||
const useLinkTooltips = (editor: Editor|null) => {
|
||||
const resetModifiedTitles = useCallback(() => {
|
||||
if (!editor) return;
|
||||
for (const element of editor.getDoc().querySelectorAll('a[data-joplin-original-title]')) {
|
||||
element.setAttribute('title', element.getAttribute('data-joplin-original-title') ?? '');
|
||||
element.removeAttribute('data-joplin-original-title');
|
||||
|
@@ -56,6 +56,7 @@ import useResourceUnwatcher from './utils/useResourceUnwatcher';
|
||||
import StatusBar from './StatusBar';
|
||||
import useVisiblePluginEditorViewIds from '@joplin/lib/hooks/plugins/useVisiblePluginEditorViewIds';
|
||||
import useConnectToEditorPlugin from './utils/useConnectToEditorPlugin';
|
||||
import getResourceBaseUrl from './utils/getResourceBaseUrl';
|
||||
|
||||
const debounce = require('debounce');
|
||||
|
||||
@@ -169,7 +170,7 @@ function NoteEditorContent(props: NoteEditorProps) {
|
||||
const theme = themeStyle(options.themeId ? options.themeId : props.themeId);
|
||||
|
||||
const markupToHtml = markupLanguageUtils.newMarkupToHtml(props.plugins, {
|
||||
resourceBaseUrl: `joplin-content://note-viewer/${Setting.value('resourceDir')}/`,
|
||||
resourceBaseUrl: getResourceBaseUrl(),
|
||||
customCss: props.customCss,
|
||||
});
|
||||
|
||||
@@ -466,6 +467,7 @@ function NoteEditorContent(props: NoteEditorProps) {
|
||||
// It is currently used to remember pdf scroll position for each attachments of each note uniquely.
|
||||
noteId: props.noteId,
|
||||
watchedNoteFiles: props.watchedNoteFiles,
|
||||
enableHtmlToMarkdownBanner: props.enableHtmlToMarkdownBanner,
|
||||
};
|
||||
|
||||
let editor = null;
|
||||
@@ -488,6 +490,17 @@ function NoteEditorContent(props: NoteEditorProps) {
|
||||
setShowRevisions(false);
|
||||
}, []);
|
||||
|
||||
const onBannerConvertItToMarkdown = useCallback(async (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
event.preventDefault();
|
||||
if (!props.selectedNoteIds || props.selectedNoteIds.length === 0) return;
|
||||
await CommandService.instance().execute('convertNoteToMarkdown', props.selectedNoteIds[0]);
|
||||
}, [props.selectedNoteIds]);
|
||||
|
||||
const onHideBannerConvertItToMarkdown = async (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
event.preventDefault();
|
||||
Setting.setValue('editor.enableHtmlToMarkdownBanner', false);
|
||||
};
|
||||
|
||||
const onBannerResourceClick = useCallback(async (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
event.preventDefault();
|
||||
const resourceId = event.currentTarget.getAttribute('data-resource-id');
|
||||
@@ -632,9 +645,30 @@ function NoteEditorContent(props: NoteEditorProps) {
|
||||
|
||||
const theme = themeStyle(props.themeId);
|
||||
|
||||
function renderConvertHtmlToMarkdown(): React.ReactNode {
|
||||
if (!props.enableHtmlToMarkdownBanner) return null;
|
||||
|
||||
const note = props.notes.find(n => n.id === props.selectedNoteIds[0]);
|
||||
if (!note) return null;
|
||||
if (note.markup_language !== MarkupLanguage.Html) return null;
|
||||
|
||||
return (
|
||||
<div style={styles.resourceWatchBanner}>
|
||||
<p style={styles.resourceWatchBannerLine}>
|
||||
{_('This note is in HTML format. Convert it to Markdown to edit it more easily.')}
|
||||
|
||||
<a href="#" style={styles.resourceWatchBannerAction} onClick={onBannerConvertItToMarkdown}>{`${_('Convert it')}`}</a>
|
||||
{' / '}
|
||||
<a href="#" style={styles.resourceWatchBannerAction} onClick={onHideBannerConvertItToMarkdown}>{_('Don\'t show this message again')}</a>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={styles.root} onDragOver={onDragOver} onDrop={onDrop} ref={containerRef}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
{renderConvertHtmlToMarkdown()}
|
||||
{renderResourceWatchingNotification()}
|
||||
{renderResourceInSearchResultsNotification()}
|
||||
<NoteTitleBar
|
||||
@@ -722,6 +756,7 @@ const mapStateToProps = (state: AppState, ownProps: ConnectProps) => {
|
||||
syncUserId: state.settings['sync.userId'],
|
||||
shareCacheSetting: state.settings['sync.shareCache'],
|
||||
searchResults: state.searchResults,
|
||||
enableHtmlToMarkdownBanner: state.settings['editor.enableHtmlToMarkdownBanner'],
|
||||
};
|
||||
};
|
||||
|
||||
|
@@ -69,6 +69,10 @@ export default function styles(props: NoteEditorProps) {
|
||||
marginTop: 0,
|
||||
marginBottom: 10,
|
||||
},
|
||||
resourceWatchBannerAction: {
|
||||
textDecoration: 'underline',
|
||||
color: theme.colorWarnUrl,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
@@ -0,0 +1,4 @@
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
|
||||
const getResourceBaseUrl = () => `joplin-content://note-viewer/${Setting.value('resourceDir')}/`;
|
||||
export default getResourceBaseUrl;
|
@@ -67,6 +67,7 @@ export interface NoteEditorProps {
|
||||
onTitleChange?: (title: string)=> void;
|
||||
bodyEditor: string;
|
||||
startupPluginsLoaded: boolean;
|
||||
enableHtmlToMarkdownBanner: boolean;
|
||||
}
|
||||
|
||||
export interface NoteBodyEditorRef {
|
||||
@@ -138,6 +139,7 @@ export interface NoteBodyEditorProps {
|
||||
noteId: string;
|
||||
useCustomPdfViewer: boolean;
|
||||
watchedNoteFiles: string[];
|
||||
enableHtmlToMarkdownBanner: boolean;
|
||||
}
|
||||
|
||||
export interface NoteBodyEditorPropsAndRef extends NoteBodyEditorProps {
|
||||
|
@@ -49,7 +49,7 @@ const useScheduleSaveCallbacks = (props: Props) => {
|
||||
}, [props.dispatch, props.editorId, props.setFormNote]);
|
||||
|
||||
const saveNoteIfWillChange = useCallback(async (formNote: FormNote) => {
|
||||
if (!formNote.id || !formNote.bodyWillChangeId) return;
|
||||
if (!formNote.id || !formNote.bodyWillChangeId || !props.editorRef.current) return;
|
||||
|
||||
const body = await props.editorRef.current.content();
|
||||
|
||||
|
@@ -343,6 +343,7 @@ class NotePropertiesDialog extends React.Component<Props, State> {
|
||||
style={styles.input}
|
||||
id={uniqueId(key)}
|
||||
name={uniqueId(key)}
|
||||
autoFocus
|
||||
/>;
|
||||
|
||||
editCompHandler = () => {
|
||||
@@ -363,6 +364,7 @@ class NotePropertiesDialog extends React.Component<Props, State> {
|
||||
id={uniqueId(key)}
|
||||
name={uniqueId(key)}
|
||||
aria-invalid={!this.state.isValid.location}
|
||||
autoFocus
|
||||
/>
|
||||
{
|
||||
this.state.isValid.location ? null
|
||||
@@ -387,6 +389,7 @@ class NotePropertiesDialog extends React.Component<Props, State> {
|
||||
style={styles.input}
|
||||
id={uniqueId(key)}
|
||||
name={uniqueId(key)}
|
||||
autoFocus
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@@ -79,5 +79,7 @@ export default function() {
|
||||
'switchProfile3',
|
||||
'pasteAsText',
|
||||
'showNoteProperties',
|
||||
'convertNoteToMarkdown',
|
||||
'toggleEditors',
|
||||
];
|
||||
}
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/app-desktop",
|
||||
"version": "3.4.4",
|
||||
"version": "3.4.7",
|
||||
"description": "Joplin for Desktop",
|
||||
"main": "main.bundle.js",
|
||||
"private": true,
|
||||
@@ -142,13 +142,13 @@
|
||||
"@joplin/renderer": "~3.4",
|
||||
"@joplin/tools": "~3.4",
|
||||
"@joplin/utils": "~3.4",
|
||||
"@playwright/test": "1.51.1",
|
||||
"@playwright/test": "1.52.0",
|
||||
"@sentry/electron": "4.24.0",
|
||||
"@testing-library/react-hooks": "8.0.1",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/mustache": "4.2.5",
|
||||
"@types/node": "18.19.87",
|
||||
"@types/react": "18.3.20",
|
||||
"@types/mustache": "4.2.6",
|
||||
"@types/node": "18.19.103",
|
||||
"@types/react": "18.3.22",
|
||||
"@types/react-dom": "18.3.7",
|
||||
"@types/react-redux": "7.1.33",
|
||||
"@types/styled-components": "5.1.32",
|
||||
@@ -160,7 +160,7 @@
|
||||
"compare-versions": "6.1.1",
|
||||
"countable": "3.0.1",
|
||||
"debounce": "1.2.1",
|
||||
"electron": "35.5.1",
|
||||
"electron": "35.7.5",
|
||||
"electron-builder": "24.13.3",
|
||||
"electron-updater": "6.6.2",
|
||||
"electron-window-state": "5.0.3",
|
||||
@@ -179,7 +179,6 @@
|
||||
"moment": "2.30.1",
|
||||
"mustache": "4.2.0",
|
||||
"nan": "2.22.2",
|
||||
"node-fetch": "2.6.7",
|
||||
"node-notifier": "10.0.1",
|
||||
"node-rsa": "1.1.1",
|
||||
"pdfjs-dist": "3.11.174",
|
||||
@@ -211,6 +210,7 @@
|
||||
"@joplin/onenote-converter": "~3.4",
|
||||
"fs-extra": "11.2.0",
|
||||
"keytar": "7.9.0",
|
||||
"node-fetch": "2.6.7",
|
||||
"sqlite3": "5.1.6"
|
||||
}
|
||||
}
|
||||
|
@@ -30,7 +30,7 @@ const makeBuildContext = (entryPoint: string, renderer: boolean, computeFileSize
|
||||
// in the final bundle.
|
||||
name: 'joplin--relative-imports-for-externals',
|
||||
setup: build => {
|
||||
const externalRegex = /^(.*\.node|sqlite3|electron|@electron\/remote\/.*|electron\/.*|@mapbox\/node-pre-gyp|jsdom)$/;
|
||||
const externalRegex = /^(.*\.node|sqlite3|node-fetch|electron|@electron\/remote\/.*|electron\/.*|@mapbox\/node-pre-gyp|jsdom)$/;
|
||||
build.onResolve({ filter: externalRegex }, args => {
|
||||
// Electron packages don't need relative requires
|
||||
if (args.path === 'electron' || args.path.startsWith('electron/')) {
|
||||
|
@@ -25,7 +25,7 @@ async function main() {
|
||||
// wrong one. However it means it will have to be manually upgraded for each
|
||||
// new Electron release. Some ABI map there:
|
||||
// https://github.com/electron/node-abi/tree/master/test
|
||||
const forceAbiArgs = '--force-abi 134';
|
||||
const forceAbiArgs = '--force-abi 138';
|
||||
|
||||
if (isWindows()) {
|
||||
// Cannot run this in parallel, or the 64-bit version might end up
|
||||
|
@@ -89,8 +89,8 @@ android {
|
||||
applicationId "net.cozic.joplin"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 2097776
|
||||
versionName "3.4.3"
|
||||
versionCode 2097777
|
||||
versionName "3.4.4"
|
||||
ndk {
|
||||
abiFilters "armeabi-v7a", "x86", "arm64-v8a", "x86_64"
|
||||
}
|
||||
|
@@ -540,6 +540,7 @@ const ComboBox: React.FC<Props> = ({
|
||||
};
|
||||
const activeId = `${baseId}-${selectedIndex}`;
|
||||
const searchResults = <NestableFlatList
|
||||
keyboardShouldPersistTaps="handled"
|
||||
ref={listRef}
|
||||
data={results}
|
||||
{...searchResultProps}
|
||||
|
@@ -63,7 +63,7 @@ const TextInputDialog: React.FC<Props> = ({ dialog, containerStyle, themeId }) =
|
||||
/>
|
||||
<PromptButton
|
||||
buttonSpec={{
|
||||
text: _('Okay'),
|
||||
text: _('OK'),
|
||||
onPress: () => dialog.onSubmit(text),
|
||||
}}
|
||||
themeId={themeId}
|
||||
|
@@ -15,7 +15,7 @@ const logger = Logger.create('ExtendedWebView');
|
||||
const ExtendedWebView = (props: Props, ref: Ref<WebViewControl>) => {
|
||||
const dom = useMemo(() => {
|
||||
// Note: Adding `runScripts: 'dangerously'` to allow running inline <script></script>s.
|
||||
// Use with caution.
|
||||
// Use with caution -- don't load untrusted WebView HTML while testing.
|
||||
return new JSDOM(props.html, { runScripts: 'dangerously', pretendToBeVisual: true });
|
||||
}, [props.html]);
|
||||
|
||||
@@ -57,6 +57,43 @@ const ExtendedWebView = (props: Props, ref: Ref<WebViewControl>) => {
|
||||
// JSDOM polyfills
|
||||
dom.window.eval(`
|
||||
window.scrollBy = (_amount) => { };
|
||||
|
||||
// JSDOM iframes are missing certain functionality required by Joplin,
|
||||
// including:
|
||||
// - MessageEvent.source: Should point to the window that created a message.
|
||||
// Joplin uses this to determine the source of messages in iframe-related IPC.
|
||||
// - iframe.srcdoc: Used by Joplin to create plugin windows.
|
||||
const polyfillIframeContentWindow = (contentWindow) => {
|
||||
contentWindow.addEventListener('message', event => {
|
||||
// Work around a missing ".source" property on events.
|
||||
// See https://github.com/jsdom/jsdom/issues/2745#issuecomment-1207414024
|
||||
if (!event.source) {
|
||||
contentWindow.dispatchEvent(new MessageEvent('message', {
|
||||
source: window,
|
||||
data: event.data,
|
||||
}));
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
});
|
||||
|
||||
contentWindow.parent.postMessage = (message) => {
|
||||
window.dispatchEvent(new MessageEvent('message', {
|
||||
data: message,
|
||||
source: contentWindow,
|
||||
}));
|
||||
};
|
||||
};
|
||||
|
||||
Object.defineProperty(HTMLIFrameElement.prototype, 'srcdoc', {
|
||||
set(value) {
|
||||
this.src = 'about:blank';
|
||||
setTimeout(() => {
|
||||
this.contentDocument.write(value);
|
||||
|
||||
polyfillIframeContentWindow(this.contentWindow);
|
||||
}, 0);
|
||||
},
|
||||
});
|
||||
`);
|
||||
|
||||
dom.window.eval(`
|
||||
@@ -71,7 +108,12 @@ const ExtendedWebView = (props: Props, ref: Ref<WebViewControl>) => {
|
||||
},
|
||||
});
|
||||
|
||||
dom.window.eval(injectedJavaScriptRef.current);
|
||||
// Wrap the injected JavaScript in (() => {...})() to more closely
|
||||
// match the behavior of injectedJavaScript on Android -- variables
|
||||
// declared with "var" or "const" should not become global variables.
|
||||
dom.window.eval(`(() => {
|
||||
${injectedJavaScriptRef.current}
|
||||
})()`);
|
||||
}, [dom]);
|
||||
|
||||
const onLoadEndRef = useRef(props.onLoadEnd);
|
||||
|
@@ -132,6 +132,7 @@ const useRerenderHandler = (props: Props) => {
|
||||
highlightedKeywords: props.highlightedKeywords,
|
||||
resources: props.noteResources,
|
||||
pluginAssetContainerSelector: '#joplin-container-pluginAssetsContainer',
|
||||
removeUnusedPluginAssets: true,
|
||||
|
||||
// If the hash changed, we don't set initial scroll -- we want to scroll to the hash
|
||||
// instead.
|
||||
|
@@ -3,13 +3,11 @@ 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';
|
||||
@@ -117,15 +115,16 @@ const MarkdownEditor: React.FC<EditorProps> = props => {
|
||||
onEditorEvent: props.onEditorEvent,
|
||||
onAttachFile: props.onAttach,
|
||||
editorOptions: {
|
||||
parentElementClassName: 'CodeMirror',
|
||||
parentElementOrClassName: 'CodeMirror',
|
||||
initialText: props.initialText,
|
||||
initialNoteId: props.noteId,
|
||||
settings: props.editorSettings,
|
||||
},
|
||||
webviewRef,
|
||||
pluginStates: props.plugins,
|
||||
});
|
||||
|
||||
props.editorRef.current = editorWebViewSetup.api.editor;
|
||||
props.editorRef.current = editorWebViewSetup.api.mainEditor;
|
||||
|
||||
const injectedJavaScript = `
|
||||
window.onerror = (message, source, lineno) => {
|
||||
@@ -153,11 +152,6 @@ const MarkdownEditor: React.FC<EditorProps> = props => {
|
||||
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;
|
||||
|
||||
@@ -182,7 +176,7 @@ const MarkdownEditor: React.FC<EditorProps> = props => {
|
||||
html={html}
|
||||
injectedJavaScript={injectedJavaScript}
|
||||
css={css}
|
||||
hasPluginScripts={codeMirrorPlugins.length > 0}
|
||||
hasPluginScripts={editorWebViewSetup.hasPlugins}
|
||||
onMessage={onMessage}
|
||||
onLoadEnd={editorWebViewSetup.webViewEventHandlers.onLoadEnd}
|
||||
onError={onError}
|
||||
|
@@ -232,6 +232,10 @@ const useEditorControl = (
|
||||
onResourceDownloaded: (id: string) => {
|
||||
editorRef.current.onResourceDownloaded(id);
|
||||
},
|
||||
|
||||
remove: () => {
|
||||
editorRef.current.remove();
|
||||
},
|
||||
};
|
||||
|
||||
return control;
|
||||
@@ -246,6 +250,8 @@ function NoteEditor(props: Props) {
|
||||
markdownMarkEnabled: Setting.value('markdown.plugin.mark'),
|
||||
katexEnabled: Setting.value('markdown.plugin.katex'),
|
||||
spellcheckEnabled: Setting.value('editor.mobile.spellcheckEnabled'),
|
||||
inlineRenderingEnabled: Setting.value('editor.inlineRendering'),
|
||||
imageRenderingEnabled: Setting.value('editor.imageRendering'),
|
||||
language: props.markupLanguage === MarkupLanguage.Html ? EditorLanguageType.Html : EditorLanguageType.Markdown,
|
||||
useExternalSearch: true,
|
||||
readOnly: props.readOnly,
|
||||
@@ -298,6 +304,7 @@ function NoteEditor(props: Props) {
|
||||
editorControl.searchControl.hideSearch();
|
||||
}
|
||||
break;
|
||||
case EditorEventType.Remove:
|
||||
case EditorEventType.Scroll:
|
||||
// Not handled
|
||||
break;
|
||||
|
@@ -173,6 +173,21 @@ describe('RichTextEditor', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should save repeated spaces using nonbreaking spaces', async () => {
|
||||
let body = 'Test';
|
||||
render(<WrappedEditor
|
||||
noteBody={body}
|
||||
onBodyChange={newBody => { body = newBody; }}
|
||||
/>);
|
||||
|
||||
const window = await getEditorWindow();
|
||||
mockTyping(window, ' test');
|
||||
|
||||
await waitFor(async () => {
|
||||
expect(body.trim()).toBe('Test \u00A0test');
|
||||
});
|
||||
});
|
||||
|
||||
it('should render clickable checkboxes', async () => {
|
||||
let body = '- [ ] Test\n- [x] Another test';
|
||||
render(<WrappedEditor
|
||||
@@ -354,4 +369,69 @@ describe('RichTextEditor', () => {
|
||||
expect(body.trim()).toBe('Test:\n\n$$\n3^2 + 4^2 = \\sqrt{625}\n$$\n\nTest. testing');
|
||||
});
|
||||
});
|
||||
|
||||
it('should be possible show an editor for math blocks', async () => {
|
||||
let body = 'Test:\n\n$$3^2 + 4^2 = 5^2$$';
|
||||
render(<WrappedEditor
|
||||
noteBody={body}
|
||||
onBodyChange={newBody => { body = newBody; }}
|
||||
/>);
|
||||
|
||||
const editButton = await findElement<HTMLButtonElement>('button.edit');
|
||||
editButton.click();
|
||||
|
||||
const editor = await findElement('dialog .cm-editor');
|
||||
expect(editor).toBeTruthy();
|
||||
expect(editor.textContent).toContain('3^2 + 4^2 = 5^2');
|
||||
});
|
||||
|
||||
it('should preserve table of contents blocks on edit', async () => {
|
||||
let body = '# Heading\n\n# Heading 2\n\n[toc]\n\nTest.';
|
||||
|
||||
render(<WrappedEditor
|
||||
noteBody={body}
|
||||
onBodyChange={newBody => { body = newBody; }}
|
||||
/>);
|
||||
|
||||
// Should render the [toc] as a joplin-editable
|
||||
const renderedTableOfContents = await findElement<HTMLElement>('div.joplin-editable');
|
||||
expect(renderedTableOfContents).toBeTruthy();
|
||||
// Should have a link for each heading
|
||||
expect(renderedTableOfContents.querySelectorAll('a[href]')).toHaveLength(2);
|
||||
|
||||
const window = await getEditorWindow();
|
||||
mockTyping(window, ' testing');
|
||||
|
||||
await waitFor(async () => {
|
||||
expect(body.trim()).toBe('# Heading\n\n# Heading 2\n\n[toc]\n\nTest. testing');
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
'**bold**',
|
||||
'*italic*',
|
||||
'$\\text{math}$',
|
||||
'<span style="color: red;">test</span>',
|
||||
'`code`',
|
||||
'==highlight==ed',
|
||||
'<sup>Super</sup>script',
|
||||
'<sub>Sub</sub>script',
|
||||
])('should preserve inline markup on edit (case %#)', async (initialBody) => {
|
||||
initialBody += 'test'; // Ensure that typing will add new content outside the formatting
|
||||
let body = initialBody;
|
||||
|
||||
render(<WrappedEditor
|
||||
noteBody={body}
|
||||
onBodyChange={newBody => { body = newBody; }}
|
||||
/>);
|
||||
|
||||
await findElement<HTMLElement>('div.prosemirror-editor');
|
||||
|
||||
const window = await getEditorWindow();
|
||||
mockTyping(window, ' testing');
|
||||
|
||||
await waitFor(async () => {
|
||||
expect(body.trim()).toBe(`${initialBody} testing`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@@ -84,6 +84,7 @@ const RichTextEditor: React.FC<EditorProps> = props => {
|
||||
initialText: props.initialText,
|
||||
noteId: props.noteId,
|
||||
settings: props.editorSettings,
|
||||
globalSearch: props.globalSearch,
|
||||
webviewRef,
|
||||
themeId: props.themeId,
|
||||
pluginStates: props.plugins,
|
||||
|
@@ -1,11 +1,10 @@
|
||||
import * as React from 'react';
|
||||
import TextInput from './TextInput';
|
||||
import { View, StyleSheet, TextInputProps, ViewStyle, TextInput as ReactNativeTextInput } from 'react-native';
|
||||
import { View, StyleSheet, TextInputProps, ViewStyle, TextInput as ReactNativeTextInput, Keyboard } from 'react-native';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { Ref, useCallback, useMemo } from 'react';
|
||||
import { themeStyle } from './global-style';
|
||||
import IconButton from './IconButton';
|
||||
import Icon from './Icon';
|
||||
|
||||
|
||||
interface SearchInputProps extends TextInputProps {
|
||||
@@ -58,11 +57,12 @@ const SearchInput: React.FC<SearchInputProps> = ({ inputRef, themeId, value, con
|
||||
}, [onChangeText]);
|
||||
|
||||
return <View style={[styles.root, containerStyle]}>
|
||||
<Icon
|
||||
aria-hidden={true}
|
||||
name='material magnify'
|
||||
accessibilityLabel={null}
|
||||
style={styles.icon}
|
||||
<IconButton
|
||||
iconName='material magnify'
|
||||
onPress={() => Keyboard.dismiss()}
|
||||
description={_('Hide keyboard')}
|
||||
iconStyle={styles.icon}
|
||||
themeId={themeId}
|
||||
/>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
|
@@ -171,6 +171,7 @@ const TagsBox: React.FC<TagsBoxProps> = props => {
|
||||
return <View style={props.styles.tagBoxRoot}>
|
||||
<Text style={props.styles.header} role='heading'>{_('Associated tags:')}</Text>
|
||||
<ScrollView
|
||||
keyboardShouldPersistTaps="handled"
|
||||
style={props.styles.tagBoxScrollView}
|
||||
// On web, specifying aria-live here announces changes to the associated tags.
|
||||
// However, on Android (and possibly iOS), this breaks focus behavior:
|
||||
|
@@ -0,0 +1,71 @@
|
||||
import * as React from 'react';
|
||||
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
|
||||
import { AppState } from '../../utils/types';
|
||||
import { Store } from 'redux';
|
||||
import createMockReduxStore from '../../utils/testing/createMockReduxStore';
|
||||
import setupGlobalStore from '../../utils/testing/setupGlobalStore';
|
||||
import PluginRunnerWebView from './PluginRunnerWebView';
|
||||
import TestProviderStack from '../testing/TestProviderStack';
|
||||
import { render, waitFor } from '../../utils/testing/testingLibrary';
|
||||
import createTestPlugin from '@joplin/lib/testing/plugins/createTestPlugin';
|
||||
import getWebViewDomById from '../../utils/testing/getWebViewDomById';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import PluginService from '@joplin/lib/services/plugins/PluginService';
|
||||
|
||||
let store: Store<AppState>;
|
||||
|
||||
interface WrapperProps { }
|
||||
|
||||
const WrappedPluginRunnerWebView: React.FC<WrapperProps> = _props => {
|
||||
return <TestProviderStack store={store}>
|
||||
<PluginRunnerWebView/>
|
||||
</TestProviderStack>;
|
||||
};
|
||||
|
||||
const defaultManifestProperties = {
|
||||
manifest_version: 1,
|
||||
version: '0.1.0',
|
||||
app_min_version: '2.3.4',
|
||||
platforms: ['desktop', 'mobile'],
|
||||
name: 'Some plugin name',
|
||||
};
|
||||
|
||||
describe('PluginRunnerWebView', () => {
|
||||
beforeEach(async () => {
|
||||
await setupDatabaseAndSynchronizer(0);
|
||||
await switchClient(0);
|
||||
|
||||
store = createMockReduxStore();
|
||||
setupGlobalStore(store);
|
||||
Setting.setValue('plugins.pluginSupportEnabled', true);
|
||||
});
|
||||
|
||||
test('should load a plugin that shows a dialog', async () => {
|
||||
const testPlugin = await createTestPlugin({
|
||||
...defaultManifestProperties,
|
||||
id: 'org.joplinapp.dialog-test',
|
||||
}, {
|
||||
onStart: `
|
||||
const dialogs = joplin.views.dialogs;
|
||||
const dialogHandle = await dialogs.create('test-dialog');
|
||||
await dialogs.setHtml(
|
||||
dialogHandle,
|
||||
'<h1>Test!</h1>',
|
||||
);
|
||||
await joplin.views.dialogs.open(dialogHandle)
|
||||
`,
|
||||
});
|
||||
render(<WrappedPluginRunnerWebView/>);
|
||||
|
||||
// Should load the plugin
|
||||
await waitFor(async () => {
|
||||
expect(PluginService.instance().pluginById(testPlugin.manifest.id)).toBeTruthy();
|
||||
});
|
||||
|
||||
// Should show the dialog
|
||||
await waitFor(async () => {
|
||||
const dom = await getWebViewDomById('joplin__PluginDialogWebView');
|
||||
expect(dom.querySelector('h1').textContent).toBe('Test!');
|
||||
});
|
||||
});
|
||||
});
|
@@ -158,6 +158,7 @@ const PluginRunnerWebViewComponent: React.FC<Props> = props => {
|
||||
const injectedJs = `
|
||||
if (!window.loadedBackgroundPage) {
|
||||
${shim.injectedJs('pluginBackgroundPage')}
|
||||
window.pluginBackgroundPage = pluginBackgroundPage;
|
||||
console.log('Loaded PluginRunnerWebView.');
|
||||
|
||||
// Necessary, because React Native WebView can re-run injectedJs
|
||||
|
@@ -101,6 +101,8 @@ const PluginUserWebView = (props: Props) => {
|
||||
return `
|
||||
if (!window.backgroundPageLoaded) {
|
||||
${shim.injectedJs('pluginBackgroundPage')}
|
||||
window.pluginBackgroundPage = pluginBackgroundPage;
|
||||
|
||||
pluginBackgroundPage.initializeDialogWebView(
|
||||
${JSON.stringify(messageChannelId)}
|
||||
);
|
||||
@@ -120,6 +122,7 @@ const PluginUserWebView = (props: Props) => {
|
||||
<ExtendedWebView
|
||||
style={props.style}
|
||||
baseDirectory={plugin.baseDir}
|
||||
testID='joplin__PluginDialogWebView'
|
||||
webviewInstanceId='joplin__PluginDialogWebView'
|
||||
html={html}
|
||||
hasPluginScripts={true}
|
||||
|
@@ -923,7 +923,7 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
|
||||
resource = await Resource.save(resource, { isNew: true });
|
||||
|
||||
const resourceTag = Resource.markupTag(resource);
|
||||
const resourceTag = Resource.markupTag(resource, this.state.note.markup_language);
|
||||
const newNote = await this.insertText(resourceTag, { newLine: true });
|
||||
|
||||
void this.refreshResource(resource, newNote.body);
|
||||
@@ -1043,9 +1043,9 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
};
|
||||
|
||||
private toggleIsTodo_onPress() {
|
||||
shared.toggleIsTodo_onPress(this);
|
||||
const newNote = shared.toggleIsTodo_onPress(this);
|
||||
|
||||
this.scheduleSave(this.state);
|
||||
this.scheduleSave({ ...this.state, note: newNote });
|
||||
}
|
||||
|
||||
private async share_onPress() {
|
||||
|
@@ -1,28 +1,57 @@
|
||||
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 { EditorProcessApi, EditorProps, EditorWithParentProps, ExportedWebViewGlobals, MainProcessApi } from './types';
|
||||
import readFileToBase64 from '../utils/readFileToBase64';
|
||||
import { EditorControl } from '@joplin/editor/types';
|
||||
import { EditorEventType } from '@joplin/editor/events';
|
||||
|
||||
export { default as setUpLogger } from '../utils/setUpLogger';
|
||||
|
||||
export const initializeEditor = ({
|
||||
parentElementClassName,
|
||||
interface ExtendedWindow extends ExportedWebViewGlobals, Window { }
|
||||
declare const window: ExtendedWindow;
|
||||
|
||||
let mainEditor: EditorControl|null = null;
|
||||
let allEditors: EditorControl[] = [];
|
||||
const messenger = new WebViewToRNMessenger<EditorProcessApi, MainProcessApi>('markdownEditor', {
|
||||
get mainEditor() {
|
||||
return mainEditor;
|
||||
},
|
||||
updatePlugins(contentScripts) {
|
||||
for (const editor of allEditors) {
|
||||
void editor.setContentScripts(contentScripts);
|
||||
}
|
||||
},
|
||||
updateSettings(settings) {
|
||||
for (const editor of allEditors) {
|
||||
editor.updateSettings(settings);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
export const createEditorWithParent = ({
|
||||
parentElementOrClassName,
|
||||
initialText,
|
||||
initialNoteId,
|
||||
settings,
|
||||
}: EditorProps) => {
|
||||
const messenger = new WebViewToRNMessenger<EditorProcessApi, MainProcessApi>('markdownEditor', null);
|
||||
|
||||
const parentElement = document.getElementsByClassName(parentElementClassName)[0] as HTMLElement;
|
||||
onEvent,
|
||||
}: EditorWithParentProps) => {
|
||||
const parentElement = (() => {
|
||||
if (parentElementOrClassName instanceof HTMLElement) {
|
||||
return parentElementOrClassName;
|
||||
}
|
||||
return document.getElementsByClassName(parentElementOrClassName)[0] as HTMLElement;
|
||||
})();
|
||||
if (!parentElement) {
|
||||
throw new Error(`Unable to find parent element for editor (class name: ${JSON.stringify(parentElementClassName)})`);
|
||||
throw new Error(`Unable to find parent element for editor (class name: ${JSON.stringify(parentElementOrClassName)})`);
|
||||
}
|
||||
|
||||
const control = createEditor(parentElement, {
|
||||
initialText,
|
||||
initialNoteId,
|
||||
settings,
|
||||
onLocalize: messenger.remoteApi.onLocalize,
|
||||
|
||||
onPasteFile: async (data) => {
|
||||
const base64 = await readFileToBase64(data);
|
||||
@@ -32,7 +61,28 @@ export const initializeEditor = ({
|
||||
onLogMessage: message => {
|
||||
void messenger.remoteApi.logMessage(message);
|
||||
},
|
||||
onEvent: (event): void => {
|
||||
onEvent: (event) => {
|
||||
onEvent(event);
|
||||
|
||||
if (event.kind === EditorEventType.Remove) {
|
||||
allEditors = allEditors.filter(other => other !== control);
|
||||
}
|
||||
},
|
||||
resolveImageSrc: (src) => {
|
||||
return messenger.remoteApi.onResolveImageSrc(src);
|
||||
},
|
||||
});
|
||||
|
||||
allEditors.push(control);
|
||||
void messenger.remoteApi.onEditorAdded();
|
||||
|
||||
return control;
|
||||
};
|
||||
|
||||
export const createMainEditor = (props: EditorProps) => {
|
||||
const control = createEditorWithParent({
|
||||
...props,
|
||||
onEvent: (event) => {
|
||||
void messenger.remoteApi.onEditorEvent(event);
|
||||
},
|
||||
});
|
||||
@@ -52,6 +102,7 @@ export const initializeEditor = ({
|
||||
|
||||
// Note: Just adding an onclick listener seems sufficient to focus the editor when its background
|
||||
// is tapped.
|
||||
const parentElement = control.editor.dom.parentElement;
|
||||
parentElement.addEventListener('click', (event) => {
|
||||
const activeElement = document.querySelector(':focus');
|
||||
if (!parentElement.contains(activeElement) && event.target === parentElement) {
|
||||
@@ -59,8 +110,9 @@ export const initializeEditor = ({
|
||||
}
|
||||
});
|
||||
|
||||
messenger.setLocalInterface({
|
||||
editor: control,
|
||||
});
|
||||
mainEditor = control;
|
||||
return control;
|
||||
};
|
||||
|
||||
window.createEditorWithParent = createEditorWithParent;
|
||||
window.createMainEditor = createMainEditor;
|
||||
|
@@ -1,8 +1,29 @@
|
||||
import { EditorEvent } from '@joplin/editor/events';
|
||||
import { EditorControl, EditorSettings } from '@joplin/editor/types';
|
||||
import { ContentScriptData, EditorControl, EditorSettings, LocalizationResult } from '@joplin/editor/types';
|
||||
|
||||
|
||||
export interface EditorProps {
|
||||
parentElementOrClassName: HTMLElement|string;
|
||||
initialText: string;
|
||||
initialNoteId: string|null;
|
||||
settings: EditorSettings;
|
||||
}
|
||||
|
||||
export interface EditorWithParentProps extends EditorProps {
|
||||
onEvent: (editorEvent: EditorEvent)=> void;
|
||||
}
|
||||
|
||||
// The Markdown editor exposes global functions within its <WebView>.
|
||||
// These functions can be used externally.
|
||||
export interface ExportedWebViewGlobals {
|
||||
createEditorWithParent: (options: EditorWithParentProps)=> EditorControl;
|
||||
createMainEditor: (props: EditorProps)=> EditorControl;
|
||||
}
|
||||
|
||||
export interface EditorProcessApi {
|
||||
editor: EditorControl;
|
||||
mainEditor: EditorControl;
|
||||
updateSettings: (settings: EditorSettings)=> void;
|
||||
updatePlugins: (contentScripts: ContentScriptData[])=> void;
|
||||
}
|
||||
|
||||
export interface SelectionRange {
|
||||
@@ -10,15 +31,11 @@ export interface SelectionRange {
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface EditorProps {
|
||||
parentElementClassName: string;
|
||||
initialText: string;
|
||||
initialNoteId: string;
|
||||
settings: EditorSettings;
|
||||
}
|
||||
|
||||
export interface MainProcessApi {
|
||||
onLocalize(text: string): LocalizationResult;
|
||||
onEditorEvent(event: EditorEvent): Promise<void>;
|
||||
onEditorAdded(): Promise<void>;
|
||||
logMessage(message: string): Promise<void>;
|
||||
onPasteFile(type: string, dataBase64: string): Promise<void>;
|
||||
onResolveImageSrc(src: string): Promise<string|null>;
|
||||
}
|
||||
|
@@ -7,14 +7,21 @@ import { OnMessageEvent, WebViewControl } from '../../components/ExtendedWebView
|
||||
import { EditorEvent } from '@joplin/editor/events';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import RNToWebViewMessenger from '../../utils/ipc/RNToWebViewMessenger';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
|
||||
import useCodeMirrorPlugins from './utils/useCodeMirrorPlugins';
|
||||
import Resource from '@joplin/lib/models/Resource';
|
||||
import { parseResourceUrl } from '@joplin/lib/urlUtils';
|
||||
const { isImageMimeType } = require('@joplin/lib/resourceUtils');
|
||||
|
||||
const logger = Logger.create('markdownEditor');
|
||||
|
||||
interface Props {
|
||||
editorOptions: EditorOptions;
|
||||
initialSelection: SelectionRange;
|
||||
initialSelection: SelectionRange|null;
|
||||
noteHash: string;
|
||||
globalSearch: string;
|
||||
pluginStates: PluginStates;
|
||||
onEditorEvent: (event: EditorEvent)=> void;
|
||||
onAttachFile: (mime: string, base64: string)=> void;
|
||||
|
||||
@@ -30,9 +37,11 @@ const defaultSearchState: SearchState = {
|
||||
dialogVisible: false,
|
||||
};
|
||||
|
||||
type Result = SetUpResult<EditorProcessApi> & { hasPlugins: boolean };
|
||||
|
||||
const useWebViewSetup = ({
|
||||
editorOptions, initialSelection, noteHash, globalSearch, webviewRef, onEditorEvent, onAttachFile,
|
||||
}: Props): SetUpResult<EditorProcessApi> => {
|
||||
editorOptions, pluginStates, initialSelection, noteHash, globalSearch, webviewRef, onEditorEvent, onAttachFile,
|
||||
}: Props): Result => {
|
||||
const setInitialSelectionJs = initialSelection ? `
|
||||
cm.select(${initialSelection.start}, ${initialSelection.end});
|
||||
cm.execCommand('scrollSelectionIntoView');
|
||||
@@ -48,23 +57,34 @@ const useWebViewSetup = ({
|
||||
` : '';
|
||||
|
||||
const injectedJavaScript = useMemo(() => `
|
||||
if (!window.cm) {
|
||||
if (typeof markdownEditorBundle === 'undefined') {
|
||||
${shim.injectedJs('markdownEditorBundle')};
|
||||
window.markdownEditorBundle = markdownEditorBundle;
|
||||
markdownEditorBundle.setUpLogger();
|
||||
}
|
||||
|
||||
window.cm = markdownEditorBundle.initializeEditor(
|
||||
${JSON.stringify(editorOptions)}
|
||||
);
|
||||
if (!window.cm) {
|
||||
const parentClassName = ${JSON.stringify(editorOptions?.parentElementOrClassName)};
|
||||
const foundParent = !!parentClassName && document.getElementsByClassName(parentClassName).length > 0;
|
||||
|
||||
${jumpToHashJs}
|
||||
// Set the initial selection after jumping to the header -- the initial selection,
|
||||
// if specified, should take precedence.
|
||||
${setInitialSelectionJs}
|
||||
${setInitialSearchJs}
|
||||
// On Android, injectedJavaScript can be run multiple times, including once before the
|
||||
// document has loaded. To avoid logging an error each time the editor starts, don't throw
|
||||
// if the parent element can't be found:
|
||||
if (foundParent) {
|
||||
window.cm = markdownEditorBundle.createMainEditor(${JSON.stringify(editorOptions)});
|
||||
|
||||
window.onresize = () => {
|
||||
cm.execCommand('scrollSelectionIntoView');
|
||||
};
|
||||
${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 if (parentClassName) {
|
||||
console.log('No parent element found with class name ', parentClassName);
|
||||
}
|
||||
}
|
||||
`, [jumpToHashJs, setInitialSearchJs, setInitialSelectionJs, editorOptions]);
|
||||
|
||||
@@ -88,6 +108,10 @@ const useWebViewSetup = ({
|
||||
const onAttachRef = useRef(onAttachFile);
|
||||
onAttachRef.current = onAttachFile;
|
||||
|
||||
const codeMirrorPlugins = useCodeMirrorPlugins(pluginStates);
|
||||
const codeMirrorPluginsRef = useRef(codeMirrorPlugins);
|
||||
codeMirrorPluginsRef.current = codeMirrorPlugins;
|
||||
|
||||
const editorMessenger = useMemo(() => {
|
||||
const localApi: MainProcessApi = {
|
||||
async onEditorEvent(event) {
|
||||
@@ -99,6 +123,30 @@ const useWebViewSetup = ({
|
||||
async onPasteFile(type, data) {
|
||||
onAttachRef.current(type, data);
|
||||
},
|
||||
async onLocalize(text) {
|
||||
const localizationFunction = _;
|
||||
return localizationFunction(text);
|
||||
},
|
||||
async onEditorAdded() {
|
||||
messenger.remoteApi.updatePlugins(codeMirrorPluginsRef.current);
|
||||
},
|
||||
async onResolveImageSrc(src) {
|
||||
const url = parseResourceUrl(src);
|
||||
if (!url.itemId) return null;
|
||||
const item = await Resource.load(url.itemId);
|
||||
|
||||
if (shim.mobilePlatform() === 'web') {
|
||||
// Maximum 6 MiB on web
|
||||
const maximumSize = 6 * 1024 * 1024;
|
||||
if (isImageMimeType(item.mime) && item.size < maximumSize) {
|
||||
const data = await shim.fsDriver().readFile(Resource.fullPath(item), 'base64');
|
||||
return `data:${item.mime};base64,${data}`;
|
||||
}
|
||||
return null;
|
||||
} else {
|
||||
return Resource.fullPath(item);
|
||||
}
|
||||
},
|
||||
};
|
||||
const messenger = new RNToWebViewMessenger<MainProcessApi, EditorProcessApi>(
|
||||
'markdownEditor', webviewRef, localApi,
|
||||
@@ -123,17 +171,22 @@ const useWebViewSetup = ({
|
||||
|
||||
const editorSettings = editorOptions.settings;
|
||||
useEffect(() => {
|
||||
api.editor.updateSettings(editorSettings);
|
||||
api.updateSettings(editorSettings);
|
||||
}, [api, editorSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
api.updatePlugins(codeMirrorPlugins);
|
||||
}, [codeMirrorPlugins, api]);
|
||||
|
||||
return useMemo(() => ({
|
||||
pageSetup: {
|
||||
js: injectedJavaScript,
|
||||
css: '',
|
||||
},
|
||||
hasPlugins: codeMirrorPlugins.length > 0,
|
||||
api,
|
||||
webViewEventHandlers,
|
||||
}), [injectedJavaScript, api, webViewEventHandlers]);
|
||||
}), [injectedJavaScript, api, webViewEventHandlers, codeMirrorPlugins]);
|
||||
};
|
||||
|
||||
export default useWebViewSetup;
|
||||
|
@@ -12,6 +12,7 @@ const defaultRendererSettings: RenderSettings = {
|
||||
noteHash: '',
|
||||
initialScroll: 0,
|
||||
readAssetBlob: async (_path: string) => new Blob(),
|
||||
removeUnusedPluginAssets: true,
|
||||
|
||||
createEditPopupSyntax: '',
|
||||
destroyEditPopupSyntax: '',
|
||||
|
@@ -23,6 +23,7 @@ export interface RenderSettings {
|
||||
initialScroll: number;
|
||||
// If [null], plugin assets are not added to the document
|
||||
pluginAssetContainerSelector: string|null;
|
||||
removeUnusedPluginAssets: boolean;
|
||||
|
||||
splitted?: boolean; // Move CSS into a separate output
|
||||
mapsToLine?: boolean; // Sourcemaps
|
||||
@@ -156,6 +157,7 @@ export default class Renderer {
|
||||
inlineAssets: this.setupOptions_.useTransferredFiles,
|
||||
readAssetBlob: settings.readAssetBlob,
|
||||
container: document.querySelector(settings.pluginAssetContainerSelector),
|
||||
removeUnusedPluginAssets: settings.removeUnusedPluginAssets,
|
||||
});
|
||||
|
||||
// Some plugins require this event to be dispatched just after being added.
|
||||
|
@@ -39,6 +39,7 @@ const rewriteInternalAssetLinks = async (asset: RenderResultPluginAsset, content
|
||||
|
||||
interface Options {
|
||||
inlineAssets: boolean;
|
||||
removeUnusedPluginAssets: boolean;
|
||||
container: HTMLElement;
|
||||
readAssetBlob?(path: string): Promise<Blob>;
|
||||
}
|
||||
@@ -137,16 +138,22 @@ const addPluginAssets = async (assets: RenderResultPluginAsset[], options: Optio
|
||||
// light to dark theme, and then back to light theme - in that case
|
||||
// the viewer would remain dark because it would use the dark
|
||||
// stylesheet that would still be in the DOM.
|
||||
for (const [assetId, asset] of Object.entries(pluginAssetsAdded_)) {
|
||||
if (!processedAssetIds.includes(assetId)) {
|
||||
try {
|
||||
asset.element.remove();
|
||||
} catch (error) {
|
||||
// We don't throw an exception but we log it since
|
||||
// it shouldn't happen
|
||||
console.warn('Tried to remove an asset but got an error', error);
|
||||
//
|
||||
// In some cases, however, we only want to rerender part of the document.
|
||||
// In this case, old plugin assets may have been from the last full-page
|
||||
// render and should not be removed.
|
||||
if (options.removeUnusedPluginAssets) {
|
||||
for (const [assetId, asset] of Object.entries(pluginAssetsAdded_)) {
|
||||
if (!processedAssetIds.includes(assetId)) {
|
||||
try {
|
||||
asset.element.remove();
|
||||
} catch (error) {
|
||||
// We don't throw an exception but we log it since
|
||||
// it shouldn't happen
|
||||
console.warn('Tried to remove an asset but got an error', error);
|
||||
}
|
||||
pluginAssetsAdded_[assetId] = null;
|
||||
}
|
||||
pluginAssetsAdded_[assetId] = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@@ -54,8 +54,13 @@ export interface RenderOptions {
|
||||
highlightedKeywords: string[];
|
||||
resources: ResourceInfos;
|
||||
themeOverrides: Record<string, string|number>;
|
||||
|
||||
// If null, plugin assets will not be added to the document.
|
||||
pluginAssetContainerSelector: string|null;
|
||||
// When true, plugin assets are removed from the container when not used by the render result.
|
||||
// This should be true for full-page renders.
|
||||
removeUnusedPluginAssets: boolean;
|
||||
|
||||
noteHash: string;
|
||||
initialScroll: number;
|
||||
|
||||
|
@@ -219,6 +219,7 @@ const useWebViewSetup = (props: Props): SetUpResult<RendererControl> => {
|
||||
}
|
||||
return shim.fsDriver().fileAtPath(resolvedPath);
|
||||
},
|
||||
removeUnusedPluginAssets: options.removeUnusedPluginAssets,
|
||||
};
|
||||
|
||||
await transferResources(options.resources);
|
||||
|
@@ -7,6 +7,8 @@ import '@joplin/editor/ProseMirror/styles';
|
||||
import readFileToBase64 from '../../utils/readFileToBase64';
|
||||
import { EditorLanguageType } from '@joplin/editor/types';
|
||||
import convertHtmlToMarkdown from './convertHtmlToMarkdown';
|
||||
import { ExportedWebViewGlobals as MarkdownEditorWebViewGlobals } from '../../markdownEditorBundle/types';
|
||||
import { EditorEventType } from '@joplin/editor/events';
|
||||
|
||||
const postprocessHtml = (html: HTMLElement) => {
|
||||
// Fix resource URLs
|
||||
@@ -16,22 +18,6 @@ const postprocessHtml = (html: HTMLElement) => {
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -51,12 +37,16 @@ const htmlToMarkdown = (html: HTMLElement): string => {
|
||||
return convertHtmlToMarkdown(html);
|
||||
};
|
||||
|
||||
export const initialize = async ({
|
||||
settings,
|
||||
initialText,
|
||||
initialNoteId,
|
||||
parentElementClassName,
|
||||
}: EditorProps) => {
|
||||
export const initialize = async (
|
||||
{
|
||||
settings,
|
||||
initialText,
|
||||
initialNoteId,
|
||||
parentElementClassName,
|
||||
initialSearch,
|
||||
}: EditorProps,
|
||||
markdownEditorApi: MarkdownEditorWebViewGlobals,
|
||||
) => {
|
||||
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');
|
||||
@@ -72,6 +62,7 @@ export const initialize = async ({
|
||||
settings,
|
||||
initialText,
|
||||
initialNoteId,
|
||||
onLocalize: messenger.remoteApi.onLocalize,
|
||||
|
||||
onPasteFile: async (data) => {
|
||||
const base64 = await readFileToBase64(data);
|
||||
@@ -84,14 +75,20 @@ export const initialize = async ({
|
||||
void messenger.remoteApi.onEditorEvent(event);
|
||||
},
|
||||
}, {
|
||||
renderMarkupToHtml: async (markup) => {
|
||||
renderMarkupToHtml: async (markup, options) => {
|
||||
let language = MarkupLanguage.Markdown;
|
||||
if (settings.language === EditorLanguageType.Html && !options.forceMarkdown) {
|
||||
language = MarkupLanguage.Html;
|
||||
}
|
||||
|
||||
return await messenger.remoteApi.onRender({
|
||||
markup,
|
||||
language: settings.language === EditorLanguageType.Html ? MarkupLanguage.Html : MarkupLanguage.Markdown,
|
||||
language,
|
||||
}, {
|
||||
pluginAssetContainerSelector: `#${assetContainer.id}`,
|
||||
splitted: true,
|
||||
mapsToLine: true,
|
||||
removeUnusedPluginAssets: options.isFullPageRender,
|
||||
});
|
||||
},
|
||||
renderHtmlToMarkup: (node) => {
|
||||
@@ -117,7 +114,20 @@ export const initialize = async ({
|
||||
return postprocessHtml(html).outerHTML;
|
||||
}
|
||||
},
|
||||
}, (parent, language, onChange) => {
|
||||
return markdownEditorApi.createEditorWithParent({
|
||||
initialText: '',
|
||||
initialNoteId: '',
|
||||
parentElementOrClassName: parent,
|
||||
settings: { ...editor.getSettings(), language },
|
||||
onEvent: (event) => {
|
||||
if (event.kind === EditorEventType.Change) {
|
||||
onChange(event.value);
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
editor.setSearchState(initialSearch);
|
||||
|
||||
messenger.setLocalInterface({
|
||||
editor,
|
||||
|
@@ -1,10 +1,11 @@
|
||||
import { EditorEvent } from '@joplin/editor/events';
|
||||
import { EditorControl, EditorSettings } from '@joplin/editor/types';
|
||||
import { EditorControl, EditorSettings, OnLocalize, SearchState } from '@joplin/editor/types';
|
||||
import { MarkupRecord, RendererControl } from '../rendererBundle/types';
|
||||
import { RenderResult } from '@joplin/renderer/types';
|
||||
|
||||
export interface EditorProps {
|
||||
initialText: string;
|
||||
initialSearch: SearchState;
|
||||
initialNoteId: string;
|
||||
parentElementClassName: string;
|
||||
settings: EditorSettings;
|
||||
@@ -18,6 +19,7 @@ type RenderOptionsSlice = {
|
||||
pluginAssetContainerSelector: string;
|
||||
splitted: boolean;
|
||||
mapsToLine: true;
|
||||
removeUnusedPluginAssets: boolean;
|
||||
};
|
||||
|
||||
export interface MainProcessApi {
|
||||
@@ -25,6 +27,7 @@ export interface MainProcessApi {
|
||||
logMessage(message: string): Promise<void>;
|
||||
onRender(markup: MarkupRecord, options: RenderOptionsSlice): Promise<RenderResult>;
|
||||
onPasteFile(type: string, base64: string): Promise<void>;
|
||||
onLocalize: OnLocalize;
|
||||
}
|
||||
|
||||
export interface RichTextEditorControl {
|
||||
|
@@ -4,6 +4,7 @@ import { SetUpResult } from '../types';
|
||||
import { EditorControl, EditorSettings } from '@joplin/editor/types';
|
||||
import RNToWebViewMessenger from '../../utils/ipc/RNToWebViewMessenger';
|
||||
import { EditorProcessApi, EditorProps, MainProcessApi } from './types';
|
||||
import useMarkdownEditorSetup from '../markdownEditorBundle/useWebViewSetup';
|
||||
import useRendererSetup from '../rendererBundle/useWebViewSetup';
|
||||
import { EditorEvent } from '@joplin/editor/events';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
@@ -11,6 +12,8 @@ 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';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { defaultSearchState } from '../../components/NoteEditor/SearchPanel';
|
||||
|
||||
const logger = Logger.create('useWebViewSetup');
|
||||
|
||||
@@ -19,6 +22,7 @@ interface Props {
|
||||
noteId: string;
|
||||
settings: EditorSettings;
|
||||
parentElementClassName: string;
|
||||
globalSearch: string;
|
||||
themeId: number;
|
||||
pluginStates: PluginStates;
|
||||
noteResources: ResourceInfos;
|
||||
@@ -48,6 +52,7 @@ const useMessenger = (props: UseMessengerProps) => {
|
||||
noteHash: '',
|
||||
initialScroll: 0,
|
||||
pluginAssetContainerSelector: null,
|
||||
removeUnusedPluginAssets: true,
|
||||
};
|
||||
|
||||
return useMemo(() => {
|
||||
@@ -68,6 +73,7 @@ const useMessenger = (props: UseMessengerProps) => {
|
||||
splitted: options.splitted,
|
||||
pluginAssetContainerSelector: options.pluginAssetContainerSelector,
|
||||
mapsToLine: options.mapsToLine,
|
||||
removeUnusedPluginAssets: options.removeUnusedPluginAssets,
|
||||
},
|
||||
);
|
||||
return renderResult;
|
||||
@@ -75,6 +81,7 @@ const useMessenger = (props: UseMessengerProps) => {
|
||||
onPasteFile: async (type: string, base64: string) => {
|
||||
onAttachRef.current(type, base64);
|
||||
},
|
||||
onLocalize: _,
|
||||
};
|
||||
|
||||
const messenger = new RNToWebViewMessenger<MainProcessApi, EditorProcessApi>(
|
||||
@@ -86,7 +93,10 @@ const useMessenger = (props: UseMessengerProps) => {
|
||||
}, [props.webviewRef]);
|
||||
};
|
||||
|
||||
type UseSourceProps = Props & { renderer: SetUpResult<RendererControl> };
|
||||
type UseSourceProps = Props & {
|
||||
renderer: SetUpResult<RendererControl>;
|
||||
markdownEditor: SetUpResult<unknown>;
|
||||
};
|
||||
|
||||
const useSource = (props: UseSourceProps) => {
|
||||
const propsRef = useRef(props);
|
||||
@@ -94,12 +104,18 @@ const useSource = (props: UseSourceProps) => {
|
||||
|
||||
const rendererJs = props.renderer.pageSetup.js;
|
||||
const rendererCss = props.renderer.pageSetup.css;
|
||||
const markdownEditorJs = props.markdownEditor.pageSetup.js;
|
||||
const markdownEditorCss = props.markdownEditor.pageSetup.css;
|
||||
|
||||
return useMemo(() => {
|
||||
const editorOptions: EditorProps = {
|
||||
parentElementClassName: propsRef.current.parentElementClassName,
|
||||
initialText: propsRef.current.initialText,
|
||||
initialNoteId: propsRef.current.noteId,
|
||||
initialSearch: {
|
||||
...defaultSearchState,
|
||||
searchText: propsRef.current.globalSearch,
|
||||
},
|
||||
settings: propsRef.current.settings,
|
||||
};
|
||||
|
||||
@@ -107,6 +123,7 @@ const useSource = (props: UseSourceProps) => {
|
||||
css: `
|
||||
${shim.injectedCss('richTextEditorBundle')}
|
||||
${rendererCss}
|
||||
${markdownEditorCss}
|
||||
|
||||
/* Increase the size of the editor to make it easier to focus the editor. */
|
||||
.prosemirror-editor {
|
||||
@@ -115,19 +132,23 @@ const useSource = (props: UseSourceProps) => {
|
||||
`,
|
||||
js: `
|
||||
${rendererJs}
|
||||
${markdownEditorJs}
|
||||
|
||||
if (!window.richTextEditorCreated) {
|
||||
window.richTextEditorCreated = true;
|
||||
${shim.injectedJs('richTextEditorBundle')}
|
||||
richTextEditorBundle.setUpLogger();
|
||||
richTextEditorBundle.initialize(${JSON.stringify(editorOptions)}).then(function(editor) {
|
||||
richTextEditorBundle.initialize(
|
||||
${JSON.stringify(editorOptions)},
|
||||
window,
|
||||
).then(function(editor) {
|
||||
/* For testing */
|
||||
window.joplinRichTextEditor_ = editor;
|
||||
});
|
||||
}
|
||||
`,
|
||||
};
|
||||
}, [rendererJs, rendererCss]);
|
||||
}, [rendererJs, rendererCss, markdownEditorCss, markdownEditorJs]);
|
||||
};
|
||||
|
||||
const useWebViewSetup = (props: Props): SetUpResult<EditorControl> => {
|
||||
@@ -138,8 +159,23 @@ const useWebViewSetup = (props: Props): SetUpResult<EditorControl> => {
|
||||
pluginStates: props.pluginStates,
|
||||
themeId: props.themeId,
|
||||
});
|
||||
const markdownEditor = useMarkdownEditorSetup({
|
||||
webviewRef: props.webviewRef,
|
||||
onAttachFile: props.onAttachFile,
|
||||
initialSelection: null,
|
||||
noteHash: '',
|
||||
globalSearch: props.globalSearch,
|
||||
editorOptions: {
|
||||
settings: props.settings,
|
||||
initialNoteId: null,
|
||||
parentElementOrClassName: '',
|
||||
initialText: '',
|
||||
},
|
||||
onEditorEvent: (_event)=>{},
|
||||
pluginStates: props.pluginStates,
|
||||
});
|
||||
const messenger = useMessenger({ ...props, renderer });
|
||||
const pageSetup = useSource({ ...props, renderer });
|
||||
const pageSetup = useSource({ ...props, renderer, markdownEditor });
|
||||
|
||||
useEffect(() => {
|
||||
void messenger.remoteApi.editor.updateSettings(props.settings);
|
||||
@@ -153,14 +189,16 @@ const useWebViewSetup = (props: Props): SetUpResult<EditorControl> => {
|
||||
onLoadEnd: () => {
|
||||
messenger.onWebViewLoaded();
|
||||
renderer.webViewEventHandlers.onLoadEnd();
|
||||
markdownEditor.webViewEventHandlers.onLoadEnd();
|
||||
},
|
||||
onMessage: (event) => {
|
||||
messenger.onWebViewMessage(event);
|
||||
renderer.webViewEventHandlers.onMessage(event);
|
||||
markdownEditor.webViewEventHandlers.onMessage(event);
|
||||
},
|
||||
},
|
||||
};
|
||||
}, [messenger, pageSetup, renderer.webViewEventHandlers]);
|
||||
}, [messenger, pageSetup, renderer.webViewEventHandlers, markdownEditor.webViewEventHandlers]);
|
||||
};
|
||||
|
||||
export default useWebViewSetup;
|
||||
|
@@ -533,7 +533,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 142;
|
||||
CURRENT_PROJECT_VERSION = 143;
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Joplin/Info.plist;
|
||||
@@ -542,7 +542,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 13.4.0;
|
||||
MARKETING_VERSION = 13.4.1;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
@@ -568,7 +568,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 142;
|
||||
CURRENT_PROJECT_VERSION = 143;
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
INFOPLIST_FILE = Joplin/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
|
||||
@@ -576,7 +576,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 13.4.0;
|
||||
MARKETING_VERSION = 13.4.1;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
@@ -769,18 +769,18 @@
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 142;
|
||||
CURRENT_PROJECT_VERSION = 143;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
INFOPLIST_FILE = ShareExtension/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.4;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 13.4.0;
|
||||
MARKETING_VERSION = 13.4.1;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
OTHER_LDFLAGS = (
|
||||
@@ -812,18 +812,18 @@
|
||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 142;
|
||||
CURRENT_PROJECT_VERSION = 143;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
INFOPLIST_FILE = ShareExtension/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.4;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 13.4.0;
|
||||
MARKETING_VERSION = 13.4.1;
|
||||
MTL_FAST_MATH = YES;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
|
@@ -1890,7 +1890,7 @@ PODS:
|
||||
- React
|
||||
- RNSecureRandom (1.0.1):
|
||||
- React
|
||||
- RNShare (12.0.9):
|
||||
- RNShare (12.0.11):
|
||||
- DoubleConversion
|
||||
- glog
|
||||
- hermes-engine
|
||||
@@ -2390,7 +2390,7 @@ SPEC CHECKSUMS:
|
||||
RNLocalize: 15463c4d79c7da45230064b4adcf5e9bb984667e
|
||||
RNQuickAction: c2c8f379e614428be0babe4d53a575739667744d
|
||||
RNSecureRandom: b64d263529492a6897e236a22a2c4249aa1b53dc
|
||||
RNShare: ef61d9be34bf9881d515851d0710a023cdc4f0f4
|
||||
RNShare: 675e8e4a84f0137baf33057cac8f7334b0bb4b98
|
||||
RNVectorIcons: d53917643fddb261b22bd6d889776f336893622b
|
||||
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748
|
||||
Yoga: c758bfb934100bb4bf9cbaccb52557cee35e8bdf
|
||||
|
@@ -58,7 +58,7 @@
|
||||
"react-native-file-viewer": "2.1.5",
|
||||
"react-native-fs": "2.20.0",
|
||||
"react-native-get-random-values": "1.11.0",
|
||||
"react-native-image-picker": "7.2.3",
|
||||
"react-native-image-picker": "8.0.0",
|
||||
"react-native-localize": "3.4.1",
|
||||
"react-native-modal-datetime-picker": "18.0.0",
|
||||
"react-native-paper": "5.13.5",
|
||||
@@ -66,9 +66,9 @@
|
||||
"react-native-quick-actions": "0.3.13",
|
||||
"react-native-quick-crypto": "0.7.13",
|
||||
"react-native-rsa-native": "2.0.5",
|
||||
"react-native-safe-area-context": "5.4.0",
|
||||
"react-native-safe-area-context": "5.4.1",
|
||||
"react-native-securerandom": "1.0.1",
|
||||
"react-native-share": "12.0.9",
|
||||
"react-native-share": "12.0.11",
|
||||
"react-native-sqlite-storage": "6.0.1",
|
||||
"react-native-url-polyfill": "2.0.0",
|
||||
"react-native-vector-icons": "10.2.0",
|
||||
@@ -106,16 +106,16 @@
|
||||
"@testing-library/react-native": "13.2.0",
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/node": "18.19.87",
|
||||
"@types/node": "18.19.103",
|
||||
"@types/react": "19.0.14",
|
||||
"@types/react-redux": "7.1.33",
|
||||
"@types/serviceworker": "0.0.133",
|
||||
"@types/serviceworker": "0.0.135",
|
||||
"@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.20.0",
|
||||
"esbuild": "0.25.3",
|
||||
"esbuild": "0.25.4",
|
||||
"fast-deep-equal": "3.1.3",
|
||||
"fs-extra": "11.2.0",
|
||||
"gulp": "4.0.2",
|
||||
@@ -123,14 +123,14 @@
|
||||
"jest-environment-jsdom": "29.7.0",
|
||||
"jetifier": "2.0.0",
|
||||
"js-draw": "1.30.0",
|
||||
"jsdom": "26.0.0",
|
||||
"jsdom": "26.1.0",
|
||||
"nodemon": "3.1.10",
|
||||
"punycode": "2.3.1",
|
||||
"react-dom": "19.0.0",
|
||||
"react-native-web": "0.20.0",
|
||||
"react-refresh": "0.17.0",
|
||||
"react-test-renderer": "19.0.0",
|
||||
"sharp": "0.34.0",
|
||||
"sharp": "0.34.2",
|
||||
"sqlite3": "5.1.6",
|
||||
"timers-browserify": "2.0.12",
|
||||
"ts-jest": "29.3.1",
|
||||
|
@@ -14,8 +14,10 @@ import { vim } from '@replit/codemirror-vim';
|
||||
import { indentUnit } from '@codemirror/language';
|
||||
import { Prec } from '@codemirror/state';
|
||||
import insertNewlineContinueMarkup from './editorCommands/insertNewlineContinueMarkup';
|
||||
import renderingExtension from './extensions/rendering/renderingExtension';
|
||||
import { RenderedContentContext } from './extensions/rendering/types';
|
||||
|
||||
const configFromSettings = (settings: EditorSettings) => {
|
||||
const configFromSettings = (settings: EditorSettings, context: RenderedContentContext) => {
|
||||
const languageExtension = (() => {
|
||||
const openingBrackets = '`([{\'"‘“(《「『【〔〖〘〚'.split('');
|
||||
|
||||
@@ -84,6 +86,12 @@ const configFromSettings = (settings: EditorSettings) => {
|
||||
extensions.push(Prec.low(keymap.of(defaultKeymap)));
|
||||
}
|
||||
|
||||
if (settings.inlineRenderingEnabled) {
|
||||
extensions.push(renderingExtension(context, {
|
||||
renderImages: settings.imageRenderingEnabled,
|
||||
}));
|
||||
}
|
||||
|
||||
return extensions;
|
||||
};
|
||||
|
||||
|
@@ -40,7 +40,9 @@ describe('createEditor', () => {
|
||||
settings: editorSettings,
|
||||
onEvent: _event => {},
|
||||
onLogMessage: _message => {},
|
||||
onLocalize: input => input,
|
||||
onPasteFile: null,
|
||||
resolveImageSrc: src => Promise.resolve(src),
|
||||
});
|
||||
|
||||
// Force the generation of the syntax tree now.
|
||||
@@ -69,7 +71,9 @@ describe('createEditor', () => {
|
||||
settings: editorSettings,
|
||||
onEvent: _event => {},
|
||||
onLogMessage: _message => {},
|
||||
onLocalize: input => input,
|
||||
onPasteFile: null,
|
||||
resolveImageSrc: src=>Promise.resolve(src),
|
||||
});
|
||||
|
||||
const getContentScriptJs = jest.fn(async () => {
|
||||
@@ -138,7 +142,9 @@ describe('createEditor', () => {
|
||||
settings: editorSettings,
|
||||
onEvent: _event => {},
|
||||
onLogMessage: _message => {},
|
||||
onLocalize: input => input,
|
||||
onPasteFile: null,
|
||||
resolveImageSrc: src=>Promise.resolve(src),
|
||||
});
|
||||
|
||||
const getContentScriptJs = jest.fn(async () => {
|
||||
@@ -188,7 +194,9 @@ describe('createEditor', () => {
|
||||
settings: editorSettings,
|
||||
onEvent: () => {},
|
||||
onLogMessage: () => {},
|
||||
onLocalize: input => input,
|
||||
onPasteFile: null,
|
||||
resolveImageSrc: src=>Promise.resolve(src),
|
||||
});
|
||||
const editorState = editor.editor.state;
|
||||
const idFacet = editor.joplinExtensions.noteIdFacet;
|
||||
|
@@ -36,6 +36,10 @@ import isCursorAtBeginning from './utils/isCursorAtBeginning';
|
||||
import overwriteModeExtension from './extensions/overwriteModeExtension';
|
||||
import handleLinkEditRequests, { showLinkEditor } from './utils/handleLinkEditRequests';
|
||||
import selectedNoteIdExtension, { setNoteIdEffect } from './extensions/selectedNoteIdExtension';
|
||||
import ctrlKeyStateClassExtension from './extensions/modifierKeyCssExtension';
|
||||
import ctrlClickLinksExtension from './extensions/links/ctrlClickLinksExtension';
|
||||
import { RenderedContentContext } from './extensions/rendering/types';
|
||||
import ctrlClickCheckboxExtension from './extensions/ctrlClickCheckboxExtension';
|
||||
|
||||
// Newer versions of CodeMirror by default use Chrome's EditContext API.
|
||||
// While this might be stable enough for desktop use, it causes significant
|
||||
@@ -47,14 +51,26 @@ import selectedNoteIdExtension, { setNoteIdEffect } from './extensions/selectedN
|
||||
type ExtendedEditorView = typeof EditorView & { EDIT_CONTEXT: boolean };
|
||||
(EditorView as ExtendedEditorView).EDIT_CONTEXT = false;
|
||||
|
||||
export type ResolveImageCallback = (imageSrc: string)=> Promise<string>;
|
||||
|
||||
interface CodeMirrorProps {
|
||||
resolveImageSrc: ResolveImageCallback;
|
||||
}
|
||||
|
||||
const createEditor = (
|
||||
parentElement: HTMLElement, props: EditorProps,
|
||||
parentElement: HTMLElement, props: EditorProps&CodeMirrorProps,
|
||||
): CodeMirrorControl => {
|
||||
const initialText = props.initialText;
|
||||
let settings = props.settings;
|
||||
|
||||
props.onLogMessage('Initializing CodeMirror...');
|
||||
|
||||
const context: RenderedContentContext = {
|
||||
resolveImageSrc: (src) => {
|
||||
return props.resolveImageSrc(src);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
// Handles firing an event when the undo/redo stack changes
|
||||
let schedulePostUndoRedoDepthChangeId_: ReturnType<typeof setTimeout>|null = null;
|
||||
@@ -228,7 +244,7 @@ const createEditor = (
|
||||
extensions: [
|
||||
keymapConfig,
|
||||
|
||||
dynamicConfig.of(configFromSettings(props.settings)),
|
||||
dynamicConfig.of(configFromSettings(props.settings, context)),
|
||||
historyCompartment.of(history()),
|
||||
searchExtension(props.onEvent, props.settings),
|
||||
|
||||
@@ -237,6 +253,10 @@ const createEditor = (
|
||||
EditorState.allowMultipleSelections.of(true),
|
||||
rectangularSelection(),
|
||||
drawSelection(),
|
||||
ctrlClickLinksExtension(link => {
|
||||
props.onEvent({ kind: EditorEventType.FollowLink, link });
|
||||
}),
|
||||
ctrlClickCheckboxExtension(),
|
||||
|
||||
highlightSpecialChars(),
|
||||
indentOnInput(),
|
||||
@@ -274,6 +294,7 @@ const createEditor = (
|
||||
|
||||
biDirectionalTextExtension,
|
||||
overwriteModeExtension,
|
||||
ctrlKeyStateClassExtension,
|
||||
|
||||
selectedNoteIdExtension,
|
||||
|
||||
@@ -320,7 +341,7 @@ const createEditor = (
|
||||
settings = newSettings;
|
||||
editor.dispatch({
|
||||
effects: dynamicConfig.reconfigure(
|
||||
configFromSettings(newSettings),
|
||||
configFromSettings(newSettings, context),
|
||||
),
|
||||
});
|
||||
},
|
||||
@@ -332,6 +353,9 @@ const createEditor = (
|
||||
onLogMessage: props.onLogMessage,
|
||||
onRemove: () => {
|
||||
editor.destroy();
|
||||
props.onEvent({
|
||||
kind: EditorEventType.Remove,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
@@ -0,0 +1,33 @@
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { Prec } from '@codemirror/state';
|
||||
|
||||
const hasMultipleCursors = (view: EditorView) => {
|
||||
return view.state.selection.ranges.length > 1;
|
||||
};
|
||||
|
||||
type OnCtrlClick = (view: EditorView, event: MouseEvent)=> boolean;
|
||||
|
||||
const ctrlClickActionExtension = (onCtrlClick: OnCtrlClick) => {
|
||||
return [
|
||||
Prec.high([
|
||||
EditorView.domEventHandlers({
|
||||
mousedown: (event: MouseEvent, view: EditorView) => {
|
||||
const hasModifier = event.ctrlKey || event.metaKey;
|
||||
// The default CodeMirror action for ctrl-click is to add another cursor
|
||||
// to the document. If the user already has multiple cursors, assume that
|
||||
// the ctrl-click action is intended to add another.
|
||||
if (hasModifier && !hasMultipleCursors(view)) {
|
||||
const handled = onCtrlClick(view, event);
|
||||
if (handled) {
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
]),
|
||||
];
|
||||
};
|
||||
|
||||
export default ctrlClickActionExtension;
|
@@ -0,0 +1,30 @@
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import modifierKeyCssExtension from './modifierKeyCssExtension';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import getCheckboxAtPosition from '../utils/markdown/getCheckboxAtPosition';
|
||||
import toggleCheckboxAt from '../utils/markdown/toggleCheckboxAt';
|
||||
import ctrlClickActionExtension from './ctrlClickActionExtension';
|
||||
|
||||
|
||||
const ctrlClickCheckboxExtension = () => {
|
||||
return [
|
||||
modifierKeyCssExtension,
|
||||
EditorView.theme({
|
||||
'&.-ctrl-or-cmd-pressed .cm-taskMarker': {
|
||||
cursor: 'pointer',
|
||||
},
|
||||
}),
|
||||
ctrlClickActionExtension((view, event) => {
|
||||
const target = view.posAtCoords(event);
|
||||
const taskMarker = getCheckboxAtPosition(target, syntaxTree(view.state));
|
||||
|
||||
if (taskMarker) {
|
||||
toggleCheckboxAt(target)(view);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}),
|
||||
];
|
||||
};
|
||||
|
||||
export default ctrlClickCheckboxExtension;
|
@@ -0,0 +1,33 @@
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import referenceLinkStateField from './referenceLinksStateField';
|
||||
import modifierKeyCssExtension from '../modifierKeyCssExtension';
|
||||
import openLink from './utils/openLink';
|
||||
import getUrlAtPosition from './utils/getUrlAtPosition';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import ctrlClickActionExtension from '../ctrlClickActionExtension';
|
||||
|
||||
type OnOpenLink = (url: string, view: EditorView)=> void;
|
||||
|
||||
const ctrlClickLinksExtension = (onOpenExternalLink: OnOpenLink) => {
|
||||
return [
|
||||
modifierKeyCssExtension,
|
||||
referenceLinkStateField,
|
||||
EditorView.theme({
|
||||
'&.-ctrl-or-cmd-pressed .cm-url, &.-ctrl-or-cmd-pressed .tok-link': {
|
||||
cursor: 'pointer',
|
||||
},
|
||||
}),
|
||||
ctrlClickActionExtension((view: EditorView, event: MouseEvent) => {
|
||||
const target = view.posAtCoords(event);
|
||||
const url = getUrlAtPosition(target, syntaxTree(view.state), view.state);
|
||||
|
||||
if (url) {
|
||||
openLink(url.url, view, onOpenExternalLink);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}),
|
||||
];
|
||||
};
|
||||
|
||||
export default ctrlClickLinksExtension;
|
@@ -0,0 +1,26 @@
|
||||
import { forceParsing } from '@codemirror/language';
|
||||
import createTestEditor from '../../testing/createTestEditor';
|
||||
import followLinkTooltip from './followLinkTooltipExtension';
|
||||
import { EditorSelection } from '@codemirror/state';
|
||||
|
||||
describe('followLinkTooltip', () => {
|
||||
it('should show a clickable tooltip for a URL link', async () => {
|
||||
const doc = '[link](http://example.com/)';
|
||||
const onOpenLink = jest.fn();
|
||||
|
||||
const editor = await createTestEditor(doc, EditorSelection.cursor(0), [], [followLinkTooltip(url => onOpenLink(url))]);
|
||||
forceParsing(editor, editor.state.doc.length);
|
||||
|
||||
editor.dispatch({
|
||||
userEvent: 'select',
|
||||
selection: { anchor: 4 },
|
||||
});
|
||||
const tooltip = editor.dom.querySelector('.cm-md-link-tooltip');
|
||||
if (!tooltip) throw new Error('No tooltip found.');
|
||||
|
||||
const link = tooltip.querySelector('button');
|
||||
link!.click();
|
||||
|
||||
expect(onOpenLink).toHaveBeenCalledWith('http://example.com/');
|
||||
});
|
||||
});
|
@@ -0,0 +1,86 @@
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import { EditorState, StateField } from '@codemirror/state';
|
||||
import { EditorView, showTooltip, Tooltip } from '@codemirror/view';
|
||||
import referenceLinkStateField from './referenceLinksStateField';
|
||||
import getUrlAtPosition from './utils/getUrlAtPosition';
|
||||
import openLink from './utils/openLink';
|
||||
import ctrlClickLinksExtension from './ctrlClickLinksExtension';
|
||||
|
||||
|
||||
type OnOpenLink = (url: string, view: EditorView)=> void;
|
||||
|
||||
// Returns tooltips for the links under the cursor(s).
|
||||
const getLinkTooltips = (onOpenLink: OnOpenLink, state: EditorState) => {
|
||||
const tree = syntaxTree(state);
|
||||
return state.selection.ranges.map((range): Tooltip|null => {
|
||||
if (!range.empty) return null;
|
||||
const url = getUrlAtPosition(range.anchor, tree, state);
|
||||
if (!url) return null;
|
||||
|
||||
return {
|
||||
pos: range.head,
|
||||
arrow: true,
|
||||
create: (view) => {
|
||||
const dom = document.createElement('div');
|
||||
dom.classList.add('cm-md-link-tooltip');
|
||||
|
||||
const link = document.createElement('button');
|
||||
link.role = 'link';
|
||||
link.textContent = `🔗 ${url.url}${url.label ? `: ${url.label}` : ''}`;
|
||||
link.title = state.phrase('Follow link: $1', url.url);
|
||||
link.onclick = () => {
|
||||
onOpenLink(url.url, view);
|
||||
};
|
||||
|
||||
dom.appendChild(link);
|
||||
|
||||
return { dom };
|
||||
},
|
||||
};
|
||||
}).filter(tooltip => !!tooltip) as Tooltip[];
|
||||
};
|
||||
|
||||
const followLinkTooltip = (onOpenExternalLink: OnOpenLink) => {
|
||||
const onOpenLink = (link: string, view: EditorView) => {
|
||||
openLink(link, view, onOpenExternalLink);
|
||||
};
|
||||
|
||||
const followLinkTooltipField = StateField.define<readonly Tooltip[]>({
|
||||
create: state => getLinkTooltips(onOpenLink, state),
|
||||
update: (tooltips, transaction) => {
|
||||
if (!transaction.docChanged && !transaction.selection) {
|
||||
return tooltips;
|
||||
}
|
||||
|
||||
return getLinkTooltips(onOpenLink, transaction.state);
|
||||
},
|
||||
provide: field => {
|
||||
const tooltipsFromState = (state: EditorState) => state.field(field);
|
||||
return showTooltip.computeN([field], tooltipsFromState);
|
||||
},
|
||||
});
|
||||
|
||||
return [
|
||||
referenceLinkStateField,
|
||||
EditorView.theme({
|
||||
'& .cm-md-link-tooltip > button': {
|
||||
backgroundColor: 'transparent',
|
||||
border: 'transparent',
|
||||
fontSize: 'inherit',
|
||||
|
||||
whiteSpace: 'pre',
|
||||
maxWidth: '95vw',
|
||||
textOverflow: 'ellipsis',
|
||||
overflowX: 'hidden',
|
||||
|
||||
textDecoration: 'underline',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--joplin-url-color)',
|
||||
},
|
||||
}),
|
||||
followLinkTooltipField,
|
||||
ctrlClickLinksExtension(onOpenExternalLink),
|
||||
];
|
||||
};
|
||||
|
||||
export default followLinkTooltip;
|
@@ -0,0 +1,94 @@
|
||||
import { EditorState, RangeSet, Range, RangeValue, StateField, Text } from '@codemirror/state';
|
||||
|
||||
class ReferenceLinkValue extends RangeValue {
|
||||
public constructor(public readonly key: string, public readonly value: string) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export const resolveReferenceById = (referenceId: string, state: EditorState) => {
|
||||
const cursor = state.field(referenceLinkStateField).iter();
|
||||
for (; cursor.value; cursor.next()) {
|
||||
if (cursor.value.key === referenceId) {
|
||||
return cursor.value.value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const referenceLinkExp = /^(\[[^\]]+\])\s*(\[[^\]]+\])?$/;
|
||||
|
||||
export const isReferenceLink = (link: string) => {
|
||||
return !!link.trim().match(referenceLinkExp);
|
||||
};
|
||||
|
||||
export const resolveReferenceFromLink = (link: string, state: EditorState) => {
|
||||
const referenceMatch = link.trim().match(referenceLinkExp);
|
||||
if (!referenceMatch) return null;
|
||||
|
||||
const resolved = resolveReferenceById(referenceMatch[2] ?? referenceMatch[1], state);
|
||||
return resolved?.trim() ?? null;
|
||||
};
|
||||
|
||||
|
||||
// Returns the key and value for a link reference definition in the form
|
||||
// [a test]: http://some/def/here/
|
||||
const parseReferenceDef = (lineText: string) => {
|
||||
const linkStart = lineText.match(/^(\[[^[\]]+\]):/);
|
||||
if (!linkStart) return null;
|
||||
|
||||
const key = linkStart[1];
|
||||
return {
|
||||
key,
|
||||
value: lineText.substring(linkStart[0].length),
|
||||
};
|
||||
};
|
||||
|
||||
const addReferencesToSet = (set: RangeSet<ReferenceLinkValue>, fromIdx: number, toIdx: number, doc: Text) => {
|
||||
const newRanges: Range<ReferenceLinkValue>[] = [];
|
||||
|
||||
const fromLine = doc.lineAt(fromIdx);
|
||||
const toLine = doc.lineAt(toIdx);
|
||||
|
||||
for (let i = fromLine.number; i <= toLine.number; i++) {
|
||||
const line = doc.line(i);
|
||||
const parsedRef = parseReferenceDef(line.text);
|
||||
if (parsedRef) {
|
||||
newRanges.push(
|
||||
new ReferenceLinkValue(parsedRef.key, parsedRef.value).range(line.from),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return set.update({ add: newRanges });
|
||||
};
|
||||
|
||||
const referenceLinkStateField = StateField.define<RangeSet<ReferenceLinkValue>>({
|
||||
create(state): RangeSet<ReferenceLinkValue> {
|
||||
return addReferencesToSet(RangeSet.empty, 0, state.doc.length, state.doc);
|
||||
},
|
||||
update(value, transaction) {
|
||||
if (!transaction.docChanged) return value.map(transaction.changes);
|
||||
|
||||
// Remove deleted/modified definitions
|
||||
transaction.changes.iterChangedRanges((fromA, toA) => {
|
||||
value = value.update({
|
||||
filterFrom: fromA,
|
||||
filterTo: toA,
|
||||
filter: () => false,
|
||||
});
|
||||
});
|
||||
|
||||
// Switch line numbers to match the new document
|
||||
value = value.map(transaction.changes);
|
||||
|
||||
transaction.changes.iterChangedRanges((_fromA, _fromB, fromB, toB) => {
|
||||
value = addReferencesToSet(value, fromB, toB, transaction.newDoc);
|
||||
});
|
||||
|
||||
return value;
|
||||
},
|
||||
});
|
||||
|
||||
export default referenceLinkStateField;
|
@@ -0,0 +1,36 @@
|
||||
import { EditorSelection } from '@codemirror/state';
|
||||
import createTestEditor from '../../../testing/createTestEditor';
|
||||
import findLineMatchingLink from './findLineMatchingLink';
|
||||
|
||||
describe('findLineMatchingLink', () => {
|
||||
test.each([
|
||||
// Should match headings
|
||||
['# Heading\n', '#heading', 1],
|
||||
['# Heading', '#heading', 1],
|
||||
['## Heading', '#heading', 1],
|
||||
['### Heading', '#heading', 1],
|
||||
// Should match headings not on the first line
|
||||
['\n### Heading', '#heading', 2],
|
||||
['# Test\n\n### Heading', '#heading', 3],
|
||||
['# Test\n\n### Heading\n\ntest', '#heading', 3],
|
||||
// Should return null when there are no matches
|
||||
['# Heading', '#missing-heading', null],
|
||||
|
||||
// Should match footnotes
|
||||
['[^1]: Footnote!\n', '[^1]', 1],
|
||||
['[^1]: Footnote!\n[^2]: Other footnote.', '[^1]', 1],
|
||||
['# ^1\n[^1]: Footnote!\n[^2]: Other footnote.', '[^1]', 2],
|
||||
['# ^1\n[^1]: Footnote!\n[^2]: Other footnote.', '[^not a footnote]', null],
|
||||
|
||||
// Should not process http:// links
|
||||
['# Test', 'http://example.com', null],
|
||||
|
||||
])('should correctly find lines matching the given link (doc: %j, link: %j) (case %#)', async (
|
||||
doc, link, expectedMatchingLine,
|
||||
) => {
|
||||
const editor = await createTestEditor(doc, EditorSelection.cursor(0), []);
|
||||
expect(
|
||||
findLineMatchingLink(link, editor.state)?.number ?? null,
|
||||
).toBe(expectedMatchingLine);
|
||||
});
|
||||
});
|
@@ -0,0 +1,36 @@
|
||||
import { EditorState, Line } from '@codemirror/state';
|
||||
import uslug from '@joplin/fork-uslug/lib/uslug';
|
||||
|
||||
// Searches the given `state` for a line that matches the target link.
|
||||
const findLineMatchingLink = (link: string, state: EditorState): Line|null => {
|
||||
const isAnchorLink = link.startsWith('#');
|
||||
const isFootnote = link.startsWith('[^') && link.endsWith(']');
|
||||
|
||||
if (!isAnchorLink && !isFootnote) return null;
|
||||
|
||||
const matchesLine = (line: string) => {
|
||||
if (isAnchorLink) {
|
||||
line = line.replace(/^#+/, '').trim();
|
||||
return uslug(line) === link.substring(1);
|
||||
} else if (isFootnote) {
|
||||
return line.trim().startsWith(`${link}:`);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
let iterator = state.doc.iterLines();
|
||||
let lineNumber = 0;
|
||||
while (!iterator.done && lineNumber <= state.doc.lines) {
|
||||
lineNumber ++;
|
||||
iterator = iterator.next();
|
||||
const line = iterator.value;
|
||||
|
||||
if (matchesLine(line)) {
|
||||
return state.doc.line(lineNumber);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default findLineMatchingLink;
|
@@ -0,0 +1,53 @@
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import { resolveReferenceFromLink } from '../referenceLinksStateField';
|
||||
import { SyntaxNodeRef, Tree } from '@lezer/common';
|
||||
|
||||
enum MatchedUrlType {
|
||||
Footnote,
|
||||
Link,
|
||||
}
|
||||
|
||||
type MatchedUrl = {
|
||||
type: MatchedUrlType;
|
||||
url: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
const getUrlAtPosition = (pos: number, tree: Tree, state: EditorState): MatchedUrl|null => {
|
||||
const nodeText = (node: SyntaxNodeRef) => {
|
||||
return state.doc.sliceString(node.from, node.to);
|
||||
};
|
||||
|
||||
let iterator = tree.resolveStack(pos);
|
||||
|
||||
while (true) {
|
||||
if (iterator.node.name === 'Link') {
|
||||
const urlNode = iterator.node.getChild('URL');
|
||||
if (urlNode) {
|
||||
return { type: MatchedUrlType.Link, url: nodeText(urlNode) };
|
||||
}
|
||||
const fullLinkText = nodeText(iterator.node);
|
||||
const referenceLink = resolveReferenceFromLink(fullLinkText, state);
|
||||
if (referenceLink) {
|
||||
const isFootnote = fullLinkText.match(/^\[\^\d+\]$/);
|
||||
if (isFootnote) {
|
||||
return { type: MatchedUrlType.Footnote, url: fullLinkText, label: referenceLink };
|
||||
} else {
|
||||
return { type: MatchedUrlType.Link, url: referenceLink };
|
||||
}
|
||||
}
|
||||
} else if (iterator.node.name === 'URL') {
|
||||
return { type: MatchedUrlType.Link, url: nodeText(iterator.node) };
|
||||
}
|
||||
|
||||
if (!iterator.next) {
|
||||
break;
|
||||
} else {
|
||||
iterator = iterator.next;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default getUrlAtPosition;
|
@@ -0,0 +1,22 @@
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import findLineMatchingLink from './findLineMatchingLink';
|
||||
|
||||
export type OnOpenExternalLink = (url: string, view: EditorView)=> void;
|
||||
const openLink = (link: string, view: EditorView, onOpenExternalLink: OnOpenExternalLink) => {
|
||||
const targetLine = findLineMatchingLink(link, view.state);
|
||||
if (targetLine) {
|
||||
view.dispatch({
|
||||
selection: { anchor: targetLine.to },
|
||||
scrollIntoView: true,
|
||||
effects: [
|
||||
EditorView.announce.of(`Jumped to line ${targetLine.number}`),
|
||||
],
|
||||
});
|
||||
// eslint-disable-next-line no-restricted-properties -- Old code from before rule was applied
|
||||
view.focus();
|
||||
} else {
|
||||
onOpenExternalLink(link, view);
|
||||
}
|
||||
};
|
||||
|
||||
export default openLink;
|
@@ -0,0 +1,45 @@
|
||||
import { StateEffect, StateField, Transaction } from '@codemirror/state';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
|
||||
const ctrlOrMetaChangedEffect = StateEffect.define<boolean>();
|
||||
|
||||
const ctrlOrMetaPressedField = StateField.define<boolean>({
|
||||
create: () => false,
|
||||
update: (value: boolean, transaction: Transaction) => {
|
||||
const toggleEffect = transaction.effects.find(effect => effect.is(ctrlOrMetaChangedEffect));
|
||||
if (toggleEffect) {
|
||||
return toggleEffect.value;
|
||||
}
|
||||
return value;
|
||||
},
|
||||
provide: (field) => [
|
||||
EditorView.editorAttributes.from(field, on => ({
|
||||
class: on ? '-ctrl-or-cmd-pressed' : '',
|
||||
})),
|
||||
...(() => {
|
||||
const onEvent = (event: KeyboardEvent|MouseEvent, view: EditorView) => {
|
||||
const ctrlOrCmdPressed = event.ctrlKey || event.metaKey;
|
||||
if (ctrlOrCmdPressed !== view.state.field(ctrlOrMetaPressedField)) {
|
||||
view.dispatch({
|
||||
effects: [
|
||||
ctrlOrMetaChangedEffect.of(ctrlOrCmdPressed),
|
||||
],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return [
|
||||
EditorView.domEventObservers({
|
||||
keydown: onEvent,
|
||||
keyup: onEvent,
|
||||
mouseenter: onEvent,
|
||||
mousemove: onEvent,
|
||||
}),
|
||||
];
|
||||
})(),
|
||||
],
|
||||
});
|
||||
|
||||
export default [
|
||||
ctrlOrMetaPressedField,
|
||||
];
|
@@ -0,0 +1,31 @@
|
||||
import { Decoration, EditorView } from '@codemirror/view';
|
||||
import makeInlineReplaceExtension from './utils/makeInlineReplaceExtension';
|
||||
|
||||
const linkClassName = 'cm-ext-unfocused-link';
|
||||
const urlMarkDecoration = Decoration.mark({ class: linkClassName });
|
||||
const strikethroughClassName = 'cm-ext-strikethrough';
|
||||
const strikethroughMarkDecoration = Decoration.mark({ class: strikethroughClassName });
|
||||
|
||||
const addFormattingClasses = [
|
||||
EditorView.theme({
|
||||
[`& .${linkClassName}, & .${linkClassName} span`]: {
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
[`& .${strikethroughClassName}, & .${strikethroughClassName} span`]: {
|
||||
textDecoration: 'line-through',
|
||||
},
|
||||
}),
|
||||
makeInlineReplaceExtension({
|
||||
createDecoration: (node) => {
|
||||
if (node.name === 'URL' || node.name === 'Link') {
|
||||
return urlMarkDecoration;
|
||||
}
|
||||
if (node.name === 'Strikethrough') {
|
||||
return strikethroughMarkDecoration;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
export default addFormattingClasses;
|
@@ -0,0 +1,42 @@
|
||||
import { EditorSelection } from '@codemirror/state';
|
||||
import createTestEditor from '../../testing/createTestEditor';
|
||||
import renderBlockImages from './renderBlockImages';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
|
||||
const createEditor = (initialMarkdown: string, hasImage: boolean) => {
|
||||
const resolveImageSrc = jest.fn(src => Promise.resolve(src));
|
||||
return createTestEditor(
|
||||
initialMarkdown,
|
||||
EditorSelection.cursor(0),
|
||||
hasImage ? ['Image'] : [],
|
||||
[renderBlockImages({ resolveImageSrc })],
|
||||
);
|
||||
};
|
||||
|
||||
const findImage = (editor: EditorView) => {
|
||||
return editor.dom.querySelector('div.cm-md-image > .image');
|
||||
};
|
||||
|
||||
describe('renderBlockImages', () => {
|
||||
test.each([
|
||||
{ spaceBefore: '', spaceAfter: '\n\n', alt: 'test' },
|
||||
{ spaceBefore: '', spaceAfter: '', alt: 'This is a test!' },
|
||||
{ spaceBefore: ' ', spaceAfter: ' ', alt: 'test' },
|
||||
{ spaceBefore: '', spaceAfter: '', alt: '!!!!' },
|
||||
])('should render images below their Markdown source (case %#)', async ({ spaceBefore, spaceAfter, alt }) => {
|
||||
const editor = await createEditor(`${spaceBefore}${spaceAfter}`, true);
|
||||
|
||||
const image = findImage(editor);
|
||||
expect(image).toBeTruthy();
|
||||
expect(image.role).toBe('image');
|
||||
expect(image.ariaLabel).toBe(alt);
|
||||
});
|
||||
|
||||
// For now, only Joplin resources are rendered. This simplifies the implementation and avoids
|
||||
// potentially-unwanted web requests when opening a note with only the editor open.
|
||||
test('should not render web images', async () => {
|
||||
const editor = await createEditor('\n\n', true);
|
||||
const image = findImage(editor);
|
||||
expect(image).toBeNull();
|
||||
});
|
||||
});
|
@@ -0,0 +1,134 @@
|
||||
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
|
||||
import { SyntaxNodeRef } from '@lezer/common';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import { RenderedContentContext } from './types';
|
||||
import makeBlockReplaceExtension from './utils/makeBlockReplaceExtension';
|
||||
|
||||
const imageClassName = 'cm-md-image';
|
||||
// Pre-set the image height for performance (allows CodeMirror to better calculate
|
||||
// the document height while scrolling).
|
||||
const imageHeight = 200;
|
||||
|
||||
class ImageWidget extends WidgetType {
|
||||
private resolvedSrc_: string;
|
||||
|
||||
public constructor(
|
||||
private readonly context_: RenderedContentContext,
|
||||
private readonly src_: string,
|
||||
private readonly alt_: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
public eq(other: ImageWidget) {
|
||||
return this.src_ === other.src_ && this.alt_ === other.alt_;
|
||||
}
|
||||
|
||||
public toDOM() {
|
||||
const container = document.createElement('div');
|
||||
container.classList.add(imageClassName);
|
||||
|
||||
const image = document.createElement('div');
|
||||
image.role = 'image';
|
||||
image.ariaLabel = this.alt_;
|
||||
image.classList.add('image');
|
||||
|
||||
const updateImageUrl = () => {
|
||||
if (this.resolvedSrc_) {
|
||||
// Use a background-image style property rather than img[src=]. This
|
||||
// simplifies setting the image to the correct size/position.
|
||||
image.style.backgroundImage = `url(${JSON.stringify(this.resolvedSrc_)})`;
|
||||
}
|
||||
};
|
||||
|
||||
if (!this.resolvedSrc_) {
|
||||
void (async () => {
|
||||
this.resolvedSrc_ = await this.context_.resolveImageSrc(this.src_);
|
||||
updateImageUrl();
|
||||
})();
|
||||
} else {
|
||||
updateImageUrl();
|
||||
}
|
||||
|
||||
container.appendChild(image);
|
||||
return container;
|
||||
}
|
||||
|
||||
public get estimatedHeight() {
|
||||
return imageHeight;
|
||||
}
|
||||
}
|
||||
|
||||
const getImageSrc = (node: SyntaxNodeRef, state: EditorState) => {
|
||||
const nodeText = state.sliceDoc(node.from, node.to);
|
||||
// For now, only render Joplin resource images (avoid auto-fetching images from
|
||||
// the internet if just the Markdown editor is open).
|
||||
const match = nodeText.match(/:\/[a-zA-Z0-9]{32}/);
|
||||
if (match) {
|
||||
return match[0];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getImageAlt = (node: SyntaxNodeRef, state: EditorState) => {
|
||||
const nodeText = state.sliceDoc(node.from, node.to);
|
||||
|
||||
const match = nodeText.match(/!\s*\[(.+)\]/);
|
||||
if (match) {
|
||||
return match[1];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const renderBlockImages = (context: RenderedContentContext) => [
|
||||
EditorView.theme({
|
||||
[`& .${imageClassName} > div`]: {
|
||||
height: `${imageHeight}px`,
|
||||
backgroundSize: 'contain',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: 'center',
|
||||
display: 'block',
|
||||
},
|
||||
}),
|
||||
makeBlockReplaceExtension({
|
||||
createDecoration: (node, state) => {
|
||||
if (node.name === 'Image') {
|
||||
const lineFrom = state.doc.lineAt(node.from);
|
||||
const lineTo = state.doc.lineAt(node.to);
|
||||
const textBefore = state.sliceDoc(lineFrom.from, node.from);
|
||||
const textAfter = state.sliceDoc(node.to, lineTo.to);
|
||||
if (textBefore.trim() === '' && textAfter.trim() === '') {
|
||||
const src = getImageSrc(node, state);
|
||||
const alt = getImageAlt(node, state);
|
||||
|
||||
if (src) {
|
||||
const isLastLine = lineTo.number === state.doc.lines;
|
||||
return Decoration.widget({
|
||||
widget: new ImageWidget(context, src, alt),
|
||||
// "side: -1": In general, when the cursor is at the widget's location, it should be at
|
||||
// the start of the next line (and so "side" should be -1).
|
||||
//
|
||||
// "side: 1": However, when the widget is at the end of the document, the widget's
|
||||
// position is **one index less** than when it isn't (to prevent the widget's
|
||||
// position from being outside the document, which would break CodeMirror).
|
||||
// This means that we need "side: 1" to put the cursor before the widget
|
||||
// when at the end of the document.
|
||||
side: isLastLine ? 1 : -1,
|
||||
block: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
getDecorationRange: (node, state) => {
|
||||
const nodeLine = state.doc.lineAt(node.to);
|
||||
return [Math.min(nodeLine.to + 1, state.doc.length)];
|
||||
},
|
||||
hideWhenContainsSelection: false,
|
||||
}),
|
||||
];
|
||||
|
||||
export default renderBlockImages;
|
@@ -0,0 +1,22 @@
|
||||
import addFormattingClasses from './addFormattingClasses';
|
||||
import renderBlockImages from './renderBlockImages';
|
||||
import replaceBulletLists from './replaceBulletLists';
|
||||
import replaceCheckboxes from './replaceCheckboxes';
|
||||
import replaceDividers from './replaceDividers';
|
||||
import replaceFormatCharacters from './replaceFormatCharacters';
|
||||
import { RenderedContentContext } from './types';
|
||||
|
||||
interface Options {
|
||||
renderImages: boolean;
|
||||
}
|
||||
|
||||
export default (context: RenderedContentContext, options: Options) => {
|
||||
return [
|
||||
replaceCheckboxes,
|
||||
replaceBulletLists,
|
||||
replaceFormatCharacters,
|
||||
replaceDividers,
|
||||
addFormattingClasses,
|
||||
...(options.renderImages ? [renderBlockImages(context)] : []),
|
||||
];
|
||||
};
|
@@ -0,0 +1,97 @@
|
||||
import { EditorView, WidgetType } from '@codemirror/view';
|
||||
import makeReplaceExtension from './utils/makeInlineReplaceExtension';
|
||||
|
||||
const listMarkerClassName = 'cm-bullet-list-marker';
|
||||
|
||||
class BulletListMarker extends WidgetType {
|
||||
private className: string;
|
||||
public constructor(depth: number) {
|
||||
super();
|
||||
if (depth % 3 === 0) {
|
||||
this.className = '-depth-0';
|
||||
} else if (depth % 3 === 1) {
|
||||
this.className = '-depth-1';
|
||||
} else {
|
||||
this.className = '-depth-2';
|
||||
}
|
||||
}
|
||||
|
||||
public eq(other: BulletListMarker) {
|
||||
return other.className === this.className;
|
||||
}
|
||||
|
||||
public toDOM() {
|
||||
const container = document.createElement('span');
|
||||
container.classList.add(listMarkerClassName, this.className);
|
||||
container.setAttribute('aria-label', 'bullet');
|
||||
container.role = 'img';
|
||||
|
||||
const sizingNode = document.createElement('span');
|
||||
sizingNode.classList.add('sizing');
|
||||
sizingNode.textContent = '-';
|
||||
container.appendChild(sizingNode);
|
||||
|
||||
const content = document.createElement('span');
|
||||
content.classList.add('content');
|
||||
container.appendChild(content);
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
public updateDOM(other: HTMLElement) {
|
||||
other.classList.remove('-depth-0', '-depth-1', '-depth-2');
|
||||
other.classList.add(this.className);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const replaceBulletLists = [
|
||||
EditorView.theme({
|
||||
[`& .${listMarkerClassName}`]: {
|
||||
'pointer-events': 'none',
|
||||
'position': 'relative',
|
||||
|
||||
'&.-depth-0 > .content': {
|
||||
'border-radius': 0,
|
||||
},
|
||||
'&.-depth-2 > .content': {
|
||||
'border': '1px solid currentcolor',
|
||||
'background-color': 'transparent',
|
||||
},
|
||||
|
||||
'& > .sizing': {
|
||||
'color': 'transparent',
|
||||
},
|
||||
|
||||
'& > .content': {
|
||||
'position': 'absolute',
|
||||
'left': '0',
|
||||
|
||||
'--size': '4px',
|
||||
// Push the content to the center of the container
|
||||
'--vertical-offset': 'calc(50% - calc(var(--size) / 2))',
|
||||
'top': 'var(--vertical-offset)',
|
||||
'bottom': 'var(--vertical-offset)',
|
||||
|
||||
'width': 'var(--size)',
|
||||
'height': 'var(--size)',
|
||||
'box-sizing': 'border-box',
|
||||
'border-radius': 'var(--size)',
|
||||
'background-color': 'currentcolor',
|
||||
},
|
||||
},
|
||||
}),
|
||||
makeReplaceExtension({
|
||||
createDecoration: (node, _view, parentTagCounts) => {
|
||||
if (node.name === 'ListMark') {
|
||||
const parent = node.node.parent;
|
||||
if (parent?.name === 'ListItem' && parent?.parent?.name === 'BulletList') {
|
||||
return new BulletListMarker(parentTagCounts.get('BulletList') ?? 1);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
export default replaceBulletLists;
|
@@ -0,0 +1,133 @@
|
||||
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
|
||||
import { SyntaxNodeRef } from '@lezer/common';
|
||||
import makeReplaceExtension from './utils/makeInlineReplaceExtension';
|
||||
import toggleCheckboxAt from '../../utils/markdown/toggleCheckboxAt';
|
||||
|
||||
const checkboxClassName = 'cm-ext-checkbox-toggle';
|
||||
|
||||
|
||||
class CheckboxWidget extends WidgetType {
|
||||
public constructor(private checked: boolean, private depth: number, private label: string) {
|
||||
super();
|
||||
}
|
||||
|
||||
public eq(other: CheckboxWidget) {
|
||||
return other.checked === this.checked && other.depth === this.depth && other.label === this.label;
|
||||
}
|
||||
|
||||
private applyContainerClasses(container: HTMLElement) {
|
||||
container.classList.add(checkboxClassName);
|
||||
|
||||
for (const className of [...container.classList]) {
|
||||
if (className.startsWith('-depth-')) {
|
||||
container.classList.remove(className);
|
||||
}
|
||||
}
|
||||
|
||||
container.classList.add(`-depth-${this.depth}`);
|
||||
}
|
||||
|
||||
public toDOM(view: EditorView) {
|
||||
const container = document.createElement('span');
|
||||
|
||||
const checkbox = document.createElement('input');
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.checked = this.checked;
|
||||
checkbox.ariaLabel = this.label;
|
||||
checkbox.title = this.label;
|
||||
container.appendChild(checkbox);
|
||||
|
||||
checkbox.oninput = () => {
|
||||
toggleCheckboxAt(view.posAtDOM(container))(view);
|
||||
};
|
||||
|
||||
this.applyContainerClasses(container);
|
||||
return container;
|
||||
}
|
||||
|
||||
public updateDOM(dom: HTMLElement): boolean {
|
||||
this.applyContainerClasses(dom);
|
||||
|
||||
const input = dom.querySelector('input');
|
||||
if (input) {
|
||||
input.checked = this.checked;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public ignoreEvent() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const completedTaskClassName = 'cm-md-completed-item';
|
||||
const completedListItemDecoration = Decoration.line({ class: completedTaskClassName, isFullLine: true });
|
||||
|
||||
const replaceCheckboxes = [
|
||||
EditorView.theme({
|
||||
[`& .${checkboxClassName}`]: {
|
||||
'& > input': {
|
||||
width: '1.1em',
|
||||
height: '1.1em',
|
||||
margin: '4px',
|
||||
verticalAlign: 'middle',
|
||||
},
|
||||
'&:not(.-depth-1) > input': {
|
||||
marginInlineStart: 0,
|
||||
},
|
||||
},
|
||||
[`& .${completedTaskClassName}`]: {
|
||||
opacity: 0.69,
|
||||
},
|
||||
}),
|
||||
EditorView.domEventHandlers({
|
||||
mousedown: (event) => {
|
||||
const target = event.target as Element;
|
||||
if (target.nodeName === 'INPUT' && target.parentElement?.classList?.contains(checkboxClassName)) {
|
||||
// Let the checkbox handle the event
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
makeReplaceExtension({
|
||||
createDecoration: (node, state, parentTags) => {
|
||||
const markerIsChecked = (marker: SyntaxNodeRef) => {
|
||||
const content = state.doc.sliceString(marker.from, marker.to);
|
||||
return content.toLowerCase().indexOf('x') !== -1;
|
||||
};
|
||||
|
||||
if (node.name === 'TaskMarker') {
|
||||
const containerLine = state.doc.lineAt(node.from);
|
||||
const labelText = state.doc.sliceString(node.to, containerLine.to);
|
||||
|
||||
return new CheckboxWidget(markerIsChecked(node), parentTags.get('ListItem') ?? 0, labelText);
|
||||
} else if (node.name === 'Task') {
|
||||
const marker = node.node.getChild('TaskMarker');
|
||||
if (marker && markerIsChecked(marker)) {
|
||||
return completedListItemDecoration;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
getDecorationRange: (node, state) => {
|
||||
if (node.name === 'TaskMarker') {
|
||||
const container = node.node.parent?.parent;
|
||||
const listMarker = container?.getChild('ListMark');
|
||||
if (!listMarker) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [listMarker.from, node.to];
|
||||
} else if (node.name === 'Task') {
|
||||
const taskLine = state.doc.lineAt(node.from);
|
||||
return [taskLine.from];
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
export default replaceCheckboxes;
|
@@ -0,0 +1,70 @@
|
||||
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
|
||||
import makeInlineReplaceExtension from './utils/makeInlineReplaceExtension';
|
||||
|
||||
const dividerClassName = 'cm-md-divider';
|
||||
const dividerLineClassName = 'cm-md-divider-line';
|
||||
|
||||
class DividerWidget extends WidgetType {
|
||||
public constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public eq(_other: DividerWidget) {
|
||||
return true;
|
||||
}
|
||||
|
||||
public toDOM() {
|
||||
const container = document.createElement('hr');
|
||||
container.classList.add(dividerClassName);
|
||||
return container;
|
||||
}
|
||||
|
||||
public ignoreEvent() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const dividerLineMark = Decoration.line({ class: dividerLineClassName });
|
||||
|
||||
const replaceDividers = [
|
||||
EditorView.theme({
|
||||
[`& .cm-line.${dividerLineClassName}`]: {
|
||||
// Use flex layout to allow the divider to fill the remainder of the line.
|
||||
// This applies, for example, to the case where the divider is in a blockquote or
|
||||
// a sub list item.
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
[`& .${dividerClassName}`]: {
|
||||
// Fill remaining width
|
||||
flexGrow: 1,
|
||||
flexShrink: 1,
|
||||
|
||||
border: 'none',
|
||||
borderBottom: '2px solid var(--joplin-divider-color)',
|
||||
position: 'relative',
|
||||
},
|
||||
}),
|
||||
makeInlineReplaceExtension({
|
||||
createDecoration: (node) => {
|
||||
if (node.name === 'HorizontalRule') {
|
||||
return new DividerWidget();
|
||||
}
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
makeInlineReplaceExtension({
|
||||
createDecoration: (node) => {
|
||||
if (node.name === 'HorizontalRule') {
|
||||
return dividerLineMark;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
getDecorationRange: (node, state) => {
|
||||
const line = state.doc.lineAt(node.from);
|
||||
return [line.from];
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
export default replaceDividers;
|
@@ -0,0 +1,81 @@
|
||||
import makeInlineReplaceExtension from './utils/makeInlineReplaceExtension';
|
||||
import { SyntaxNodeRef } from '@lezer/common';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import referenceLinkStateField, { isReferenceLink, resolveReferenceFromLink } from '../links/referenceLinksStateField';
|
||||
import { Decoration } from '@codemirror/view';
|
||||
|
||||
const shouldFullReplace = (node: SyntaxNodeRef, state: EditorState) => {
|
||||
const getParentName = () => node.node.parent?.name;
|
||||
const getNodeStartLine = () => state.doc.lineAt(node.from);
|
||||
|
||||
if (['HeaderMark', 'CodeMark', 'EmphasisMark', 'StrikethroughMark', 'HighlightMarker'].includes(node.name)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ((node.name === 'URL' || node.name === 'LinkMark') && getParentName() === 'Link') {
|
||||
const parent = node.node.parent!;
|
||||
const parentContent = state.sliceDoc(parent.from, parent.to);
|
||||
if (node.name === 'LinkMark') {
|
||||
if (isReferenceLink(parentContent)) {
|
||||
return !!resolveReferenceFromLink(parentContent, state);
|
||||
}
|
||||
} else if (node.name === 'URL') {
|
||||
// Find all closing link marks
|
||||
const closingBracketNodes = parent.getChildren('LinkMark').filter(mark => {
|
||||
const isClosingBracket = state.sliceDoc(mark.from, mark.to) === ']';
|
||||
return isClosingBracket;
|
||||
});
|
||||
|
||||
// URLs can only be hidden if after the last ].
|
||||
const lastClosingBracketIdx = closingBracketNodes.length > 0 ? closingBracketNodes[closingBracketNodes.length - 1].from : null;
|
||||
if (!lastClosingBracketIdx || node.from < lastClosingBracketIdx) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (node.name === 'QuoteMark' && node.from === getNodeStartLine().from) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const hideDecoration = Decoration.replace({});
|
||||
|
||||
const replaceFormatCharacters = [
|
||||
// Dependency
|
||||
referenceLinkStateField,
|
||||
|
||||
makeInlineReplaceExtension({
|
||||
createDecoration: (node, state) => {
|
||||
if (shouldFullReplace(node, state)) {
|
||||
return hideDecoration;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
getDecorationRange: (node, state) => {
|
||||
// Headers in the form "## Header" should have the "##"s and the
|
||||
// space immediately after hidden
|
||||
if (node.name === 'HeaderMark') {
|
||||
const markerLine = state.doc.lineAt(node.from);
|
||||
|
||||
// Certain header styles DON'T have a space after the header mark:
|
||||
const hasRoomForSpace = node.to + 1 >= markerLine.to;
|
||||
if (hasRoomForSpace) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Include the space in the hidden region, if it's available
|
||||
if (state.doc.sliceString(node.to, node.to + 1) === ' ') {
|
||||
return [node.from, node.to + 1];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
export default replaceFormatCharacters;
|
20
packages/editor/CodeMirror/extensions/rendering/types.ts
Normal file
20
packages/editor/CodeMirror/extensions/rendering/types.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { EditorState } from '@codemirror/state';
|
||||
import type { Decoration, WidgetType } from '@codemirror/view';
|
||||
import type { SyntaxNodeRef } from '@lezer/common';
|
||||
|
||||
export interface ReplacementExtension {
|
||||
// Should return the widget that replaces `node`. Returning `null` preserves `node` without replacement.
|
||||
createDecoration(node: SyntaxNodeRef, state: EditorState, parentTags: Readonly<Map<string, number>>): Decoration|WidgetType|null;
|
||||
|
||||
// Returns a range ([from, to]) to which the decoration should be applied. Returning `null`
|
||||
// replaces the entire widget with the decoration.
|
||||
// Only a single number should be returned to create a point/full line range.
|
||||
getDecorationRange?(node: SyntaxNodeRef, state: EditorState): [number]|[number, number]|null;
|
||||
|
||||
// Disable the decoration when near the cursor. Defaults to true.
|
||||
hideWhenContainsSelection?: boolean;
|
||||
}
|
||||
|
||||
export interface RenderedContentContext {
|
||||
resolveImageSrc(src: string): Promise<string>;
|
||||
}
|
@@ -0,0 +1,89 @@
|
||||
import { EditorView, Decoration, DecorationSet, WidgetType } from '@codemirror/view';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import { EditorState, Range, StateField } from '@codemirror/state';
|
||||
import { ReplacementExtension } from '../types';
|
||||
import nodeIntersectsSelection from './nodeIntersectsSelection';
|
||||
|
||||
const updateDecorations = (state: EditorState, extensionSpec: ReplacementExtension) => {
|
||||
const doc = state.doc;
|
||||
const cursorLine = doc.lineAt(state.selection.main.anchor);
|
||||
|
||||
const parentTagCounts = new Map<string, number>();
|
||||
const widgets: Range<Decoration>[] = [];
|
||||
syntaxTree(state).iterate({
|
||||
enter: node => {
|
||||
parentTagCounts.set(node.name, (parentTagCounts.get(node.name) ?? 0) + 1);
|
||||
|
||||
const nodeLineFrom = doc.lineAt(node.from);
|
||||
const nodeLineTo = doc.lineAt(node.to);
|
||||
const selectionIsNearNode = Math.abs(nodeLineFrom.number - cursorLine.number) <= 1 || Math.abs(nodeLineTo.number - cursorLine.number) <= 1;
|
||||
const shouldHide = (
|
||||
(extensionSpec.hideWhenContainsSelection ?? true) && (
|
||||
nodeIntersectsSelection(state.selection, node) || selectionIsNearNode
|
||||
)
|
||||
);
|
||||
|
||||
if (!shouldHide) {
|
||||
const widget = extensionSpec.createDecoration(node, state, parentTagCounts);
|
||||
if (widget) {
|
||||
let decoration;
|
||||
if (widget instanceof WidgetType) {
|
||||
decoration = Decoration.replace({
|
||||
widget,
|
||||
block: true,
|
||||
});
|
||||
} else {
|
||||
decoration = widget;
|
||||
}
|
||||
|
||||
let rangeFrom = nodeLineFrom.from;
|
||||
let rangeTo = nodeLineTo.to;
|
||||
let skip = false;
|
||||
if (extensionSpec.getDecorationRange) {
|
||||
const range = extensionSpec.getDecorationRange(node, state);
|
||||
if (range) {
|
||||
rangeFrom = range[0];
|
||||
rangeTo = range.length === 1 ? range[0] : range[1];
|
||||
} else {
|
||||
skip = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!skip) {
|
||||
widgets.push(decoration.range(rangeFrom, rangeTo));
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
leave: node => {
|
||||
parentTagCounts.set(node.name, (parentTagCounts.get(node.name) ?? 0) - 1);
|
||||
},
|
||||
});
|
||||
|
||||
return Decoration.set(widgets, true);
|
||||
};
|
||||
|
||||
const makeBlockReplaceExtension = (extensionSpec: ReplacementExtension) => {
|
||||
const blockDecorationField = StateField.define<DecorationSet>({
|
||||
create(state) {
|
||||
return updateDecorations(state, extensionSpec);
|
||||
},
|
||||
update(decorations, transaction) {
|
||||
decorations = decorations.map(transaction.changes);
|
||||
const selectionChanged = !transaction.newSelection.eq(transaction.startState.selection);
|
||||
|
||||
if (transaction.docChanged || selectionChanged) {
|
||||
decorations = updateDecorations(transaction.state, extensionSpec);
|
||||
}
|
||||
|
||||
return decorations;
|
||||
},
|
||||
provide: f => EditorView.decorations.from(f),
|
||||
});
|
||||
return [
|
||||
blockDecorationField,
|
||||
];
|
||||
};
|
||||
|
||||
export default makeBlockReplaceExtension;
|
||||
|
@@ -0,0 +1,91 @@
|
||||
// Ref: https://codemirror.net/examples/bundle/
|
||||
// and https://codemirror.net/examples/decoration/
|
||||
|
||||
import { EditorView, Decoration, DecorationSet, WidgetType } from '@codemirror/view';
|
||||
import { ViewPlugin, ViewUpdate } from '@codemirror/view';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import { Range } from '@codemirror/state';
|
||||
import { SyntaxNodeRef } from '@lezer/common';
|
||||
import { ReplacementExtension } from '../types';
|
||||
import nodeIntersectsSelection from './nodeIntersectsSelection';
|
||||
|
||||
|
||||
export const makeInlineReplaceExtension = (extensionSpec: ReplacementExtension) => ViewPlugin.fromClass(class {
|
||||
public decorations: DecorationSet;
|
||||
|
||||
public constructor(view: EditorView) {
|
||||
this.updateDecorations(view);
|
||||
}
|
||||
|
||||
private updateDecorations(view: EditorView) {
|
||||
const doc = view.state.doc;
|
||||
const cursorLine = doc.lineAt(view.state.selection.main.anchor);
|
||||
const selection = view.state.selection;
|
||||
|
||||
const parentTagCounts = new Map<string, number>();
|
||||
const decorateNode = (node: SyntaxNodeRef) => {
|
||||
const widgetOrDecoration = extensionSpec.createDecoration(node, view.state, parentTagCounts);
|
||||
let decoration;
|
||||
if (widgetOrDecoration instanceof WidgetType) {
|
||||
decoration = Decoration.replace({
|
||||
widget: widgetOrDecoration,
|
||||
});
|
||||
} else if (widgetOrDecoration instanceof Decoration) {
|
||||
decoration = widgetOrDecoration;
|
||||
}
|
||||
|
||||
if (decoration) {
|
||||
const range = extensionSpec.getDecorationRange?.(node, view.state) ?? [node.from, node.to];
|
||||
const rangeLineFrom = doc.lineAt(range[0]);
|
||||
const rangeLineTo = range.length === 2 ? doc.lineAt(range[1]) : rangeLineFrom;
|
||||
|
||||
// A different start/end line causes errors.
|
||||
if (rangeLineFrom.number === rangeLineTo.number) {
|
||||
if (range.length === 1) {
|
||||
widgets.push(decoration.range(range[0]));
|
||||
} else {
|
||||
widgets.push(decoration.range(range[0], range[1]));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const widgets: Range<Decoration>[] = [];
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
parentTagCounts.clear();
|
||||
syntaxTree(view.state).iterate({
|
||||
from, to,
|
||||
enter: node => {
|
||||
parentTagCounts.set(node.name, (parentTagCounts.get(node.name) ?? 0) + 1);
|
||||
|
||||
const nodeLineFrom = doc.lineAt(node.from);
|
||||
const nodeLineTo = doc.lineAt(node.from);
|
||||
const nodeLineContainsSelection = cursorLine.number === nodeLineFrom.number || cursorLine.number === nodeLineTo.number;
|
||||
const shouldHide = (
|
||||
(extensionSpec.hideWhenContainsSelection ?? true) && (
|
||||
nodeIntersectsSelection(selection, node) || nodeLineContainsSelection
|
||||
)
|
||||
);
|
||||
|
||||
if (!shouldHide) {
|
||||
decorateNode(node);
|
||||
}
|
||||
},
|
||||
leave: node => {
|
||||
parentTagCounts.set(node.name, (parentTagCounts.get(node.name) ?? 0) - 1);
|
||||
},
|
||||
});
|
||||
}
|
||||
this.decorations = Decoration.set(widgets, true);
|
||||
}
|
||||
|
||||
public update(update: ViewUpdate) {
|
||||
if (update.docChanged || update.viewportChanged || update.selectionSet) {
|
||||
this.updateDecorations(update.view);
|
||||
}
|
||||
}
|
||||
}, {
|
||||
decorations: view => view.decorations,
|
||||
});
|
||||
|
||||
export default makeInlineReplaceExtension;
|
@@ -0,0 +1,17 @@
|
||||
import { EditorSelection } from '@codemirror/state';
|
||||
import { SyntaxNodeRef } from '@lezer/common';
|
||||
|
||||
const nodeIntersectsSelection = (selection: EditorSelection, node: SyntaxNodeRef) => {
|
||||
const mainSelection = selection.main;
|
||||
|
||||
const nodeContains = (point: number) => {
|
||||
return point >= node.from && point <= node.to;
|
||||
};
|
||||
const selectionContains = (point: number) => {
|
||||
return point >= mainSelection.from && point <= mainSelection.to;
|
||||
};
|
||||
return nodeContains(mainSelection.from) || nodeContains(mainSelection.to)
|
||||
|| selectionContains(node.from) || selectionContains(node.to);
|
||||
};
|
||||
|
||||
export default nodeIntersectsSelection;
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user