You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-12-08 23:07:32 +02:00
Compare commits
124 Commits
ios-v13.4.
...
v3.4.13
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97fa85a3f7 | ||
|
|
defe36bba1 | ||
|
|
711d214741 | ||
|
|
0795c67354 | ||
|
|
e9a9f68568 | ||
|
|
6ee9571069 | ||
|
|
f25db9bbd7 | ||
|
|
44ac261304 | ||
|
|
3a9f57e13f | ||
|
|
b72c48c693 | ||
|
|
f1e42f3bac | ||
|
|
93c908286d | ||
|
|
4eb8777ed0 | ||
|
|
e884da8312 | ||
|
|
85585d16d2 | ||
|
|
6482ab5a4e | ||
|
|
ec74abe754 | ||
|
|
859bc8d88e | ||
|
|
3e9bb914e5 | ||
|
|
f75e911a4e | ||
|
|
6390ef43ed | ||
|
|
0d1d50768b | ||
|
|
cba5cf660b | ||
|
|
0024722c79 | ||
|
|
b179509dd3 | ||
|
|
ac289c5198 | ||
|
|
62faa48aac | ||
|
|
5daa7a1f4c | ||
|
|
32be071601 | ||
|
|
0dc63dd306 | ||
|
|
78ed58187a | ||
|
|
b8b8dd8011 | ||
|
|
0bc72b45be | ||
|
|
c52523134d | ||
|
|
aff871eee6 | ||
|
|
a5a68a2238 | ||
|
|
e066b8f9bc | ||
|
|
e7827a3a64 | ||
|
|
4ceca647dc | ||
|
|
4185afebdb | ||
|
|
c530b07f45 | ||
|
|
0ed7daaed8 | ||
|
|
2eb107c716 | ||
|
|
c99780db1b | ||
|
|
ac05b7d389 | ||
|
|
9719d82c47 | ||
|
|
48694a585f | ||
|
|
b577a27887 | ||
|
|
9f649c9fc2 | ||
|
|
8c9c5d13bd | ||
|
|
96692de93c | ||
|
|
3d8e1dd146 | ||
|
|
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 |
@@ -10,7 +10,7 @@ QUEUE_TTL=900000
|
||||
QUEUE_RETRY_COUNT=2
|
||||
QUEUE_MAINTENANCE_INTERVAL=30000
|
||||
|
||||
HTR_CLI_DOCKER_IMAGE=joplin/htr-cli:0.0.2
|
||||
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=
|
||||
@@ -18,6 +18,8 @@ HTR_CLI_IMAGES_FOLDER=
|
||||
QUEUE_DRIVER=pg
|
||||
# QUEUE_DRIVER=sqlite
|
||||
|
||||
FILE_STORAGE_MAINTENANCE_INTERVAL=3600000
|
||||
FILE_STORAGE_TTL=604800000 # one week
|
||||
|
||||
# =============================================================================
|
||||
# Queue driver
|
||||
|
||||
@@ -117,6 +117,8 @@ packages/app-cli/app/command-ls.js
|
||||
packages/app-cli/app/command-mkbook.test.js
|
||||
packages/app-cli/app/command-mkbook.js
|
||||
packages/app-cli/app/command-mv.js
|
||||
packages/app-cli/app/command-publish.test.js
|
||||
packages/app-cli/app/command-publish.js
|
||||
packages/app-cli/app/command-ren.js
|
||||
packages/app-cli/app/command-restore.js
|
||||
packages/app-cli/app/command-rmbook.test.js
|
||||
@@ -129,6 +131,8 @@ packages/app-cli/app/command-share.test.js
|
||||
packages/app-cli/app/command-share.js
|
||||
packages/app-cli/app/command-sync.js
|
||||
packages/app-cli/app/command-testing.js
|
||||
packages/app-cli/app/command-unpublish.test.js
|
||||
packages/app-cli/app/command-unpublish.js
|
||||
packages/app-cli/app/command-use.js
|
||||
packages/app-cli/app/command-version.js
|
||||
packages/app-cli/app/gui/FolderListWidget.js
|
||||
@@ -698,7 +702,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
|
||||
@@ -868,6 +871,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
|
||||
@@ -939,6 +943,7 @@ packages/app-mobile/utils/fs-driver/testUtil/verifyDirectoryMatches.js
|
||||
packages/app-mobile/utils/getPackageInfo.js
|
||||
packages/app-mobile/utils/getVersionInfoText.js
|
||||
packages/app-mobile/utils/hooks/useBackHandler.js
|
||||
packages/app-mobile/utils/hooks/useIsScreenReaderEnabled.js
|
||||
packages/app-mobile/utils/hooks/useKeyboardState.js
|
||||
packages/app-mobile/utils/hooks/useOnLongPressProps.js
|
||||
packages/app-mobile/utils/hooks/useReduceMotionEnabled.js
|
||||
@@ -1004,6 +1009,9 @@ 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/highlightActiveLineExtension.js
|
||||
packages/editor/CodeMirror/extensions/keyUpHandlerExtension.js
|
||||
packages/editor/CodeMirror/extensions/links/ctrlClickLinksExtension.js
|
||||
packages/editor/CodeMirror/extensions/links/followLinkTooltipExtension.test.js
|
||||
@@ -1074,14 +1082,18 @@ 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/createEditorDialog.js
|
||||
packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.test.js
|
||||
@@ -1102,12 +1114,17 @@ 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/postprocessEditorOutput.test.js
|
||||
packages/editor/ProseMirror/utils/postprocessEditorOutput.js
|
||||
packages/editor/ProseMirror/utils/preprocessEditorInput.test.js
|
||||
packages/editor/ProseMirror/utils/preprocessEditorInput.js
|
||||
packages/editor/ProseMirror/utils/sanitizeHtml.js
|
||||
@@ -1765,6 +1782,7 @@ 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
|
||||
|
||||
20
.gitignore
vendored
20
.gitignore
vendored
@@ -90,6 +90,8 @@ packages/app-cli/app/command-ls.js
|
||||
packages/app-cli/app/command-mkbook.test.js
|
||||
packages/app-cli/app/command-mkbook.js
|
||||
packages/app-cli/app/command-mv.js
|
||||
packages/app-cli/app/command-publish.test.js
|
||||
packages/app-cli/app/command-publish.js
|
||||
packages/app-cli/app/command-ren.js
|
||||
packages/app-cli/app/command-restore.js
|
||||
packages/app-cli/app/command-rmbook.test.js
|
||||
@@ -102,6 +104,8 @@ packages/app-cli/app/command-share.test.js
|
||||
packages/app-cli/app/command-share.js
|
||||
packages/app-cli/app/command-sync.js
|
||||
packages/app-cli/app/command-testing.js
|
||||
packages/app-cli/app/command-unpublish.test.js
|
||||
packages/app-cli/app/command-unpublish.js
|
||||
packages/app-cli/app/command-use.js
|
||||
packages/app-cli/app/command-version.js
|
||||
packages/app-cli/app/gui/FolderListWidget.js
|
||||
@@ -671,7 +675,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
|
||||
@@ -841,6 +844,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
|
||||
@@ -912,6 +916,7 @@ packages/app-mobile/utils/fs-driver/testUtil/verifyDirectoryMatches.js
|
||||
packages/app-mobile/utils/getPackageInfo.js
|
||||
packages/app-mobile/utils/getVersionInfoText.js
|
||||
packages/app-mobile/utils/hooks/useBackHandler.js
|
||||
packages/app-mobile/utils/hooks/useIsScreenReaderEnabled.js
|
||||
packages/app-mobile/utils/hooks/useKeyboardState.js
|
||||
packages/app-mobile/utils/hooks/useOnLongPressProps.js
|
||||
packages/app-mobile/utils/hooks/useReduceMotionEnabled.js
|
||||
@@ -977,6 +982,9 @@ 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/highlightActiveLineExtension.js
|
||||
packages/editor/CodeMirror/extensions/keyUpHandlerExtension.js
|
||||
packages/editor/CodeMirror/extensions/links/ctrlClickLinksExtension.js
|
||||
packages/editor/CodeMirror/extensions/links/followLinkTooltipExtension.test.js
|
||||
@@ -1047,14 +1055,18 @@ 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/createEditorDialog.js
|
||||
packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.test.js
|
||||
@@ -1075,12 +1087,17 @@ 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/postprocessEditorOutput.test.js
|
||||
packages/editor/ProseMirror/utils/postprocessEditorOutput.js
|
||||
packages/editor/ProseMirror/utils/preprocessEditorInput.test.js
|
||||
packages/editor/ProseMirror/utils/preprocessEditorInput.js
|
||||
packages/editor/ProseMirror/utils/sanitizeHtml.js
|
||||
@@ -1738,6 +1755,7 @@ 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
|
||||
|
||||
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
|
||||
@@ -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;
|
||||
|
||||
@@ -220,6 +220,7 @@ class Application extends BaseApplication {
|
||||
return { ...this.commandMetadata_ };
|
||||
}
|
||||
|
||||
|
||||
public hasGui() {
|
||||
return this.gui() && !this.gui().isDummy();
|
||||
}
|
||||
@@ -330,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
|
||||
@@ -415,8 +417,10 @@ class Application extends BaseApplication {
|
||||
if (argv.length) {
|
||||
this.gui_ = this.dummyGui();
|
||||
|
||||
const initialFolder = await Folder.load(Setting.value('activeFolderId'));
|
||||
await this.switchCurrentFolder(initialFolder);
|
||||
await this.applySettingsSideEffects();
|
||||
await this.refreshCurrentFolder();
|
||||
|
||||
try {
|
||||
await this.execCommand(argv);
|
||||
} catch (error) {
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
|
||||
104
packages/app-cli/app/command-publish.test.ts
Normal file
104
packages/app-cli/app/command-publish.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import ShareService from '@joplin/lib/services/share/ShareService';
|
||||
import mockShareService from '@joplin/lib/testing/share/mockShareService';
|
||||
import { createFolderTree, setupDatabaseAndSynchronizer, switchClient, waitFor } from '@joplin/lib/testing/test-utils';
|
||||
import { setupApplication, setupCommandForTesting } from './utils/testUtils';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
const Command = require('./command-publish');
|
||||
|
||||
const setUpCommand = () => {
|
||||
const onStdout = jest.fn();
|
||||
const command = setupCommandForTesting(Command, onStdout);
|
||||
|
||||
return { command, onStdout };
|
||||
};
|
||||
|
||||
describe('command-publish', () => {
|
||||
beforeEach(async () => {
|
||||
await setupDatabaseAndSynchronizer(1);
|
||||
await switchClient(1);
|
||||
await setupApplication();
|
||||
|
||||
mockShareService({
|
||||
getShares: async () => {
|
||||
return { items: [] };
|
||||
},
|
||||
postShares: async () => ({ id: 'test-id' }),
|
||||
getShareInvitations: async () => null,
|
||||
}, ShareService.instance());
|
||||
});
|
||||
|
||||
test('should publish a note', async () => {
|
||||
const { command, onStdout } = setUpCommand();
|
||||
|
||||
const testFolder = await Folder.save({ title: 'Test' });
|
||||
const testNote = await Note.save({ title: 'test', parent_id: testFolder.id });
|
||||
|
||||
await command.action({
|
||||
note: testNote.id,
|
||||
options: {
|
||||
force: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Should be shared
|
||||
await waitFor(async () => {
|
||||
expect(await Note.load(testNote.id)).toMatchObject({
|
||||
is_shared: 1,
|
||||
});
|
||||
});
|
||||
|
||||
// Should have logged the publication URL
|
||||
expect(onStdout).toHaveBeenCalled();
|
||||
expect(onStdout.mock.lastCall[0]).toMatch(/Published at URL:/);
|
||||
});
|
||||
|
||||
test('should be enabled for Joplin Server and Cloud sync targets', () => {
|
||||
const { command } = setUpCommand();
|
||||
|
||||
Setting.setValue('sync.target', 1);
|
||||
expect(command.enabled()).toBe(false);
|
||||
|
||||
const supportedSyncTargets = [9, 10, 11];
|
||||
for (const id of supportedSyncTargets) {
|
||||
Setting.setValue('sync.target', id);
|
||||
expect(command.enabled()).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('should not ask for confirmation if a note is already published', async () => {
|
||||
const { command } = setUpCommand();
|
||||
|
||||
const promptMock = jest.fn(() => true);
|
||||
command.setPrompt(promptMock);
|
||||
|
||||
await createFolderTree('', [
|
||||
{
|
||||
title: 'folder 1',
|
||||
children: [
|
||||
{
|
||||
title: 'note 1',
|
||||
body: 'test',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
const noteId = (await Note.loadByTitle('note 1')).id;
|
||||
|
||||
// Should ask for confirmation when first sharing
|
||||
await command.action({
|
||||
note: noteId,
|
||||
options: { },
|
||||
});
|
||||
expect(promptMock).toHaveBeenCalledTimes(1);
|
||||
expect(await Note.load(noteId)).toMatchObject({ is_shared: 1 });
|
||||
|
||||
// Should not ask for confirmation if called again for the same note
|
||||
await command.action({
|
||||
note: noteId,
|
||||
options: { },
|
||||
});
|
||||
expect(promptMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
64
packages/app-cli/app/command-publish.ts
Normal file
64
packages/app-cli/app/command-publish.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import BaseCommand from './base-command';
|
||||
import app from './app';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import ShareService from '@joplin/lib/services/share/ShareService';
|
||||
import { ModelType } from '@joplin/lib/BaseModel';
|
||||
import SyncTargetRegistry from '@joplin/lib/SyncTargetRegistry';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { reg } from '@joplin/lib/registry';
|
||||
|
||||
const logger = Logger.create('command-publish');
|
||||
|
||||
type Args = {
|
||||
note: string;
|
||||
options: {
|
||||
force?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
class Command extends BaseCommand {
|
||||
public usage() {
|
||||
return 'publish [note]';
|
||||
}
|
||||
|
||||
public description() {
|
||||
return _('Publishes a note to Joplin Server or Joplin Cloud');
|
||||
}
|
||||
|
||||
public options() {
|
||||
return [
|
||||
['-f, --force', _('Do not ask for user confirmation.')],
|
||||
];
|
||||
}
|
||||
|
||||
public enabled() {
|
||||
return SyncTargetRegistry.isJoplinServerOrCloud(Setting.value('sync.target'));
|
||||
}
|
||||
|
||||
public async action(args: Args) {
|
||||
const targetNote = await app().loadItemOrFail(ModelType.Note, args.note);
|
||||
const parent = await app().loadItem(ModelType.Folder, targetNote.parent_id);
|
||||
|
||||
const force = args.options.force;
|
||||
const alreadyShared = !!targetNote.is_shared;
|
||||
const ok = force || alreadyShared ? true : await this.prompt(
|
||||
_('Publish note "%s" (in notebook "%s")?', targetNote.title, parent.title ?? '<root>'),
|
||||
{ booleanAnswerDefault: 'n' },
|
||||
);
|
||||
if (!ok) return;
|
||||
|
||||
logger.info('Share note: ', targetNote.id);
|
||||
const share = await ShareService.instance().shareNote(targetNote.id, false);
|
||||
|
||||
this.stdout(_('Synchronising...'));
|
||||
await reg.waitForSyncFinishedThenSync();
|
||||
|
||||
const userId = ShareService.instance().userId;
|
||||
const shareUrl = ShareService.instance().shareUrl(userId, share);
|
||||
this.stdout(_('Published at URL: %s', shareUrl));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Command;
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
43
packages/app-cli/app/command-unpublish.test.ts
Normal file
43
packages/app-cli/app/command-unpublish.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import ShareService from '@joplin/lib/services/share/ShareService';
|
||||
import mockShareService from '@joplin/lib/testing/share/mockShareService';
|
||||
import { setupDatabaseAndSynchronizer, switchClient, waitFor } from '@joplin/lib/testing/test-utils';
|
||||
import { setupApplication, setupCommandForTesting } from './utils/testUtils';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
const Command = require('./command-unpublish');
|
||||
|
||||
|
||||
describe('command-unpublish', () => {
|
||||
beforeEach(async () => {
|
||||
await setupDatabaseAndSynchronizer(1);
|
||||
await switchClient(1);
|
||||
await setupApplication();
|
||||
|
||||
mockShareService({
|
||||
getShares: async () => {
|
||||
return { items: [{ id: 'test-id' }] };
|
||||
},
|
||||
postShares: async () => {
|
||||
throw new Error('Unexpected call to postShares');
|
||||
},
|
||||
getShareInvitations: async () => null,
|
||||
}, ShareService.instance());
|
||||
});
|
||||
|
||||
test('should unpublish a note', async () => {
|
||||
const command = setupCommandForTesting(Command, ()=>{});
|
||||
|
||||
const testFolder = await Folder.save({ title: 'Test' });
|
||||
const testNote = await Note.save({ title: 'test', parent_id: testFolder.id, is_shared: 1 });
|
||||
|
||||
await command.action({
|
||||
note: testNote.id,
|
||||
});
|
||||
|
||||
await waitFor(async () => {
|
||||
expect(await Note.load(testNote.id)).toMatchObject({
|
||||
is_shared: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
57
packages/app-cli/app/command-unpublish.ts
Normal file
57
packages/app-cli/app/command-unpublish.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import BaseCommand from './base-command';
|
||||
import app from './app';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import ShareService from '@joplin/lib/services/share/ShareService';
|
||||
import { ModelType } from '@joplin/lib/BaseModel';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import SyncTargetRegistry from '@joplin/lib/SyncTargetRegistry';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { reg } from '@joplin/lib/registry';
|
||||
|
||||
const logger = Logger.create('command-unpublish');
|
||||
|
||||
type Args = {
|
||||
note: string;
|
||||
};
|
||||
|
||||
class Command extends BaseCommand {
|
||||
public usage() {
|
||||
return 'publish [note]';
|
||||
}
|
||||
|
||||
public description() {
|
||||
return _('Publishes a note to Joplin Server or Joplin Cloud');
|
||||
}
|
||||
|
||||
public options() {
|
||||
return [
|
||||
['-f, --force', _('Do not ask for user confirmation.')],
|
||||
];
|
||||
}
|
||||
|
||||
public enabled() {
|
||||
return SyncTargetRegistry.isJoplinServerOrCloud(Setting.value('sync.target'));
|
||||
}
|
||||
|
||||
public async action(args: Args) {
|
||||
const targetNote = await app().loadItemOrFail(ModelType.Note, args.note);
|
||||
|
||||
if (!targetNote.is_shared) {
|
||||
throw new Error(_('Note not published: %s', targetNote.title));
|
||||
}
|
||||
|
||||
logger.info('Unshare note: ', targetNote.id);
|
||||
await ShareService.instance().unshareNote(targetNote.id);
|
||||
|
||||
const note = await Note.load(targetNote.id);
|
||||
if (note.is_shared) {
|
||||
throw new Error('Assertion failure: The note is still shared.');
|
||||
}
|
||||
|
||||
this.stdout(_('Synchronising...'));
|
||||
await reg.waitForSyncFinishedThenSync();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Command;
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
],
|
||||
"owner": "Laurent Cozic"
|
||||
},
|
||||
"version": "3.4.0",
|
||||
"version": "3.4.1",
|
||||
"bin": "./main.js",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
@@ -57,7 +57,7 @@
|
||||
"proper-lockfile": "4.1.2",
|
||||
"redux": "4.2.1",
|
||||
"server-destroy": "1.0.1",
|
||||
"sharp": "0.34.1",
|
||||
"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.100",
|
||||
"@types/node": "18.19.103",
|
||||
"@types/proper-lockfile": "^4.1.2",
|
||||
"gulp": "4.0.2",
|
||||
"jest": "29.7.0",
|
||||
|
||||
@@ -67,6 +67,11 @@ import 'codemirror/mode/diff/diff';
|
||||
import 'codemirror/mode/erlang/erlang';
|
||||
import 'codemirror/mode/sql/sql';
|
||||
|
||||
interface ExtendedWindow {
|
||||
CodeMirror?: unknown;
|
||||
}
|
||||
declare const window: ExtendedWindow;
|
||||
|
||||
|
||||
export interface EditorProps {
|
||||
value: string;
|
||||
@@ -100,6 +105,14 @@ function Editor(props: EditorProps, ref: any) {
|
||||
const editorParent = useRef(null);
|
||||
const lastEditTime = useRef(NaN);
|
||||
|
||||
useEffect(() => {
|
||||
window.CodeMirror = CodeMirror;
|
||||
|
||||
return () => {
|
||||
window.CodeMirror = undefined;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Codemirror plugins add new commands to codemirror (or change it's behavior)
|
||||
// This command adds the smartListIndent function which will be bound to tab
|
||||
useListIdent(CodeMirror);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
|
||||
import bridge from '../../../../../../services/bridge';
|
||||
import { contentScriptsToCodeMirrorPlugin } from '@joplin/lib/services/plugins/utils/loadContentScripts';
|
||||
import { extname } from 'path';
|
||||
import shim from '@joplin/lib/shim';
|
||||
@@ -7,6 +8,18 @@ import uuid from '@joplin/lib/uuid';
|
||||
|
||||
import { reg } from '@joplin/lib/registry';
|
||||
|
||||
const addPluginDependency = (path: string) => {
|
||||
const id = `content-script-${encodeURIComponent(path)}`;
|
||||
if (document.getElementById(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const element = document.createElement('script');
|
||||
element.setAttribute('id', id);
|
||||
element.setAttribute('src', path);
|
||||
document.head.appendChild(element);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
export default function useExternalPlugins(CodeMirror: any, plugins: PluginStates) {
|
||||
const [options, setOptions] = useState({});
|
||||
@@ -23,7 +36,14 @@ export default function useExternalPlugins(CodeMirror: any, plugins: PluginState
|
||||
if (mod.codeMirrorResources) {
|
||||
for (const asset of mod.codeMirrorResources) {
|
||||
try {
|
||||
require(`codemirror/${asset}`);
|
||||
let assetPath = shim.fsDriver().resolveRelativePathWithinDir(`${bridge().vendorDir()}/lib/codemirror/`, asset);
|
||||
|
||||
// Compatibility with old versions of Joplin, where the file extension was automatically added by require().
|
||||
if (extname(assetPath) === '') {
|
||||
assetPath += '.js';
|
||||
}
|
||||
|
||||
addPluginDependency(assetPath);
|
||||
} catch (error) {
|
||||
error.message = `${asset} is not a valid CodeMirror asset, keymap or mode. You can find a list of valid assets here: https://codemirror.net/doc/manual.html#addons`;
|
||||
throw error;
|
||||
|
||||
@@ -366,6 +366,7 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
|
||||
katexEnabled: Setting.value('markdown.plugin.katex'),
|
||||
inlineRenderingEnabled: Setting.value('editor.inlineRendering'),
|
||||
imageRenderingEnabled: Setting.value('editor.imageRendering'),
|
||||
highlightActiveLine: Setting.value('editor.highlightActiveLine'),
|
||||
themeData: {
|
||||
...styles.globalTheme,
|
||||
marginLeft: 0,
|
||||
|
||||
@@ -114,6 +114,7 @@ const Editor = (props: Props, ref: ForwardedRef<CodeMirrorControl>) => {
|
||||
const url = parseResourceUrl(src);
|
||||
if (!url.itemId) return null;
|
||||
const item = await Resource.load(url.itemId);
|
||||
if (!item) return null;
|
||||
return `${getResourceBaseUrl()}/${resourceFilename(item)}`;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ export default class NoteListUtils {
|
||||
|
||||
menu.append(new MenuItem({ type: 'separator' }));
|
||||
|
||||
if ([9, 10].includes(Setting.value('sync.target'))) {
|
||||
if ([9, 10, 11].includes(Setting.value('sync.target'))) {
|
||||
menu.append(
|
||||
new MenuItem(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
|
||||
@@ -53,6 +53,10 @@ const { rootProfileDir } = determineBaseAppDirs(profileFromArgs, appName, altIns
|
||||
// various places early in the initialisation code.
|
||||
mkdirpSync(rootProfileDir);
|
||||
|
||||
// Required for correct display of Windows notifications. Should be done near the beginning of startup. See
|
||||
// https://www.electron.build/nsis.html#guid-vs-application-name
|
||||
electronApp.setAppUserModelId(appId);
|
||||
|
||||
const settingsPath = `${rootProfileDir}/settings.json`;
|
||||
let autoUploadCrashDumps = false;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/app-desktop",
|
||||
"version": "3.4.5",
|
||||
"version": "3.4.13",
|
||||
"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.6",
|
||||
"@types/node": "18.19.100",
|
||||
"@types/react": "18.3.21",
|
||||
"@types/node": "18.19.103",
|
||||
"@types/react": "18.3.23",
|
||||
"@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/')) {
|
||||
|
||||
@@ -82,7 +82,7 @@ async function main() {
|
||||
const files = [
|
||||
'@fortawesome/fontawesome-free/css/all.min.css',
|
||||
'@joeattardi/emoji-button/dist/index.js',
|
||||
'codemirror/addon/dialog/dialog.css',
|
||||
'codemirror/addon/',
|
||||
'codemirror/lib/codemirror.css',
|
||||
'mark.js/dist/mark.min.js',
|
||||
'roboto-fontface/css/roboto/roboto-fontface.css',
|
||||
|
||||
@@ -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 2097777
|
||||
versionName "3.4.4"
|
||||
versionCode 2097780
|
||||
versionName "3.4.7"
|
||||
ndk {
|
||||
abiFilters "armeabi-v7a", "x86", "arm64-v8a", "x86_64"
|
||||
}
|
||||
|
||||
@@ -54,6 +54,14 @@ const Camera = (props: Props, ref: ForwardedRef<CameraRef>) => {
|
||||
logger.error(message);
|
||||
}, []);
|
||||
|
||||
const isReadyRef = useRef(false);
|
||||
const onCameraReady = useCallback(() => {
|
||||
if (isReadyRef.current) return; // Already emitted
|
||||
|
||||
isReadyRef.current = true;
|
||||
props.onCameraReady();
|
||||
}, [props.onCameraReady]);
|
||||
|
||||
useAsyncEffect(async (event) => {
|
||||
// iOS issue workaround: Since upgrading to Expo SDK 52, closing and reopening the camera on iOS
|
||||
// never emits onCameraReady. As a workaround, call .resumePreview and wait for it to resolve,
|
||||
@@ -63,16 +71,16 @@ const Camera = (props: Props, ref: ForwardedRef<CameraRef>) => {
|
||||
// Instead, wait for the preview to start using resumePreview:
|
||||
await camera.resumePreview();
|
||||
if (event.cancelled) return;
|
||||
props.onCameraReady();
|
||||
onCameraReady();
|
||||
}
|
||||
}, [camera, props.onCameraReady]);
|
||||
}, [camera, onCameraReady]);
|
||||
|
||||
return hasPermission?.granted ? <CameraView
|
||||
ref={setCamera}
|
||||
style={props.style}
|
||||
facing={props.cameraType === CameraDirection.Front ? 'front' : 'back'}
|
||||
ratio={props.ratio as CameraRatio}
|
||||
onCameraReady={Platform.OS === 'android' ? props.onCameraReady : undefined}
|
||||
onCameraReady={onCameraReady}
|
||||
onMountError={onMountError}
|
||||
animateShutter={false}
|
||||
barcodeScannerSettings={barcodeScannerSettings}
|
||||
|
||||
@@ -11,6 +11,7 @@ import SearchInput from './SearchInput';
|
||||
import focusView from '../utils/focusView';
|
||||
import AsyncActionQueue from '@joplin/lib/AsyncActionQueue';
|
||||
import NestableFlatList, { NestableFlatListControl } from './NestableFlatList';
|
||||
import useKeyboardState from '../utils/hooks/useKeyboardState';
|
||||
const naturalCompare = require('string-natural-compare');
|
||||
|
||||
|
||||
@@ -151,7 +152,12 @@ const useSelectedIndex = (search: string, searchResults: Option[]) => {
|
||||
};
|
||||
|
||||
const useStyles = (themeId: number, showSearchResults: boolean) => {
|
||||
const { fontScale } = useWindowDimensions();
|
||||
const { fontScale, height: screenHeight } = useWindowDimensions();
|
||||
const { dockedKeyboardHeight: keyboardHeight } = useKeyboardState();
|
||||
|
||||
// Allow the search results size to decrease when the keyboard is visible.
|
||||
const searchResultsHeight = Math.max(128, Math.min(200, (screenHeight - keyboardHeight) / 3));
|
||||
|
||||
const menuItemHeight = 40 * fontScale;
|
||||
const theme = themeStyle(themeId);
|
||||
|
||||
@@ -187,7 +193,7 @@ const useStyles = (themeId: number, showSearchResults: boolean) => {
|
||||
minHeight: 32,
|
||||
},
|
||||
searchResults: {
|
||||
height: 200,
|
||||
height: searchResultsHeight,
|
||||
flexGrow: 1,
|
||||
flexShrink: 1,
|
||||
...(showSearchResults ? {} : {
|
||||
@@ -220,7 +226,7 @@ const useStyles = (themeId: number, showSearchResults: boolean) => {
|
||||
backgroundColor: theme.selectedColor,
|
||||
},
|
||||
});
|
||||
}, [theme, menuItemHeight, showSearchResults]);
|
||||
}, [theme, menuItemHeight, searchResultsHeight, showSearchResults]);
|
||||
|
||||
return { menuItemHeight, styles };
|
||||
};
|
||||
@@ -540,6 +546,7 @@ const ComboBox: React.FC<Props> = ({
|
||||
};
|
||||
const activeId = `${baseId}-${selectedIndex}`;
|
||||
const searchResults = <NestableFlatList
|
||||
keyboardShouldPersistTaps="handled"
|
||||
ref={listRef}
|
||||
data={results}
|
||||
{...searchResultProps}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from 'react';
|
||||
import { RefObject, useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { GestureResponderEvent, Modal, ModalProps, Platform, Pressable, ScrollView, ScrollViewProps, StyleSheet, View, ViewStyle } from 'react-native';
|
||||
import { GestureResponderEvent, KeyboardAvoidingView, Modal, ModalProps, Platform, Pressable, ScrollView, ScrollViewProps, StyleSheet, View, ViewStyle } from 'react-native';
|
||||
import FocusControl from './accessibility/FocusControl/FocusControl';
|
||||
import { msleep, Second } from '@joplin/utils/time';
|
||||
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
|
||||
@@ -8,7 +8,7 @@ import { ModalState } from './accessibility/FocusControl/types';
|
||||
import useSafeAreaPadding from '../utils/hooks/useSafeAreaPadding';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
|
||||
interface ModalElementProps extends ModalProps {
|
||||
export interface ModalElementProps extends ModalProps {
|
||||
children: React.ReactNode;
|
||||
containerStyle?: ViewStyle;
|
||||
backgroundColor?: string;
|
||||
@@ -27,11 +27,23 @@ interface ModalElementProps extends ModalProps {
|
||||
const useStyles = (hasScrollView: boolean, backgroundColor: string|undefined) => {
|
||||
const safeAreaPadding = useSafeAreaPadding();
|
||||
return useMemo(() => {
|
||||
// On Android, the top-level container seems to need to be absolutely positioned
|
||||
// to prevent it from being larger than the screen size:
|
||||
const absoluteFill = {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
} satisfies ViewStyle;
|
||||
|
||||
return StyleSheet.create({
|
||||
modalBackground: {
|
||||
...safeAreaPadding,
|
||||
flexGrow: 1,
|
||||
flexShrink: 1,
|
||||
...(hasScrollView ? {
|
||||
flexGrow: 1,
|
||||
flexShrink: 1,
|
||||
} : absoluteFill),
|
||||
|
||||
// When hasScrollView, the modal background is wrapped in a ScrollView. In this case, it's
|
||||
// possible to scroll content outside the background into view. To prevent the edge of the
|
||||
@@ -39,6 +51,10 @@ const useStyles = (hasScrollView: boolean, backgroundColor: string|undefined) =>
|
||||
// instead:
|
||||
backgroundColor: hasScrollView ? null : backgroundColor,
|
||||
},
|
||||
keyboardAvoidingView: {
|
||||
...absoluteFill,
|
||||
flex: 1,
|
||||
},
|
||||
modalScrollView: {
|
||||
backgroundColor,
|
||||
flexGrow: 1,
|
||||
@@ -159,11 +175,13 @@ const ModalElement: React.FC<ModalElementProps> = ({
|
||||
{...modalProps}
|
||||
>
|
||||
{scrollOverflow ? (
|
||||
<ScrollView
|
||||
{...extraScrollViewProps}
|
||||
style={[styles.modalScrollView, extraScrollViewProps.style]}
|
||||
contentContainerStyle={[styles.modalScrollViewContent, extraScrollViewProps.contentContainerStyle]}
|
||||
>{contentAndBackdrop}</ScrollView>
|
||||
<KeyboardAvoidingView behavior='padding' style={styles.keyboardAvoidingView}>
|
||||
<ScrollView
|
||||
{...extraScrollViewProps}
|
||||
style={[styles.modalScrollView, extraScrollViewProps.style]}
|
||||
contentContainerStyle={[styles.modalScrollViewContent, extraScrollViewProps.contentContainerStyle]}
|
||||
>{contentAndBackdrop}</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
) : contentAndBackdrop}
|
||||
</Modal>
|
||||
</FocusControl.ModalWrapper>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useMemo } from 'react';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
import { themeStyle } from './global-style';
|
||||
|
||||
import Modal from './Modal';
|
||||
import Modal, { ModalElementProps } from './Modal';
|
||||
import { PrimaryButton } from './buttons';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { Button } from 'react-native-paper';
|
||||
@@ -11,6 +11,7 @@ import { Button } from 'react-native-paper';
|
||||
interface Props {
|
||||
themeId: number;
|
||||
children: React.ReactNode;
|
||||
modalProps: Partial<ModalElementProps>;
|
||||
|
||||
buttonBarEnabled: boolean;
|
||||
okTitle: string;
|
||||
@@ -27,19 +28,15 @@ const useStyles = (themeId: number) => {
|
||||
borderRadius: 4,
|
||||
backgroundColor: theme.backgroundColor,
|
||||
maxWidth: 600,
|
||||
maxHeight: 500,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
alignSelf: 'center',
|
||||
marginVertical: 'auto',
|
||||
flexGrow: 1,
|
||||
flexShrink: 1,
|
||||
padding: theme.margin,
|
||||
},
|
||||
title: theme.headerStyle,
|
||||
contentWrapper: {
|
||||
flexGrow: 1,
|
||||
flexShrink: 1,
|
||||
},
|
||||
buttonRow: {
|
||||
flexDirection: 'row',
|
||||
@@ -66,6 +63,7 @@ const ModalDialog: React.FC<Props> = props => {
|
||||
onRequestClose={null}
|
||||
containerStyle={styles.container}
|
||||
backgroundColor={theme.backgroundColorTransparent2}
|
||||
{...props.modalProps}
|
||||
>
|
||||
<View style={styles.contentWrapper}>{props.children}</View>
|
||||
<View style={styles.buttonRow}>
|
||||
|
||||
@@ -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,16 +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,
|
||||
onLocalize: _,
|
||||
},
|
||||
webviewRef,
|
||||
pluginStates: props.plugins,
|
||||
});
|
||||
|
||||
props.editorRef.current = editorWebViewSetup.api.editor;
|
||||
props.editorRef.current = editorWebViewSetup.api.mainEditor;
|
||||
|
||||
const injectedJavaScript = `
|
||||
window.onerror = (message, source, lineno) => {
|
||||
@@ -154,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;
|
||||
|
||||
@@ -183,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}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { WebViewControl } from '../ExtendedWebView/types';
|
||||
import * as React from 'react';
|
||||
import { Ref, RefObject, useEffect, useImperativeHandle } from 'react';
|
||||
import { useMemo, useState, useCallback, useRef } from 'react';
|
||||
import { LayoutChangeEvent, View, ViewStyle } from 'react-native';
|
||||
import { LayoutChangeEvent, Platform, View, ViewStyle } from 'react-native';
|
||||
import { editorFont } from '../global-style';
|
||||
|
||||
import { EditorControl as EditorBodyControl, ContentScriptData } from '@joplin/editor/types';
|
||||
@@ -32,6 +32,7 @@ import { dirname } from '@joplin/utils/path';
|
||||
import { toFileExtension } from '@joplin/lib/mime-utils';
|
||||
import { MarkupLanguage } from '@joplin/renderer';
|
||||
import WarningBanner from './WarningBanner';
|
||||
import useIsScreenReaderEnabled from '../../utils/hooks/useIsScreenReaderEnabled';
|
||||
|
||||
type ChangeEventHandler = (event: ChangeEvent)=> void;
|
||||
type UndoRedoDepthChangeHandler = (event: UndoRedoDepthChangeEvent)=> void;
|
||||
@@ -232,15 +233,28 @@ const useEditorControl = (
|
||||
onResourceDownloaded: (id: string) => {
|
||||
editorRef.current.onResourceDownloaded(id);
|
||||
},
|
||||
|
||||
remove: () => {
|
||||
editorRef.current.remove();
|
||||
},
|
||||
};
|
||||
|
||||
return control;
|
||||
}, [webviewRef, editorRef, setLinkDialogVisible, setSearchState]);
|
||||
};
|
||||
|
||||
const useHighlightActiveLine = () => {
|
||||
const screenReaderEnabled = useIsScreenReaderEnabled();
|
||||
// Guess whether highlighting the active line can be enabled without triggering
|
||||
// https://github.com/codemirror/dev/issues/1559.
|
||||
const canHighlight = Platform.OS !== 'ios' || !screenReaderEnabled;
|
||||
return canHighlight && Setting.value('editor.highlightActiveLine');
|
||||
};
|
||||
|
||||
function NoteEditor(props: Props) {
|
||||
const webviewRef = useRef<WebViewControl>(null);
|
||||
|
||||
const highlightActiveLine = useHighlightActiveLine();
|
||||
const editorSettings: EditorSettings = useMemo(() => ({
|
||||
themeData: editorTheme(props.themeId),
|
||||
markdownMarkEnabled: Setting.value('markdown.plugin.mark'),
|
||||
@@ -251,6 +265,7 @@ function NoteEditor(props: Props) {
|
||||
language: props.markupLanguage === MarkupLanguage.Html ? EditorLanguageType.Html : EditorLanguageType.Markdown,
|
||||
useExternalSearch: true,
|
||||
readOnly: props.readOnly,
|
||||
highlightActiveLine,
|
||||
|
||||
keymap: EditorKeymap.Default,
|
||||
|
||||
@@ -263,7 +278,7 @@ function NoteEditor(props: Props) {
|
||||
indentWithTabs: true,
|
||||
|
||||
editorLabel: _('Markdown editor'),
|
||||
}), [props.themeId, props.readOnly, props.markupLanguage]);
|
||||
}), [props.themeId, props.readOnly, props.markupLanguage, highlightActiveLine]);
|
||||
|
||||
const [selectionState, setSelectionState] = useState<SelectionFormatting>(defaultSelectionFormatting);
|
||||
const [linkDialogVisible, setLinkDialogVisible] = useState(false);
|
||||
@@ -300,6 +315,7 @@ function NoteEditor(props: Props) {
|
||||
editorControl.searchControl.hideSearch();
|
||||
}
|
||||
break;
|
||||
case EditorEventType.Remove:
|
||||
case EditorEventType.Scroll:
|
||||
// Not handled
|
||||
break;
|
||||
|
||||
@@ -288,6 +288,26 @@ describe('RichTextEditor', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should avoid rendering URLs with unknown protocols', async () => {
|
||||
let body = '[link](unknown://test)';
|
||||
|
||||
render(<WrappedEditor
|
||||
noteBody={body}
|
||||
onBodyChange={newBody => { body = newBody; }}
|
||||
/>);
|
||||
|
||||
const renderedLink = await findElement<HTMLAnchorElement>('a[href][data-original-href]');
|
||||
expect(renderedLink.getAttribute('href')).toBe('#');
|
||||
expect(renderedLink.getAttribute('data-original-href')).toBe('unknown://test');
|
||||
|
||||
const window = await getEditorWindow();
|
||||
mockTyping(window, ' testing');
|
||||
|
||||
await waitFor(async () => {
|
||||
expect(body.trim()).toBe('[link](unknown://test) testing');
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
MarkupLanguage.Markdown, MarkupLanguage.Html,
|
||||
])('should preserve image attachments on edit (case %#)', async (markupLanguage) => {
|
||||
@@ -369,4 +389,85 @@ 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 save lists as single-spaced', async () => {
|
||||
let body = 'Test:\n\n- this\n- is\n- a\n- test.';
|
||||
|
||||
render(<WrappedEditor
|
||||
noteBody={body}
|
||||
onBodyChange={newBody => { body = newBody; }}
|
||||
/>);
|
||||
|
||||
const window = await getEditorWindow();
|
||||
mockTyping(window, ' Testing');
|
||||
|
||||
await waitFor(async () => {
|
||||
expect(body.trim()).toBe('Test:\n\n- this\n- is\n- a\n- test. Testing');
|
||||
});
|
||||
});
|
||||
|
||||
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`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ import { saveProfileConfig, switchProfile } from '../../services/profiles';
|
||||
import { themeStyle } from '../global-style';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import { DialogContext } from '../DialogManager';
|
||||
import { FAB, List, Portal } from 'react-native-paper';
|
||||
import { FAB, List } from 'react-native-paper';
|
||||
import { TextStyle } from 'react-native';
|
||||
import useOnLongPressProps from '../../utils/hooks/useOnLongPressProps';
|
||||
import { Dispatch } from 'redux';
|
||||
@@ -206,19 +206,17 @@ export default (props: Props) => {
|
||||
extraData={extraListItemData}
|
||||
/>
|
||||
</View>
|
||||
<Portal>
|
||||
<FAB
|
||||
icon="plus"
|
||||
accessibilityLabel={_('New profile')}
|
||||
style={style.fab}
|
||||
onPress={() => {
|
||||
props.dispatch({
|
||||
type: 'NAV_GO',
|
||||
routeName: 'ProfileEditor',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Portal>
|
||||
<FAB
|
||||
icon="plus"
|
||||
accessibilityLabel={_('New profile')}
|
||||
style={style.fab}
|
||||
onPress={() => {
|
||||
props.dispatch({
|
||||
type: 'NAV_GO',
|
||||
routeName: 'ProfileEditor',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -101,12 +101,24 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const styleObject: any = {
|
||||
container: {
|
||||
outerContainer: {
|
||||
flexDirection: 'column',
|
||||
},
|
||||
innerContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: theme.backgroundColor2,
|
||||
shadowColor: '#000000',
|
||||
elevation: 5,
|
||||
},
|
||||
// A small border above the header: Covers the part of the shadow that would otherwise
|
||||
// be shown above the header on Android.
|
||||
aboveHeader: {
|
||||
backgroundColor: theme.backgroundColor2,
|
||||
paddingBottom: 6,
|
||||
marginTop: -6,
|
||||
zIndex: 2,
|
||||
},
|
||||
sideMenuButton: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
@@ -678,8 +690,9 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={this.styles().container}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
<View style={this.styles().outerContainer}>
|
||||
<View style={this.styles().aboveHeader}/>
|
||||
<View style={this.styles().innerContainer}>
|
||||
{sideMenuComp}
|
||||
{backButtonComp}
|
||||
{renderUndoButton()}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -51,7 +51,7 @@ const useStyles = (themeId: number, headerStyle: TextStyle|undefined) => {
|
||||
},
|
||||
tagBoxRoot: {
|
||||
flexDirection: 'column',
|
||||
flexGrow: 1,
|
||||
flexGrow: 0.5,
|
||||
flexShrink: 1,
|
||||
},
|
||||
tagBoxScrollView: {
|
||||
@@ -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:
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import * as React from 'react';
|
||||
import { ConfigScreenStyles } from '../configScreenStyles';
|
||||
import Icon from '../../../Icon';
|
||||
import BetaChip from '../../../BetaChip';
|
||||
import { TouchableRipple, Text } from 'react-native-paper';
|
||||
import { View } from 'react-native';
|
||||
import Setting, { AppType, SettingMetadataSection } from '@joplin/lib/models/Setting';
|
||||
@@ -21,9 +20,6 @@ const SectionTab: React.FC<Props> = ({ styles, onPress, selected, section }) =>
|
||||
const styleSheet = styles.styleSheet;
|
||||
const titleStyle = selected ? styleSheet.sidebarSelectedButtonText : styleSheet.sidebarButtonMainText;
|
||||
|
||||
const isBeta = section.name === 'plugins';
|
||||
const betaChip = isBeta ? <BetaChip size={10}/> : null;
|
||||
|
||||
return (
|
||||
<TouchableRipple
|
||||
key={section.name}
|
||||
@@ -47,8 +43,6 @@ const SectionTab: React.FC<Props> = ({ styles, onPress, selected, section }) =>
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
|
||||
{betaChip}
|
||||
</View>
|
||||
<Text
|
||||
style={styleSheet.sidebarButtonDescriptionText}
|
||||
|
||||
@@ -12,7 +12,6 @@ import useRepoApi from './utils/useRepoApi';
|
||||
import RepositoryApi from '@joplin/lib/services/plugins/RepositoryApi';
|
||||
import PluginInfoModal from './PluginInfoModal';
|
||||
import usePluginCallbacks from './utils/usePluginCallbacks';
|
||||
import BetaChip from '../../../BetaChip';
|
||||
import SectionLabel from './SectionLabel';
|
||||
|
||||
interface Props {
|
||||
@@ -191,10 +190,6 @@ const PluginStates: React.FC<Props> = props => {
|
||||
return (
|
||||
<View>
|
||||
{renderRepoApiStatus()}
|
||||
<Banner visible={true} elevation={0} icon={() => <BetaChip size={13}/>}>
|
||||
<Text>Plugin support on mobile is still in beta. Plugins may cause performance issues. Some have only partial support for Joplin mobile.</Text>
|
||||
</Banner>
|
||||
<Divider/>
|
||||
|
||||
{showSearch ? searchSection : null}
|
||||
<View style={styles.installedPluginsContainer}>
|
||||
|
||||
@@ -17,6 +17,13 @@ interface Props {
|
||||
tags: TagEntity[];
|
||||
}
|
||||
|
||||
const modalPropOverrides = {
|
||||
scrollOverflow: {
|
||||
// Prevent the keyboard from auto-dismissing when tapping outside the search input
|
||||
keyboardShouldPersistTaps: true,
|
||||
},
|
||||
};
|
||||
|
||||
const NoteTagsDialogComponent: React.FC<Props> = props => {
|
||||
const [noteId, setNoteId] = useState(props.noteId);
|
||||
const [savingTags, setSavingTags] = useState(false);
|
||||
@@ -57,6 +64,7 @@ const NoteTagsDialogComponent: React.FC<Props> = props => {
|
||||
buttonBarEnabled={!savingTags}
|
||||
okTitle={_('Apply')}
|
||||
cancelTitle={_('Cancel')}
|
||||
modalProps={modalPropOverrides}
|
||||
>
|
||||
<TagEditor
|
||||
themeId={props.themeId}
|
||||
@@ -64,6 +72,7 @@ const NoteTagsDialogComponent: React.FC<Props> = props => {
|
||||
allTags={props.tags}
|
||||
onTagsChange={setNoteTags}
|
||||
mode={TagEditorMode.Large}
|
||||
searchResultProps={{ nestedScrollEnabled: true }}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</ModalDialog>;
|
||||
|
||||
@@ -1,30 +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,
|
||||
onLocalize,
|
||||
}: 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,
|
||||
onLocalize: messenger.remoteApi.onLocalize,
|
||||
|
||||
onPasteFile: async (data) => {
|
||||
const base64 = await readFileToBase64(data);
|
||||
@@ -34,14 +61,32 @@ export const initializeEditor = ({
|
||||
onLogMessage: message => {
|
||||
void messenger.remoteApi.logMessage(message);
|
||||
},
|
||||
onEvent: (event): void => {
|
||||
void messenger.remoteApi.onEditorEvent(event);
|
||||
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);
|
||||
},
|
||||
});
|
||||
|
||||
// Works around https://github.com/laurent22/joplin/issues/10047 by handling
|
||||
// the text/uri-list MIME type when pasting, rather than sending the paste event
|
||||
// to CodeMirror.
|
||||
@@ -57,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) {
|
||||
@@ -64,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, OnLocalize } 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,16 +31,10 @@ export interface SelectionRange {
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface EditorProps {
|
||||
parentElementClassName: string;
|
||||
initialText: string;
|
||||
initialNoteId: string;
|
||||
onLocalize: OnLocalize;
|
||||
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,6 +7,9 @@ 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');
|
||||
@@ -15,9 +18,10 @@ 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;
|
||||
|
||||
@@ -33,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');
|
||||
@@ -51,20 +57,21 @@ const useWebViewSetup = ({
|
||||
` : '';
|
||||
|
||||
const injectedJavaScript = useMemo(() => `
|
||||
if (typeof window.markdownEditorBundle === 'undefined') {
|
||||
${shim.injectedJs('markdownEditorBundle')};
|
||||
window.markdownEditorBundle = markdownEditorBundle;
|
||||
markdownEditorBundle.setUpLogger();
|
||||
}
|
||||
|
||||
if (!window.cm) {
|
||||
const parentClassName = ${JSON.stringify(editorOptions.parentElementClassName)};
|
||||
const foundParent = document.getElementsByClassName(parentClassName).length > 0;
|
||||
const parentClassName = ${JSON.stringify(editorOptions?.parentElementOrClassName)};
|
||||
const foundParent = !!parentClassName && document.getElementsByClassName(parentClassName).length > 0;
|
||||
|
||||
// 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) {
|
||||
${shim.injectedJs('markdownEditorBundle')};
|
||||
markdownEditorBundle.setUpLogger();
|
||||
|
||||
window.cm = markdownEditorBundle.initializeEditor(
|
||||
${JSON.stringify(editorOptions)}
|
||||
);
|
||||
window.cm = markdownEditorBundle.createMainEditor(${JSON.stringify(editorOptions)});
|
||||
|
||||
${jumpToHashJs}
|
||||
// Set the initial selection after jumping to the header -- the initial selection,
|
||||
@@ -75,7 +82,7 @@ const useWebViewSetup = ({
|
||||
window.onresize = () => {
|
||||
cm.execCommand('scrollSelectionIntoView');
|
||||
};
|
||||
} else {
|
||||
} else if (parentClassName) {
|
||||
console.log('No parent element found with class name ', parentClassName);
|
||||
}
|
||||
}
|
||||
@@ -101,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) {
|
||||
@@ -112,6 +123,13 @@ 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;
|
||||
@@ -153,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;
|
||||
|
||||
@@ -17,6 +17,7 @@ import Resource from '@joplin/lib/models/Resource';
|
||||
import { ResourceInfos } from '@joplin/renderer/types';
|
||||
import useContentScripts from './utils/useContentScripts';
|
||||
import uuid from '@joplin/lib/uuid';
|
||||
import AsyncActionQueue from '@joplin/lib/AsyncActionQueue';
|
||||
|
||||
const logger = Logger.create('renderer/useWebViewSetup');
|
||||
|
||||
@@ -149,6 +150,8 @@ const useWebViewSetup = (props: Props): SetUpResult<RendererControl> => {
|
||||
void messenger.remoteApi.renderer.setExtraContentScriptsAndRerender(contentScripts);
|
||||
}, [messenger, contentScripts]);
|
||||
|
||||
const onRerenderRequestRef = useRef(()=>{});
|
||||
|
||||
const rendererControl = useMemo((): RendererControl => {
|
||||
const renderer = messenger.remoteApi.renderer;
|
||||
|
||||
@@ -185,7 +188,7 @@ const useWebViewSetup = (props: Props): SetUpResult<RendererControl> => {
|
||||
};
|
||||
|
||||
let settingsChanged = false;
|
||||
const settings: RenderSettings = {
|
||||
const getSettings = (): RenderSettings => ({
|
||||
...options,
|
||||
codeTheme: theme.codeThemeCss,
|
||||
// We .stringify the theme to avoid a JSON serialization error involving
|
||||
@@ -201,6 +204,7 @@ const useWebViewSetup = (props: Props): SetUpResult<RendererControl> => {
|
||||
const key = `${pluginId}.${settingKey}`;
|
||||
if (!pluginSettingKeysRef.current.has(key)) {
|
||||
pluginSettingKeysRef.current.add(key);
|
||||
onRerenderRequestRef.current();
|
||||
settingsChanged = true;
|
||||
}
|
||||
},
|
||||
@@ -220,12 +224,12 @@ const useWebViewSetup = (props: Props): SetUpResult<RendererControl> => {
|
||||
return shim.fsDriver().fileAtPath(resolvedPath);
|
||||
},
|
||||
removeUnusedPluginAssets: options.removeUnusedPluginAssets,
|
||||
};
|
||||
});
|
||||
|
||||
await transferResources(options.resources);
|
||||
|
||||
return {
|
||||
settings,
|
||||
getSettings,
|
||||
getSettingsChanged() {
|
||||
return settingsChanged;
|
||||
},
|
||||
@@ -234,23 +238,28 @@ const useWebViewSetup = (props: Props): SetUpResult<RendererControl> => {
|
||||
|
||||
return {
|
||||
rerenderToBody: async (markup, options, cancelEvent) => {
|
||||
const { settings, getSettingsChanged } = await prepareRenderer(options);
|
||||
const { getSettings } = await prepareRenderer(options);
|
||||
if (cancelEvent?.cancelled) return null;
|
||||
|
||||
const output = await renderer.rerenderToBody(markup, settings);
|
||||
if (cancelEvent?.cancelled) return null;
|
||||
const render = async () => {
|
||||
if (cancelEvent?.cancelled) return;
|
||||
|
||||
if (getSettingsChanged()) {
|
||||
return await renderer.rerenderToBody(markup, settings);
|
||||
}
|
||||
return output;
|
||||
await renderer.rerenderToBody(markup, getSettings());
|
||||
};
|
||||
|
||||
const queue = new AsyncActionQueue();
|
||||
onRerenderRequestRef.current = async () => {
|
||||
queue.push(render);
|
||||
};
|
||||
|
||||
return await render();
|
||||
},
|
||||
render: async (markup, options) => {
|
||||
const { settings, getSettingsChanged } = await prepareRenderer(options);
|
||||
const output = await renderer.render(markup, settings);
|
||||
const { getSettings, getSettingsChanged } = await prepareRenderer(options);
|
||||
const output = await renderer.render(markup, getSettings());
|
||||
|
||||
if (getSettingsChanged()) {
|
||||
return await renderer.render(markup, settings);
|
||||
return await renderer.render(markup, getSettings());
|
||||
}
|
||||
return output;
|
||||
},
|
||||
|
||||
@@ -7,17 +7,8 @@ import '@joplin/editor/ProseMirror/styles';
|
||||
import readFileToBase64 from '../../utils/readFileToBase64';
|
||||
import { EditorLanguageType } from '@joplin/editor/types';
|
||||
import convertHtmlToMarkdown from './convertHtmlToMarkdown';
|
||||
|
||||
const postprocessHtml = (html: HTMLElement) => {
|
||||
// Fix resource URLs
|
||||
const resources = html.querySelectorAll<HTMLImageElement>('img[data-resource-id]');
|
||||
for (const resource of resources) {
|
||||
const resourceId = resource.getAttribute('data-resource-id');
|
||||
resource.src = `:/${resourceId}`;
|
||||
}
|
||||
|
||||
return html;
|
||||
};
|
||||
import { ExportedWebViewGlobals as MarkdownEditorWebViewGlobals } from '../../markdownEditorBundle/types';
|
||||
import { EditorEventType } from '@joplin/editor/events';
|
||||
|
||||
const wrapHtmlForMarkdownConversion = (html: HTMLElement) => {
|
||||
// Add a container element -- when converting to HTML, Turndown
|
||||
@@ -30,18 +21,19 @@ const wrapHtmlForMarkdownConversion = (html: HTMLElement) => {
|
||||
|
||||
|
||||
const htmlToMarkdown = (html: HTMLElement): string => {
|
||||
html = postprocessHtml(html);
|
||||
|
||||
return convertHtmlToMarkdown(html);
|
||||
};
|
||||
|
||||
export const initialize = async ({
|
||||
settings,
|
||||
initialText,
|
||||
initialNoteId,
|
||||
parentElementClassName,
|
||||
initialSearch,
|
||||
}: 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');
|
||||
@@ -86,29 +78,25 @@ export const initialize = async ({
|
||||
removeUnusedPluginAssets: options.isFullPageRender,
|
||||
});
|
||||
},
|
||||
renderHtmlToMarkup: (node) => {
|
||||
// By default, if `src` is specified on an image, the browser will try to load the image, even if it isn't added
|
||||
// to the DOM. (A similar problem is described here: https://stackoverflow.com/q/62019538).
|
||||
// Since :/resourceId isn't a valid image URI, this results in a large number of warnings. As a workaround,
|
||||
// move the element to a temporary document before processing:
|
||||
const dom = document.implementation.createHTMLDocument();
|
||||
node = dom.importNode(node, true);
|
||||
|
||||
let html: HTMLElement;
|
||||
if ((node instanceof HTMLElement)) {
|
||||
html = node;
|
||||
} else {
|
||||
const container = document.createElement('div');
|
||||
container.appendChild(html);
|
||||
html = container;
|
||||
}
|
||||
|
||||
renderHtmlToMarkup: (html) => {
|
||||
if (settings.language === EditorLanguageType.Markdown) {
|
||||
return htmlToMarkdown(wrapHtmlForMarkdownConversion(html));
|
||||
} else {
|
||||
return postprocessHtml(html).outerHTML;
|
||||
return 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);
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -92,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);
|
||||
@@ -100,6 +104,8 @@ 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 = {
|
||||
@@ -117,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 {
|
||||
@@ -125,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> => {
|
||||
@@ -148,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);
|
||||
@@ -163,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;
|
||||
|
||||
@@ -363,6 +363,7 @@
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/boost/boost_privacy.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/glog/glog_privacy.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/react-native-image-picker/RNImagePickerPrivacyInfo.bundle",
|
||||
);
|
||||
name = "[CP] Copy Pods Resources";
|
||||
@@ -394,6 +395,7 @@
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/boost_privacy.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/glog_privacy.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNImagePickerPrivacyInfo.bundle",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@@ -533,7 +535,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 143;
|
||||
CURRENT_PROJECT_VERSION = 145;
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Joplin/Info.plist;
|
||||
@@ -542,7 +544,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 13.4.1;
|
||||
MARKETING_VERSION = 13.4.3;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
@@ -568,7 +570,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 143;
|
||||
CURRENT_PROJECT_VERSION = 145;
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
INFOPLIST_FILE = Joplin/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
|
||||
@@ -576,7 +578,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 13.4.1;
|
||||
MARKETING_VERSION = 13.4.3;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
@@ -769,7 +771,7 @@
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 143;
|
||||
CURRENT_PROJECT_VERSION = 145;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
@@ -780,7 +782,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 13.4.1;
|
||||
MARKETING_VERSION = 13.4.3;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
OTHER_LDFLAGS = (
|
||||
@@ -812,7 +814,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 143;
|
||||
CURRENT_PROJECT_VERSION = 145;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
@@ -823,7 +825,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 13.4.1;
|
||||
MARKETING_VERSION = 13.4.3;
|
||||
MTL_FAST_MATH = YES;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
|
||||
@@ -1458,7 +1458,7 @@ PODS:
|
||||
- Yoga
|
||||
- react-native-get-random-values (1.11.0):
|
||||
- React-Core
|
||||
- react-native-image-picker (7.2.3):
|
||||
- react-native-image-picker (8.0.0):
|
||||
- DoubleConversion
|
||||
- glog
|
||||
- hermes-engine
|
||||
@@ -1514,9 +1514,9 @@ PODS:
|
||||
- Yoga
|
||||
- react-native-rsa-native (2.0.5):
|
||||
- React
|
||||
- react-native-saf-x (3.4.0):
|
||||
- react-native-saf-x (3.4.1):
|
||||
- React-Core
|
||||
- react-native-safe-area-context (5.4.0):
|
||||
- react-native-safe-area-context (5.4.1):
|
||||
- React-Core
|
||||
- react-native-sqlite-storage (6.0.1):
|
||||
- React-Core
|
||||
@@ -2285,7 +2285,7 @@ EXTERNAL SOURCES:
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90
|
||||
DoubleConversion: 76ab83afb40bddeeee456813d9c04f67f78771b5
|
||||
DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb
|
||||
EXAV: ae28256069c4cdde93d185c007d8f68d92902c2e
|
||||
EXConstants: 98bcf0f22b820f9b28f9fee55ff2daededadd2f8
|
||||
Expo: 4b1c6de7c441e1caa1918671ae0aa34d51f019a5
|
||||
@@ -2298,7 +2298,7 @@ SPEC CHECKSUMS:
|
||||
fast_float: 06eeec4fe712a76acc9376682e4808b05ce978b6
|
||||
FBLazyVector: 84b955f7b4da8b895faf5946f73748267347c975
|
||||
fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd
|
||||
glog: c5d68082e772fa1c511173d6b30a9de2c05a69a2
|
||||
glog: 5683914934d5b6e4240e497e0f4a3b42d1854183
|
||||
hermes-engine: 314be5250afa5692b57b4dd1705959e1973a8ebe
|
||||
JoplinCommonShareExtension: a8b60b02704d85a7305627912c0240e94af78db7
|
||||
JoplinRNShareExtension: e158a4b53ee0aa9cd3037a16221dc8adbd6f7860
|
||||
@@ -2338,13 +2338,13 @@ SPEC CHECKSUMS:
|
||||
react-native-document-picker: da39c5e4f279d39c0356dca157b98f9dc349e5bb
|
||||
react-native-geolocation: ec15ffebc53790314885eb9e5f2132132fbc2600
|
||||
react-native-get-random-values: d16467cf726c618e9c7a8c3c39c31faa2244bbba
|
||||
react-native-image-picker: 99fbcec11cf4679170a7cfba4e4d9f598297448c
|
||||
react-native-image-picker: 922b9ba90f144b5866d07d04b0fb2b4e9ab0ed75
|
||||
react-native-image-resizer: 24c5d06fae2176dc0caed4b6396e02befb44064a
|
||||
react-native-netinfo: cec9c4e86083cb5b6aba0e0711f563e2fbbff187
|
||||
react-native-quick-crypto: 988d8d57cd720dbe218272b60775a8e0210d0b80
|
||||
react-native-rsa-native: a7931cdda1f73a8576a46d7f431378c5550f0c38
|
||||
react-native-saf-x: 24ebe9aa153f82ec6726de459ae77508d68d5599
|
||||
react-native-safe-area-context: 9d72abf6d8473da73033b597090a80b709c0b2f1
|
||||
react-native-saf-x: 3f8b52fb8160d7322161dec02a564271cc8f4138
|
||||
react-native-safe-area-context: dde2052b903c11d677c320b599c3244021c34ce8
|
||||
react-native-sqlite-storage: 0c84826214baaa498796c7e46a5ccc9a82e114ed
|
||||
react-native-version-info: f0b04e16111c4016749235ff6d9a757039189141
|
||||
react-native-webview: 1b5778b306d4ed09d13829a6e7a6550e3c1a644a
|
||||
|
||||
@@ -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,7 +66,7 @@
|
||||
"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.11",
|
||||
"react-native-sqlite-storage": "6.0.1",
|
||||
@@ -106,10 +106,10 @@
|
||||
"@testing-library/react-native": "13.2.0",
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/node": "18.19.100",
|
||||
"@types/node": "18.19.103",
|
||||
"@types/react": "19.0.14",
|
||||
"@types/react-redux": "7.1.33",
|
||||
"@types/serviceworker": "0.0.134",
|
||||
"@types/serviceworker": "0.0.135",
|
||||
"@types/tar-stream": "3.1.3",
|
||||
"babel-jest": "29.7.0",
|
||||
"babel-loader": "9.1.3",
|
||||
@@ -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.1",
|
||||
"sharp": "0.34.2",
|
||||
"sqlite3": "5.1.6",
|
||||
"timers-browserify": "2.0.12",
|
||||
"ts-jest": "29.3.1",
|
||||
|
||||
@@ -7,11 +7,17 @@ import libStateToWhenClauseContext, { WhenClauseContextOptions } from '@joplin/l
|
||||
import { AppState } from '../../utils/types';
|
||||
|
||||
const stateToWhenClauseContext = (state: AppState, options: WhenClauseContextOptions = null) => {
|
||||
const markdownEditorVisible = state.noteEditorVisible && state.settings['editor.codeView'];
|
||||
const richTextEditorVisible = state.noteEditorVisible && !state.settings['editor.codeView'];
|
||||
return {
|
||||
...libStateToWhenClauseContext(state, options),
|
||||
keyboardVisible: state.keyboardVisible,
|
||||
markdownEditorVisible: state.noteEditorVisible && state.settings['editor.codeView'],
|
||||
richTextEditorVisible: state.noteEditorVisible && !state.settings['editor.codeView'],
|
||||
|
||||
// Provide both markdownEditorPaneVisible and markdownEditorVisible for compatibility
|
||||
// with the desktop app.
|
||||
markdownEditorPaneVisible: markdownEditorVisible,
|
||||
markdownEditorVisible: markdownEditorVisible,
|
||||
richTextEditorVisible: richTextEditorVisible,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
24
packages/app-mobile/utils/hooks/useIsScreenReaderEnabled.ts
Normal file
24
packages/app-mobile/utils/hooks/useIsScreenReaderEnabled.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AccessibilityInfo } from 'react-native';
|
||||
|
||||
let lastScreenReaderEnabled = false;
|
||||
const useIsScreenReaderEnabled = () => {
|
||||
const [screenReaderEnabled, setIsScreenReaderEnabled] = useState(lastScreenReaderEnabled);
|
||||
useEffect(() => {
|
||||
AccessibilityInfo.addEventListener('screenReaderChanged', (enabled) => {
|
||||
lastScreenReaderEnabled = enabled;
|
||||
setIsScreenReaderEnabled(enabled);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useAsyncEffect(async () => {
|
||||
const enabled = await AccessibilityInfo.isScreenReaderEnabled();
|
||||
lastScreenReaderEnabled = enabled;
|
||||
setIsScreenReaderEnabled(enabled);
|
||||
}, []);
|
||||
|
||||
return screenReaderEnabled;
|
||||
};
|
||||
|
||||
export default useIsScreenReaderEnabled;
|
||||
@@ -5,19 +5,23 @@ const useKeyboardState = () => {
|
||||
const [keyboardVisible, setKeyboardVisible] = useState(false);
|
||||
const [hasSoftwareKeyboard, setHasSoftwareKeyboard] = useState(false);
|
||||
const [isFloatingKeyboard, setIsFloatingKeyboard] = useState(false);
|
||||
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
||||
useEffect(() => {
|
||||
const showListener = Keyboard.addListener('keyboardDidShow', () => {
|
||||
const showListener = Keyboard.addListener('keyboardDidShow', (evt) => {
|
||||
setKeyboardVisible(true);
|
||||
setHasSoftwareKeyboard(true);
|
||||
setKeyboardHeight(evt.endCoordinates.height);
|
||||
});
|
||||
const hideListener = Keyboard.addListener('keyboardDidHide', () => {
|
||||
setKeyboardVisible(false);
|
||||
setKeyboardHeight(0);
|
||||
});
|
||||
const floatingListener = Keyboard.addListener('keyboardWillChangeFrame', (evt) => {
|
||||
const windowWidth = Dimensions.get('window').width;
|
||||
// If the keyboard isn't as wide as the window, the floating keyboard is disabled.
|
||||
// See https://github.com/facebook/react-native/issues/29473#issuecomment-696658937
|
||||
setIsFloatingKeyboard(evt.endCoordinates.width < windowWidth);
|
||||
setKeyboardHeight(evt.endCoordinates.height);
|
||||
});
|
||||
|
||||
return (() => {
|
||||
@@ -28,8 +32,13 @@ const useKeyboardState = () => {
|
||||
});
|
||||
|
||||
return useMemo(() => {
|
||||
return { keyboardVisible, hasSoftwareKeyboard, isFloatingKeyboard };
|
||||
}, [keyboardVisible, hasSoftwareKeyboard, isFloatingKeyboard]);
|
||||
return {
|
||||
keyboardVisible,
|
||||
hasSoftwareKeyboard,
|
||||
isFloatingKeyboard,
|
||||
dockedKeyboardHeight: isFloatingKeyboard ? 0 : keyboardHeight,
|
||||
};
|
||||
}, [keyboardVisible, hasSoftwareKeyboard, isFloatingKeyboard, keyboardHeight]);
|
||||
};
|
||||
|
||||
export default useKeyboardState;
|
||||
|
||||
@@ -10,8 +10,8 @@ const useSafeAreaPadding = () => {
|
||||
return isLandscape ? {
|
||||
paddingRight: safeAreaInsets.right,
|
||||
paddingLeft: safeAreaInsets.left,
|
||||
paddingTop: 15,
|
||||
paddingBottom: 15,
|
||||
paddingTop: safeAreaInsets.top,
|
||||
paddingBottom: 0,
|
||||
} : {
|
||||
paddingTop: safeAreaInsets.top,
|
||||
paddingBottom: safeAreaInsets.bottom,
|
||||
|
||||
@@ -16,6 +16,8 @@ import { Prec } from '@codemirror/state';
|
||||
import insertNewlineContinueMarkup from './editorCommands/insertNewlineContinueMarkup';
|
||||
import renderingExtension from './extensions/rendering/renderingExtension';
|
||||
import { RenderedContentContext } from './extensions/rendering/types';
|
||||
import highlightActiveLineExtension from './extensions/highlightActiveLineExtension';
|
||||
import renderBlockImages from './extensions/rendering/renderBlockImages';
|
||||
|
||||
const configFromSettings = (settings: EditorSettings, context: RenderedContentContext) => {
|
||||
const languageExtension = (() => {
|
||||
@@ -87,9 +89,15 @@ const configFromSettings = (settings: EditorSettings, context: RenderedContentCo
|
||||
}
|
||||
|
||||
if (settings.inlineRenderingEnabled) {
|
||||
extensions.push(renderingExtension(context, {
|
||||
renderImages: settings.imageRenderingEnabled,
|
||||
}));
|
||||
extensions.push(renderingExtension());
|
||||
}
|
||||
|
||||
if (settings.imageRenderingEnabled) {
|
||||
extensions.push(renderBlockImages(context));
|
||||
}
|
||||
|
||||
if (settings.highlightActiveLine) {
|
||||
extensions.push(highlightActiveLineExtension());
|
||||
}
|
||||
|
||||
return extensions;
|
||||
|
||||
@@ -39,6 +39,7 @@ import selectedNoteIdExtension, { setNoteIdEffect } from './extensions/selectedN
|
||||
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
|
||||
@@ -255,6 +256,7 @@ const createEditor = (
|
||||
ctrlClickLinksExtension(link => {
|
||||
props.onEvent({ kind: EditorEventType.FollowLink, link });
|
||||
}),
|
||||
ctrlClickCheckboxExtension(),
|
||||
|
||||
highlightSpecialChars(),
|
||||
indentOnInput(),
|
||||
@@ -351,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,20 @@
|
||||
import { EditorView, highlightActiveLine } from '@codemirror/view';
|
||||
|
||||
// Be careful when enabling this on mobile --- on some devices, this can
|
||||
// break certain accessibility features:
|
||||
// https://github.com/codemirror/dev/issues/1559
|
||||
const highlightActiveLineExtension = () => {
|
||||
return [
|
||||
EditorView.baseTheme({
|
||||
'&light .cm-line.cm-activeLine': {
|
||||
backgroundColor: 'rgba(100, 100, 140, 0.1)',
|
||||
},
|
||||
'&dark .cm-line.cm-activeLine': {
|
||||
backgroundColor: 'rgba(200, 200, 240, 0.1)',
|
||||
},
|
||||
}),
|
||||
highlightActiveLine(),
|
||||
];
|
||||
};
|
||||
|
||||
export default highlightActiveLineExtension;
|
||||
@@ -4,12 +4,10 @@ import modifierKeyCssExtension from '../modifierKeyCssExtension';
|
||||
import openLink from './utils/openLink';
|
||||
import getUrlAtPosition from './utils/getUrlAtPosition';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import { Prec } from '@codemirror/state';
|
||||
|
||||
import ctrlClickActionExtension from '../ctrlClickActionExtension';
|
||||
|
||||
type OnOpenLink = (url: string, view: EditorView)=> void;
|
||||
|
||||
|
||||
const ctrlClickLinksExtension = (onOpenExternalLink: OnOpenLink) => {
|
||||
return [
|
||||
modifierKeyCssExtension,
|
||||
@@ -19,27 +17,16 @@ const ctrlClickLinksExtension = (onOpenExternalLink: OnOpenLink) => {
|
||||
cursor: 'pointer',
|
||||
},
|
||||
}),
|
||||
Prec.high([
|
||||
EditorView.domEventHandlers({
|
||||
mousedown: (event: MouseEvent, view: EditorView) => {
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
const target = view.posAtCoords(event);
|
||||
const url = getUrlAtPosition(target, syntaxTree(view.state), view.state);
|
||||
const hasMultipleCursors = view.state.selection.ranges.length > 1;
|
||||
ctrlClickActionExtension((view: EditorView, event: MouseEvent) => {
|
||||
const target = view.posAtCoords(event);
|
||||
const url = getUrlAtPosition(target, syntaxTree(view.state), view.state);
|
||||
|
||||
// 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 (url && !hasMultipleCursors) {
|
||||
openLink(url.url, view, onOpenExternalLink);
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
]),
|
||||
if (url) {
|
||||
openLink(url.url, view, onOpenExternalLink);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}),
|
||||
];
|
||||
};
|
||||
|
||||
|
||||
@@ -1,22 +1,15 @@
|
||||
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) => {
|
||||
export default () => {
|
||||
return [
|
||||
replaceCheckboxes,
|
||||
replaceBulletLists,
|
||||
replaceFormatCharacters,
|
||||
replaceDividers,
|
||||
addFormattingClasses,
|
||||
...(options.renderImages ? [renderBlockImages(context)] : []),
|
||||
];
|
||||
};
|
||||
|
||||
@@ -1,30 +1,10 @@
|
||||
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';
|
||||
|
||||
const toggleCheckbox = (view: EditorView, linePos: number) => {
|
||||
if (linePos >= view.state.doc.length) {
|
||||
// Position out of range
|
||||
return false;
|
||||
}
|
||||
|
||||
const line = view.state.doc.lineAt(linePos);
|
||||
const checkboxMarkup = line.text.match(/\[(x|\s)\]/);
|
||||
if (!checkboxMarkup) {
|
||||
// Couldn't find the checkbox
|
||||
return false;
|
||||
}
|
||||
|
||||
const isChecked = checkboxMarkup[0] === '[x]';
|
||||
const checkboxPos = checkboxMarkup.index! + line.from;
|
||||
|
||||
view.dispatch({
|
||||
changes: [{ from: checkboxPos, to: checkboxPos + 3, insert: isChecked ? '[ ]' : '[x]' }],
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
class CheckboxWidget extends WidgetType {
|
||||
public constructor(private checked: boolean, private depth: number, private label: string) {
|
||||
@@ -58,7 +38,7 @@ class CheckboxWidget extends WidgetType {
|
||||
container.appendChild(checkbox);
|
||||
|
||||
checkbox.oninput = () => {
|
||||
toggleCheckbox(view, view.posAtDOM(container));
|
||||
toggleCheckboxAt(view.posAtDOM(container))(view);
|
||||
};
|
||||
|
||||
this.applyContainerClasses(container);
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Tree } from '@lezer/common';
|
||||
|
||||
const getCheckboxAtPosition = (pos: number, tree: Tree) => {
|
||||
let iterator = tree.resolveStack(pos);
|
||||
|
||||
while (true) {
|
||||
if (iterator.node.name === 'TaskMarker') {
|
||||
return iterator.node;
|
||||
}
|
||||
|
||||
if (!iterator.next) {
|
||||
break;
|
||||
} else {
|
||||
iterator = iterator.next;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default getCheckboxAtPosition;
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Command, EditorView } from '@codemirror/view';
|
||||
|
||||
const toggleCheckbox = (linePos: number): Command => (target: EditorView) => {
|
||||
const state = target.state;
|
||||
if (linePos >= state.doc.length) {
|
||||
// Position out of range
|
||||
return false;
|
||||
}
|
||||
|
||||
const line = state.doc.lineAt(linePos);
|
||||
const checkboxMarkup = line.text.match(/\[(x|\s)\]/);
|
||||
if (!checkboxMarkup) {
|
||||
// Couldn't find the checkbox
|
||||
return false;
|
||||
}
|
||||
|
||||
const isChecked = checkboxMarkup[0] === '[x]';
|
||||
const checkboxPos = checkboxMarkup.index! + line.from;
|
||||
|
||||
target.dispatch({
|
||||
changes: [{ from: checkboxPos, to: checkboxPos + 3, insert: isChecked ? '[ ]' : '[x]' }],
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
export default toggleCheckbox;
|
||||
@@ -202,7 +202,7 @@ const commands: Record<EditorCommandType, ExtendedCommand|null> = {
|
||||
[EditorCommandType.ReplaceSelection]: null,
|
||||
[EditorCommandType.SetText]: null,
|
||||
[EditorCommandType.JumpToHash]: (state, dispatch, view, [targetHash]) => {
|
||||
return jumpToHash(targetHash, schema.nodes.heading)(state, dispatch, view);
|
||||
return jumpToHash(targetHash)(state, dispatch, view);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { focus } from '@joplin/lib/utils/focusHandler';
|
||||
import { ContentScriptData, EditorCommandType, EditorControl, EditorProps, EditorSettings, SearchState, UpdateBodyOptions, UserEventSource } from '../types';
|
||||
import { EditorState, TextSelection, Transaction } from 'prosemirror-state';
|
||||
import { EditorView } from 'prosemirror-view';
|
||||
@@ -21,18 +22,28 @@ import listPlugin from './plugins/listPlugin';
|
||||
import searchExtension from './plugins/searchPlugin';
|
||||
import joplinEditorApiPlugin, { setEditorApi } from './plugins/joplinEditorApiPlugin';
|
||||
import linkTooltipPlugin from './plugins/linkTooltipPlugin';
|
||||
import { RendererControl } from './types';
|
||||
import { OnCreateCodeEditor as OnCreateCodeEditor, RendererControl } from './types';
|
||||
import resourcePlaceholderPlugin, { onResourceDownloaded } from './plugins/resourcePlaceholderPlugin';
|
||||
import getFileFromPasteEvent from '../utils/getFileFromPasteEvent';
|
||||
import { RenderResult } from '../../renderer/types';
|
||||
import postprocessEditorOutput from './utils/postprocessEditorOutput';
|
||||
import detailsPlugin from './plugins/detailsPlugin';
|
||||
|
||||
interface ProseMirrorControl extends EditorControl {
|
||||
getSettings(): EditorSettings;
|
||||
}
|
||||
|
||||
|
||||
const createEditor = async (
|
||||
parentElement: HTMLElement,
|
||||
props: EditorProps,
|
||||
renderer: RendererControl,
|
||||
): Promise<EditorControl> => {
|
||||
createCodeEditor: OnCreateCodeEditor,
|
||||
): Promise<ProseMirrorControl> => {
|
||||
const renderNodeToMarkup = (node: Node|DocumentFragment) => {
|
||||
return renderer.renderHtmlToMarkup(node);
|
||||
return renderer.renderHtmlToMarkup(
|
||||
postprocessEditorOutput(node),
|
||||
);
|
||||
};
|
||||
|
||||
const proseMirrorParser = ProseMirrorDomParser.fromSchema(schema);
|
||||
@@ -73,6 +84,7 @@ const createEditor = async (
|
||||
gapCursor(),
|
||||
dropCursor(),
|
||||
history(),
|
||||
detailsPlugin,
|
||||
searchPlugin,
|
||||
joplinEditablePlugin,
|
||||
markupTracker,
|
||||
@@ -89,6 +101,7 @@ const createEditor = async (
|
||||
setEditorApi(state.tr, {
|
||||
onEvent: props.onEvent,
|
||||
renderer,
|
||||
createCodeEditor: createCodeEditor,
|
||||
localize: async (input: string) => {
|
||||
if (cachedLocalizations.has(input)) {
|
||||
return cachedLocalizations.get(input);
|
||||
@@ -157,7 +170,7 @@ const createEditor = async (
|
||||
},
|
||||
});
|
||||
|
||||
const editorControl: EditorControl = {
|
||||
const editorControl: ProseMirrorControl = {
|
||||
supportsCommand: (name: EditorCommandType | string) => {
|
||||
return name in commands && !!commands[name as keyof typeof commands];
|
||||
},
|
||||
@@ -192,6 +205,9 @@ const createEditor = async (
|
||||
updateBody: async (newBody: string, _updateBodyOptions?: UpdateBodyOptions) => {
|
||||
view.updateState(await createInitialState(newBody));
|
||||
},
|
||||
getSettings: () => {
|
||||
return settings;
|
||||
},
|
||||
updateSettings: async (newSettings: EditorSettings) => {
|
||||
const oldSettings = settings;
|
||||
settings = newSettings;
|
||||
@@ -271,6 +287,11 @@ const createEditor = async (
|
||||
const resourceSrc = renderedImage?.src;
|
||||
onResourceDownloaded(view, resourceId, resourceSrc);
|
||||
},
|
||||
remove: () => {
|
||||
view.dom.remove();
|
||||
props.onEvent({ kind: EditorEventType.Remove });
|
||||
},
|
||||
focus: () => focus('createEditor', view),
|
||||
};
|
||||
return editorControl;
|
||||
};
|
||||
|
||||
64
packages/editor/ProseMirror/plugins/detailsPlugin.test.ts
Normal file
64
packages/editor/ProseMirror/plugins/detailsPlugin.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import createTestEditor from '../testing/createTestEditor';
|
||||
import detailsPlugin from './detailsPlugin';
|
||||
import originalMarkupPlugin from './originalMarkupPlugin';
|
||||
|
||||
describe('detailsPlugin', () => {
|
||||
it('should add jop-noMdConv attributes to <details> and <summary>', () => {
|
||||
const serializer = new XMLSerializer();
|
||||
const markupToHtml = originalMarkupPlugin(node => serializer.serializeToString(node));
|
||||
const view = createTestEditor({
|
||||
html: `
|
||||
<details><summary>Test</summary>
|
||||
<p>Test...</p>
|
||||
</details>
|
||||
`,
|
||||
plugins: [detailsPlugin, markupToHtml.plugin],
|
||||
});
|
||||
|
||||
// Serialize, then parse to normalize the HTML (for comparison
|
||||
// with the HTML serialized by markupToHtml).
|
||||
const expectedState = serializer.serializeToString(
|
||||
new DOMParser().parseFromString([
|
||||
'<details class="jop-noMdConv"><summary class="jop-noMdConv">Test</summary>',
|
||||
'<p>Test...</p>',
|
||||
'</details>',
|
||||
].join(''), 'text/html').querySelector('details'),
|
||||
);
|
||||
|
||||
expect(
|
||||
markupToHtml.stateToMarkup(view.state).trim(),
|
||||
).toBe(expectedState);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ initialOpen: false },
|
||||
{ initialOpen: true },
|
||||
])('toggling the details element should update its state (%j)', ({ initialOpen }) => {
|
||||
const view = createTestEditor({
|
||||
html: `
|
||||
<details${initialOpen ? ' open' : ''}><summary>Test summary</summary>
|
||||
<p>Test content.</p>
|
||||
</details>
|
||||
`,
|
||||
plugins: [detailsPlugin],
|
||||
});
|
||||
|
||||
const details = view.dom.querySelector('details');
|
||||
details.open = !initialOpen;
|
||||
details.dispatchEvent(new Event('toggle'));
|
||||
|
||||
// The changes to the DOM should be reflected in the editor state
|
||||
expect(view.state.doc.toJSON()).toMatchObject({
|
||||
content: [
|
||||
{
|
||||
type: 'details',
|
||||
attrs: { open: !initialOpen },
|
||||
content: [
|
||||
{ type: 'details_summary' },
|
||||
{ type: 'paragraph' },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
123
packages/editor/ProseMirror/plugins/detailsPlugin.ts
Normal file
123
packages/editor/ProseMirror/plugins/detailsPlugin.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { Plugin } from 'prosemirror-state';
|
||||
import { AttributeSpec, Node, NodeSpec } from 'prosemirror-model';
|
||||
import { EditorView, NodeView, ViewMutationRecord } from 'prosemirror-view';
|
||||
|
||||
type NodeAttrs = Readonly<{
|
||||
open: boolean;
|
||||
}>;
|
||||
|
||||
const attrsSpec = {
|
||||
open: { default: true, validate: 'boolean' },
|
||||
} satisfies Record<keyof NodeAttrs, AttributeSpec>;
|
||||
|
||||
const detailsSpec: NodeSpec = {
|
||||
group: 'block',
|
||||
inline: false,
|
||||
attrs: attrsSpec,
|
||||
content: 'details_summary block+',
|
||||
parseDOM: [
|
||||
{
|
||||
tag: 'details',
|
||||
getAttrs: (node): NodeAttrs => {
|
||||
return {
|
||||
open: node.hasAttribute('open') && node.getAttribute('open') !== 'false',
|
||||
};
|
||||
},
|
||||
},
|
||||
],
|
||||
toDOM: (node) => [
|
||||
'details',
|
||||
{
|
||||
...(node.attrs.open ? { open: true } : {}),
|
||||
|
||||
// Allows the details element to be correctly converted back to Markdown (in Markdown notes).
|
||||
class: 'jop-noMdConv',
|
||||
},
|
||||
0,
|
||||
],
|
||||
};
|
||||
|
||||
const detailsSummarySpec: NodeSpec = {
|
||||
inline: false,
|
||||
content: 'inline+',
|
||||
parseDOM: [
|
||||
{ tag: 'summary' },
|
||||
],
|
||||
toDOM: () => ['summary', { class: 'jop-noMdConv' }, 0],
|
||||
};
|
||||
|
||||
export const nodeSpecs = {
|
||||
details: detailsSpec,
|
||||
details_summary: detailsSummarySpec,
|
||||
};
|
||||
|
||||
type GetPosition = ()=> number|undefined;
|
||||
|
||||
class DetailsView implements NodeView {
|
||||
public readonly dom: HTMLDetailsElement;
|
||||
public readonly contentDOM: HTMLElement;
|
||||
|
||||
public constructor(private node_: Node, view: EditorView, getPosition: GetPosition) {
|
||||
this.dom = document.createElement('details');
|
||||
this.dom.open = this.node_.attrs.open;
|
||||
this.dom.ontoggle = () => {
|
||||
const position = getPosition();
|
||||
if (this.dom.open !== this.node_.attrs.open && position !== undefined) {
|
||||
view.dispatch(view.state.tr.setNodeAttribute(
|
||||
position, 'open', this.dom.open,
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// Allow the user to click on the "summary" label's text without toggling the details
|
||||
// element:
|
||||
this.dom.onclick = (event) => {
|
||||
const summaryNode = this.dom.querySelector('summary');
|
||||
if (event.target === summaryNode) {
|
||||
const contentRange = document.createRange();
|
||||
contentRange.setStart(summaryNode, 0);
|
||||
contentRange.setEnd(summaryNode, summaryNode.childNodes.length);
|
||||
const bbox = contentRange.getBoundingClientRect();
|
||||
|
||||
const horizontalPadding = 10;
|
||||
const eventIsInLabelText = (
|
||||
event.x >= bbox.left &&
|
||||
event.x <= bbox.left + bbox.width + horizontalPadding &&
|
||||
event.y >= bbox.top &&
|
||||
event.y <= bbox.top + bbox.height
|
||||
);
|
||||
|
||||
if (eventIsInLabelText) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.contentDOM = this.dom;
|
||||
}
|
||||
|
||||
public ignoreMutation(mutation: ViewMutationRecord) {
|
||||
// Prevent ProseMirror from immediately resetting the "open" attribute when toggled.
|
||||
return mutation.target === this.dom && mutation.type === 'attributes' && mutation.attributeName === 'open';
|
||||
}
|
||||
|
||||
public update(node: Node) {
|
||||
if (node.type.spec !== this.node_.type.spec) return false;
|
||||
|
||||
this.node_ = node;
|
||||
this.dom.open = node.attrs.open;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const detailsPlugin = new Plugin({
|
||||
props: {
|
||||
nodeViews: {
|
||||
details: (node, view, getPos, _decorations) => {
|
||||
return new DetailsView(node, view, getPos);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default detailsPlugin;
|
||||
@@ -1,6 +1,7 @@
|
||||
import { focus } from '@joplin/lib/utils/focusHandler';
|
||||
import createTextNode from '../../utils/dom/createTextNode';
|
||||
import createTextArea from '../../utils/dom/createTextArea';
|
||||
import { EditorApi } from '../joplinEditorApiPlugin';
|
||||
import { EditorLanguageType } from '../../../types';
|
||||
|
||||
interface SourceBlockData {
|
||||
start: string;
|
||||
@@ -12,31 +13,40 @@ interface Options {
|
||||
editorLabel: string|Promise<string>;
|
||||
doneLabel: string|Promise<string>;
|
||||
block: SourceBlockData;
|
||||
editorApi: EditorApi;
|
||||
onSave: (newContent: SourceBlockData)=> void;
|
||||
onDismiss: ()=> void;
|
||||
}
|
||||
|
||||
const createEditorDialog = ({ editorLabel, doneLabel, block, onSave }: Options) => {
|
||||
const createEditorDialog = ({ editorApi, doneLabel, block, onSave, onDismiss }: Options) => {
|
||||
const dialog = document.createElement('dialog');
|
||||
dialog.classList.add('editor-dialog', '-visible');
|
||||
document.body.appendChild(dialog);
|
||||
|
||||
dialog.onclose = () => {
|
||||
onDismiss();
|
||||
dialog.remove();
|
||||
editor.remove();
|
||||
};
|
||||
|
||||
const { textArea, label: textAreaLabel } = createTextArea({
|
||||
label: editorLabel,
|
||||
initialContent: block.content,
|
||||
onChange: (newContent) => {
|
||||
const editor = editorApi.createCodeEditor(
|
||||
dialog,
|
||||
EditorLanguageType.Markdown,
|
||||
(newContent) => {
|
||||
block = {
|
||||
...block,
|
||||
start: '',
|
||||
end: '',
|
||||
content: newContent,
|
||||
};
|
||||
onSave(block);
|
||||
},
|
||||
spellCheck: false,
|
||||
});
|
||||
|
||||
);
|
||||
editor.updateBody([
|
||||
block.start,
|
||||
block.content,
|
||||
block.end,
|
||||
].join(''));
|
||||
|
||||
const submitButton = document.createElement('button');
|
||||
submitButton.appendChild(createTextNode(doneLabel));
|
||||
@@ -45,14 +55,12 @@ const createEditorDialog = ({ editorLabel, doneLabel, block, onSave }: Options)
|
||||
if (dialog.close) {
|
||||
dialog.close();
|
||||
} else {
|
||||
// .remove the dialog in browsers with limited support for
|
||||
// HTMLDialogElement (and in JSDOM).
|
||||
dialog.remove();
|
||||
// Handle the case where the dialog element is not supported by the
|
||||
// browser/testing environment.
|
||||
dialog.onclose(new Event('close'));
|
||||
}
|
||||
};
|
||||
|
||||
dialog.appendChild(textAreaLabel);
|
||||
dialog.appendChild(textArea);
|
||||
dialog.appendChild(submitButton);
|
||||
|
||||
|
||||
@@ -61,7 +69,7 @@ const createEditorDialog = ({ editorLabel, doneLabel, block, onSave }: Options)
|
||||
dialog.showModal();
|
||||
} else {
|
||||
dialog.classList.add('-fake-modal');
|
||||
focus('createEditorDialog/legacy', textArea);
|
||||
focus('createEditorDialog/legacy', editor);
|
||||
}
|
||||
|
||||
return {};
|
||||
|
||||
@@ -99,4 +99,22 @@ describe('joplinEditablePlugin', () => {
|
||||
// Should render the updated content
|
||||
expect(renderedEditable.querySelector('.test-content').innerHTML).toBe('Mocked!');
|
||||
});
|
||||
|
||||
test('should make #hash links clickable', () => {
|
||||
const editor = createEditor(`
|
||||
<div class="joplin-editable">
|
||||
<a href="#test-heading-1">Test</a>
|
||||
<a href="#test-heading-2">Test</a>
|
||||
</div>
|
||||
<h1>Test heading 1</h1>
|
||||
<h1>Test heading 2</h1>
|
||||
`);
|
||||
const hashLinks = editor.dom.querySelectorAll<HTMLAnchorElement>('a[href^="#test"]');
|
||||
|
||||
hashLinks[0].click();
|
||||
expect(editor.state.selection.$from.parent.textContent).toBe('Test heading 1');
|
||||
|
||||
hashLinks[1].click();
|
||||
expect(editor.state.selection.$from.parent.textContent).toBe('Test heading 2');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,33 +1,49 @@
|
||||
import { Plugin } from 'prosemirror-state';
|
||||
import { Node, NodeSpec } from 'prosemirror-model';
|
||||
import { Node, NodeSpec, TagParseRule } from 'prosemirror-model';
|
||||
import { EditorView, NodeView } from 'prosemirror-view';
|
||||
import sanitizeHtml from '../../utils/sanitizeHtml';
|
||||
import createEditorDialog from './createEditorDialog';
|
||||
import { getEditorApi } from '../joplinEditorApiPlugin';
|
||||
import { msleep } from '@joplin/utils/time';
|
||||
import createTextNode from '../../utils/dom/createTextNode';
|
||||
import postProcessRenderedHtml from './postProcessRenderedHtml';
|
||||
import createButton from '../../utils/dom/createButton';
|
||||
import makeLinksClickableInElement from '../../utils/makeLinksClickableInElement';
|
||||
|
||||
// See the fold example for more information about
|
||||
// writing similar ProseMirror plugins:
|
||||
// https://prosemirror.net/examples/fold/
|
||||
|
||||
interface JoplinEditableAttributes {
|
||||
contentHtml: string;
|
||||
source: string;
|
||||
language: string;
|
||||
openCharacters: string;
|
||||
closeCharacters: string;
|
||||
readOnly: boolean;
|
||||
}
|
||||
|
||||
const makeJoplinEditableSpec = (inline: boolean): NodeSpec => ({
|
||||
const joplinEditableAttributes = {
|
||||
contentHtml: { default: '', validate: 'string' },
|
||||
source: { default: '', validate: 'string' },
|
||||
language: { default: '', validate: 'string' },
|
||||
openCharacters: { default: '', validate: 'string' },
|
||||
closeCharacters: { default: '', validate: 'string' },
|
||||
readOnly: { default: false, validate: 'boolean' },
|
||||
} satisfies Record<keyof JoplinEditableAttributes, unknown>;
|
||||
|
||||
const makeJoplinEditableSpec = (
|
||||
inline: boolean,
|
||||
// Additional tags that should be interpreted as joplinEditable-like blocks.
|
||||
additionalParseRules: TagParseRule[],
|
||||
): NodeSpec => ({
|
||||
group: inline ? 'inline' : 'block',
|
||||
inline: inline,
|
||||
draggable: true,
|
||||
attrs: {
|
||||
contentHtml: { default: '', validate: 'string' },
|
||||
source: { default: '', validate: 'string' },
|
||||
language: { default: '', validate: 'string' },
|
||||
openCharacters: { default: '', validate: 'string' },
|
||||
closeCharacters: { default: '', validate: 'string' },
|
||||
},
|
||||
attrs: joplinEditableAttributes,
|
||||
parseDOM: [
|
||||
{
|
||||
tag: `${inline ? 'span' : 'div'}.joplin-editable`,
|
||||
getAttrs: node => {
|
||||
getAttrs: (node): Partial<JoplinEditableAttributes> => {
|
||||
const sourceNode = node.querySelector('.joplin-source');
|
||||
return {
|
||||
contentHtml: node.innerHTML,
|
||||
@@ -35,20 +51,38 @@ const makeJoplinEditableSpec = (inline: boolean): NodeSpec => ({
|
||||
openCharacters: sourceNode?.getAttribute('data-joplin-source-open'),
|
||||
closeCharacters: sourceNode?.getAttribute('data-joplin-source-close'),
|
||||
language: sourceNode?.getAttribute('data-joplin-language'),
|
||||
readOnly: !!node.hasAttribute('data-joplin-readonly'),
|
||||
};
|
||||
},
|
||||
},
|
||||
...additionalParseRules,
|
||||
],
|
||||
toDOM: node => {
|
||||
const attrs = node.attrs as JoplinEditableAttributes;
|
||||
const content = document.createElement(inline ? 'span' : 'div');
|
||||
content.classList.add('joplin-editable');
|
||||
content.innerHTML = sanitizeHtml(node.attrs.contentHtml);
|
||||
content.innerHTML = sanitizeHtml(attrs.contentHtml);
|
||||
|
||||
const sourceNode = content.querySelector('.joplin-source');
|
||||
const getSourceNode = () => {
|
||||
let sourceNode = content.querySelector('.joplin-source');
|
||||
// If the node has a "source" attribute, its content still needs to be saved
|
||||
if (!sourceNode && attrs.source) {
|
||||
sourceNode = document.createElement(inline ? 'span' : 'div');
|
||||
sourceNode.classList.add('joplin-source');
|
||||
content.appendChild(sourceNode);
|
||||
}
|
||||
return sourceNode;
|
||||
};
|
||||
|
||||
const sourceNode = getSourceNode();
|
||||
if (sourceNode) {
|
||||
sourceNode.textContent = node.attrs.source;
|
||||
sourceNode.setAttribute('data-joplin-source-open', node.attrs.openCharacters);
|
||||
sourceNode.setAttribute('data-joplin-source-close', node.attrs.closeCharacters);
|
||||
sourceNode.textContent = attrs.source;
|
||||
sourceNode.setAttribute('data-joplin-source-open', attrs.openCharacters);
|
||||
sourceNode.setAttribute('data-joplin-source-close', attrs.closeCharacters);
|
||||
}
|
||||
|
||||
if (attrs.readOnly) {
|
||||
content.setAttribute('data-joplin-readonly', 'true');
|
||||
}
|
||||
|
||||
return content;
|
||||
@@ -56,13 +90,34 @@ const makeJoplinEditableSpec = (inline: boolean): NodeSpec => ({
|
||||
});
|
||||
|
||||
export const nodeSpecs = {
|
||||
joplinEditableInline: makeJoplinEditableSpec(true),
|
||||
joplinEditableBlock: makeJoplinEditableSpec(false),
|
||||
joplinEditableInline: makeJoplinEditableSpec(true, []),
|
||||
joplinEditableBlock: makeJoplinEditableSpec(false, [
|
||||
// Table of contents regions are also handled as block editable regions
|
||||
{
|
||||
tag: 'nav.table-of-contents',
|
||||
getAttrs: (node): false|Partial<JoplinEditableAttributes> => {
|
||||
// Additional validation to check that this is indeed a [toc].
|
||||
if (node.children.length !== 1 || node.children[0]?.tagName !== 'UL') {
|
||||
return false; // The rule doesn't match
|
||||
}
|
||||
|
||||
return {
|
||||
contentHtml: node.innerHTML,
|
||||
source: '[toc]',
|
||||
// Disable the [toc]'s default rerendering behavior -- table of contents rendering
|
||||
// requires the document's full content and won't work if "[toc]" is rendered on its
|
||||
// own.
|
||||
readOnly: true,
|
||||
};
|
||||
},
|
||||
},
|
||||
]),
|
||||
};
|
||||
|
||||
type GetPosition = ()=> number;
|
||||
|
||||
class EditableSourceBlockView implements NodeView {
|
||||
private editDialogVisible_ = false;
|
||||
public readonly dom: HTMLElement;
|
||||
public constructor(private node: Node, inline: boolean, private view: EditorView, private getPosition: GetPosition) {
|
||||
if ((node.attrs.contentHtml ?? undefined) === undefined) {
|
||||
@@ -71,16 +126,26 @@ class EditableSourceBlockView implements NodeView {
|
||||
|
||||
this.dom = document.createElement(inline ? 'span' : 'div');
|
||||
this.dom.classList.add('joplin-editable');
|
||||
|
||||
// The link tooltip used for other in-editor links won't be shown for links within a
|
||||
// rendered source block -- these links need custom logic to be clickable:
|
||||
makeLinksClickableInElement(this.dom, view);
|
||||
|
||||
this.updateContent_();
|
||||
}
|
||||
|
||||
private showEditDialog_() {
|
||||
if (this.editDialogVisible_) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { localize: _ } = getEditorApi(this.view.state);
|
||||
|
||||
let saveCounter = 0;
|
||||
createEditorDialog({
|
||||
doneLabel: _('Done'),
|
||||
editorLabel: _('Code:'),
|
||||
editorApi: getEditorApi(this.view.state),
|
||||
block: {
|
||||
content: this.node.attrs.source,
|
||||
start: this.node.attrs.openCharacters,
|
||||
@@ -118,6 +183,9 @@ class EditableSourceBlockView implements NodeView {
|
||||
),
|
||||
);
|
||||
},
|
||||
onDismiss: () => {
|
||||
this.editDialogVisible_ = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -126,21 +194,19 @@ class EditableSourceBlockView implements NodeView {
|
||||
this.dom.innerHTML = sanitizeHtml(html);
|
||||
};
|
||||
|
||||
const attrs = this.node.attrs as JoplinEditableAttributes;
|
||||
const addEditButton = () => {
|
||||
const editButton = document.createElement('button');
|
||||
editButton.classList.add('edit');
|
||||
|
||||
const { localize: _ } = getEditorApi(this.view.state);
|
||||
|
||||
editButton.appendChild(createTextNode(_('Edit')));
|
||||
editButton.onclick = (event) => {
|
||||
this.showEditDialog_();
|
||||
event.preventDefault();
|
||||
};
|
||||
this.dom.appendChild(editButton);
|
||||
const editButton = createButton(_('Edit'), () => this.showEditDialog_());
|
||||
editButton.classList.add('edit');
|
||||
|
||||
if (!attrs.readOnly) {
|
||||
this.dom.appendChild(editButton);
|
||||
}
|
||||
};
|
||||
|
||||
setDomContentSafe(this.node.attrs.contentHtml);
|
||||
setDomContentSafe(attrs.contentHtml);
|
||||
postProcessRenderedHtml(this.dom, this.node.isInline);
|
||||
addEditButton();
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { EditorState, Plugin, Transaction } from 'prosemirror-state';
|
||||
import { OnEventCallback, OnLocalize } from '../../types';
|
||||
import { RendererControl } from '../types';
|
||||
import { OnCreateCodeEditor, RendererControl } from '../types';
|
||||
import { focus } from '@joplin/lib/utils/focusHandler';
|
||||
import createTextArea from '../utils/dom/createTextArea';
|
||||
|
||||
export interface EditorApi {
|
||||
renderer: RendererControl;
|
||||
onEvent: OnEventCallback;
|
||||
createCodeEditor: OnCreateCodeEditor;
|
||||
localize: OnLocalize;
|
||||
}
|
||||
|
||||
@@ -30,8 +33,23 @@ const joplinEditorApiPlugin = new Plugin<EditorApi>({
|
||||
throw new Error('Not initialized');
|
||||
},
|
||||
},
|
||||
settings: null,
|
||||
localize: input => input,
|
||||
|
||||
// A default implementation for testing environments
|
||||
createCodeEditor: (parent, _language, onChange) => {
|
||||
const editor = createTextArea({ label: 'Editor', initialContent: '', onChange });
|
||||
parent.appendChild(editor.textArea);
|
||||
|
||||
return {
|
||||
focus: () => focus('joplinEditorApiPlugin', editor.textArea),
|
||||
remove: () => {
|
||||
editor.textArea.remove();
|
||||
},
|
||||
updateBody: (newValue) => {
|
||||
editor.textArea.value = newValue;
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
apply: (tr, value) => {
|
||||
const proposedValue = tr.getMeta(joplinEditorApiPlugin);
|
||||
|
||||
@@ -62,7 +62,7 @@ class LinkTooltip {
|
||||
this.tooltipContent_.onclick = () => {
|
||||
const href = linkMark.attrs.href;
|
||||
if (href.startsWith('#')) {
|
||||
const command = jumpToHash(href.substring(1), schema.nodes.heading);
|
||||
const command = jumpToHash(href.substring(1));
|
||||
command(view.state, view.dispatch, view);
|
||||
} else {
|
||||
this.onEditorEvent_({
|
||||
|
||||
@@ -3,6 +3,9 @@ import { nodeSpecs as joplinEditableNodes } from './plugins/joplinEditablePlugin
|
||||
import { tableNodes } from 'prosemirror-tables';
|
||||
import { nodeSpecs as listNodes } from './plugins/listPlugin';
|
||||
import { nodeSpecs as resourcePlaceholderNodes } from './plugins/resourcePlaceholderPlugin';
|
||||
import { hasProtocol } from '@joplin/utils/url';
|
||||
import { isResourceUrl } from '@joplin/lib/models/utils/resourceUtils';
|
||||
import { nodeSpecs as detailsNodes } from './plugins/detailsPlugin';
|
||||
|
||||
// For reference, see:
|
||||
// - https://prosemirror.net/docs/guide/#schema
|
||||
@@ -20,6 +23,9 @@ const domOutputSpecs = {
|
||||
listItem: ['li', 0],
|
||||
blockQuote: ['blockquote', 0],
|
||||
hr: ['hr'],
|
||||
sub: ['sub', 0],
|
||||
sup: ['sup', 0],
|
||||
mark: ['mark', 0],
|
||||
} satisfies Record<string, DOMOutputSpec>;
|
||||
|
||||
type AttributeSpecs = Record<string, AttributeSpec>;
|
||||
@@ -129,6 +135,7 @@ const nodes = addDefaultToplevelAttributes({
|
||||
return result;
|
||||
},
|
||||
},
|
||||
...detailsNodes,
|
||||
...resourcePlaceholderNodes,
|
||||
...listNodes,
|
||||
...joplinEditableNodes,
|
||||
@@ -214,6 +221,18 @@ const marks = {
|
||||
toDOM: () => domOutputSpecs.code,
|
||||
excludes: '_',
|
||||
},
|
||||
sub: {
|
||||
parseDOM: [{ tag: 'sub' }],
|
||||
toDOM: () => domOutputSpecs.sub,
|
||||
},
|
||||
sup: {
|
||||
parseDOM: [{ tag: 'sup' }],
|
||||
toDOM: () => domOutputSpecs.sup,
|
||||
},
|
||||
mark: {
|
||||
parseDOM: [{ tag: 'mark' }],
|
||||
toDOM: () => domOutputSpecs.mark,
|
||||
},
|
||||
color: {
|
||||
inclusive: false,
|
||||
parseDOM: [{
|
||||
@@ -242,8 +261,15 @@ const marks = {
|
||||
tag: 'a[href]',
|
||||
getAttrs: node => {
|
||||
const resourceId = node.getAttribute('data-resource-id');
|
||||
const href = node.getAttribute('href');
|
||||
let href = node.getAttribute('href');
|
||||
const isResourceLink = resourceId && href === '#';
|
||||
if (isResourceLink) {
|
||||
href = `:/${resourceId}`;
|
||||
}
|
||||
|
||||
if (href === '#' && node.hasAttribute('data-original-href')) {
|
||||
href = node.getAttribute('data-original-href');
|
||||
}
|
||||
|
||||
return {
|
||||
href: isResourceLink ? `:/${resourceId}` : href,
|
||||
@@ -252,10 +278,29 @@ const marks = {
|
||||
};
|
||||
},
|
||||
}],
|
||||
toDOM: node => [
|
||||
'a',
|
||||
{ href: node.attrs.href, title: node.attrs.title, 'data-resource-id': node.attrs.dataResourceId },
|
||||
],
|
||||
toDOM: node => {
|
||||
const isSafeForRendering = (href: string) => {
|
||||
return hasProtocol(href, ['http', 'https', 'joplin']) || isResourceUrl(href);
|
||||
};
|
||||
|
||||
// Avoid rendering URLs with unknown protocols (avoid rendering or pasting unsafe HREFs).
|
||||
// Note that URL click handling is handled elsewhere and does not use the HTML "href" attribute.
|
||||
// However "href" may be used by the right-click menu on web:
|
||||
const safeHref = isSafeForRendering(node.attrs.href) ? node.attrs.href : '#';
|
||||
|
||||
return [
|
||||
'a',
|
||||
{
|
||||
href: safeHref,
|
||||
...(safeHref !== node.attrs.href ? {
|
||||
'data-original-href': node.attrs.href,
|
||||
} : {}),
|
||||
|
||||
title: node.attrs.title,
|
||||
'data-resource-id': node.attrs.dataResourceId,
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
} satisfies Record<string, MarkSpec>;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { RenderResult } from '../../renderer/types';
|
||||
import { EditorLanguageType } from '../types';
|
||||
|
||||
interface MarkupToHtmlOptions {
|
||||
isFullPageRender: boolean;
|
||||
@@ -6,9 +7,21 @@ interface MarkupToHtmlOptions {
|
||||
}
|
||||
|
||||
export type MarkupToHtml = (markup: string, options: MarkupToHtmlOptions)=> Promise<RenderResult>;
|
||||
export type HtmlToMarkup = (html: Node|DocumentFragment)=> string;
|
||||
export type HtmlToMarkup = (html: HTMLElement)=> string;
|
||||
|
||||
export interface RendererControl {
|
||||
renderMarkupToHtml: MarkupToHtml;
|
||||
renderHtmlToMarkup: HtmlToMarkup;
|
||||
}
|
||||
|
||||
export interface CodeEditorControl {
|
||||
focus: ()=> void;
|
||||
remove: ()=> void;
|
||||
updateBody: (newValue: string)=> void;
|
||||
}
|
||||
export type OnCodeEditorChange = (newValue: string)=> void;
|
||||
|
||||
// Creates a text editor for editing code blocks
|
||||
export type OnCreateCodeEditor = (
|
||||
parent: HTMLElement, language: EditorLanguageType, onChange: OnCodeEditorChange,
|
||||
)=> CodeEditorControl;
|
||||
|
||||
41
packages/editor/ProseMirror/utils/dom/createButton.ts
Normal file
41
packages/editor/ProseMirror/utils/dom/createButton.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { LocalizationResult } from '../../../types';
|
||||
import createTextNode from './createTextNode';
|
||||
|
||||
type OnClick = ()=> void;
|
||||
|
||||
const createButton = (label: LocalizationResult, onClick: OnClick) => {
|
||||
const button = document.createElement('button');
|
||||
button.appendChild(createTextNode(label));
|
||||
|
||||
// Works around an issue on iOS in which certain <button> elements within the selected
|
||||
// region of a contenteditable container do not emit a "click" event when tapped with a touchscreen.
|
||||
const applyIOSClickWorkaround = () => {
|
||||
// touchend events can be received even when a touch is no longer contained within
|
||||
// the initial element.
|
||||
const buttonContainsTouch = (touch: Touch) => {
|
||||
return document.elementFromPoint(touch.clientX, touch.clientY) === button;
|
||||
};
|
||||
|
||||
let containedTouchStart = false;
|
||||
button.addEventListener('touchcancel', () => {
|
||||
containedTouchStart = false;
|
||||
});
|
||||
button.addEventListener('touchstart', () => {
|
||||
containedTouchStart = true;
|
||||
});
|
||||
button.addEventListener('touchend', (event) => {
|
||||
if (containedTouchStart && event.touches.length === 0 && buttonContainsTouch(event.changedTouches[0])) {
|
||||
onClick();
|
||||
event.preventDefault();
|
||||
}
|
||||
containedTouchStart = false;
|
||||
});
|
||||
};
|
||||
|
||||
applyIOSClickWorkaround();
|
||||
button.onclick = onClick;
|
||||
|
||||
return button;
|
||||
};
|
||||
|
||||
export default createButton;
|
||||
@@ -4,14 +4,12 @@ import createUniqueId from './createUniqueId';
|
||||
|
||||
interface Options {
|
||||
label: LocalizationResult;
|
||||
spellCheck: boolean;
|
||||
initialContent: string;
|
||||
onChange: (newContent: string)=> void;
|
||||
}
|
||||
|
||||
const createTextArea = ({ label, initialContent, spellCheck, onChange }: Options) => {
|
||||
const createTextArea = ({ label, initialContent, onChange }: Options) => {
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.spellcheck = spellCheck;
|
||||
textArea.oninput = () => {
|
||||
onChange(textArea.value);
|
||||
};
|
||||
|
||||
@@ -4,47 +4,133 @@ import extractSelectedLinesTo from './extractSelectedLinesTo';
|
||||
import schema from '../schema';
|
||||
|
||||
describe('extractSelectedLinesTo', () => {
|
||||
test('should extract a single line containing the cursor to a heading', () => {
|
||||
test.each([
|
||||
{
|
||||
label: 'should extract a single line containing the cursor to a heading',
|
||||
initial: {
|
||||
docHtml: '<p>Line 1<br>Line 2<br>Line 3</p>',
|
||||
// Put the cursor in the middle of the second line
|
||||
cursorPosition: '<Line 1|Line'.length,
|
||||
},
|
||||
convertTo: { type: schema.nodes.heading, attrs: { level: 1 } },
|
||||
expected: {
|
||||
// The section of the document containing the cursor should now be a new line
|
||||
docJson: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'Line 1' }],
|
||||
},
|
||||
{
|
||||
type: 'heading',
|
||||
attrs: { level: 1 },
|
||||
content: [{ type: 'text', text: 'Line 2' }],
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'Line 3' }],
|
||||
},
|
||||
],
|
||||
// The cursor should move to the end of the extracted line
|
||||
cursorPosition: '<Line 1><Line 2'.length,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'should convert an empty paragraph to a heading',
|
||||
initial: {
|
||||
docHtml: '<p>Line 1</p><p></p><p>Line 3</p>',
|
||||
cursorPosition: '<Line 1><'.length,
|
||||
},
|
||||
convertTo: { type: schema.nodes.heading },
|
||||
expected: {
|
||||
docJson: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'Line 1' }],
|
||||
},
|
||||
{
|
||||
type: 'heading',
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'Line 3' }],
|
||||
},
|
||||
],
|
||||
cursorPosition: '<Line 1><'.length,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'should convert the last line in a paragraph to a heading',
|
||||
initial: {
|
||||
docHtml: '<p>Line 1<br/></p><p>End</p>',
|
||||
cursorPosition: '<Line 1|'.length,
|
||||
},
|
||||
convertTo: { type: schema.nodes.heading },
|
||||
expected: {
|
||||
docJson: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'Line 1' }],
|
||||
},
|
||||
{
|
||||
type: 'heading',
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'End' }],
|
||||
},
|
||||
],
|
||||
cursorPosition: '<Line 1><'.length,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'should convert the first line in a paragraph to a heading',
|
||||
initial: {
|
||||
docHtml: '<p><br/>Line 1</p><p>End</p>',
|
||||
cursorPosition: '<'.length,
|
||||
},
|
||||
convertTo: { type: schema.nodes.heading },
|
||||
expected: {
|
||||
docJson: [
|
||||
{
|
||||
type: 'heading',
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'Line 1' }],
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'End' }],
|
||||
},
|
||||
],
|
||||
cursorPosition: '<'.length,
|
||||
},
|
||||
},
|
||||
])('$label', ({ initial, expected, convertTo }) => {
|
||||
const view = createTestEditor({
|
||||
html: '<p>Line 1<br>Line 2<br>Line 3</p>',
|
||||
html: initial.docHtml,
|
||||
});
|
||||
view.dispatch(view.state.tr.setSelection(
|
||||
// Put the cursor in the middle of the second line
|
||||
TextSelection.create(view.state.doc, '<Line 1|Line'.length),
|
||||
TextSelection.create(
|
||||
view.state.doc,
|
||||
initial.cursorPosition,
|
||||
),
|
||||
));
|
||||
|
||||
const { transaction } = extractSelectedLinesTo(
|
||||
{ type: schema.nodes.heading, attrs: { level: 1 } },
|
||||
{ type: convertTo.type, attrs: convertTo.attrs ?? { } },
|
||||
view.state.tr,
|
||||
view.state.selection,
|
||||
);
|
||||
|
||||
view.dispatch(transaction);
|
||||
|
||||
// The section of the document containing the cursor should now be a new line
|
||||
expect(view.state.doc.toJSON()).toMatchObject({
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'Line 1' }],
|
||||
},
|
||||
{
|
||||
type: 'heading',
|
||||
attrs: { level: 1 },
|
||||
content: [{ type: 'text', text: 'Line 2' }],
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'Line 3' }],
|
||||
},
|
||||
],
|
||||
content: expected.docJson,
|
||||
});
|
||||
|
||||
// The selection should still be in the heading
|
||||
expect(view.state.selection.$anchor.parent.toJSON()).toMatchObject({
|
||||
type: 'heading',
|
||||
attrs: { level: 1 },
|
||||
});
|
||||
// All of the tests in this group expect a single cursor
|
||||
expect(view.state.selection.empty).toBe(true);
|
||||
expect(view.state.selection.from).toBe(expected.cursorPosition);
|
||||
});
|
||||
|
||||
test('should extract multiple lines in the same paragraph to a new paragraph', () => {
|
||||
|
||||
@@ -33,7 +33,8 @@ const extractSelectedLinesTo = (extractTo: ExtractToOptions, transaction: Transa
|
||||
|
||||
const firstParagraphFrom = firstParagraphPos;
|
||||
const lastParagraph = transaction.doc.nodeAt(lastParagraphPos);
|
||||
const lastParagraphTo = lastParagraphPos + lastParagraph.nodeSize;
|
||||
// -1: Exclude the end token
|
||||
const lastParagraphTo = lastParagraphPos + lastParagraph.nodeSize - 1;
|
||||
|
||||
// Find the previous and next <br/>s (or the start/end of the paragraph)
|
||||
let fromBreakPosition = firstParagraphFrom;
|
||||
|
||||
27
packages/editor/ProseMirror/utils/forEachHeading.ts
Normal file
27
packages/editor/ProseMirror/utils/forEachHeading.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import uslug from '@joplin/fork-uslug/lib/uslug';
|
||||
import { Node } from 'prosemirror-model';
|
||||
|
||||
type OnHeading = (node: Node, hash: string, pos: number)=> boolean|void;
|
||||
|
||||
const forEachHeading = (doc: Node, callback: OnHeading) => {
|
||||
let done = false;
|
||||
const seenHashes = new Set<string>();
|
||||
doc.descendants((node, pos) => {
|
||||
if (node.type.name === 'heading') {
|
||||
const originalHash = uslug(node.textContent);
|
||||
|
||||
let hash = originalHash;
|
||||
let counter = 1;
|
||||
while (seenHashes.has(hash)) {
|
||||
counter++;
|
||||
hash = `${originalHash}-${counter}`;
|
||||
}
|
||||
seenHashes.add(hash);
|
||||
|
||||
done = !!callback(node, hash, pos);
|
||||
}
|
||||
return !done;
|
||||
});
|
||||
};
|
||||
|
||||
export default forEachHeading;
|
||||
@@ -1,18 +1,18 @@
|
||||
import { Command, TextSelection } from 'prosemirror-state';
|
||||
import uslug from '@joplin/fork-uslug/lib/uslug';
|
||||
import { NodeType } from 'prosemirror-model';
|
||||
import { focus } from '@joplin/lib/utils/focusHandler';
|
||||
import forEachHeading from './forEachHeading';
|
||||
|
||||
const jumpToHash = (targetHash: string): Command => (state, dispatch, view) => {
|
||||
if (targetHash.startsWith('#')) {
|
||||
targetHash = targetHash.substring(1);
|
||||
}
|
||||
|
||||
const jumpToHash = (targetHash: string, headingType: NodeType): Command => (state, dispatch, view) => {
|
||||
let targetHeaderPos: number|null = null;
|
||||
state.doc.descendants((node, pos) => {
|
||||
if (node.type === headingType) {
|
||||
const hash = uslug(node.textContent);
|
||||
if (hash === targetHash) {
|
||||
// Subtract one to move the selection to the end of
|
||||
// the node:
|
||||
targetHeaderPos = pos + node.nodeSize - 1;
|
||||
}
|
||||
forEachHeading(view.state.doc, (node, hash, pos) => {
|
||||
if (hash === targetHash) {
|
||||
// Subtract one to move the selection to the end of
|
||||
// the node:
|
||||
targetHeaderPos = pos + node.nodeSize - 1;
|
||||
}
|
||||
|
||||
return targetHeaderPos !== null;
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { EditorView } from 'prosemirror-view';
|
||||
import jumpToHash from './jumpToHash';
|
||||
import { getEditorApi } from '../plugins/joplinEditorApiPlugin';
|
||||
import { EditorEventType } from '../../events';
|
||||
|
||||
const makeLinksClickableInElement = (element: HTMLElement, view: EditorView) => {
|
||||
const followLink = (target: HTMLAnchorElement) => {
|
||||
const href = target.getAttribute('href');
|
||||
if (href) {
|
||||
if (href.startsWith('#')) {
|
||||
return jumpToHash(href)(view.state, view.dispatch, view);
|
||||
} else {
|
||||
getEditorApi(view.state).onEvent({
|
||||
kind: EditorEventType.FollowLink,
|
||||
link: href,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
element.addEventListener('click', event => {
|
||||
if (event.target instanceof Element && !event.defaultPrevented) {
|
||||
const closestLink = event.target.closest<HTMLAnchorElement>('a[href]');
|
||||
if (closestLink && followLink(closestLink)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export default makeLinksClickableInElement;
|
||||
@@ -0,0 +1,34 @@
|
||||
import postprocessEditorOutput from './postprocessEditorOutput';
|
||||
|
||||
const normalizeHtmlString = (html: string) => {
|
||||
return html.replace(/\s+/g, ' ').trim();
|
||||
};
|
||||
|
||||
describe('postprocessEditorOutput', () => {
|
||||
// Removing extra space around list items prevents extra space from being
|
||||
// added when converting from HTML to Markdown
|
||||
test('should remove extra paragraphs from around list items', () => {
|
||||
const doc = new DOMParser().parseFromString(`
|
||||
<body>
|
||||
<ul>
|
||||
<li><p>Test</p></li>
|
||||
<li>Test 2</li>
|
||||
<li><p></p><p>Test 3</p><p></p></li>
|
||||
</ul>
|
||||
`, 'text/html');
|
||||
|
||||
const output = postprocessEditorOutput(doc.body);
|
||||
|
||||
expect(
|
||||
normalizeHtmlString(output.querySelector('ul').outerHTML),
|
||||
).toBe(
|
||||
normalizeHtmlString(`
|
||||
<ul>
|
||||
<li>Test</li>
|
||||
<li>Test 2</li>
|
||||
<li>Test 3</li>
|
||||
</ul>
|
||||
`),
|
||||
);
|
||||
});
|
||||
});
|
||||
59
packages/editor/ProseMirror/utils/postprocessEditorOutput.ts
Normal file
59
packages/editor/ProseMirror/utils/postprocessEditorOutput.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import trimEmptyParagraphs from './trimEmptyParagraphs';
|
||||
|
||||
const fixResourceUrls = (container: HTMLElement) => {
|
||||
const resources = container.querySelectorAll<HTMLImageElement>('img[data-resource-id]');
|
||||
for (const resource of resources) {
|
||||
const resourceId = resource.getAttribute('data-resource-id');
|
||||
resource.src = `:/${resourceId}`;
|
||||
}
|
||||
};
|
||||
|
||||
const removeListItemWrapperParagraphs = (container: HTMLElement) => {
|
||||
const listItems = container.querySelectorAll<HTMLLIElement>('li');
|
||||
for (const item of listItems) {
|
||||
trimEmptyParagraphs(item);
|
||||
|
||||
if (item.children.length === 1) {
|
||||
const firstChild = item.children[0];
|
||||
if (firstChild.tagName === 'P') {
|
||||
firstChild.replaceWith(...firstChild.childNodes);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const restoreOriginalLinks = (container: HTMLElement) => {
|
||||
// Restore HREFs
|
||||
const links = container.querySelectorAll<HTMLAnchorElement>('a[href="#"][data-original-href]');
|
||||
for (const link of links) {
|
||||
link.href = link.getAttribute('data-original-href');
|
||||
link.removeAttribute('data-original-href');
|
||||
}
|
||||
};
|
||||
|
||||
const postprocessEditorOutput = (node: Node|DocumentFragment) => {
|
||||
// By default, if `src` is specified on an image, the browser will try to load the image, even if it isn't added
|
||||
// to the DOM. (A similar problem is described here: https://stackoverflow.com/q/62019538).
|
||||
// Since :/resourceId isn't a valid image URI, this results in a large number of warnings. As a workaround,
|
||||
// move the element to a temporary document before processing:
|
||||
const dom = document.implementation.createHTMLDocument();
|
||||
node = dom.importNode(node, true);
|
||||
|
||||
let html: HTMLElement;
|
||||
if ((node instanceof HTMLElement)) {
|
||||
html = node;
|
||||
} else {
|
||||
const container = document.createElement('div');
|
||||
container.appendChild(node);
|
||||
html = container;
|
||||
}
|
||||
|
||||
fixResourceUrls(html);
|
||||
restoreOriginalLinks(html);
|
||||
removeListItemWrapperParagraphs(html);
|
||||
|
||||
return html;
|
||||
};
|
||||
|
||||
export default postprocessEditorOutput;
|
||||
@@ -10,6 +10,7 @@ export enum EditorEventType {
|
||||
EditLink,
|
||||
FollowLink,
|
||||
Scroll,
|
||||
Remove,
|
||||
}
|
||||
|
||||
export interface ChangeEvent {
|
||||
@@ -62,9 +63,13 @@ export interface FollowLinkEvent {
|
||||
link: string;
|
||||
}
|
||||
|
||||
export interface RemoveEvent {
|
||||
kind: EditorEventType.Remove;
|
||||
}
|
||||
|
||||
export type EditorEvent =
|
||||
ChangeEvent|UndoRedoDepthChangeEvent|SelectionRangeChangeEvent|
|
||||
EditorScrolledEvent|
|
||||
SelectionFormattingChangeEvent|UpdateSearchDialogEvent|
|
||||
RequestEditLinkEvent|FollowLinkEvent;
|
||||
RequestEditLinkEvent|FollowLinkEvent|RemoveEvent;
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"@joplin/utils": "~3.4",
|
||||
"@testing-library/react-hooks": "8.0.1",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/react": "18.3.21",
|
||||
"@types/react": "18.3.23",
|
||||
"@types/react-redux": "7.1.33",
|
||||
"@types/styled-components": "5.1.32",
|
||||
"jest": "29.7.0",
|
||||
|
||||
@@ -14,6 +14,7 @@ const createEditorSettings = (themeId: number) => {
|
||||
autocompleteMarkup: true,
|
||||
tabMovesFocus: false,
|
||||
inlineRenderingEnabled: true,
|
||||
highlightActiveLine: false,
|
||||
|
||||
keymap: EditorKeymap.Default,
|
||||
language: EditorLanguageType.Markdown,
|
||||
|
||||
@@ -128,6 +128,9 @@ export interface EditorControl {
|
||||
|
||||
// Called when a resource associated with the current note finishes downloading.
|
||||
onResourceDownloaded(id: string): void;
|
||||
|
||||
remove(): void;
|
||||
focus(): void;
|
||||
}
|
||||
|
||||
export enum EditorLanguageType {
|
||||
@@ -182,6 +185,7 @@ export interface EditorSettings {
|
||||
inlineRenderingEnabled: boolean;
|
||||
imageRenderingEnabled: boolean;
|
||||
readOnly: boolean;
|
||||
highlightActiveLine: boolean;
|
||||
|
||||
indentWithTabs: boolean;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@joplin/fork-htmlparser2",
|
||||
"description": "Fast & forgiving HTML/XML/RSS parser",
|
||||
"version": "4.1.58",
|
||||
"version": "4.1.59",
|
||||
"author": "Felix Boehm <me@feedic.com>",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
@@ -46,7 +46,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/node": "18.19.100",
|
||||
"@types/node": "18.19.103",
|
||||
"@typescript-eslint/eslint-plugin": "6.21.0",
|
||||
"@typescript-eslint/parser": "6.21.0",
|
||||
"coveralls": "3.1.1",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@joplin/fork-sax",
|
||||
"description": "An evented streaming XML parser in JavaScript",
|
||||
"author": "Isaac Z. Schlueter <i@izs.me> (http://blog.izs.me/)",
|
||||
"version": "1.2.62",
|
||||
"version": "1.2.63",
|
||||
"main": "lib/sax.js",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/fork-uslug",
|
||||
"version": "2.0.1",
|
||||
"version": "2.0.2",
|
||||
"description": "A permissive slug generator that works with unicode.",
|
||||
"author": "Jeremy Selier <jerem.selier@gmail.com>",
|
||||
"publishConfig": {
|
||||
@@ -17,7 +17,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/node": "18.19.100",
|
||||
"@types/node": "18.19.103",
|
||||
"jest": "29.7.0",
|
||||
"typescript": "5.8.2"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/htmlpack",
|
||||
"version": "3.4.0",
|
||||
"version": "3.4.1",
|
||||
"description": "Pack an HTML file and all its linked resources into a single HTML file",
|
||||
"main": "dist/index.js",
|
||||
"types": "index.ts",
|
||||
@@ -25,8 +25,8 @@
|
||||
"author": "Laurent Cozic",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@adobe/css-tools": "4.4.2",
|
||||
"@joplin/fork-htmlparser2": "^4.1.58",
|
||||
"@adobe/css-tools": "4.4.3",
|
||||
"@joplin/fork-htmlparser2": "^4.1.59",
|
||||
"datauri": "4.1.0",
|
||||
"fs-extra": "11.2.0",
|
||||
"html-entities": "1.4.0"
|
||||
|
||||
@@ -214,7 +214,7 @@ class BaseModel {
|
||||
return fields.indexOf(name) >= 0;
|
||||
}
|
||||
|
||||
public static fieldNames(withPrefix = false) {
|
||||
public static fieldNames(withPrefix: string|boolean = false) {
|
||||
const output = this.db().tableFieldNames(this.tableName());
|
||||
if (!withPrefix) return output;
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user