1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-12-08 23:07:32 +02:00

Compare commits

...

124 Commits

Author SHA1 Message Date
Laurent Cozic
97fa85a3f7 Desktop release v3.4.13 2025-10-02 09:35:36 +01:00
Laurent Cozic
defe36bba1 Server: Enable publish and share notebook for SAML login 2025-10-02 09:34:51 +01:00
Henry Heino
711d214741 Android: Fixes #13193: Fix Markdown toolbar buttons sometimes don't work (#13233) 2025-09-18 12:05:57 +01:00
pedr
0795c67354 All: Fixes #12249: Change default content-type for Webdav connector to application/octet-stream (#13053) 2025-09-13 14:13:27 +01:00
Laurent Cozic
e9a9f68568 Desktop release v3.4.12 2025-09-09 15:36:24 +01:00
Laurent Cozic
6ee9571069 iOS 13.4.3 2025-09-09 09:25:40 +01:00
Laurent Cozic
f25db9bbd7 Android 3.4.7 2025-09-09 09:14:11 +01:00
Laurent Cozic
44ac261304 Desktop release v3.4.11 2025-09-09 09:05:11 +01:00
Henry Heino
3a9f57e13f Cli: Fixes #13086: Fix "use" command when not in TUI mode (#13091) 2025-09-08 10:56:08 +01:00
Henry Heino
b72c48c693 Mobile, Desktop: Fixes #13103: Fix error when saving in-editor rendering-related settings (#13105) 2025-09-08 10:56:01 +01:00
Henry Heino
f1e42f3bac iOS: Fixes #13111: Fix "scan notebook" tool on iOS (#13114) 2025-09-08 10:55:48 +01:00
Henry Heino
93c908286d Mobile: Plugins: Fix renderer plugins that use the settingValue API (#13131) 2025-09-08 10:55:42 +01:00
Henry Heino
4eb8777ed0 Mobile: Fix light bar shown above header in dark mode (#13132) 2025-09-08 10:55:15 +01:00
Laurent Cozic
e884da8312 Android 3.4.6 2025-09-01 14:48:38 +02:00
Laurent Cozic
85585d16d2 Desktop release v3.4.10 2025-09-01 13:50:43 +02:00
Henry Heino
6482ab5a4e Mobile: Plugin API: Fix compatibility with certain plugins targetting the desktop app (#13077) 2025-08-29 23:28:45 +02:00
Henry Heino
ec74abe754 Mobile: Plugin API: Fix certain renderer plugins fail to load (#13078) 2025-08-29 23:28:39 +02:00
Henry Heino
859bc8d88e Mobile: Plugins: Fix plugin panel buttons are offscreen on recent versions of Android (#13080) 2025-08-29 23:28:22 +02:00
Henry Heino
3e9bb914e5 Android: Fixes #13015: Fix "edit profile" button is partially offscreen (#13084) 2025-08-29 23:27:51 +02:00
Henry Heino
f75e911a4e Docs: Update the privacy policy (#13087) 2025-08-29 23:27:44 +02:00
Henry Heino
6390ef43ed Desktop: Clarify handwritten text transcription setting (#13073)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2025-08-28 09:20:55 +03:00
Henry Heino
0d1d50768b Android: Fix shadow shown above the screen header (#13074) 2025-08-28 09:04:10 +03:00
Laurent Cozic
cba5cf660b Desktop release v3.4.9 2025-08-27 22:09:47 +03:00
Laurent Cozic
0024722c79 Desktop: Clarified that handwritten transcription may not always work 2025-08-27 22:09:26 +03:00
Henry Heino
b179509dd3 Desktop: Fixes #12855: Legacy editor: Fix plugin support (#13066) 2025-08-27 22:02:09 +03:00
Laurent Cozic
ac289c5198 Desktop: Clarified that handwritten transcription may not always work 2025-08-27 17:22:06 +03:00
Laurent Cozic
62faa48aac lock files 2025-08-27 10:15:15 +03:00
Laurent Cozic
5daa7a1f4c Chore: By default, create new releases as pre-releases when publishing desktop app 2025-08-27 09:54:06 +03:00
Laurent Cozic
32be071601 CLI v3.4.1 2025-08-27 09:50:10 +03:00
Laurent Cozic
0dc63dd306 Lock file 2025-08-27 09:47:17 +03:00
Laurent Cozic
78ed58187a Releasing sub-packages 2025-08-27 09:46:45 +03:00
Laurent Cozic
b8b8dd8011 iOS 13.4.2 2025-08-27 09:33:18 +03:00
Laurent Cozic
0bc72b45be Android 3.4.5 2025-08-27 09:28:51 +03:00
Laurent Cozic
c52523134d Desktop release v3.4.8 2025-08-27 09:23:04 +03:00
Henry Heino
aff871eee6 Desktop, Mobile: Markdown editor: Fix image rendering is disabled unless markup rendering is also enabled (#13056) 2025-08-27 09:21:26 +03:00
Henry Heino
a5a68a2238 Cli: Add commands for publishing and unpublishing notes with Joplin Server (#13060) 2025-08-27 09:21:10 +03:00
Henry Heino
e066b8f9bc Desktop: Fixes #13043: OCR: Fix processing resources with an invalid ocr_driver_id (#13051) 2025-08-27 09:20:00 +03:00
Henry Heino
e7827a3a64 Mobile: Remove the "beta" warning from the plugin settings screen (#13063) 2025-08-27 09:19:21 +03:00
Henry Heino
4ceca647dc Desktop, Mobile: Resolves #13048: Auto-disable plugin settings when conflicting built-in settings are enabled (#13055) 2025-08-27 09:19:07 +03:00
Henry Heino
4185afebdb Chore: Fix build (#13050) 2025-08-26 18:40:26 +03:00
Henry Heino
c530b07f45 Desktop, Mobile: Disable in-editor Markdown rendering by default (can be re-enabled in settings > note) (#13022) 2025-08-26 10:56:53 +03:00
Henry Heino
0ed7daaed8 Linux, Windows: Fixes #12991, #12733: Fix notifications (#13007) 2025-08-26 10:56:42 +03:00
Henry Heino
2eb107c716 Desktop, Mobile: Add a "highlight active line" setting (#12967) 2025-08-26 10:49:59 +03:00
Henry Heino
c99780db1b Mobile: Rich Text Editor: Avoid rendering links with unknown protocols (#12943)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2025-08-26 10:49:26 +03:00
Henry Heino
ac05b7d389 Mobile: Rich Text Editor: Fix additional blank lines added around list items on save (#12935) 2025-08-26 10:46:00 +03:00
Henry Heino
9719d82c47 Desktop: Fixes #13024: OCR: Fix infinite loop (#13025) 2025-08-26 10:45:27 +03:00
Henry Heino
48694a585f Mobile: Fixes #12953: Allow the tag dialog to scroll when little screen space is available (#13028) 2025-08-26 10:44:32 +03:00
Henry Heino
b577a27887 Mobile: Fixes #13027: Fix additional space added around app content in landscape mode (#13030) 2025-08-26 10:44:06 +03:00
Helmut K. C. Tessarek
9f649c9fc2 All: Update translations 2025-08-25 19:40:33 -04:00
Eric Duarte
8c9c5d13bd All: Translation: Update es_ES.po (#13041) 2025-08-25 19:32:14 -04:00
renovate[bot]
96692de93c Update dependency @types/react to v18.3.23 (#13042)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-25 13:44:38 +00:00
Joplin Bot
3d8e1dd146 Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-08-24 18:26:08 +00:00
Joplin Bot
227e41b69a Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-08-24 12:31:05 +00:00
renovate[bot]
a616e26a0f Update dependency react-native-safe-area-context to v5.4.1 (#13000)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2025-08-24 13:02:03 +03:00
Joplin Bot
ba0e7e2226 Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-08-23 12:30:39 +00:00
Laurent Cozic
b5a4ba554d Doc: Add sponsor 2025-08-23 13:14:58 +03:00
Arda Kılıçdağı
9037da8f2d All: Translation: Update tr_TR.po (#13019) 2025-08-22 16:07:31 -04:00
renovate[bot]
6998606ec9 Update dependency pg to v8.15.6 (#13021)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-22 17:00:30 +00:00
Laurent Cozic
66d52c90a3 Desktop release v3.4.7 2025-08-22 13:19:27 +03:00
renovate[bot]
f6fb1f7fbf Update dependency pg to v8.15.5 (#13001)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2025-08-22 13:14:06 +03:00
Henry Heino
3aac6043da Chore: Sync fuzzer: Support testing Joplin Cloud readonly shares (#13003) 2025-08-22 11:33:54 +03:00
Henry Heino
ae170e0aa0 Desktop: Fixes #12998: Fix error logged when rendering a non-existent resource (#13004) 2025-08-22 11:33:16 +03:00
Henry Heino
371f027a24 MacOS: Fix startup failure when unable to access the keychain (#13006) 2025-08-22 11:32:59 +03:00
Henry Heino
37422f316e Desktop: Downgrade to Electron 35.7.5 (#13013) 2025-08-22 11:30:39 +03:00
Henry Heino
a9f284ae45 Desktop: Fixes #13009: Fix custom root CA support (#13018) 2025-08-22 11:29:54 +03:00
Milo Ivir
fd2f69cc73 All: Translation: Update hr_HR.po (#13011) 2025-08-21 18:45:39 -04:00
Joplin Bot
c4eab3c79c Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-08-21 01:03:33 +00:00
renovate[bot]
a0b9c6376e Update dependency react-native-image-picker to v8 (#12997)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-20 23:37:39 +03:00
Henry Heino
e2fc056369 Desktop,Mobile,Cli: Fixes #12648: Fix unshare action requires two syncs to be reflected locally (#12999) 2025-08-20 23:36:47 +03:00
renovate[bot]
453b4705b1 Update dependency @types/node to v18.19.103 (#12985)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-20 19:25:54 +00:00
Laurent Cozic
4128061e40 Desktop release v3.4.6 2025-08-20 22:22:42 +03:00
Joplin Bot
432b0ca870 Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-08-20 12:32:59 +00:00
renovate[bot]
c484cd2e48 Update dependency sass to v1.87.0 (#12995)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-20 15:06:57 +03:00
Laurent Cozic
58f0725c6b Doc: Add sponsor 2025-08-20 15:05:28 +03:00
Henry Heino
bf8fbec0cd Chore: Sync fuzzer: Add support for adding and removing share participants (#12988) 2025-08-20 09:46:23 +03:00
pedr
f1d452f130 Server: Fixes #12983: Not handling correctly non JSON error responses from Transcribe (#12986) 2025-08-20 09:46:15 +03:00
Henry Heino
26012cd7d5 Cli,Mobile,Desktop: Shared folders: Fix moving shared subfolder to toplevel briefly marks it as a toplevel share (#12964) 2025-08-20 09:39:39 +03:00
mrjo118
a414241541 Mobile: Improve tag screen usability to allow add or remove tag with a single press, when the keyboard is open (#12954) 2025-08-20 09:33:31 +03:00
Henry Heino
0f13bf9d51 Mobile: Rich Text Editor: Support rendering subscript, superscript, and highlighted formatting (#12944) 2025-08-20 09:33:13 +03:00
Henry Heino
c142c5c5c0 Desktop,Mobile: Markdown editor: Toggle checkboxes on ctrl-click (#12927) 2025-08-20 09:32:16 +03:00
Henry Heino
af5c0135dc Mobile: Rich Text Editor: Enable syntax highlighting and auto-indent in the code block editor (#12909) 2025-08-20 09:29:30 +03:00
pedr
8a811b9e78 Doc: Resolves #12861: Add end point documentation for Transcribe (#12870)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2025-08-20 09:29:12 +03:00
Henry Heino
602484f143 Desktop: Upgrade to Electron v37.3.0 (#12951) 2025-08-20 08:53:50 +03:00
renovate[bot]
dc84db1657 Update dependency sharp to v0.34.2 (#12982)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2025-08-20 08:33:29 +03:00
Henry Heino
f5882ecfcc Chore: Improve type safety (#12992) 2025-08-20 08:33:10 +03:00
Laurent Cozic
30000c34ec Cli: If no notebook is provided when importing a file, use the default one 2025-08-19 23:33:52 +03:00
renovate[bot]
6e3df1bd90 Update dependency @types/react to v18.3.22 (#12990)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-19 18:59:17 +03:00
Joplin Bot
67196ac0b2 Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-08-19 12:33:44 +00:00
Laurent Cozic
69646b5522 Doc: Update sponsors 2025-08-19 12:31:13 +03:00
Laurent Cozic
9147afce9a Server v3.4.2 2025-08-18 19:54:35 +03:00
Henry Heino
c92701c52f iOS: Rich Text Editor: Fix the "edit" button for code blocks (#12924) 2025-08-18 18:46:02 +03:00
pedr
ab3e9d1a3e Transcribe: Fixes: Use latest version of joplin/htr-cli available (#12875) 2025-08-18 18:45:52 +03:00
Henry Heino
f9cab8843b Chore: Fix tsc (#12981) 2025-08-18 18:40:58 +03:00
yuudi
c36289c024 Server: Fixes #12947 Skip CORS check for SAML callback (#12948)
Co-authored-by: yuudi <yuudi@users.noreply.github.com>
2025-08-18 16:10:20 +03:00
Henry Heino
60b6db8cd4 Mobile: Rich Text Editor: Add basic support for collapsible <details> blocks (#12946) 2025-08-18 16:10:00 +03:00
Henry Heino
bbd8f6f40e Mobile: Rich Text Editor: Fix adding headings moves the cursor to the next line (#12934) 2025-08-18 16:07:55 +03:00
Henry Heino
34b7f4e1f8 Chore: Sync fuzzer: Fix "DecryptionWorker: Cannot start because..." warning (#12925) 2025-08-18 16:04:26 +03:00
Henry Heino
06b681d897 Chore: Sync fuzzer: Add new possible actions: Adding and syncing a new temporary client on an existing account (#12741) 2025-08-18 15:57:44 +03:00
pedr
f02a94bef5 Transcribe: Fixes #12766: Remove processed files and clean up after a retention period (#12827) 2025-08-18 15:57:34 +03:00
Miguel Matos
ae6b57c5a5 CLI: Add collapsible notebooks functionality (#12718) 2025-08-18 15:55:55 +03:00
Henry Heino
88ab916008 Mobile: Rich Text Editor: Support rendering table of contents blocks (#12949) 2025-08-18 11:35:48 +03:00
pedr
97b0ffc263 Transcribe: #12883: Disable JobProcessor tests by default (#12955) 2025-08-18 11:34:26 +03:00
pedr
ff8848d138 Desktop: Fixes #12315: Clicking Edit URL button in Note properties does not focus in url field (#12970) 2025-08-18 11:20:54 +03:00
renovate[bot]
2b686e6318 Update dependency @playwright/test to v1.52.0 (#12972)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-18 11:17:35 +03:00
renovate[bot]
b913d18882 Update dependency @adobe/css-tools to v4.4.3 (#12979)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-17 22:02:58 +00:00
renovate[bot]
a2c9a01722 Update dependency @types/node to v18.19.101 (#12978)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-17 05:42:21 +00:00
Milo Ivir
000d23c20f All: Translation: Update hr_HR.po (#12961) 2025-08-16 21:46:55 -04:00
Liffindra Angga Zaaldian
9e9f2f2930 All: Translation: Update id_ID.po (#12977) 2025-08-16 20:42:49 -04:00
VortexP
c5a1a759c7 All: Translation: Update fi_FI.po (#12971) 2025-08-15 18:15:58 -04:00
cedecode
0b6a1c75ba All: Translation: Update de_DE.po (#12966) 2025-08-14 22:09:33 -04:00
renovate[bot]
53a0f8ddbc Update dependency python to v3.13.3 (#12965)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-15 01:08:28 +02:00
Laurent Cozic
67eabb5038 Doc: Update recommended Postgres for JSB 2025-08-14 22:32:00 +02:00
Laurent Cozic
983fced410 Doc: Fixed Transcribe graph 2025-08-14 22:32:00 +02:00
Jozef Gaal
4f5bbc1132 All: Translation: Update sk_SK.po (#12950) 2025-08-14 00:22:49 -04:00
ERYpTION
2f10235ecb All: Translation: Update da_DK.po (#12945) 2025-08-13 17:22:50 -04:00
Joplin Bot
cfa7d6cb31 Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-08-13 18:27:34 +00:00
renovate[bot]
f5d62a50fe Update dependency @types/serviceworker to v0.0.135 (#12937)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-13 10:12:25 +00:00
Laurent Cozic
b52f5435aa Doc: Add Transcribe System Architecture documentation 2025-08-12 19:24:27 +01:00
renovate[bot]
bfd5bfc004 Update dependency git to v2.48.1 (#12921)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-11 15:32:53 +01:00
renovate[bot]
82965fe991 Update dependency jsdom to v26.1.0 (#12922)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-11 15:32:45 +01:00
summoner
b2c162c25b All: Translation: Update hu_HU.po (#12918) 2025-08-10 16:05:34 -04:00
Mihai Vasiliu
022e76fe8d All: Translation: Update ro_RO.po and ro_MD.po (#12917) 2025-08-10 16:04:28 -04:00
Joplin Bot
4b2d1895fd Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-08-10 18:26:11 +00:00
Joplin Bot
534507a31f Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-08-10 12:32:07 +00:00
222 changed files with 13455 additions and 6494 deletions

View File

@@ -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

View File

@@ -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
View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View 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š"

View File

@@ -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&amp;mtm_kwd=joplinapp&amp;mtm_source=joplinapp-webseite&amp;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

View File

@@ -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": [

View 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>.

View File

@@ -0,0 +1 @@
Program za bilješke i popis zadataka sa sinkronizacijom između Linuxa, macOS-a, Windowsa i mobitela

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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 = '';

View 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);
});
});

View 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;

View File

@@ -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,
},

View 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,
});
});
});
});

View 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;

View File

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

View File

@@ -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();
}
}

View File

@@ -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",

View File

@@ -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);

View File

@@ -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;

View File

@@ -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,

View File

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

View File

@@ -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
/>
);
}

View File

@@ -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

View File

@@ -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;

View File

@@ -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"
}
}

View File

@@ -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/')) {

View File

@@ -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',

View File

@@ -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

View File

@@ -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"
}

View File

@@ -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}

View File

@@ -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}

View File

@@ -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>

View File

@@ -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}>

View File

@@ -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}

View File

@@ -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;

View File

@@ -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`);
});
});
});

View File

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

View File

@@ -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()}

View File

@@ -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}

View File

@@ -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:

View File

@@ -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}

View File

@@ -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}>

View File

@@ -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>;

View File

@@ -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;

View File

@@ -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>;

View File

@@ -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;

View File

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

View File

@@ -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);

View File

@@ -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;

View File

@@ -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)",

View File

@@ -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

View File

@@ -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",

View File

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

View 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;

View File

@@ -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;

View File

@@ -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,

View File

@@ -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;

View File

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

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

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

View File

@@ -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)] : []),
];
};

View File

@@ -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);

View File

@@ -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;

View File

@@ -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;

View File

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

View File

@@ -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;
};

View 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' },
],
},
],
});
});
});

View 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;

View File

@@ -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 {};

View File

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

View File

@@ -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();
}

View File

@@ -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);

View File

@@ -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_({

View File

@@ -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>;

View File

@@ -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;

View 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;

View File

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

View File

@@ -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', () => {

View File

@@ -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;

View 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;

View File

@@ -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;

View File

@@ -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;

View File

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

View 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;

View File

@@ -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;

View File

@@ -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",

View File

@@ -14,6 +14,7 @@ const createEditorSettings = (themeId: number) => {
autocompleteMarkup: true,
tabMovesFocus: false,
inlineRenderingEnabled: true,
highlightActiveLine: false,
keymap: EditorKeymap.Default,
language: EditorLanguageType.Markdown,

View File

@@ -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;

View File

@@ -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",

View File

@@ -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"

View File

@@ -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"
},

View File

@@ -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"

View File

@@ -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