1
0
mirror of https://github.com/laurent22/joplin.git synced 2026-06-18 20:16:34 +02:00

Compare commits

...

101 Commits

Author SHA1 Message Date
Laurent Cozic 0b7667c773 update 2026-05-10 14:29:22 +01:00
Laurent Cozic c980c7131f update 2026-05-10 14:14:09 +01:00
Laurent Cozic 4200e72208 update 2026-05-10 13:03:10 +01:00
Laurent Cozic 435ffe6d8a update 2026-05-10 12:35:39 +01:00
Laurent Cozic 7d9d7aa082 Localise the default text-card content 2026-05-10 12:23:32 +01:00
Laurent Cozic b51ab1bacb Pass user_updated_time and changeSource on Note.save to close the remaining write window 2026-05-10 12:22:51 +01:00
Laurent Cozic 003ab6aaea Round-trip serialisation of edges introduces spurious onChange on load 2026-05-10 12:20:50 +01:00
Laurent Cozic 5d5c9cb245 External content reload does not update lastSerializedRef, so the next debounce can re-emit the same body 2026-05-10 12:17:49 +01:00
Laurent Cozic 9d345256c0 update 2026-05-10 12:15:03 +01:00
Laurent Cozic 076c6a9bd4 update 2026-05-10 11:44:56 +01:00
Laurent Cozic 48e67e23b0 Extract createNoteInActiveFolder helper
newWhiteboard duplicated newNote's folder lookup, preview defaults, order
computation, save and dispatches. Pull the shared logic into one helper
exported from newNote.ts and route both commands through it. The whiteboard
opts out of the geolocation update since it isn't a place-stamped capture.
2026-05-10 11:33:02 +01:00
Laurent Cozic 5efb74daf0 Short-circuit hasWhiteboardFence with literal substring check
The fence regex starts with [\s\S]*? lazy matching, which is O(n) on
strings without a fence. hasWhiteboardFence runs on every render of the
note editor (per-keystroke during typing), so we add a cheap indexOf()
precheck for the literal opening marker before falling through to the
regex.
2026-05-10 11:30:55 +01:00
Laurent Cozic 45bdb581d8 Remove unreliable noteIsWhiteboard whenClause
It was computed on every redux dispatch but had no consumers — both
whiteboard commands explicitly avoided it because `selectedNote.body` isn't
in the note preview fields, so the regex always ran on an empty body and
the result was always false. activeNoteIsWhiteboard (set by NoteEditor when
it detects a fence) remains the canonical source.
2026-05-10 11:28:27 +01:00
Laurent Cozic b9861fc13c Pending debounced save dropped on unmount
Switching notes within the 400ms debounce window silently dropped the
pending edit because the schedule effects cleanup ran clearTimeout on
every canvas change. Split scheduling from unmount cleanup, and route
content() through a synchronous flush so external readers (the form-note
save flow) always see the post-debounce body.
2026-05-10 11:24:53 +01:00
Laurent Cozic d4da382345 Untrack accidentally committed bundled CSS
The bundler output `main-html.bundle.css` was tracked in git despite being
a build artefact — sibling `.bundle.js`, `.bundle.js.map`, and `.bundle.css.map`
files were correctly ignored, only the `.css` slipped through. Nothing
references it (index.html only loads main-html.bundle.js, and the React Flow
stylesheet is read from node_modules at runtime).
2026-05-10 11:22:05 +01:00
Laurent Cozic be8f2e35ee Dark mode 2026-05-10 11:19:32 +01:00
Laurent Cozic 8ef62586ce Rapid checkbox toggles can silently drop edits 2026-05-09 18:07:11 +01:00
Laurent Cozic ede0270ce3 Refactor 2026-05-09 18:02:53 +01:00
Laurent Cozic 6f55b47fba Recompute checkbox index at click time to avoid stale task mapping 2026-05-09 17:46:45 +01:00
Laurent Cozic db6e2afb07 Non-internal image/PDF detection never reaches the preview path 2026-05-09 17:42:16 +01:00
Laurent Cozic 3173dbd9fd Reset resolved immediately when the target ref changes 2026-05-09 17:40:35 +01:00
Laurent Cozic 43a64aa143 added tests 2026-05-09 17:31:21 +01:00
Laurent Cozic 94c0c83a3d Use proper file-path-to-URL conversion in resourceUrlFor 2026-05-09 17:25:18 +01:00
Laurent Cozic 9234de7bac Avoid exposing whiteboard.enabled on mobile until mobile whiteboard is supported 2026-05-09 17:12:57 +01:00
Laurent Cozic 498eb8a5cc Race condition: stale body overwrites edits made during the picker 2026-05-09 17:09:26 +01:00
Laurent Cozic 8b33f39d28 Localise the action-panel strings 2026-05-09 17:07:31 +01:00
Laurent Cozic 9220f6b3bd onOpenRef silently swallows non-http(s) / non-internal URLs 2026-05-09 17:03:29 +01:00
Laurent Cozic 789c76d888 Image/PDF previews are broken for internal resource references 2026-05-09 17:01:43 +01:00
Laurent Cozic 90115dc8ba Restore visible keyboard focus for the input 2026-05-09 16:57:21 +01:00
Laurent Cozic b11d2b7e6e Guard WHITEBOARD_FORCE_MARKDOWN_TOGGLE against missing noteId. 2026-05-09 16:54:42 +01:00
Laurent Cozic 344428d7cf Potential race condition in onPromoteTextNode 2026-05-09 16:54:25 +01:00
Laurent Cozic 9f185b4e08 Localize user-facing strings 2026-05-09 16:51:39 +01:00
Laurent Cozic 476800ebf9 Address concurrent edit and read-only risks in checkbox save path 2026-05-09 16:49:38 +01:00
Laurent Cozic 4b1f0f8314 update 2026-05-09 16:26:43 +01:00
Laurent Cozic d68e7e839c update 2026-05-09 15:33:23 +01:00
Laurent Cozic 14f28767a2 init 2026-05-09 15:29:28 +01:00
Laurent Cozic f3d2065a79 Chore: check-pr-title: Handle case where PR is eligible for reopening, but contributor has already opened a new PR on same branch 2026-05-09 10:29:38 +01:00
Laurent Cozic 749390153a Chore: Improve check-pr-title action reliability, and reopen once the title is fixed 2026-05-09 10:24:46 +01:00
Joplin Bot 0b3372b00a Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-05-08 19:27:39 +00:00
Laurent Cozic c7ea2c71bc Chore: Update CLA consent records 2026-05-08 16:12:09 +01:00
Laurent Cozic 54add1e9a3 iOS 13.6.5 2026-05-08 15:53:29 +01:00
Laurent Cozic 887ba6fefe Android 3.6.18 2026-05-08 15:53:28 +01:00
Laurent Cozic 44f542d459 Desktop release v3.6.11 2026-05-08 15:53:28 +01:00
Sriram Varun Kumar 698b59b8fc Mobile: Fixes #13134: Fix Android IME text corruption by upgrading @codemirror/view to 6.39.9 (#15283) 2026-05-07 14:56:20 +01:00
Laurent Cozic 81efd996e9 Revert "Doc: Document Terminal sync.target configuration for OneDrive" (#15284) 2026-05-07 12:51:47 +01:00
rubalo 411959fd3f Doc: Document Terminal sync.target configuration for OneDrive (#15281) 2026-05-07 09:21:53 +01:00
Laurent Cozic 272c0f862c Chore: Exclude Doc prefix 2026-05-07 09:21:09 +01:00
renovate[bot] 59210161cb fix(deps): update dependency react-native-nitro-modules to v0.33.7 (#15272)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-06 21:50:58 +01:00
renovate[bot] d741e1ae57 fix(deps): update dependency abcjs to v6.6.0 (#15273)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-06 21:50:35 +01:00
Laurent Cozic d0555e344d Chore: Add Rygaa to list of exception for check_pr_title 2026-05-06 17:52:44 +01:00
Laurent Cozic 99e979f383 Chore: Add images for news post 2026-05-06 13:37:03 +01:00
Laurent Cozic 28163caf86 Chore: Add images for news post 2026-05-06 11:30:11 +01:00
Laurent Cozic b8d9f0c1d2 CI: Add Check PR Title action 2026-05-06 10:36:48 +01:00
renovate[bot] 9eebfe49f9 fix(deps): update dependency react-native-svg to v15.15.2 (#15270)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-06 00:33:14 +01:00
renovate[bot] 63926d7d87 chore(deps): update dependency nitrogen to v0.33.7 (#15269)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-06 00:33:06 +01:00
renovate[bot] 25a1b141cb chore(deps): update dependency @axe-core/playwright to v4.11.1 (#15266)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-05-06 00:32:40 +01:00
renovate[bot] ff88777e67 fix(deps): update dependency ldapts to v8.1.6 (#15259)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-05 13:52:06 +01:00
renovate[bot] d0a6803f47 chore(deps): update dependency @types/serviceworker to v0.0.183 (#15267)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-05 13:51:25 +01:00
renovate[bot] 96e5b53c2a fix(deps): update dependency react-native-share to v12.2.5 (#15261)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-05 09:06:41 +01:00
renovate[bot] a22fdaa5c9 fix(deps): update dependency react-native-nitro-modules to v0.33.3 (#15260)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-05 09:06:32 +01:00
Alon Diament Carmel 25048623ce All: Exclude user_data from note revisions (#15245) 2026-05-04 11:19:42 +01:00
renovate[bot] 21367da256 chore(deps): update dependency nitrogen to v0.33.3 (#15256)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-03 23:30:25 +00:00
renovate[bot] 00f25718e6 fix(deps): update dependency @fortawesome/react-fontawesome to v3 (#15251)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-03 23:15:14 +01:00
renovate[bot] 08fb54f9f3 fix(deps): update dependency ldapts to v8.1.4 (#15238)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-02 22:14:28 +00:00
renovate[bot] 8d2c0a52d2 chore(deps): update dependency npm-package-json-lint to v9.1.0 (#15250)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-02 23:12:35 +01:00
mrjo118 af9a4f076a All: Fix inability to cancel the sync during the deletion step (#15243) 2026-05-02 21:36:00 +01:00
renovate[bot] 8cc2e56a4a chore(deps): update node.js to v22 (#15240)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-02 21:31:00 +01:00
Joplin Bot 6aed6bf97b Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-05-02 19:10:26 +00:00
Joplin Bot 80e951dfef Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-05-02 13:23:27 +00:00
Himanshu Mishra bbcd8c83fa Desktop: Display inline error instead of smalltalk dialog for invalid master password (#15236) 2026-05-02 10:00:02 +01:00
renovate[bot] a93998ea9d fix(deps): update dependency expo to v54.0.33 (#15237)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-01 17:09:18 +00:00
renovate[bot] a81088c33f chore(deps): update actions/setup-python action to v6 (#15234)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-01 16:06:12 +01:00
renovate[bot] 2326202db7 fix(deps): update dependency ldapts to v8.1.3 (#15233)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-01 16:06:03 +01:00
renovate[bot] 34b0a9b2f3 chore(deps): update dependency nodejs to v24.12.0 (#15230)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-01 13:33:49 +01:00
renovate[bot] 7ac3710f5c chore(deps): update actions/setup-java action to v5 (#15232)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-01 13:33:41 +01:00
renovate[bot] b9f287904f fix(deps): update dependency ldapts to v8.1.2 (#15231)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-01 13:33:31 +01:00
Self Not Found d1d492622c Mobile: Add notebook sort options in config screen (#15203) 2026-05-01 11:16:28 +01:00
renovate[bot] 7637829d2d fix(deps): update dependency pg-boss to v10.4.2 (#15229)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-01 11:15:35 +01:00
renovate[bot] 47b8b0a16f chore(deps): update dependency python to v3.14.2 (#15228)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-01 11:15:18 +01:00
renovate[bot] 3969b450b4 chore(deps): update dependency @types/serviceworker to v0.0.182 (#15227)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-01 11:15:14 +01:00
Himanshu Mishra d0f87f0c69 Desktop: Update Disable Encryption dialog (#15211) 2026-05-01 09:57:17 +01:00
mrjo118 147738ec52 Desktop, Mobile: Fixes #14506: Add the ability to delete the default profile (#15153) 2026-05-01 09:56:22 +01:00
mrjo118 ec85b1a7e6 Desktop, CLI: Fix potential unresolved promise race conditions when scheduling the sync (#15216) 2026-05-01 09:55:11 +01:00
renovate[bot] 57bd5ff14a chore(deps): update dependency @types/serviceworker to v0.0.181 (#15222)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-01 08:57:54 +01:00
renovate[bot] 55b2b8c49b chore(deps): update dependency @types/nodemailer to v6.4.22 (#15221)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-01 08:57:49 +01:00
renovate[bot] 4759ab151e chore(deps): update eslint (#15224)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-01 08:57:09 +01:00
Joplin Bot 074dd6ed35 Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-05-01 03:34:54 +00:00
mrjo118 f16f6ea5ad Mobile: Fix log screen auto-scroll loop when toggling errors (#15219) 2026-04-30 18:50:46 +01:00
Laurent Cozic 99e3a2eaec Chore: gitignore merge temp files 2026-04-30 16:49:15 +01:00
Henry Heino 293f6661c1 Desktop: Upgrade Electron to v40.9.2 (#15192) 2026-04-30 16:38:08 +01:00
Sriram Varun Kumar 8aa8e648ca Mobile: Fixes #15120: RTE heading links scroll to bottom instead of top in Editing mode (#15128) 2026-04-30 16:31:23 +01:00
Ehtesham Zahid 570d2cc1f3 Mobile: Fix #15104: Truncate verbose decryption error payloads in Status screen (#15112) 2026-04-30 16:26:37 +01:00
Laurent Cozic 8e8a6dd656 Mobile: Load plugin scripts directly from filesystem instead of passing through bridge (#15095) 2026-04-30 16:23:55 +01:00
Laurent Cozic c1b5e0dade Web: Fix crash that happens when the app receives an unknown message (#15087) 2026-04-30 16:19:44 +01:00
Laurent Cozic 22eb606a53 Desktop, Mobile: Resolves #15081: Speed up app startup by skipping unnecessary plugin file processing (#15085) 2026-04-30 16:08:28 +01:00
Kanishka.. b9fddca475 Desktop: Resolves #12372: Add table editing commands (add/delete rows and columns) (#14519) 2026-04-30 15:59:52 +01:00
mrjo118 5fdd648954 Desktop, Mobile: Fixes #14647: Fix inconsistent note order upon note creation, when custom order is set (#14656) 2026-04-30 15:54:15 +01:00
slimu d744db5063 Desktop: Resolves #14763: Add settings search to config screen (#14820)
Co-authored-by: Henry Heino <46334387+personalizedrefrigerator@users.noreply.github.com>
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-04-30 15:52:05 +01:00
Aissa Benfodda 98b1502a4a Desktop: Fixes #13903: Avoid OOM when printing notes with large attachment links (#15026) 2026-04-30 15:51:45 +01:00
Alex Martens ae5ab0bfc1 CLI, Desktop: Add support for post-quantum cryptography (PQS) TLS (#15055) 2026-04-30 15:50:48 +01:00
Laurent Cozic bb983ff1d4 Desktop, Cli: Do not load plugin if it is disabled (#15083) 2026-04-30 15:49:27 +01:00
324 changed files with 90773 additions and 2170 deletions
+36
View File
@@ -200,6 +200,7 @@ packages/app-desktop/gui/ClipperConfigScreen.js
packages/app-desktop/gui/ConfigScreen/ButtonBar.js
packages/app-desktop/gui/ConfigScreen/ConfigScreen.js
packages/app-desktop/gui/ConfigScreen/Sidebar.js
packages/app-desktop/gui/ConfigScreen/configSearch.js
packages/app-desktop/gui/ConfigScreen/controls/FontSearch.js
packages/app-desktop/gui/ConfigScreen/controls/GlobalHotkeyInput.test.js
packages/app-desktop/gui/ConfigScreen/controls/GlobalHotkeyInput.js
@@ -212,6 +213,8 @@ packages/app-desktop/gui/ConfigScreen/controls/ToggleAdvancedSettingsButton.js
packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.js
packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.js
packages/app-desktop/gui/ConfigScreen/controls/plugins/SearchPlugins.js
packages/app-desktop/gui/ConfigScreen/searchHighlight.test.js
packages/app-desktop/gui/ConfigScreen/searchHighlight.js
packages/app-desktop/gui/DialogButtonRow.js
packages/app-desktop/gui/DialogButtonRow/useKeyboardHandler.js
packages/app-desktop/gui/DialogTitle.js
@@ -299,6 +302,20 @@ packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useScroll.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useTabIndenter.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useTextPatternsLookup.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useWebViewApi.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/ActionPanel.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/WhiteboardContext.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/WhiteboardEditor.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/WhiteboardSurface.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/canvasFlow.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/injectStyle.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/loadReactFlowCss.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/FileNode.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/LinkNode.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/TextNode.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/sharedStyles.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/theme.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/useCheckboxToggle.test.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/useCheckboxToggle.js
packages/app-desktop/gui/NoteEditor/NoteEditor.js
packages/app-desktop/gui/NoteEditor/NoteTitle/NoteTitleBar.js
packages/app-desktop/gui/NoteEditor/StatusBar.js
@@ -476,6 +493,7 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/AppDialogs.js
packages/app-desktop/gui/WindowCommandsAndDialogs/ModalMessageOverlay.js
packages/app-desktop/gui/WindowCommandsAndDialogs/PluginDialogs.js
packages/app-desktop/gui/WindowCommandsAndDialogs/WindowCommandsAndDialogs.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/addNoteToWhiteboard.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/addProfile.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/commandPalette.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/deleteFolder.js
@@ -491,9 +509,11 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/index.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/linkToNote.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/moveToFolder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newFolder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newNote.test.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newNote.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newSubFolder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newTodo.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newWhiteboard.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openFolder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openFolderDialog.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openItem.js
@@ -527,6 +547,7 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleNotesSortOrderR
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/togglePerFolderSortOrder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleSideBar.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleVisiblePanes.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleWhiteboardEditor.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/utils/canUseNativeUndo.js
packages/app-desktop/gui/WindowCommandsAndDialogs/types.js
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/appDialogs.js
@@ -1060,6 +1081,8 @@ packages/editor/CodeMirror/editorCommands/markdownCommands.js
packages/editor/CodeMirror/editorCommands/sortSelectedLines.test.js
packages/editor/CodeMirror/editorCommands/sortSelectedLines.js
packages/editor/CodeMirror/editorCommands/supportsCommand.js
packages/editor/CodeMirror/editorCommands/tableCommands.test.js
packages/editor/CodeMirror/editorCommands/tableCommands.js
packages/editor/CodeMirror/extensions/biDirectionalTextExtension.js
packages/editor/CodeMirror/extensions/ctrlClickActionExtension.js
packages/editor/CodeMirror/extensions/ctrlClickCheckboxExtension.js
@@ -1088,6 +1111,7 @@ packages/editor/CodeMirror/extensions/overwriteModeExtension.js
packages/editor/CodeMirror/extensions/rendering/addFormattingClasses.js
packages/editor/CodeMirror/extensions/rendering/renderBlockImages.test.js
packages/editor/CodeMirror/extensions/rendering/renderBlockImages.js
packages/editor/CodeMirror/extensions/rendering/renderTables.js
packages/editor/CodeMirror/extensions/rendering/renderingExtension.js
packages/editor/CodeMirror/extensions/rendering/replaceBackslashEscapes.js
packages/editor/CodeMirror/extensions/rendering/replaceBulletLists.js
@@ -1148,6 +1172,8 @@ 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/tableUtils.test.js
packages/editor/CodeMirror/utils/markdown/tableUtils.js
packages/editor/CodeMirror/utils/markdown/toggleCheckboxAt.js
packages/editor/CodeMirror/utils/setupVim.js
packages/editor/CodeMirror/vendor/announceSearchMatch.js
@@ -1248,6 +1274,8 @@ packages/generator-joplin/tools/updateCategories.js
packages/htmlpack/index.test.js
packages/htmlpack/index.js
packages/htmlpack/packToString.js
packages/htmlpack/packToWriter.test.js
packages/htmlpack/packToWriter.js
packages/htmlpack/utils/parseHtmlAsync.js
packages/lib/ArrayUtils.js
packages/lib/AsyncActionQueue.test.js
@@ -1323,6 +1351,8 @@ packages/lib/components/shared/ShareNoteDialog/useEncryptionWarningMessage.js
packages/lib/components/shared/ShareNoteDialog/useOnShareLinkClick.js
packages/lib/components/shared/ShareNoteDialog/useShareStatusMessage.js
packages/lib/components/shared/SsoScreenShared.js
packages/lib/components/shared/config/config-search-text.js
packages/lib/components/shared/config/config-search.test.js
packages/lib/components/shared/config/config-shared.js
packages/lib/components/shared/config/plugins/types.js
packages/lib/components/shared/config/plugins/useOnDeleteHandler.js
@@ -1747,6 +1777,12 @@ packages/lib/services/trash/permanentlyDeleteOldItems.test.js
packages/lib/services/trash/permanentlyDeleteOldItems.js
packages/lib/services/trash/restoreItems.test.js
packages/lib/services/trash/restoreItems.js
packages/lib/services/whiteboard/generateId.js
packages/lib/services/whiteboard/jsoncanvas.js
packages/lib/services/whiteboard/parse.js
packages/lib/services/whiteboard/resolveRef.js
packages/lib/services/whiteboard/serialize.js
packages/lib/services/whiteboard/whiteboard.test.js
packages/lib/shim-init-node.test.js
packages/lib/shim-init-node.js
packages/lib/shim.js
+25 -12
View File
@@ -6,24 +6,37 @@ If this is a Google Summer of Code pull request, please read the [GSoC pull requ
---
**Pull request title**: Please prefix the title with the platform you are targetting.
**Pull request title**: Please prefix the title with the area you are targeting, then add the issue you are addressing.
Here are some examples of good titles:
The format is:
<Prefix>: <Fixes|Resolves> #<issue>: <description>
Use "Resolves #123" for new features or improvements, and "Fixes #123" for bug fixes.
Examples of good titles:
- Desktop: Resolves #123: Added new setting to change font
- Mobile, Desktop: Fixes #456: Fixed config screen error
- All: Resolves #777: Made synchronisation faster
And here's an explanation of the title format:
Valid prefixes:
- "Desktop" for the Windows/macOS/Linux app (Electron app)
- "Mobile" for the mobile app (or "Android" / "iOS" if the pull request only applies to one of the mobile platforms)
- "CLI" for the CLI app
- `All` — change applies to all client apps (Desktop, Mobile and CLI)
- `Desktop` — the Windows/macOS/Linux app (Electron app)
- `Mobile` — the mobile app (both Android and iOS)
- `Android` — only the Android app
- `iOS` — only the iOS app
- `Cli` — the command line app
- `Server` — the Joplin Server
- `Clipper` — the web clipper browser extension
- `Plugins` — the plugin API or built-in plugins
- `Plugin Repo` — the plugin repository
- `Tools` — internal scripts and build tools
- `CI` — continuous integration and GitHub workflows
- `Doc` — documentation, README, website content
- `Chore` — maintenance work that does not fit any of the above (dependency bumps, refactoring, cleanup)
If it's two platforms, separate them with commas - "Desktop, Mobile" or if it's for all platforms, prefix with "All".
If the change targets two areas, separate them with commas — for example "Desktop, Mobile". If it applies to all client apps, use "All".
If it's not related to any platform (such as a translation, change to the documentation, etc.), simply don't add a platform.
Then please append the issue that you've addressed or fixed. Use "Resolves #123" for new features or improvements and "Fixes #123" for bug fixes.
-->
-->
+142
View File
@@ -0,0 +1,142 @@
// Validates the PR title and acts on the result.
//
// - Renovate is filtered out at the workflow level (job `if:`).
// - Translation-only PRs (every changed file is a .po) are skipped.
// - Users in `softCheckUsers` get a relaxed check (issue number optional)
// and only ever receive a comment, never a close.
// - Everyone else must match the strict format. Invalid titles get a
// comment and the PR is closed. We also apply a marker label so that
// we can later tell our closures apart from any other closure.
// - If the title becomes valid and the marker label is present, the PR
// is reopened and the label is removed. Closures by humans (or by
// another workflow) lack the label and are never overturned.
//
// Invoked from .github/workflows/check-pr-title.yml via actions/github-script.
// Required inputs come from `env`: PR_AUTHOR, PR_NUMBER. The title is
// fetched from the API rather than passed via env to avoid YAML expansion
// silently stripping leading whitespace from `${{ ... }}`.
module.exports = async ({ github, context, core }) => {
const softCheckUsers = ['laurent22', 'personalizedrefrigerator', 'mrjo118', 'tessus', 'CalebJohn', 'Rygaa'];
const autoClosedLabel = 'auto-closed: invalid-title';
const prefix = '(Desktop|Mobile|All|Cli|Tools|Chore|Clipper|Server|Android|iOS|Plugins|CI|Plugin Repo|Doc)';
const prefixList = `${prefix}(,\\s*${prefix})*`;
const strictRegex = new RegExp(`^${prefixList}: (Fixes|Resolves) #[0-9]+: .+`);
const softRegex = new RegExp(`^${prefixList}: ((Fixes|Resolves) #[0-9]+: )?.+`);
const author = process.env.PR_AUTHOR;
const prNumber = Number(process.env.PR_NUMBER);
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
});
const title = pr.title;
core.info(`Title (length=${title.length}): ${JSON.stringify(title)}`);
const isSoft = softCheckUsers.includes(author);
// listFiles returns up to 30 files per page; a pure translation PR is
// small, so checking the first page is enough.
const { data: files } = await github.rest.pulls.listFiles({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
});
const isTranslationOnly = files.length > 0 && files.every(f => f.filename.endsWith('.po'));
if (isTranslationOnly) {
core.info('Translation-only PR — skipping title check.');
return;
}
// Doc-only PRs do not require an issue number.
const isDocOnly = /^Doc(,\s*Doc)*:/.test(title);
const regex = isSoft || isDocOnly ? softRegex : strictRegex;
if (regex.test(title)) {
core.info('Title is valid.');
// If we previously closed this PR for an invalid title and the
// title is now valid, reopen it. We only reopen if our marker
// label is present, so closures by humans (or other workflows)
// are never overturned. A maintainer can also remove the label
// by hand to lock a PR closed regardless of future fixes.
const wasAutoClosed = pr.state === 'closed' && pr.labels.some(l => l.name === autoClosedLabel);
if (wasAutoClosed) {
try {
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
state: 'open',
});
} catch (error) {
// GitHub refuses to reopen a PR when another open PR
// already exists from the same head→base branch pair.
// In that case the contributor has already opened a
// replacement, so leave this PR closed.
if (error.status === 422) {
core.info('Cannot reopen — another PR is already open from the same branch.');
return;
}
throw error;
}
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
name: autoClosedLabel,
});
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: `@${author} thanks for fixing the title — this PR has been reopened.`,
});
core.info('PR reopened after title was fixed.');
}
return;
}
const helpMessage = [
`@${author} the pull request title does not match the required format.`,
'',
'Please prefix the title with the area you are targeting, then add the issue you are addressing. For example:',
'',
'- `Desktop: Resolves #123: Added new setting to change font`',
'- `Mobile, Desktop: Fixes #456: Fixed config screen error`',
'- `All: Resolves #777: Made synchronisation faster`',
'',
'See the [pull request template](https://github.com/laurent22/joplin/blob/dev/.github/PULL_REQUEST_TEMPLATE) for the list of valid prefixes and the full specification.',
'',
isSoft
? '_This PR has been left open — please update the title when you have a moment._'
: '_This PR has been closed automatically. Once you update the title to match the format above, the PR will be reopened automatically._',
].join('\n');
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: helpMessage,
});
if (!isSoft) {
// Label first so the marker is set before the close event lands.
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
labels: [autoClosedLabel],
});
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
state: 'closed',
});
}
core.setFailed('Pull request title does not match the required format.');
};
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
sudo apt-get update || true
sudo apt-get install -y libsecret-1-dev
- uses: actions/setup-java@v4
- uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '20'
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
brew install pango
# See github-action-main.yml for explanation
- uses: actions/setup-python@v5
- uses: actions/setup-python@v6
with:
python-version: '3.14'
+25 -3
View File
@@ -1,9 +1,31 @@
name: Check pull request title
on: [pull_request]
on:
pull_request_target:
types: [opened, edited, reopened, synchronize]
jobs:
main:
# Skip the check entirely for these automation accounts.
if: github.event.pull_request.user.login != 'renovate[bot]'
runs-on: ubuntu-latest
permissions:
pull-requests: write
issues: write
steps:
- uses: Slashgear/action-check-pr-title@v5.0.1
# Sparse checkout so we only pull the script, not the full repo.
# `pull_request_target` checks out the base branch by default,
# which is what we want — we never execute PR-supplied code.
- uses: actions/checkout@v4
with:
regexp: "(Desktop|Mobile|All|Cli|Tools|Chore|Clipper|Server|Android|iOS|Plugins|CI|Plugin Repo|Doc): (Fixes|Resolves) #[0-9]+: .+"
sparse-checkout: .github/scripts
sparse-checkout-cone-mode: false
- name: Check title
uses: actions/github-script@v7
env:
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
PR_NUMBER: ${{ github.event.pull_request.number }}
with:
script: |
const check = require('./.github/scripts/check_pr_title.js');
await check({ github, context, core });
@@ -70,6 +70,6 @@ runs:
# Python to an earlier version.
# Fixes error `ModuleNotFoundError: No module named 'distutils'`
# Ref: https://github.com/nodejs/node-gyp/issues/2869
- uses: actions/setup-python@v5
- uses: actions/setup-python@v6
with:
python-version: '3.14'
+40
View File
@@ -54,6 +54,10 @@ docs/**/*.mustache
.idea
/readme/i18n
.watchman-cookie-*
*_BACKUP_*.js
*_BASE_*.js
*_LOCAL_*.js
*_REMOTE_*.js
# Yarn stuff
# https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored
@@ -173,6 +177,7 @@ packages/app-desktop/gui/ClipperConfigScreen.js
packages/app-desktop/gui/ConfigScreen/ButtonBar.js
packages/app-desktop/gui/ConfigScreen/ConfigScreen.js
packages/app-desktop/gui/ConfigScreen/Sidebar.js
packages/app-desktop/gui/ConfigScreen/configSearch.js
packages/app-desktop/gui/ConfigScreen/controls/FontSearch.js
packages/app-desktop/gui/ConfigScreen/controls/GlobalHotkeyInput.test.js
packages/app-desktop/gui/ConfigScreen/controls/GlobalHotkeyInput.js
@@ -185,6 +190,8 @@ packages/app-desktop/gui/ConfigScreen/controls/ToggleAdvancedSettingsButton.js
packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.js
packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.js
packages/app-desktop/gui/ConfigScreen/controls/plugins/SearchPlugins.js
packages/app-desktop/gui/ConfigScreen/searchHighlight.test.js
packages/app-desktop/gui/ConfigScreen/searchHighlight.js
packages/app-desktop/gui/DialogButtonRow.js
packages/app-desktop/gui/DialogButtonRow/useKeyboardHandler.js
packages/app-desktop/gui/DialogTitle.js
@@ -272,6 +279,20 @@ packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useScroll.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useTabIndenter.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useTextPatternsLookup.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useWebViewApi.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/ActionPanel.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/WhiteboardContext.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/WhiteboardEditor.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/WhiteboardSurface.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/canvasFlow.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/injectStyle.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/loadReactFlowCss.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/FileNode.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/LinkNode.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/TextNode.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/sharedStyles.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/theme.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/useCheckboxToggle.test.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/useCheckboxToggle.js
packages/app-desktop/gui/NoteEditor/NoteEditor.js
packages/app-desktop/gui/NoteEditor/NoteTitle/NoteTitleBar.js
packages/app-desktop/gui/NoteEditor/StatusBar.js
@@ -449,6 +470,7 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/AppDialogs.js
packages/app-desktop/gui/WindowCommandsAndDialogs/ModalMessageOverlay.js
packages/app-desktop/gui/WindowCommandsAndDialogs/PluginDialogs.js
packages/app-desktop/gui/WindowCommandsAndDialogs/WindowCommandsAndDialogs.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/addNoteToWhiteboard.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/addProfile.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/commandPalette.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/deleteFolder.js
@@ -464,9 +486,11 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/index.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/linkToNote.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/moveToFolder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newFolder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newNote.test.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newNote.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newSubFolder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newTodo.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newWhiteboard.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openFolder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openFolderDialog.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openItem.js
@@ -500,6 +524,7 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleNotesSortOrderR
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/togglePerFolderSortOrder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleSideBar.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleVisiblePanes.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleWhiteboardEditor.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/utils/canUseNativeUndo.js
packages/app-desktop/gui/WindowCommandsAndDialogs/types.js
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/appDialogs.js
@@ -1033,6 +1058,8 @@ packages/editor/CodeMirror/editorCommands/markdownCommands.js
packages/editor/CodeMirror/editorCommands/sortSelectedLines.test.js
packages/editor/CodeMirror/editorCommands/sortSelectedLines.js
packages/editor/CodeMirror/editorCommands/supportsCommand.js
packages/editor/CodeMirror/editorCommands/tableCommands.test.js
packages/editor/CodeMirror/editorCommands/tableCommands.js
packages/editor/CodeMirror/extensions/biDirectionalTextExtension.js
packages/editor/CodeMirror/extensions/ctrlClickActionExtension.js
packages/editor/CodeMirror/extensions/ctrlClickCheckboxExtension.js
@@ -1061,6 +1088,7 @@ packages/editor/CodeMirror/extensions/overwriteModeExtension.js
packages/editor/CodeMirror/extensions/rendering/addFormattingClasses.js
packages/editor/CodeMirror/extensions/rendering/renderBlockImages.test.js
packages/editor/CodeMirror/extensions/rendering/renderBlockImages.js
packages/editor/CodeMirror/extensions/rendering/renderTables.js
packages/editor/CodeMirror/extensions/rendering/renderingExtension.js
packages/editor/CodeMirror/extensions/rendering/replaceBackslashEscapes.js
packages/editor/CodeMirror/extensions/rendering/replaceBulletLists.js
@@ -1121,6 +1149,8 @@ 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/tableUtils.test.js
packages/editor/CodeMirror/utils/markdown/tableUtils.js
packages/editor/CodeMirror/utils/markdown/toggleCheckboxAt.js
packages/editor/CodeMirror/utils/setupVim.js
packages/editor/CodeMirror/vendor/announceSearchMatch.js
@@ -1221,6 +1251,8 @@ packages/generator-joplin/tools/updateCategories.js
packages/htmlpack/index.test.js
packages/htmlpack/index.js
packages/htmlpack/packToString.js
packages/htmlpack/packToWriter.test.js
packages/htmlpack/packToWriter.js
packages/htmlpack/utils/parseHtmlAsync.js
packages/lib/ArrayUtils.js
packages/lib/AsyncActionQueue.test.js
@@ -1296,6 +1328,8 @@ packages/lib/components/shared/ShareNoteDialog/useEncryptionWarningMessage.js
packages/lib/components/shared/ShareNoteDialog/useOnShareLinkClick.js
packages/lib/components/shared/ShareNoteDialog/useShareStatusMessage.js
packages/lib/components/shared/SsoScreenShared.js
packages/lib/components/shared/config/config-search-text.js
packages/lib/components/shared/config/config-search.test.js
packages/lib/components/shared/config/config-shared.js
packages/lib/components/shared/config/plugins/types.js
packages/lib/components/shared/config/plugins/useOnDeleteHandler.js
@@ -1720,6 +1754,12 @@ packages/lib/services/trash/permanentlyDeleteOldItems.test.js
packages/lib/services/trash/permanentlyDeleteOldItems.js
packages/lib/services/trash/restoreItems.test.js
packages/lib/services/trash/restoreItems.js
packages/lib/services/whiteboard/generateId.js
packages/lib/services/whiteboard/jsoncanvas.js
packages/lib/services/whiteboard/parse.js
packages/lib/services/whiteboard/resolveRef.js
packages/lib/services/whiteboard/serialize.js
packages/lib/services/whiteboard/whiteboard.test.js
packages/lib/shim-init-node.test.js
packages/lib/shim-init-node.js
packages/lib/shim.js
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -5,7 +5,7 @@ nodeLinker: node-modules
compressionLevel: mixed
enableGlobalCache: false
yarnPath: .yarn/releases/yarn-4.9.2.cjs
yarnPath: .yarn/releases/yarn-4.12.0.cjs
logFilters:
Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1012 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 438 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

+2 -2
View File
@@ -9,9 +9,9 @@
"vips.dev": {
"platforms": ["aarch64-darwin"],
},
"nodejs": "24.11.1",
"nodejs": "24.12.0",
"pkg-config": "latest",
"python": "3.14.0",
"python": "3.14.2",
"bat": "latest",
"electron": {
"version": "latest",
+6 -4
View File
@@ -10,7 +10,7 @@
},
"engines": {
"node": ">=18",
"yarn": "4.9.2"
"yarn": "4.12.0"
},
"scripts": {
"buildApiDoc": "yarn workspace joplin start apidoc ../../readme/api/references/rest_api.md",
@@ -90,8 +90,8 @@
"lerna": "3.22.1",
"lint-staged": "16.2.7",
"madge": "8.0.0",
"npm-package-json-lint": "9.0.0",
"typescript": "5.8.3"
"npm-package-json-lint": "9.1.0",
"typescript": "5.9.3"
},
"dependencies": {
"@types/fs-extra": "11.0.4",
@@ -100,8 +100,10 @@
"node-gyp": "11.5.0",
"nodemon": "3.1.11"
},
"packageManager": "yarn@4.9.2",
"packageManager": "yarn@4.12.0",
"resolutions": {
"@codemirror/view": "6.39.9",
"@codemirror/state": "6.5.4",
"react-native-camera@4.2.1": "patch:react-native-camera@npm%3A4.2.1#./.yarn/patches/react-native-camera-npm-4.2.1-24b2600a7e.patch",
"eslint": "patch:eslint@8.57.1#./.yarn/patches/eslint-npm-8.39.0-d92bace04d.patch",
"app-builder-lib@24.4.0": "patch:app-builder-lib@npm%3A24.4.0#./.yarn/patches/app-builder-lib-npm-24.4.0-05322ff057.patch",
+1 -1
View File
@@ -248,7 +248,7 @@ class Command extends BaseCommand {
logger.info('Unsharing folder', folder.id);
await ShareService.instance().unshareFolder(folder.id);
await reg.scheduleSync();
await reg.waitForSyncFinishedThenSync();
};
if (args.command === 'add' || args.command === 'remove' || args.command === 'delete') {
+1 -1
View File
@@ -78,6 +78,6 @@
"gulp": "4.0.2",
"jest": "29.7.0",
"temp": "0.9.4",
"typescript": "5.8.3"
"typescript": "5.9.3"
}
}
+1
View File
@@ -29,3 +29,4 @@ downloads/
# Bundler output
*.js.meta.json
*.bundle.js
*.bundle.css
@@ -58,6 +58,7 @@ export default class InteropServiceHelper {
const exportOptions = {
customCss: options.customCss ? options.customCss : '',
plugins: options.plugins,
shouldEmbedOnlyImages: true,
};
htmlFile = await this.exportNoteToHtmlFile(noteId, exportOptions);
+30
View File
@@ -40,6 +40,14 @@ export interface AppWindowState extends WindowState {
devToolsVisible: boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
watchedResources: any;
// Note IDs for which the user has chosen to view the underlying Markdown
// instead of the Whiteboard editor. Per-window, in-memory only.
whiteboardForceMarkdown: Record<string, boolean>;
// Whether the currently-active note in this window contains a whiteboard
// fence. Set by the NoteEditor when it loads / saves the body, used by
// the toolbar to show the editor toggle button. (We can't compute this
// from the redux note list because `body` isn't in the preview fields.)
activeNoteIsWhiteboard: boolean;
}
interface BackgroundWindowStates {
@@ -72,6 +80,8 @@ export const createAppDefaultWindowState = (): AppWindowState => {
editorCodeView: true,
devToolsVisible: false,
watchedResources: {},
whiteboardForceMarkdown: {},
activeNoteIsWhiteboard: false,
};
};
@@ -205,6 +215,26 @@ export default function(state: AppState, action: any) {
};
break;
case 'WHITEBOARD_FORCE_MARKDOWN_TOGGLE': {
const id: unknown = action.noteId;
// Guard against dispatchers forgetting to pass a noteId — writing
// an `undefined` key into the map would persist a junk entry.
if (typeof id !== 'string' || !id) break;
const current = !!state.whiteboardForceMarkdown?.[id];
newState = {
...state,
whiteboardForceMarkdown: { ...(state.whiteboardForceMarkdown || {}), [id]: !current },
};
break;
}
case 'WHITEBOARD_ACTIVE_NOTE_SET':
newState = {
...state,
activeNoteIsWhiteboard: !!action.value,
};
break;
case 'MAIN_LAYOUT_SET':
newState = {
@@ -16,10 +16,14 @@ import restart from '../../services/restart';
import JoplinCloudConfigScreen from '../JoplinCloudConfigScreen';
import ToggleAdvancedSettingsButton from './controls/ToggleAdvancedSettingsButton';
import shouldShowMissingPasswordWarning from '@joplin/lib/components/shared/config/shouldShowMissingPasswordWarning';
import { normalizeQuery } from '@joplin/lib/components/shared/config/config-search-text.js';
import { searchResultGroups, matchedSearchSections } from './configSearch';
import MacOSMissingPasswordHelpLink from './controls/MissingPasswordHelpLink';
const { KeymapConfigScreen } = require('../KeymapConfig/KeymapConfigScreen');
import SettingComponent, { UpdateSettingValueEvent } from './controls/SettingComponent';
import shim, { MessageBoxType } from '@joplin/lib/shim';
import { OnChangeEvent } from '../lib/SearchInput/SearchInput';
import highlightSearchText from './searchHighlight';
interface Font {
@@ -52,6 +56,8 @@ class ConfigScreenComponent extends React.Component<any, any> {
changedSettingKeys: [],
needRestart: false,
fonts: [],
searchQuery: '',
searchSectionFilter: null,
};
this.rowStyle_ = {
@@ -64,6 +70,22 @@ class ConfigScreenComponent extends React.Component<any, any> {
this.onSaveClick = this.onSaveClick.bind(this);
this.onApplyClick = this.onApplyClick.bind(this);
this.handleSettingButton = this.handleSettingButton.bind(this);
this.onSearchQueryChange = this.onSearchQueryChange.bind(this);
this.onSearchButtonClick = this.onSearchButtonClick.bind(this);
}
private onSearchQueryChange(event: OnChangeEvent) {
this.setState({
searchQuery: event.value,
searchSectionFilter: null,
});
}
private onSearchButtonClick() {
this.setState({
searchQuery: '',
searchSectionFilter: null,
});
}
private async checkSyncConfig_() {
@@ -165,7 +187,17 @@ class ConfigScreenComponent extends React.Component<any, any> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
private sidebar_selectionChange(event: any) {
void this.switchSection(event.section.name);
const sectionName = event.section.name;
const searchMode = !!normalizeQuery(this.state.searchQuery);
if (searchMode) {
this.setState({
searchSectionFilter: sectionName,
});
return;
}
void this.switchSection(sectionName);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
@@ -184,6 +216,7 @@ class ConfigScreenComponent extends React.Component<any, any> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
public sectionToComponent(key: string, section: any, settings: any, selected: boolean) {
const theme = themeStyle(this.props.themeId);
const searchMode = !!normalizeQuery(this.state.searchQuery);
const createSettingComponents = (advanced: boolean) => {
const output = [];
@@ -308,16 +341,19 @@ class ConfigScreenComponent extends React.Component<any, any> {
let advancedSettingsButton = null;
const advancedSettingsSectionStyle = { display: 'none' };
const advancedSettingsGroupId = `advanced_settings_${key}`;
const advancedSettingsVisible = this.state.showAdvancedSettings || searchMode;
if (advancedSettingComps.length) {
advancedSettingsButton = (
<ToggleAdvancedSettingsButton
onClick={() => shared.advancedSettingsButton_click(this)}
advancedSettingsVisible={this.state.showAdvancedSettings}
aria-controls={advancedSettingsGroupId}
/>
);
advancedSettingsSectionStyle.display = this.state.showAdvancedSettings ? 'block' : 'none';
if (!searchMode) {
advancedSettingsButton = (
<ToggleAdvancedSettingsButton
onClick={() => shared.advancedSettingsButton_click(this)}
advancedSettingsVisible={advancedSettingsVisible}
aria-controls={advancedSettingsGroupId}
/>
);
}
advancedSettingsSectionStyle.display = advancedSettingsVisible ? 'block' : 'none';
}
return (
@@ -342,6 +378,10 @@ class ConfigScreenComponent extends React.Component<any, any> {
shared.updateSettingValue(this, key, value);
};
private renderSearchHighlightedText = (text: string): React.ReactNode => {
return highlightSearchText(text, this.state.searchQuery);
};
public settingToComponent<T extends string>(key: T, value: SettingValueType<T>) {
return (
<SettingComponent
@@ -352,6 +392,7 @@ class ConfigScreenComponent extends React.Component<any, any> {
fonts={this.state.fonts}
onUpdateSettingValue={this.onUpdateSettingValue}
onSettingButtonClick={this.handleSettingButton}
renderSearchText={this.renderSearchHighlightedText}
/>
);
}
@@ -399,6 +440,9 @@ class ConfigScreenComponent extends React.Component<any, any> {
public render() {
const theme = themeStyle(this.props.themeId);
const searchQuery = normalizeQuery(this.state.searchQuery);
const searchMode = !!searchQuery;
const sectionFilter = this.state.searchSectionFilter;
const style = {
...this.props.style,
@@ -410,14 +454,6 @@ class ConfigScreenComponent extends React.Component<any, any> {
const settings = this.state.settings;
const containerStyle: React.CSSProperties = {
overflow: 'auto',
padding: theme.configScreenPadding,
paddingTop: 0,
display: 'flex',
flex: 1,
};
const hasChanges = this.hasChanges();
const settingComps = shared.settingsToComponents2(this, AppType.Desktop, settings, this.state.selectedSectionName);
@@ -427,9 +463,13 @@ class ConfigScreenComponent extends React.Component<any, any> {
// When screenComp is null, it means we are viewing the regular settings.
const screenComp = this.state.screenName ? <div className="config-screen-content-wrapper" style={{ overflow: 'scroll', flex: 1 }}>{this.screenFromName(this.state.screenName)}</div> : null;
if (screenComp) containerStyle.display = 'none';
const shouldHideSettingsContainer = !!screenComp && !searchMode;
const sections = shared.settingsSections({ device: AppType.Desktop, settings });
const searchResultGroupItems = searchResultGroups(this.state.searchQuery, sections, AppType.Desktop);
const matchedSections = matchedSearchSections(sections, searchResultGroupItems);
const hasValidSectionFilter = !!sectionFilter && matchedSections.some(group => group.section.name === sectionFilter);
const filteredMatchedSections = hasValidSectionFilter ? matchedSections.filter(group => group.section.name === sectionFilter) : matchedSections;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const needRestartComp: any = this.state.needRestart ? (
@@ -443,50 +483,119 @@ class ConfigScreenComponent extends React.Component<any, any> {
delete style.width;
const tabComponents: React.ReactNode[] = [];
for (const section of sections) {
const sectionId = `setting-section-${section.name}`;
let content = null;
const visible = section.name === this.state.selectedSectionName;
if (visible) {
content = (
<>
{screenComp}
<div style={containerStyle}>{settingComps}</div>
</>
if (searchMode) {
const searchContent = filteredMatchedSections.map(({ section }) => {
const sectionComp = section.isScreen ? (
<div className='search-message'>
{_('This section opens in its own screen and is matched by section title.')}
</div>
) : this.sectionToComponent(section.name, section, settings, true);
if (!sectionComp) return null;
return (
<div key={`search-result-${section.name}`}>
<h2 className='search-section-title'>
<i
className={Setting.sectionNameToIcon(section.name, AppType.Desktop)}
role='img'
aria-hidden='true'
/>
{this.renderSearchHighlightedText(Setting.sectionNameToLabel(section.name))}
</h2>
{sectionComp}
</div>
);
}
});
const noResultsMessage = filteredMatchedSections.length === 0 ? (
<div className='search-no-results'>
{_('No matching results')}
</div>
) : null;
tabComponents.push(
<div
key={sectionId}
id={sectionId}
className={`setting-tab-panel ${!visible ? '-hidden' : ''}`}
hidden={!visible}
aria-labelledby={`setting-tab-${section.name}`}
tabIndex={0}
role='tabpanel'
key='setting-section-search-results'
id='setting-section-search-results'
className='setting-tab-panel'
role='region'
aria-label={_('Search results')}
>
{content}
<div className='search-results'>
<div aria-live='polite' aria-atomic='true' style={{ position: 'absolute', width: 1, height: 1, overflow: 'hidden', clip: 'rect(0,0,0,0)', whiteSpace: 'nowrap' }}>
{filteredMatchedSections.length === 0 ? _('No matching results') : _('%d sections found', filteredMatchedSections.length)}
</div>
<div className='search-filter-control'>
{hasValidSectionFilter ?
_('Filtered by section [%s]', Setting.sectionNameToLabel(sectionFilter)) :
_('Showing all matching settings')}
{hasValidSectionFilter ? (
<button
type='button'
className='link-button'
onClick={() => {
this.setState({ searchSectionFilter: null });
}}
>
{_('Show all results')}
</button>
) : null}
</div>
{searchContent}
{noResultsMessage}
</div>
</div>,
);
} else {
for (const section of sections) {
const sectionId = `setting-section-${section.name}`;
let content = null;
const visible = section.name === this.state.selectedSectionName;
if (visible) {
content = (
<>
{screenComp}
<div className={`config-screen-settings-container ${shouldHideSettingsContainer ? 'hidden' : ''}`}>{settingComps}</div>
</>
);
}
tabComponents.push(
<div
key={sectionId}
id={sectionId}
className={`setting-tab-panel ${!visible ? '-hidden' : ''}`}
hidden={!visible}
aria-labelledby={`setting-tab-${section.name}`}
tabIndex={0}
role='tabpanel'
>
{content}
</div>,
);
}
}
return (
<div className="config-screen" role="main" style={{ display: 'flex', flexDirection: 'row', height: this.props.style.height }}>
<Sidebar
selection={this.state.selectedSectionName}
selection={searchMode ? (sectionFilter ?? matchedSections[0]?.section.name ?? this.state.selectedSectionName) : this.state.selectedSectionName}
onSelectionChange={this.sidebar_selectionChange}
sections={sections}
searchQuery={this.state.searchQuery}
onSearchQueryChange={this.onSearchQueryChange}
onSearchButtonClick={this.onSearchButtonClick}
searchResultGroups={searchResultGroupItems}
/>
<div style={rightStyle}>
{needRestartComp}
{tabComponents}
<ButtonBar
hasChanges={hasChanges}
backButtonTitle={hasChanges && !screenComp ? _('Cancel') : _('Back')}
backButtonTitle={hasChanges && (!screenComp || searchMode) ? _('Cancel') : _('Back')}
onCancelClick={this.onCancelClick}
onSaveClick={screenComp ? null : this.onSaveClick}
onApplyClick={screenComp ? null : this.onApplyClick}
onSaveClick={screenComp && !searchMode ? undefined : this.onSaveClick}
onApplyClick={screenComp && !searchMode ? undefined : this.onApplyClick}
/>
</div>
</div>
@@ -2,12 +2,12 @@ import { AppType, MetadataBySection, SettingMetadataSection, SettingSectionSourc
import * as React from 'react';
import Setting from '@joplin/lib/models/Setting';
import { _ } from '@joplin/lib/locale';
import { useCallback, useRef } from 'react';
import { useCallback, useMemo, useRef } from 'react';
import { focus } from '@joplin/lib/utils/focusHandler';
const styled = require('styled-components').default;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied;
type StyleProps = any;
import SearchInput, { OnChangeEvent } from '../lib/SearchInput/SearchInput';
import { normalizeQuery } from '@joplin/lib/components/shared/config/config-search-text';
import { type SearchResultGroup } from './configSearch';
import highlightSearchText from './searchHighlight';
interface SectionChangeEvent {
section: SettingMetadataSection;
@@ -17,67 +17,19 @@ interface Props {
selection: string;
onSelectionChange: (event: SectionChangeEvent)=> void;
sections: MetadataBySection;
searchQuery: string;
onSearchQueryChange: (event: OnChangeEvent)=> void;
onSearchButtonClick: ()=> void;
searchResultGroups: SearchResultGroup[];
}
export const StyledRoot = styled.div`
display: flex;
background-color: ${(props: StyleProps) => props.theme.backgroundColor2};
flex-direction: column;
overflow-x: hidden;
overflow-y: auto;
`;
export const StyledListItem = styled.a`
box-sizing: border-box;
display: flex;
flex-direction: row;
padding: ${(props: StyleProps) => props.theme.mainPadding}px;
background: ${(props: StyleProps) => props.selected ? props.theme.selectedColor2 : 'none'};
transition: 0.1s;
text-decoration: none;
cursor: default;
opacity: ${(props: StyleProps) => props.selected ? 1 : 0.8};
padding-left: ${(props: StyleProps) => props.isSubSection ? '35' : props.theme.mainPadding}px;
&:hover {
background-color: ${(props: StyleProps) => props.theme.backgroundColorHover2};
}
`;
export const StyledDivider = styled.div`
box-sizing: border-box;
display: flex;
flex-direction: row;
color: ${(props: StyleProps) => props.theme.color2};
padding: ${(props: StyleProps) => props.theme.mainPadding}px;
padding-top: ${(props: StyleProps) => props.theme.mainPadding * .8}px;
padding-bottom: ${(props: StyleProps) => props.theme.mainPadding * .8}px;
border-top: 1px solid ${(props: StyleProps) => props.theme.dividerColor};
border-bottom: 1px solid ${(props: StyleProps) => props.theme.dividerColor};
background-color: ${(props: StyleProps) => props.theme.selectedColor2};
font-size: ${(props: StyleProps) => Math.round(props.theme.fontSize)}px;
opacity: 0.58;
`;
export const StyledListItemLabel = styled.span`
font-size: ${(props: StyleProps) => Math.round(props.theme.fontSize * 1.2)}px;
font-weight: 500;
color: ${(props: StyleProps) => props.theme.color2};
white-space: nowrap;
display: flex;
flex: 1;
align-items: center;
user-select: none;
`;
export const StyledListItemIcon = styled.i`
font-size: ${(props: StyleProps) => Math.round(props.theme.fontSize * 1.4)}px;
color: ${(props: StyleProps) => props.theme.color2};
margin-right: ${(props: StyleProps) => props.theme.mainPadding / 1.5}px;
`;
export default function Sidebar(props: Props) {
const buttonRefs = useRef<HTMLElement[]>([]);
const isSearching = !!normalizeQuery(props.searchQuery);
const matchedSectionNames = useMemo(() => {
return new Set(props.searchResultGroups.map(group => group.sectionName));
}, [props.searchResultGroups]);
// Making a tabbed region accessible involves supporting keyboard interaction.
// See https://www.w3.org/WAI/ARIA/apg/patterns/tabs/ for details
@@ -85,19 +37,43 @@ export default function Sidebar(props: Props) {
const selectedIndex = props.sections.findIndex(section => section.name === props.selection);
let newIndex = selectedIndex;
// Determine navigation direction
let isMovingUp = false;
if (event.code === 'ArrowUp') {
newIndex --;
isMovingUp = true;
} else if (event.code === 'ArrowDown') {
newIndex ++;
isMovingUp = false;
} else if (event.code === 'Home') {
newIndex = 0;
isMovingUp = false;
} else if (event.code === 'End') {
newIndex = props.sections.length - 1;
isMovingUp = true;
}
if (newIndex < 0) newIndex += props.sections.length;
newIndex %= props.sections.length;
// Skip disabled (no-match) sections during search
if (isSearching) {
const initialIndex = newIndex;
while (!matchedSectionNames.has(props.sections[newIndex].name)) {
if (isMovingUp) {
newIndex--;
if (newIndex < 0) newIndex += props.sections.length;
} else {
newIndex++;
newIndex %= props.sections.length;
}
// Prevent infinite loop if no matched sections
if (newIndex === initialIndex) break;
}
if (!matchedSectionNames.has(props.sections[newIndex].name)) return;
}
if (newIndex !== selectedIndex) {
event.preventDefault();
props.onSelectionChange({ section: props.sections[newIndex] });
@@ -107,46 +83,60 @@ export default function Sidebar(props: Props) {
focus('Sidebar', targetButton);
}
}
}, [props.sections, props.selection, props.onSelectionChange]);
}, [props.sections, props.selection, props.onSelectionChange, matchedSectionNames, isSearching]);
const buttons: React.ReactNode[] = [];
function renderButton(section: SettingMetadataSection, index: number) {
const selected = props.selection === section.name;
const hasMatch = matchedSectionNames.has(section.name);
const isDisabled = isSearching && !hasMatch;
const isActiveTab = selected && !isDisabled;
const classNames = ['item'];
if (Setting.isSubSection(section.name)) classNames.push('sub');
if (isActiveTab) classNames.push('selected');
if (isDisabled) classNames.push('disabled');
return (
<StyledListItem
<button
key={section.name}
href='#'
type='button'
role='tab'
ref={(item: HTMLElement) => { buttonRefs.current[index] = item; }}
ref={(item: HTMLElement | null) => {
if (item) {
buttonRefs.current[index] = item;
}
}}
className={classNames.join(' ')}
id={`setting-tab-${section.name}`}
aria-controls={`setting-section-${section.name}`}
aria-selected={selected}
tabIndex={selected ? 0 : -1}
isSubSection={Setting.isSubSection(section.name)}
selected={selected}
onClick={() => { props.onSelectionChange({ section: section }); }}
onKeyDown={onKeyDown}
aria-controls={isSearching ? (isDisabled ? undefined : 'setting-section-search-results') : `setting-section-${section.name}`}
aria-selected={isActiveTab}
aria-disabled={isDisabled}
tabIndex={isActiveTab ? 0 : -1}
onClick={() => {
if (isDisabled) return;
props.onSelectionChange({ section: section });
}}
onKeyDown={!isDisabled ? onKeyDown : undefined}
>
<StyledListItemIcon
className={Setting.sectionNameToIcon(section.name, AppType.Desktop)}
<i
className={`icon ${Setting.sectionNameToIcon(section.name, AppType.Desktop)}`}
role='img'
aria-hidden='true'
/>
<StyledListItemLabel>
{Setting.sectionNameToLabel(section.name)}
</StyledListItemLabel>
</StyledListItem>
<span className='label'>
{highlightSearchText(Setting.sectionNameToLabel(section.name), props.searchQuery)}
</span>
</button>
);
}
function renderDivider(key: string) {
return (
<StyledDivider key={key}>
<div key={key} className='separator' role='presentation' aria-hidden='true'>
{_('Plugins')}
</StyledDivider>
</div>
);
}
@@ -164,8 +154,23 @@ export default function Sidebar(props: Props) {
}
return (
<StyledRoot className='settings-sidebar _scrollbar2' role='tablist'>
{buttons}
</StyledRoot>
<div className='settings-sidebar _scrollbar2'>
<div className='searchbox'>
<SearchInput
inputRef={null}
inputClassName='settings'
value={props.searchQuery}
onChange={props.onSearchQueryChange}
onSearchButtonClick={props.onSearchButtonClick}
searchStarted={isSearching}
placeholder={_('Search settings...')}
aria-controls={isSearching ? 'setting-section-search-results' : undefined}
iconButtonTabIndex={-1}
/>
</div>
<div role='tablist' className='tablist'>
{buttons}
</div>
</div>
);
}
@@ -0,0 +1,94 @@
.settings-sidebar {
display: flex;
background-color: var(--joplin-background-color2);
flex-direction: column;
overflow-x: hidden;
overflow-y: auto;
> .searchbox {
padding: var(--joplin-main-padding);
padding-bottom: calc(var(--joplin-main-padding) / 2);
}
> .tablist > .separator {
box-sizing: border-box;
display: flex;
flex-direction: row;
color: var(--joplin-color2);
padding: var(--joplin-main-padding);
padding-top: calc(var(--joplin-main-padding) * 0.8);
padding-bottom: calc(var(--joplin-main-padding) * 0.8);
border-top: 1px solid var(--joplin-divider-color);
border-bottom: 1px solid var(--joplin-divider-color);
background-color: var(--joplin-selected-color2);
font-size: var(--joplin-font-size);
}
> .tablist > .item {
box-sizing: border-box;
display: flex;
flex-direction: row;
width: 100%;
border: none;
padding: var(--joplin-main-padding);
background: none;
transition: 0.1s;
text-align: left;
cursor: default;
font-family: inherit;
font-size: inherit;
&.sub {
padding-left: 35px;
}
&.selected {
background: var(--joplin-selected-color2);
opacity: 1;
> .icon {
color: var(--joplin-color2);
}
> .label {
color: var(--joplin-color2);
}
}
&:not(.selected):not(.disabled) {
opacity: 0.8;
}
&:hover:not(.disabled) {
background-color: var(--joplin-background-color-hover2);
}
&.disabled {
opacity: 0.3;
cursor: not-allowed;
}
> .icon {
font-size: calc(var(--joplin-font-size) * 1.4);
color: var(--joplin-color2);
margin-right: calc(var(--joplin-main-padding) / 1.5);
}
> .label {
font-size: calc(var(--joplin-font-size) * 1.2);
font-weight: 500;
color: var(--joplin-color2);
white-space: nowrap;
display: flex;
flex: 1;
align-items: center;
user-select: none;
> mark {
background-color: var(--joplin-search-marker-background-color);
color: var(--joplin-search-marker-color);
padding: 0;
}
}
}
}
@@ -0,0 +1,107 @@
import Setting, { AppType, SettingItem, SettingMetadataSection } from '@joplin/lib/models/Setting';
import { includesNormalizedQuery, normalizeQuery } from '@joplin/lib/components/shared/config/config-search-text';
const isMetadataMatched = (
normalizedQuery: string,
section: SettingMetadataSection,
metadata: SettingItem,
appType: AppType,
): boolean => {
const metadataLabel = metadata.label ? metadata.label() : '';
const metadataDescription = metadata.description ? metadata.description(appType) : '';
const sectionLabel = Setting.sectionNameToLabel(section.name);
const normalizedCandidates = [
sectionLabel,
metadataLabel,
metadataDescription,
];
return normalizedCandidates.some(value => includesNormalizedQuery(normalizedQuery, value || ''));
};
export interface SearchResultGroup {
sectionName: string;
matchingKeys: string[];
}
export interface MatchedSearchSection {
section: SettingMetadataSection;
matchingKeys: string[];
}
export const searchResultGroups = (
query: string,
sections: SettingMetadataSection[],
appType: AppType,
): SearchResultGroup[] => {
const normalizedQuery = normalizeQuery(query);
if (!normalizedQuery) return [];
const output: SearchResultGroup[] = [];
for (const section of sections) {
const sectionTitleMatched = includesNormalizedQuery(normalizedQuery, Setting.sectionNameToLabel(section.name));
if (sectionTitleMatched && section.isScreen) {
output.push({
sectionName: section.name,
matchingKeys: [],
});
continue;
}
const matchingKeys: string[] = [];
for (const metadata of section.metadatas) {
if (!metadata.key) continue;
if (sectionTitleMatched || isMetadataMatched(normalizedQuery, section, metadata, appType)) {
matchingKeys.push(metadata.key);
}
}
if (!matchingKeys.length) continue;
output.push({
sectionName: section.name,
matchingKeys,
});
}
return output;
};
export const matchedSearchSections = (
sections: SettingMetadataSection[],
groups: SearchResultGroup[],
): MatchedSearchSection[] => {
if (!groups.length) return [];
const sectionByName: Record<string, SettingMetadataSection> = {};
for (const section of sections) {
sectionByName[section.name] = section;
}
const output: MatchedSearchSection[] = [];
for (const group of groups) {
const section = sectionByName[group.sectionName];
if (!section) continue;
const matchingKeySet = new Set(group.matchingKeys);
const metadatas = section.metadatas.filter(metadata => metadata.key && matchingKeySet.has(metadata.key));
if (!metadatas.length && !section.isScreen) continue;
output.push({
section: {
...section,
metadatas,
},
matchingKeys: group.matchingKeys,
});
}
return output;
};
@@ -30,6 +30,7 @@ interface Props {
fonts: string[];
onUpdateSettingValue: (event: UpdateSettingValueEvent)=> void;
onSettingButtonClick: (key: string)=> void;
renderSearchText?: (text: string)=> React.ReactNode;
}
const SettingComponent: React.FC<Props> = props => {
@@ -41,6 +42,11 @@ const SettingComponent: React.FC<Props> = props => {
props.onUpdateSettingValue({ key, value });
}, [props.onUpdateSettingValue]);
const renderText = useCallback((text: string): React.ReactNode => {
if (!props.renderSearchText) return text;
return props.renderSearchText(text);
}, [props.renderSearchText]);
const rowStyle = {
marginBottom: theme.mainPadding * 1.5,
};
@@ -72,15 +78,15 @@ const SettingComponent: React.FC<Props> = props => {
const descriptionText = Setting.keyDescription(key, AppType.Desktop);
const inputId = useId();
const descriptionId = useId();
const descriptionComp = <SettingDescription id={descriptionId} text={descriptionText}/>;
const descriptionComp = <SettingDescription id={descriptionId} text={descriptionText} renderText={renderText}/>;
if (key in settingKeyToControl) {
const CustomSettingComponent = settingKeyToControl[key];
const label = md.label ? <SettingLabel text={md.label()} htmlFor={null} /> : null;
const label = md.label ? <SettingLabel text={md.label()} htmlFor={null} renderText={renderText} /> : null;
return (
<div style={rowStyle}>
{label}
<SettingDescription id={descriptionId} text={md.description ? md.description(AppType.Desktop) : null}/>
<SettingDescription id={descriptionId} text={md.description ? md.description(AppType.Desktop) : null} renderText={renderText}/>
<CustomSettingComponent
value={props.value}
themeId={props.themeId}
@@ -112,7 +118,7 @@ const SettingComponent: React.FC<Props> = props => {
return (
<div style={rowStyle}>
<SettingLabel htmlFor={inputId} text={md.label()}/>
<SettingLabel htmlFor={inputId} text={md.label()} renderText={renderText}/>
<select
value={value}
className='setting-select-control'
@@ -152,7 +158,7 @@ const SettingComponent: React.FC<Props> = props => {
className='setting-label -for-checkbox'
htmlFor={inputId}
>
{md.label()}
{renderText(md.label())}
</label>
</div>
{descriptionComp}
@@ -254,7 +260,7 @@ const SettingComponent: React.FC<Props> = props => {
const pathDescriptionId = `setting_path_label_${key}`;
return (
<div style={rowStyle}>
<SettingLabel text={md.label()} htmlFor={inputId}/>
<SettingLabel text={md.label()} htmlFor={inputId} renderText={renderText}/>
<div style={{ display: 'flex' }}>
<div style={{ flex: 1 }}>
<div style={{ ...rowStyle, marginBottom: 5 }}>
@@ -295,7 +301,7 @@ const SettingComponent: React.FC<Props> = props => {
};
return (
<div style={rowStyle}>
<SettingLabel text={md.label()} htmlFor={inputId}/>
<SettingLabel text={md.label()} htmlFor={inputId} renderText={renderText}/>
{
md.subType === SettingItemSubType.FontFamily || md.subType === SettingItemSubType.MonospaceFontFamily ?
<FontSearch
@@ -335,7 +341,7 @@ const SettingComponent: React.FC<Props> = props => {
return (
<div style={rowStyle}>
<SettingLabel htmlFor={inputId} text={label.join(' ')}/>
<SettingLabel htmlFor={inputId} text={label.join(' ')} renderText={renderText}/>
<input
type="number"
style={textInputBaseStyle}
@@ -353,7 +359,7 @@ const SettingComponent: React.FC<Props> = props => {
);
} else if (md.type === Setting.TYPE_BUTTON) {
const labelComp = md.hideLabel ? null : (
<SettingLabel text={md.label()} htmlFor={null} />
<SettingLabel text={md.label()} htmlFor={null} renderText={renderText} />
);
return (
@@ -1,12 +1,14 @@
import * as React from 'react';
interface Props {
text: string;
text: string|null;
id?: string;
renderText?: (text: string)=> React.ReactNode;
}
const SettingDescription: React.FC<Props> = props => {
return <div className={`setting-description ${!props.text ? '-empty' : ''}`} id={props.id}>{props.text}</div>;
const renderedText = props.text && props.renderText ? props.renderText(props.text) : props.text;
return <div className={`setting-description ${!props.text ? '-empty' : ''}`} id={props.id}>{renderedText}</div>;
};
export default SettingDescription;
@@ -7,7 +7,7 @@ interface Props {
const SettingHeader: React.FC<Props> = props => {
return (
<div className='setting-header'>
<label>{props.text}</label>
<span>{props.text}</span>
</div>
);
};
@@ -3,12 +3,13 @@ import * as React from 'react';
interface Props {
htmlFor: string|null;
text: string;
renderText?: (text: string)=> React.ReactNode;
}
const SettingLabel: React.FC<Props> = props => {
return (
<div className='setting-label'>
<label htmlFor={props.htmlFor}>{props.text}</label>
<label htmlFor={props.htmlFor}>{props.renderText ? props.renderText(props.text) : props.text}</label>
</div>
);
};
@@ -173,7 +173,7 @@ export default function(props: Props) {
themeId={props.themeId}
value={item.enabled}
onToggle={() => props.onToggle({ item })}
aria-label={_('Enabled')}
aria-label={item.enabled ? _('Disable %s', item.manifest.name) : _('Enable %s', item.manifest.name)}
/>;
}
@@ -0,0 +1,74 @@
import * as React from 'react';
import { render } from '@testing-library/react';
import highlightSearchText from './searchHighlight';
describe('searchHighlight', () => {
const countMarks = (result: React.ReactNode[]): number => {
return result.filter((element) => React.isValidElement(element) && (element as React.ReactElement).type === 'mark').length;
};
it('should highlight all matching occurrences (case-insensitive)', () => {
const text = 'Synchronization settings for sync behavior';
const query = 'sync';
const result = highlightSearchText(text, query) as React.ReactNode[];
const markCount = countMarks(result);
expect(markCount).toBe(2);
});
it('should return original text when query is empty', () => {
const text = 'Some test text';
const resultEmpty = highlightSearchText(text, '');
expect(resultEmpty).toBe(text);
});
it('should return original text when query is whitespace only', () => {
const text = 'Some test text';
const resultWhitespace = highlightSearchText(text, ' ');
expect(resultWhitespace).toBe(text);
});
it('should return original text when input text is empty', () => {
const result = highlightSearchText('', 'query');
expect(result).toBe('');
});
it('should handle special regex characters in query', () => {
const text = 'Test (nested) [brackets] and {braces}';
const query = '(nested)';
const result = highlightSearchText(text, query) as React.ReactNode[];
const markCount = countMarks(result);
expect(markCount).toBe(1);
});
it('should render mark elements for case-insensitive matches', () => {
const result = highlightSearchText('Synchronization', 'sync');
const rendered = render(<>{result}</>);
const marks = rendered.container.querySelectorAll('mark');
expect(marks.length).toBeGreaterThan(0);
});
it('should preserve full text content when highlighting', () => {
const text = 'Search and Find';
const result = highlightSearchText(text, 'find');
const rendered = render(<>{result}</>);
expect(rendered.container.textContent).toBe(text);
});
it('should render highlighted mark for matches', () => {
const result = highlightSearchText('Find this', 'find');
const rendered = render(<>{result}</>);
const marks = rendered.container.querySelectorAll('mark');
expect(marks.length).toBeGreaterThan(0);
expect(marks[0].textContent?.toLowerCase()).toContain('find');
});
});
@@ -0,0 +1,28 @@
import * as React from 'react';
import { escapeRegExp } from '@joplin/lib/string-utils';
// Returns a React node where every case-insensitive match of `query` in `text`
// is wrapped in a `mark` element.
const highlightSearchText = (
text: string,
query: string,
): React.ReactNode => {
if (!text) return text;
const trimmedQuery = query.trim();
if (!trimmedQuery) return text;
const matcher = new RegExp(`(${escapeRegExp(trimmedQuery)})`, 'ig');
const parts = text.split(matcher);
if (parts.length === 1) return text;
return parts.map((part, index) => {
if (index % 2 === 1) {
return <mark key={`highlight-${index}`}>{part}</mark>;
}
return <React.Fragment key={`text-${index}`}>{part}</React.Fragment>;
});
};
export default highlightSearchText;
@@ -1,4 +1,5 @@
@use "./styles/index.scss";
@use "./Sidebar/style.scss" as sidebar-styles;
.config-screen-content-wrapper {
padding: 24px;
@@ -45,4 +46,67 @@
background-color: var(--joplin-background-color3);
display: flex;
align-items: center;
}
// Container styles
.config-screen-settings-container {
overflow: auto;
padding: var(--joplin-config-screen-padding);
padding-top: 0;
display: flex;
flex: 1;
&.hidden {
display: none;
}
}
// Search mode styles
.search-results {
display: block;
overflow: auto;
padding: var(--joplin-config-screen-padding);
padding-top: 0;
flex: 1;
}
.search-filter-control {
display: flex;
align-items: center;
gap: 8px;
margin-top: 20px;
margin-bottom: 8px;
color: var(--joplin-color);
}
.search-section-title {
font-weight: 500;
margin-top: 30px;
margin-bottom: 10px;
padding: 8px;
display: flex;
align-items: center;
color: var(--joplin-color2);
background-color: var(--joplin-background-color2);
font-size: calc(var(--joplin-font-size) * 1.2);
> i {
margin-right: 8px;
}
}
.search-message {
margin-bottom: 20px;
color: var(--joplin-color);
}
.search-no-results {
margin-top: 20px;
color: var(--joplin-color-faded);
}
.config-screen mark {
background-color: var(--joplin-search-marker-background-color);
color: var(--joplin-search-marker-color);
padding: 0;
}
@@ -40,13 +40,33 @@ interface Props {
masterPasswordDialogOpen: boolean;
}
interface EncryptionDialogOptions{
className: string;
title: string;
content: React.ReactNode;
onClose: ()=> void;
onDialogButtonRowClick: (event: { buttonName: string })=> void;
okButtonDisabled?: boolean;
}
export const EncryptionConfigScreen = (props: Props) => {
const { inputPasswords, onInputPasswordChange } = useInputPasswords(props.passwords);
const [pendingEnableEncryption, setPendingEnableEncryption] = useState(false);
const [enableEncryptionPromptVisible, setEnableEncryptionPromptVisible] = useState(false);
const [enableEncryptionPassword, setEnableEncryptionPassword] = useState('');
const [enableEncryptionError, setEnableEncryptionError] = useState('');
const [disableEncryptionPromptVisible, setDisableEncryptionPromptVisible] = useState(false);
const disablePromptPromiseRef = useRef<(value: boolean)=> void>(null);
const promptPromiseRef = useRef<(password: string | null)=> void>(null);
// Cleanup on unmount to resolve pending promises if the user navigates away
useEffect(() => {
return () => {
if (disablePromptPromiseRef.current) disablePromptPromiseRef.current(false);
if (promptPromiseRef.current) promptPromiseRef.current(null);
};
}, []);
const wasMasterPasswordDialogOpen = useRef(props.masterPasswordDialogOpen);
const theme = useMemo(() => {
@@ -246,7 +266,10 @@ export const EncryptionConfigScreen = (props: Props) => {
let newPassword: string | null = '';
if (isEnabled) {
const answer = await dialogs.confirm(_('Disabling encryption means *all* your notes and attachments are going to be re-synchronised and sent unencrypted to the sync target. Do you wish to continue?'));
setDisableEncryptionPromptVisible(true);
const answer = await new Promise<boolean>((resolve) => {
disablePromptPromiseRef.current = resolve;
});
if (!answer) return;
} else {
if (shouldOpenMasterPasswordDialogForEnable({
@@ -263,6 +286,7 @@ export const EncryptionConfigScreen = (props: Props) => {
// Wait for the custom React Dialog to resolve
setEnableEncryptionPassword('');
setEnableEncryptionError('');
setEnableEncryptionPromptVisible(true);
newPassword = await new Promise<string | null>((resolve) => {
promptPromiseRef.current = resolve;
@@ -271,13 +295,6 @@ export const EncryptionConfigScreen = (props: Props) => {
if (newPassword === null) return; // User cancelled
}
if (hasMasterPassword && newEnabled) {
if (!(await masterPasswordIsValid(newPassword))) {
await dialogs.alert('Invalid password. Please try again. If you have forgotten your password you will need to reset it.');
return;
}
}
try {
await toggleAndSetupEncryption(EncryptionService.instance(), newEnabled, masterKey, newPassword);
} catch (error) {
@@ -285,6 +302,24 @@ export const EncryptionConfigScreen = (props: Props) => {
}
}, [props.dispatch, props.masterPassword, props.masterPasswordDialogOpen]);
const renderEncryptionDialog = (options: EncryptionDialogOptions) => {
return (
<Dialog onCancel={options.onClose} className={options.className}>
<div className='dialog-root'>
<DialogTitle title={options.title}/>
<div className='dialog-content'>
{options.content}
</div>
<DialogButtonRow
themeId={props.themeId}
onClick={options.onDialogButtonRowClick}
okButtonDisabled={options.okButtonDisabled ?? false}
/>
</div>
</Dialog>
);
};
const renderEnableEncryptionDialog = () => {
if (!enableEncryptionPromptVisible) return null;
@@ -299,12 +334,19 @@ export const EncryptionConfigScreen = (props: Props) => {
if (promptPromiseRef.current) promptPromiseRef.current(null);
};
const onDialogButtonRowClick = (event: { buttonName: string }) => {
const onDialogButtonRowClick = async (event: { buttonName: string }) => {
if (event.buttonName === 'cancel') {
onClose();
return;
}
if (event.buttonName === 'ok') {
if (hasMasterPassword) {
if (!(await masterPasswordIsValid(enableEncryptionPassword))) {
setEnableEncryptionError(_('Invalid password. Please try again. If you have forgotten your password you will need to reset it.'));
return;
}
}
setEnableEncryptionError('');
setEnableEncryptionPromptVisible(false);
if (promptPromiseRef.current) promptPromiseRef.current(enableEncryptionPassword);
}
@@ -312,34 +354,72 @@ export const EncryptionConfigScreen = (props: Props) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Required because PasswordInput's ChangeEventHandler type is incorrect
const onPasswordInputChange = (event: any) => {
setEnableEncryptionError('');
setEnableEncryptionPassword(event.target.value);
};
return (
<Dialog onCancel={onClose} className="enable-encryption-dialog">
<div className="dialog-root">
<DialogTitle title={_('Enable encryption')}/>
<div className="dialog-content">
<div style={{ marginBottom: 16 }}>
{messageComps}
</div>
<div style={{ marginBottom: 16 }}>
<label style={{ ...theme.textStyle, marginBottom: 5, display: 'block' }} htmlFor="enable-encryption-password">{_('Password:')}</label>
<PasswordInput
inputId="enable-encryption-password"
value={enableEncryptionPassword}
onChange={onPasswordInputChange}
/>
</div>
return renderEncryptionDialog({
className: 'enable-encryption-dialog',
title: _('Enable encryption'),
content: (
<>
<div style={{ marginBottom: 16 }}>
{messageComps}
</div>
<DialogButtonRow
themeId={props.themeId}
onClick={onDialogButtonRowClick}
okButtonDisabled={!enableEncryptionPassword}
/>
<div style={{ marginBottom: 16 }}>
<label style={{ ...theme.textStyle, marginBottom: 5, display: 'block' }} htmlFor="enable-encryption-password">{_('Password:')}</label>
<PasswordInput
inputId="enable-encryption-password"
value={enableEncryptionPassword}
onChange={onPasswordInputChange}
/>
</div>
{enableEncryptionError && (
<div style={{ ...theme.textStyle, color: theme.colorError, marginTop: 10, marginBottom: 10 }}>
{enableEncryptionError}
</div>
)}
</>
),
onClose,
onDialogButtonRowClick,
okButtonDisabled: !enableEncryptionPassword,
});
};
const renderDisableEncryptionDialog = () => {
if (!disableEncryptionPromptVisible) return null;
const onClose = () => {
setDisableEncryptionPromptVisible(false);
if (disablePromptPromiseRef.current) disablePromptPromiseRef.current(false);
};
const onDialogButtonRowClick = (event: { buttonName: string }) => {
if (event.buttonName === 'cancel') {
onClose();
return;
}
if (event.buttonName === 'ok') {
setDisableEncryptionPromptVisible(false);
if (disablePromptPromiseRef.current) disablePromptPromiseRef.current(true);
}
};
return renderEncryptionDialog({
className: 'disable-encryption-dialog',
title: _('Disable encryption'),
content: (
<div style={{ marginBottom: 16 }}>
<p style={theme.textStyle}>
{_('Disabling encryption means *all* your notes and attachments are going to be re-synchronised and sent unencrypted to the sync target. Do you wish to continue?')}
</p>
</div>
</Dialog>
);
),
onClose,
onDialogButtonRowClick,
});
};
const renderEncryptionSection = () => {
@@ -523,6 +603,7 @@ export const EncryptionConfigScreen = (props: Props) => {
{renderNonExistingMasterKeysSection()}
{renderAdvancedSection()}
{renderEnableEncryptionDialog()}
{renderDisableEncryptionDialog()}
</div>
);
};
@@ -12,6 +12,7 @@ import KvStore from '@joplin/lib/services/KvStore';
import ShareService from '@joplin/lib/services/share/ShareService';
import LabelledPasswordInput from '../PasswordInput/LabelledPasswordInput';
import shim from '@joplin/lib/shim';
import time from '@joplin/lib/time';
interface Props {
themeId: number;
@@ -24,6 +25,11 @@ enum Mode {
Reset = 2,
}
const syncAfterDefaultInterval = async () => {
await time.msleep(reg.defaultScheduleInterval());
await reg.waitForSyncFinishedThenSync();
};
export default function(props: Props) {
const [status, setStatus] = useState(MasterPasswordStatus.NotSet);
const [hasMasterPasswordEncryptedData, setHasMasterPasswordEncryptedData] = useState(true);
@@ -78,7 +84,8 @@ export default function(props: Props) {
} else {
throw new Error(`Unknown mode: ${mode}`);
}
void reg.waitForSyncFinishedThenSync(null);
// We need to defer the sync, as enabling encryption may take a few seconds to complete
void syncAfterDefaultInterval();
onClose();
} catch (error) {
void shim.showErrorDialog(error.message);
+9 -5
View File
@@ -530,12 +530,16 @@ function useMenu(props: Props) {
];
// the following menu items will be available for all OS under Tools
const toolsItemsAll = [{
label: _('Note attachments...'),
click: () => {
navigateTo('Resources');
const toolsItemsAll = [
menuItemDic.newWhiteboard,
separator(),
{
label: _('Note attachments...'),
click: () => {
navigateTo('Resources');
},
},
}];
];
if (!shim.isMac()) {
toolsItems = toolsItems.concat(toolsItemsWindowsLinux);
@@ -66,6 +66,7 @@ const mapStateToProps = (state: AppState, connectProps: ConnectProps) => {
'textCheckbox',
'textHeading',
'textHorizontalRule',
'editor.textTable',
'insertDateTime',
'toggleEditors',
].concat(pluginUtils.commandNamesFromViews(state.pluginService.plugins, 'editorToolbar'));
@@ -252,6 +252,23 @@ function CodeMirror(props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
reg.logger().warn('"editor.scrollToText" is unsupported in legacy editor - please use the new editor');
return false;
},
// Table editing commands are only supported in the v6 editor
'editor.tableAddRow': () => {
reg.logger().warn('Table editing commands are not supported in the legacy editor');
return false;
},
'editor.tableAddColumn': () => {
reg.logger().warn('Table editing commands are not supported in the legacy editor');
return false;
},
'editor.tableDeleteRow': () => {
reg.logger().warn('Table editing commands are not supported in the legacy editor');
return false;
},
'editor.tableDeleteColumn': () => {
reg.logger().warn('Table editing commands are not supported in the legacy editor');
return false;
},
};
if (commands[cmd.name]) {
@@ -21,6 +21,7 @@ const useEditorSettings = (props: EditorSettingsProps) => {
markdownInsert: state.settings['markdown.plugin.insert'],
katex: state.settings['markdown.plugin.katex'],
inlineRendering: state.settings['editor.inlineRendering'],
tableEditing: state.settings['editor.tableEditing'],
imageRendering: state.settings['editor.imageRendering'],
highlightActiveLine: state.settings['editor.highlightActiveLine'],
monospaceFont: state.settings['style.editor.monospaceFontFamily'],
@@ -48,6 +49,7 @@ const useEditorSettings = (props: EditorSettingsProps) => {
markdownInsertEnabled: settings.markdownInsert,
katexEnabled: settings.katex,
inlineRenderingEnabled: settings.inlineRendering,
tableEditingEnabled: settings.tableEditing,
imageRenderingEnabled: settings.imageRendering,
highlightActiveLine: settings.highlightActiveLine,
themeData: {
@@ -0,0 +1,168 @@
import * as React from 'react';
import { CSSProperties, ReactNode, useMemo } from 'react';
import { useWhiteboardContext } from './WhiteboardContext';
import { whiteboardColors, WhiteboardThemeColors } from './theme';
interface Props {
// Where to anchor the panel relative to its positioned ancestor.
// 'bottom-center' is the default — used for selection-context panels.
position?: 'bottom-center' | 'top-center' | 'top-right';
// Optional caption shown at the start of the bar (e.g. "1 connection").
caption?: ReactNode;
// Buttons / inputs / dividers shown in the bar.
children?: ReactNode;
}
const positionStyles: Record<NonNullable<Props['position']>, CSSProperties> = {
'bottom-center': { bottom: 16, left: '50%', transform: 'translateX(-50%)' },
'top-center': { top: 16, left: '50%', transform: 'translateX(-50%)' },
'top-right': { top: 8, right: 8 },
};
const baseStyle = (colors: WhiteboardThemeColors): CSSProperties => ({
position: 'absolute',
zIndex: 10,
display: 'flex',
alignItems: 'center',
gap: 0,
padding: 4,
background: colors.cardBackground,
border: `1px solid ${colors.cardBorder}`,
borderRadius: 8,
boxShadow: '0 4px 12px rgba(0,0,0,0.10)',
fontSize: 12,
height: 36,
color: colors.textColor,
});
const captionStyle = (colors: WhiteboardThemeColors): CSSProperties => ({
color: colors.mutedColor,
padding: '0 10px',
whiteSpace: 'nowrap',
});
const dividerStyle = (colors: WhiteboardThemeColors): CSSProperties => ({
width: 1,
height: 20,
background: colors.dividerColor,
margin: '0 4px',
});
export const ActionPanel = ({ position = 'bottom-center', caption, children }: Props) => {
const ctx = useWhiteboardContext();
const colors = useMemo(() => whiteboardColors(ctx.themeId), [ctx.themeId]);
const style: CSSProperties = { ...baseStyle(colors), ...positionStyles[position] };
return (
<div style={style}>
{caption ? (
<>
<div style={captionStyle(colors)}>{caption}</div>
<div style={dividerStyle(colors)} />
</>
) : null}
{children}
</div>
);
};
const buttonBase = (colors: WhiteboardThemeColors): CSSProperties => ({
height: 28,
padding: '0 10px',
fontSize: 12,
border: 'none',
background: 'transparent',
color: colors.textColor,
cursor: 'pointer',
borderRadius: 6,
display: 'inline-flex',
alignItems: 'center',
gap: 4,
whiteSpace: 'nowrap',
});
interface ActionButtonProps {
onClick: ()=> void;
active?: boolean;
disabled?: boolean;
title?: string;
children: ReactNode;
}
export const ActionButton = ({ onClick, active, disabled, title, children }: ActionButtonProps) => {
const ctx = useWhiteboardContext();
const colors = useMemo(() => whiteboardColors(ctx.themeId), [ctx.themeId]);
// "Active" tint is a translucent brand blue overlay so it reads on both
// light and dark backgrounds without needing a second hex value.
const activeBg = 'rgba(74, 144, 226, 0.18)';
const activeFg = '#2766b8';
const hoverBg = 'rgba(127,127,127,0.12)';
const style: CSSProperties = {
...buttonBase(colors),
background: active ? activeBg : 'transparent',
color: active ? activeFg : colors.textColor,
opacity: disabled ? 0.45 : 1,
cursor: disabled ? 'default' : 'pointer',
};
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
title={title}
// Many ActionButtons render only a glyph (—, →, ↔ …) whose Unicode
// name isn't a useful accessible name. Mirror `title` into
// aria-label so screen readers and voice-control users get the
// human-readable label the tooltip already shows.
aria-label={title}
aria-pressed={active}
style={style}
onMouseEnter={e => { if (!disabled && !active) (e.currentTarget.style.background = hoverBg); }}
onMouseLeave={e => { if (!active) (e.currentTarget.style.background = 'transparent'); }}
>
{children}
</button>
);
};
export const ActionDivider = () => {
const ctx = useWhiteboardContext();
const colors = useMemo(() => whiteboardColors(ctx.themeId), [ctx.themeId]);
return <div style={dividerStyle(colors)} />;
};
const inputStyle = (colors: WhiteboardThemeColors): CSSProperties => ({
height: 24,
padding: '0 8px',
fontSize: 12,
border: `1px solid ${colors.cardBorder}`,
borderRadius: 4,
margin: '0 4px',
background: colors.cardBackground,
color: colors.textColor,
// Keep the browser's default focus ring for keyboard accessibility — do
// not set `outline: 'none'`.
});
interface ActionInputProps {
value: string;
placeholder?: string;
width?: number;
onChange: (value: string)=> void;
}
export const ActionInput = ({ value, placeholder, width = 140, onChange }: ActionInputProps) => {
const ctx = useWhiteboardContext();
const colors = useMemo(() => whiteboardColors(ctx.themeId), [ctx.themeId]);
return (
<input
type="text"
value={value}
placeholder={placeholder}
// Without a visible <label> the placeholder is the only label a
// screen reader / voice-control user has to identify the field.
aria-label={placeholder}
onChange={e => onChange(e.target.value)}
style={{ ...inputStyle(colors), width }}
/>
);
};
@@ -0,0 +1,23 @@
import { createContext, useContext } from 'react';
import { MarkupLanguage } from '@joplin/renderer';
import { ResourceInfos } from '../../utils/types';
import { MarkupToHtmlOptions } from '../../../hooks/useMarkupToHtml';
import { RenderResult } from '@joplin/renderer/types';
export interface WhiteboardContextValue {
markupToHtml: (markupLanguage: MarkupLanguage, md: string, options?: MarkupToHtmlOptions)=> Promise<RenderResult>;
resourceInfos: ResourceInfos;
resourceDirectory: string;
themeId: number;
onOpenRef: (ref: string)=> void;
onUpdateNode: (canvasNodeId: string, patch: Record<string, unknown>)=> void;
onPromoteTextNode: (canvasNodeId: string)=> void;
}
export const WhiteboardContext = createContext<WhiteboardContextValue | null>(null);
export const useWhiteboardContext = () => {
const ctx = useContext(WhiteboardContext);
if (!ctx) throw new Error('WhiteboardContext used outside provider');
return ctx;
};
@@ -0,0 +1,197 @@
import * as React from 'react';
import { ForwardedRef, forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
import { NoteBodyEditorProps, NoteBodyEditorRef } from '../../utils/types';
import CommandService from '@joplin/lib/services/CommandService';
import Note from '@joplin/lib/models/Note';
import { Canvas, CanvasNode, FileCanvasNode, TextCanvasNode } from '@joplin/lib/services/whiteboard/jsoncanvas';
import { parseWhiteboard } from '@joplin/lib/services/whiteboard/parse';
import { serializeWhiteboard } from '@joplin/lib/services/whiteboard/serialize';
import { themeStyle } from '@joplin/lib/theme';
import { WhiteboardContext } from './WhiteboardContext';
import WhiteboardSurface from './WhiteboardSurface';
const SAVE_DEBOUNCE_MS = 400;
const WhiteboardEditor = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditorRef>) => {
const bodyRef = useRef(props.content);
bodyRef.current = props.content;
const initialParse = useMemo(() => parseWhiteboard(props.content), [props.content]);
const initialCanvas = initialParse.canvas;
const parseError = initialParse.parseError;
const [canvas, setCanvas] = useState<Canvas>(initialCanvas);
// Mirror the canvas state in a ref so async handlers can read the latest
// version without going through a setCanvas updater (which must stay pure).
const canvasRef = useRef<Canvas>(initialCanvas);
canvasRef.current = canvas;
// Debounced save. We split "schedule" from "unmount" cleanup because the
// effect's normal cleanup runs whenever `canvas` changes — that's a
// re-schedule, not a reason to drop the pending save. Only an actual
// unmount (or an explicit caller via `content()`) should flush.
const lastSerializedRef = useRef<string>(JSON.stringify(canvas));
const pendingSerializedRef = useRef<string | null>(null);
const pendingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const onChangeRef = useRef(props.onChange);
onChangeRef.current = props.onChange;
// Reload when the body switches to a different note, or when the body has
// changed underneath us (external write — e.g. the "add note to whiteboard"
// command — which produces a body we didn't emit).
const lastEmittedBodyRef = useRef<string>(props.content);
useEffect(() => {
if (props.content === lastEmittedBodyRef.current) return;
lastEmittedBodyRef.current = props.content;
const parsed = parseWhiteboard(props.content);
setCanvas(parsed.canvas);
// Mark the freshly-loaded canvas as already-synced so the debounced
// save effect doesn't echo it straight back as a write.
lastSerializedRef.current = JSON.stringify(parsed.canvas);
}, [props.content, props.contentKey]);
const flushPendingSave = useCallback((): string => {
if (pendingTimeoutRef.current !== null) {
clearTimeout(pendingTimeoutRef.current);
pendingTimeoutRef.current = null;
}
const serialized = pendingSerializedRef.current;
if (serialized === null) return bodyRef.current;
pendingSerializedRef.current = null;
lastSerializedRef.current = serialized;
const newBody = serializeWhiteboard(bodyRef.current, JSON.parse(serialized) as Canvas);
bodyRef.current = newBody;
lastEmittedBodyRef.current = newBody;
onChangeRef.current({ changeId: null, content: newBody });
return newBody;
}, []);
useEffect(() => {
// Never write back when the source body had an unparseable fence —
// otherwise opening a corrupt note would silently overwrite the
// user's recoverable JSON with an empty canvas.
if (parseError) return undefined;
const serialized = JSON.stringify(canvas);
if (serialized === lastSerializedRef.current) return undefined;
pendingSerializedRef.current = serialized;
// Replace any prior pending timeout — we'll re-schedule from the new
// canvas. Crucially we do NOT clear the pending serialised payload
// here, so the unmount-flush effect can still see it.
if (pendingTimeoutRef.current !== null) clearTimeout(pendingTimeoutRef.current);
pendingTimeoutRef.current = setTimeout(() => {
pendingTimeoutRef.current = null;
flushPendingSave();
}, SAVE_DEBOUNCE_MS);
return undefined;
}, [canvas, flushPendingSave, parseError]);
// Flush on unmount. The empty-deps effect's cleanup only fires once.
useEffect(() => {
return () => { flushPendingSave(); };
}, [flushPendingSave]);
useImperativeHandle(ref, () => ({
// Callers that read `content()` (e.g. the form-note save flow) get
// the latest body even if a debounced save is still pending.
content: () => flushPendingSave(),
resetScroll: () => { /* not applicable */ },
scrollTo: () => { /* not applicable */ },
supportsCommand: () => false,
execCommand: async () => { /* not applicable */ },
}), [flushPendingSave]);
const onUpdateNode = useCallback((nodeId: string, patch: Record<string, unknown>) => {
setCanvas(prev => ({
...prev,
nodes: prev.nodes.map(n => n.id === nodeId ? { ...n, ...patch } as CanvasNode : n),
}));
}, []);
const onOpenRef = useCallback((value: string) => {
if (!value) return;
// `openItem` already handles every supported link form: `:/id`,
// `joplin://`, `file://`, any other URL scheme (http/https/mailto/
// ftp/...), and shows a user-facing error for unsupported strings.
void CommandService.instance().execute('openItem', value);
}, []);
// Promote a text card to a real Joplin note: create a note in the same
// folder as the whiteboard, with the card's text as body and its first
// non-empty line as title; replace the text node with a file-ref node
// pointing at the new note.
const onPromoteTextNode = useCallback(async (canvasNodeId: string) => {
const noteId = props.noteId;
if (!noteId) return;
// Read the latest canvas state directly — never inside a setCanvas
// updater, since updaters must stay pure (React 18 strict mode runs
// them twice in dev, which would create the note twice).
const node = canvasRef.current.nodes.find(n => n.id === canvasNodeId) as TextCanvasNode | undefined;
if (!node || node.type !== 'text') return;
const parentNote = await Note.load(noteId);
if (!parentNote) return;
const title = (node.text.split('\n').find(l => l.trim().length) || '').replace(/^#+\s*/, '').trim() || 'Untitled';
const created = await Note.save({
parent_id: parentNote.parent_id,
title,
body: node.text,
});
// Re-locate the node from the latest state in case it moved/resized
// between the promote click and the save returning. If it was deleted
// in the meantime, drop the operation silently.
const latest = canvasRef.current.nodes.find(n => n.id === canvasNodeId) as TextCanvasNode | undefined;
if (!latest || latest.type !== 'text') return;
const replacement: FileCanvasNode = {
id: latest.id,
type: 'file',
x: latest.x,
y: latest.y,
width: latest.width,
height: latest.height,
file: `:/${created.id}`,
};
setCanvas(curr => ({
...curr,
nodes: curr.nodes.map(n => n.id === latest.id ? replacement : n),
}));
}, [props.noteId]);
const contextValue = useMemo(() => ({
markupToHtml: props.markupToHtml,
resourceInfos: props.resourceInfos,
resourceDirectory: props.resourceDirectory,
themeId: props.themeId,
onOpenRef,
onUpdateNode,
onPromoteTextNode,
}), [props.markupToHtml, props.resourceInfos, props.resourceDirectory, props.themeId, onOpenRef, onUpdateNode, onPromoteTextNode]);
if (parseError) {
const theme = themeStyle(props.themeId);
return (
<div style={{ ...props.style, display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, padding: 24, overflow: 'auto' }}>
<div style={{ backgroundColor: theme.warningBackgroundColor, color: theme.color, padding: 16, borderRadius: 4, display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ fontWeight: 600 }}>This whiteboard could not be loaded</div>
<div style={{ whiteSpace: 'pre-wrap', fontFamily: 'monospace', fontSize: 12 }}>{parseError}</div>
<div>Click the eye icon in the toolbar to switch to the Markdown editor and fix the JSON manually.</div>
</div>
</div>
);
}
return (
<div style={{ ...props.style, display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
<WhiteboardContext.Provider value={contextValue}>
<WhiteboardSurface
canvas={canvas}
onChange={setCanvas}
/>
</WhiteboardContext.Provider>
</div>
);
};
export default forwardRef(WhiteboardEditor);
@@ -0,0 +1,340 @@
import * as React from 'react';
import { CSSProperties, DragEvent as ReactDragEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Background,
Connection,
ConnectionMode,
Controls,
Edge,
MarkerType,
MiniMap,
Node,
NodeTypes,
OnConnect,
OnEdgesChange,
OnNodesChange,
ReactFlow,
ReactFlowProvider,
applyEdgeChanges,
applyNodeChanges,
useReactFlow,
} from '@xyflow/react';
import ensureReactFlowCss, { applyReactFlowTheme } from './loadReactFlowCss';
import generateId from '@joplin/lib/services/whiteboard/generateId';
import { _, _n } from '@joplin/lib/locale';
import { Canvas, CanvasEdge, CanvasNode } from '@joplin/lib/services/whiteboard/jsoncanvas';
ensureReactFlowCss();
import { canvasNodeToFlowNode, canvasToFlow, flowToCanvas, WhiteboardFlowEdge, WhiteboardFlowNode } from './canvasFlow';
import TextNode from './nodes/TextNode';
import FileNode from './nodes/FileNode';
import LinkNode from './nodes/LinkNode';
import { ActionButton, ActionDivider, ActionInput, ActionPanel } from './ActionPanel';
import { useWhiteboardContext } from './WhiteboardContext';
import { whiteboardColors } from './theme';
// `markerUnits: 'userSpaceOnUse'` keeps the arrowhead at an absolute size,
// independent of the edge's stroke width. Without it, selected edges (which
// have a thicker stroke) would render a proportionally bigger arrow.
const makeArrowMarker = () => ({ type: MarkerType.ArrowClosed, width: 27, height: 27, markerUnits: 'userSpaceOnUse' });
type ArrowMode = 'none' | 'forward' | 'backward' | 'both' | 'mixed';
const arrowModeFor = (e: WhiteboardFlowEdge): Exclude<ArrowMode, 'mixed'> => {
const start = !!e.markerStart;
const end = !!e.markerEnd;
if (start && end) return 'both';
if (end) return 'forward';
if (start) return 'backward';
return 'none';
};
interface Props {
canvas: Canvas;
onChange: (canvas: Canvas)=> void;
}
const nodeTypes: NodeTypes = {
wbText: TextNode as unknown as NodeTypes[string],
wbFile: FileNode as unknown as NodeTypes[string],
wbLink: LinkNode as unknown as NodeTypes[string],
};
const InnerSurface = ({ canvas, onChange }: Props) => {
const ctx = useWhiteboardContext();
const colors = useMemo(() => whiteboardColors(ctx.themeId), [ctx.themeId]);
// Re-apply React Flow's CSS custom properties whenever the theme changes
// so edges, minimap, controls and dot grid follow the active Joplin theme.
useEffect(() => {
applyReactFlowTheme(colors);
}, [colors]);
const containerStyle: CSSProperties = useMemo(() => ({
position: 'relative',
flex: 1,
width: '100%',
height: '100%',
background: colors.surfaceBackground,
outline: 'none',
}), [colors]);
const initial = useMemo(() => canvasToFlow(canvas), [canvas]);
const [flowNodes, setFlowNodes] = useState<WhiteboardFlowNode[]>(initial.nodes);
const [flowEdges, setFlowEdges] = useState<WhiteboardFlowEdge[]>(initial.edges);
// JSONCanvas group nodes are not rendered, but we preserve them through
// round-trip so importing a canvas from another tool and
// re-saving doesn't silently drop them.
const preservedGroupsRef = useRef<CanvasNode[]>(initial.preservedGroups);
const containerRef = useRef<HTMLDivElement | null>(null);
const rf = useReactFlow();
// When the incoming canvas changes (note loaded externally), reload state
// — but skip if it's the same canvas we just emitted (avoid feedback loops).
// Seed with the *round-tripped* serialization so optional edge fields
// (fromEnd/toEnd) added by flowToCanvas don't make the very first push-back
// effect see the canvas as "different" and emit a spurious onChange.
const lastEmittedRef = useRef<string>(JSON.stringify(flowToCanvas(initial.nodes, initial.edges, initial.preservedGroups)));
useEffect(() => {
const incoming = JSON.stringify(canvas);
if (incoming === lastEmittedRef.current) return;
const next = canvasToFlow(canvas);
setFlowNodes(next.nodes);
setFlowEdges(next.edges);
preservedGroupsRef.current = next.preservedGroups;
// Stamp with the round-tripped form too, for the same reason as the
// initial seed above.
lastEmittedRef.current = JSON.stringify(flowToCanvas(next.nodes, next.edges, next.preservedGroups));
}, [canvas]);
// Push changes back to the parent whenever the local flow state changes.
useEffect(() => {
const out = flowToCanvas(flowNodes, flowEdges, preservedGroupsRef.current);
const serialized = JSON.stringify(out);
if (serialized === lastEmittedRef.current) return;
lastEmittedRef.current = serialized;
onChange(out);
}, [flowNodes, flowEdges, onChange]);
const onNodesChange: OnNodesChange = useCallback((changes) => {
setFlowNodes(prev => applyNodeChanges(changes, prev) as WhiteboardFlowNode[]);
}, []);
const onEdgesChange: OnEdgesChange = useCallback((changes) => {
setFlowEdges(prev => applyEdgeChanges(changes, prev) as WhiteboardFlowEdge[]);
}, []);
const onConnect: OnConnect = useCallback((connection: Connection) => {
const edge: WhiteboardFlowEdge = {
id: generateId(),
source: connection.source,
target: connection.target,
sourceHandle: connection.sourceHandle ?? undefined,
targetHandle: connection.targetHandle ?? undefined,
markerEnd: makeArrowMarker(),
data: {
canvasEdge: {
id: '',
fromNode: connection.source,
toNode: connection.target,
} as CanvasEdge,
},
};
setFlowEdges(prev => [...prev, edge]);
}, []);
const defaultEdgeOptions = useMemo(() => ({
markerEnd: makeArrowMarker(),
}), []);
// Append a freshly-created canvas node to the surface's local flow state.
// The render-cycle effect at flowToCanvas → onChange propagates the new
// node up to the parent, so we don't need an explicit onAddNode prop.
const addCanvasNode = useCallback((n: Exclude<CanvasNode, { type: 'group' }>) => {
setFlowNodes(prev => [...prev, canvasNodeToFlowNode(n)]);
}, []);
const onAddText = useCallback(() => {
const view = rf.getViewport();
const rect = containerRef.current?.getBoundingClientRect();
const cx = rect ? (rect.width / 2 - view.x) / view.zoom : 0;
const cy = rect ? (rect.height / 2 - view.y) / view.zoom : 0;
addCanvasNode({
id: generateId(),
type: 'text',
x: cx - 100,
y: cy - 50,
width: 200,
height: 100,
text: _('New text card'),
});
}, [rf, addCanvasNode]);
// Selection summaries for the action panels.
const selectedEdges = useMemo(() => flowEdges.filter(e => e.selected), [flowEdges]);
const selectedNodes = useMemo(() => flowNodes.filter(n => n.selected), [flowNodes]);
// Edges fed to React Flow. For selected edges we override marker colour
// to match the selection blue — markers are SVG <marker> defs that don't
// inherit stroke colour from the edge path automatically.
const SELECTED_EDGE_COLOR = '#4a90e2';
const renderedEdges = useMemo<WhiteboardFlowEdge[]>(() => {
return flowEdges.map(e => {
if (!e.selected) return e;
const tint = (m: WhiteboardFlowEdge['markerEnd']) =>
(m && typeof m === 'object') ? { ...m, color: SELECTED_EDGE_COLOR } : m;
return { ...e, markerEnd: tint(e.markerEnd), markerStart: tint(e.markerStart) };
});
}, [flowEdges]);
const updateSelectedEdges = useCallback((patch: (e: WhiteboardFlowEdge)=> WhiteboardFlowEdge) => {
setFlowEdges(prev => prev.map(e => e.selected ? patch(e) : e));
}, []);
const currentArrowMode: ArrowMode = useMemo(() => {
if (!selectedEdges.length) return 'none';
const first = arrowModeFor(selectedEdges[0]);
for (const e of selectedEdges) if (arrowModeFor(e) !== first) return 'mixed';
return first;
}, [selectedEdges]);
const setArrowMode = useCallback((mode: Exclude<ArrowMode, 'mixed'>) => {
updateSelectedEdges(e => ({
...e,
markerStart: (mode === 'backward' || mode === 'both') ? makeArrowMarker() : undefined,
markerEnd: (mode === 'forward' || mode === 'both') ? makeArrowMarker() : undefined,
}));
}, [updateSelectedEdges]);
const flipDirection = useCallback(() => {
updateSelectedEdges(e => ({
...e,
source: e.target,
target: e.source,
sourceHandle: e.targetHandle,
targetHandle: e.sourceHandle,
markerStart: e.markerEnd,
markerEnd: e.markerStart,
}));
}, [updateSelectedEdges]);
const setEdgeLabel = useCallback((label: string) => {
updateSelectedEdges(e => ({ ...e, label }));
}, [updateSelectedEdges]);
const onDragOver = useCallback((e: ReactDragEvent<HTMLDivElement>) => {
const types = Array.from(e.dataTransfer.types);
if (types.includes('text/x-jop-note-ids') || types.includes('text/x-jop-resource-ids')) {
e.preventDefault();
e.dataTransfer.dropEffect = 'link';
}
}, []);
const onDrop = useCallback((e: ReactDragEvent<HTMLDivElement>) => {
const noteIdsRaw = e.dataTransfer.getData('text/x-jop-note-ids');
const resourceIdsRaw = e.dataTransfer.getData('text/x-jop-resource-ids');
if (!noteIdsRaw && !resourceIdsRaw) return;
e.preventDefault();
e.stopPropagation();
const drop = rf.screenToFlowPosition({ x: e.clientX, y: e.clientY });
const tryParse = (raw: string): string[] => {
if (!raw) return [];
try { const v = JSON.parse(raw); return Array.isArray(v) ? v : []; } catch { return []; }
};
const ids = [
...tryParse(noteIdsRaw),
...tryParse(resourceIdsRaw),
];
let offset = 0;
for (const id of ids) {
addCanvasNode({
id: generateId(),
type: 'file',
x: drop.x - 120 + offset,
y: drop.y - 60 + offset,
width: 240,
height: 160,
file: `:/${id}`,
});
offset += 24;
}
}, [rf, addCanvasNode]);
return (
<div
ref={containerRef}
style={containerStyle}
onDragOver={onDragOver}
onDrop={onDrop}
>
<ReactFlow
nodes={flowNodes as unknown as Node[]}
edges={renderedEdges as unknown as Edge[]}
nodeTypes={nodeTypes}
defaultEdgeOptions={defaultEdgeOptions}
connectionMode={ConnectionMode.Loose}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
deleteKeyCode={['Backspace', 'Delete']}
multiSelectionKeyCode={['Shift', 'Meta', 'Control']}
selectionKeyCode={['Shift']}
panOnScroll
panOnDrag
zoomOnPinch
zoomOnScroll={false}
fitView={flowNodes.length > 0}
proOptions={{ hideAttribution: true }}
>
<Background gap={16} size={1} />
<Controls showInteractive={false} />
<MiniMap pannable zoomable style={{ width: 160, height: 100 }} />
<ActionPanel position="top-right">
<ActionButton onClick={onAddText} title={_('Add a text card')}>{_('+ Text')}</ActionButton>
</ActionPanel>
{selectedEdges.length > 0 ? (
<ActionPanel
position="bottom-center"
caption={_n('%d connection', '%d connections', selectedEdges.length, selectedEdges.length)}
>
<ActionButton onClick={() => setArrowMode('none')} active={currentArrowMode === 'none'} title={_('No arrow')}></ActionButton>
<ActionButton onClick={() => setArrowMode('forward')} active={currentArrowMode === 'forward'} title={_('Arrow at target')}></ActionButton>
<ActionButton onClick={() => setArrowMode('backward')} active={currentArrowMode === 'backward'} title={_('Arrow at source')}></ActionButton>
<ActionButton onClick={() => setArrowMode('both')} active={currentArrowMode === 'both'} title={_('Bidirectional')}></ActionButton>
<ActionDivider />
<ActionButton onClick={flipDirection} title={_('Swap source and target')}>{_('Flip')}</ActionButton>
{selectedEdges.length === 1 ? (
<>
<ActionDivider />
<ActionInput
value={typeof selectedEdges[0].label === 'string' ? selectedEdges[0].label : ''}
placeholder={_('Label')}
onChange={setEdgeLabel}
/>
</>
) : null}
</ActionPanel>
) : null}
{selectedNodes.length > 0 && selectedEdges.length === 0 ? (
<ActionPanel
position="bottom-center"
caption={_n('%d card', '%d cards', selectedNodes.length, selectedNodes.length)}
>
{/* Per-card actions can be added here later (colour, alignment, etc.). */}
</ActionPanel>
) : null}
</ReactFlow>
</div>
);
};
const WhiteboardSurface = (props: Props) => (
<ReactFlowProvider>
<InnerSurface {...props} />
</ReactFlowProvider>
);
export default WhiteboardSurface;
@@ -0,0 +1,137 @@
// Translation between the JSONCanvas spec shape and React Flow's
// node/edge shape. Pure functions, no React imports.
import { Edge as FlowEdge, MarkerType, Node as FlowNode } from '@xyflow/react';
import { Canvas, CanvasEdge, CanvasNode, CanvasNodeSide } from '@joplin/lib/services/whiteboard/jsoncanvas';
export type WhiteboardNodeData = {
canvasNode: CanvasNode;
};
export type WhiteboardFlowNode = FlowNode<WhiteboardNodeData>;
export type WhiteboardFlowEdge = FlowEdge<{ canvasEdge: CanvasEdge }>;
// Maps JSONCanvas node types to our React Flow node types. Group nodes are
// not rendered, so they're filtered out before this is called.
const flowTypeForCanvasType = (type: 'text' | 'file' | 'link'): string => {
switch (type) {
case 'text': return 'wbText';
case 'file': return 'wbFile';
case 'link': return 'wbLink';
}
};
// Convert a single (non-group) JSONCanvas node into the React Flow shape.
// Used both by the bulk converter at load time and by the surface when the
// user creates a new node interactively.
export const canvasNodeToFlowNode = (n: Exclude<CanvasNode, { type: 'group' }>): WhiteboardFlowNode => ({
id: n.id,
type: flowTypeForCanvasType(n.type),
position: { x: n.x, y: n.y },
data: { canvasNode: n },
style: { width: n.width, height: n.height },
width: n.width,
height: n.height,
selectable: true,
draggable: true,
});
const sideToHandle = (side: CanvasNodeSide | undefined): string | undefined => {
if (!side) return undefined;
return side; // React Flow handle ids match side names ('top', 'right', 'bottom', 'left').
};
// `markerUnits: 'userSpaceOnUse'` keeps the arrowhead at an absolute size,
// independent of the edge's stroke width. Without it, selected edges (which
// have a thicker stroke) would render a proportionally bigger arrow.
const arrowMarker = () => ({ type: MarkerType.ArrowClosed, width: 27, height: 27, markerUnits: 'userSpaceOnUse' });
export interface CanvasToFlowResult {
nodes: WhiteboardFlowNode[];
edges: WhiteboardFlowEdge[];
// JSONCanvas group nodes are not rendered by this editor, but we keep them
// here so they can be merged back on save and round-trip cleanly.
preservedGroups: CanvasNode[];
}
export const canvasToFlow = (canvas: Canvas): CanvasToFlowResult => {
const preservedGroups: CanvasNode[] = [];
const nodes: WhiteboardFlowNode[] = [];
for (const n of canvas.nodes) {
if (n.type === 'group') {
preservedGroups.push(n);
continue;
}
nodes.push(canvasNodeToFlowNode(n));
}
const edges: WhiteboardFlowEdge[] = canvas.edges.map(e => ({
id: e.id,
source: e.fromNode,
target: e.toNode,
sourceHandle: sideToHandle(e.fromSide),
targetHandle: sideToHandle(e.toSide),
label: e.label,
data: { canvasEdge: e },
type: 'default',
markerStart: e.fromEnd === 'arrow' ? arrowMarker() : undefined,
markerEnd: e.toEnd === 'none' ? undefined : arrowMarker(),
}));
return { nodes, edges, preservedGroups };
};
const handleToSide = (handle?: string | null): CanvasNodeSide | undefined => {
if (handle === 'top' || handle === 'right' || handle === 'bottom' || handle === 'left') return handle;
return undefined;
};
// Apply React Flow positions back to canvas nodes. Preserves any field we
// don't track (color, subpath, etc.) by spreading the original canvasNode
// from `data`. Group nodes that were filtered out at load time are merged
// back unchanged via `preservedGroups`.
export const flowToCanvas = (
flowNodes: WhiteboardFlowNode[],
flowEdges: WhiteboardFlowEdge[],
preservedGroups: CanvasNode[] = [],
): Canvas => {
const nodes: CanvasNode[] = flowNodes.map(fn => {
const orig = fn.data?.canvasNode;
const width = (typeof fn.width === 'number' ? fn.width : (typeof fn.style?.width === 'number' ? fn.style.width : orig?.width)) ?? 200;
const height = (typeof fn.height === 'number' ? fn.height : (typeof fn.style?.height === 'number' ? fn.style.height : orig?.height)) ?? 100;
const base = {
id: fn.id,
x: fn.position.x,
y: fn.position.y,
width,
height,
};
if (!orig) {
return { ...base, type: 'text', text: '' };
}
return { ...orig, ...base };
});
// Re-attach preserved group nodes so a round-trip doesn't drop them.
for (const g of preservedGroups) nodes.push(g);
const edges: CanvasEdge[] = flowEdges.map(fe => {
const orig = fe.data?.canvasEdge;
const fromEnd: CanvasEdge['fromEnd'] = fe.markerStart ? 'arrow' : 'none';
const toEnd: CanvasEdge['toEnd'] = fe.markerEnd ? 'arrow' : 'none';
return {
id: fe.id,
fromNode: fe.source,
toNode: fe.target,
fromSide: handleToSide(fe.sourceHandle),
toSide: handleToSide(fe.targetHandle),
label: typeof fe.label === 'string' ? fe.label : (orig?.label),
...(orig?.color ? { color: orig.color } : {}),
fromEnd,
toEnd,
};
});
return { nodes, edges };
};
@@ -0,0 +1,28 @@
// Joplin's desktop build doesn't run CSS imports through a loader, so we
// inject style sheets at runtime as <style> tags.
//
// - `injectStyle(id, css)` is a one-shot, idempotent injection.
// - `replaceStyle(id, css)` updates an existing <style> in place — for
// theme-dependent CSS that needs to change when the theme changes.
const injectStyle = (id: string, css: string) => {
if (typeof document === 'undefined') return;
if (document.getElementById(id)) return;
const el = document.createElement('style');
el.id = id;
el.textContent = css;
document.head.appendChild(el);
};
export const replaceStyle = (id: string, css: string) => {
if (typeof document === 'undefined') return;
let el = document.getElementById(id) as HTMLStyleElement | null;
if (!el) {
el = document.createElement('style');
el.id = id;
document.head.appendChild(el);
}
el.textContent = css;
};
export default injectStyle;
@@ -0,0 +1,92 @@
// React Flow ships CSS as a separate file. Joplin's desktop build doesn't run
// CSS imports through a loader, so we read the stylesheet at runtime and
// inject it into the document head once. We also expose a theme-aware
// override that overrides React Flow's CSS custom properties (--xy-*) so
// edges, minimap and dot grid follow the active Joplin theme.
import injectStyle, { replaceStyle } from './injectStyle';
import { SELECTION_COLOR, WhiteboardThemeColors } from './theme';
const STYLE_ELEMENT_ID = 'whiteboard-react-flow-css';
const THEME_STYLE_ELEMENT_ID = 'whiteboard-react-flow-theme';
let injected = false;
const ensureReactFlowCss = () => {
if (injected) return;
if (typeof document === 'undefined') return;
try {
// require() at runtime so this resolves through Node, which is fine in
// Electron's renderer process.
// eslint-disable-next-line @typescript-eslint/no-var-requires
const fs = require('fs');
// eslint-disable-next-line @typescript-eslint/no-var-requires
const path = require('path');
// eslint-disable-next-line @typescript-eslint/no-var-requires
const pkgPath = require.resolve('@xyflow/react/package.json');
const cssPath = path.join(path.dirname(pkgPath), 'dist', 'style.css');
const baseCss = fs.readFileSync(cssPath, 'utf8');
// React Flow base styles, then our overrides. Selected edges should
// stand out as clearly as selected cards, and connection handles are
// hidden until hover/selection so the canvas isn't littered with dots.
const overrides = `
.react-flow__edge.selected .react-flow__edge-path,
.react-flow__edge:focus .react-flow__edge-path,
.react-flow__edge:focus-visible .react-flow__edge-path {
stroke: ${SELECTION_COLOR} !important;
stroke-width: 2 !important;
}
.react-flow__edge.selected .react-flow__edge-textbg {
fill: ${SELECTION_COLOR};
}
.react-flow__edge.selected .react-flow__edge-text {
fill: #ffffff;
}
.react-flow__node .react-flow__handle {
opacity: 0;
transition: opacity 120ms ease;
}
.react-flow__node:hover .react-flow__handle,
.react-flow__node.selected .react-flow__handle,
.react-flow__handle.connectingfrom,
.react-flow__handle.connectingto {
opacity: 1;
}
`;
injectStyle(STYLE_ELEMENT_ID, `${baseCss}${overrides}`);
injected = true;
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to load React Flow CSS', error);
}
};
// Apply the active Joplin theme to React Flow by setting its `--xy-*` CSS
// custom properties at the `.react-flow` root. Re-injected on every theme
// change so dark mode actually looks dark. Scoped via a class so we don't
// accidentally affect other React Flow instances if Joplin ever embeds one.
export const applyReactFlowTheme = (colors: WhiteboardThemeColors) => {
const css = `
.react-flow {
--xy-background-color-default: ${colors.surfaceBackground};
--xy-background-pattern-dots-color-default: ${colors.dividerColor};
--xy-edge-stroke-default: ${colors.dividerColor};
--xy-edge-stroke-selected-default: ${SELECTION_COLOR};
--xy-connectionline-stroke-default: ${colors.handleColor};
--xy-attribution-background-color-default: ${colors.cardBackground};
--xy-minimap-background-color-default: ${colors.cardBackground};
--xy-minimap-mask-background-color-default: ${colors.surfaceBackground};
--xy-minimap-node-background-color-default: ${colors.handleColor};
--xy-node-color-default: ${colors.textColor};
--xy-node-background-color-default: ${colors.cardBackground};
--xy-controls-button-background-color-default: ${colors.cardBackground};
--xy-controls-button-color-default: ${colors.textColor};
--xy-controls-button-border-color-default: ${colors.dividerColor};
}
`;
replaceStyle(THEME_STYLE_ELEMENT_ID, css);
};
export default ensureReactFlowCss;
@@ -0,0 +1,278 @@
import * as React from 'react';
import { CSSProperties, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Handle, NodeProps, NodeResizer } from '@xyflow/react';
import { pathToFileURL } from 'url';
import { MarkupLanguage } from '@joplin/renderer';
import BaseItem from '@joplin/lib/models/BaseItem';
import Note from '@joplin/lib/models/Note';
import ItemChange from '@joplin/lib/models/ItemChange';
import { ModelType } from '@joplin/lib/BaseModel';
import attachedResources from '@joplin/lib/utils/attachedResources';
import Logger from '@joplin/utils/Logger';
import { resourceFullPath } from '@joplin/lib/models/utils/resourceUtils';
import { ResourceEntity } from '@joplin/lib/services/database/types';
import { FileCanvasNode } from '@joplin/lib/services/whiteboard/jsoncanvas';
import { isInternalRef, RefKind, resolveFileRef } from '@joplin/lib/services/whiteboard/resolveRef';
import { useWhiteboardContext } from '../WhiteboardContext';
import { WhiteboardNodeData } from '../canvasFlow';
import useCheckboxToggle from '../useCheckboxToggle';
import { whiteboardColors } from '../theme';
import { bodyStyle, cardStyle, handlePositions, headerStyle } from './sharedStyles';
const logger = Logger.create('WhiteboardFileNode');
// Header showing the linked note's title — replaces the generic "NOTE" badge
// when we know the title. Truncated with ellipsis on overflow.
const noteHeaderStyle = (textColor: string, dividerColor: string): CSSProperties => ({
fontSize: 12,
fontWeight: 600,
color: textColor,
padding: '5px 8px',
borderBottom: `1px solid ${dividerColor}`,
flexShrink: 0,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
});
// Build a file:// URL pointing at the resource's blob on disk. Delegates
// the path-on-disk computation to resourceFullPath so we get the same
// extension logic as the rest of Joplin (file_extension first, then a
// mime → extension fallback for resources missing an explicit extension).
// pathToFileURL handles Windows separators and special-character encoding.
const resourceUrlFor = (resource: ResourceEntity | null, resourceDirectory: string): string | null => {
if (!resource || !resourceDirectory) return null;
return pathToFileURL(resourceFullPath(resource, resourceDirectory)).href;
};
interface ResolvedItem {
kind: 'note' | 'resource' | 'unknown';
title: string;
body?: string;
// Note metadata used to gate writes from this card (e.g. checkbox
// toggling) and to enable conflict detection on save.
userUpdatedTime?: number;
deletedTime?: number;
// The full resource entity for `kind: 'resource'` items, so we can pass
// it straight to resourceFullPath / resourceFilename (which know how to
// fall back from missing file_extension to a mime-derived one).
resource?: ResourceEntity;
}
const useResolvedRef = (file: string): { resolved: ResolvedItem | null; refetch: ()=> void } => {
const [resolved, setResolved] = useState<ResolvedItem | null>(null);
const [refetchCount, setRefetchCount] = useState(0);
const lastLoadedFileRef = useRef<string | null>(null);
useEffect(() => {
let cancelled = false;
const ref = resolveFileRef(file);
if (ref.kind === RefKind.External) {
setResolved(null);
lastLoadedFileRef.current = null;
return undefined;
}
// Clear any previously-resolved item before loading when the ref has
// changed, so switching from one internal ref to another doesn't show
// stale content during the async load. Skip the clear on a refetch
// of the same ref (e.g. after a checkbox toggle saves the note) —
// otherwise the preview would flicker on every refetch.
if (lastLoadedFileRef.current !== file) {
setResolved(null);
lastLoadedFileRef.current = file;
}
void (async () => {
try {
const item = await BaseItem.loadItemById(ref.id);
if (cancelled) return;
if (!item) {
setResolved({ kind: 'unknown', title: file });
return;
}
if (item.type_ === ModelType.Note) {
setResolved({
kind: 'note',
title: item.title || 'Untitled',
body: item.body || '',
userUpdatedTime: item.user_updated_time,
deletedTime: item.deleted_time,
});
} else if (item.type_ === ModelType.Resource) {
setResolved({
kind: 'resource',
title: item.title || file,
resource: item as ResourceEntity,
});
} else {
setResolved({ kind: 'unknown', title: file });
}
} catch {
if (!cancelled) setResolved({ kind: 'unknown', title: file });
}
})();
return () => { cancelled = true; };
}, [file, refetchCount]);
return { resolved, refetch: () => setRefetchCount(c => c + 1) };
};
const FileNode = ({ data, selected }: NodeProps<{ id: string; type: 'wbFile'; data: WhiteboardNodeData; position: { x: number; y: number } }>) => {
const ctx = useWhiteboardContext();
const node = data.canvasNode as FileCanvasNode;
const colors = useMemo(() => whiteboardColors(ctx.themeId), [ctx.themeId]);
const onDoubleClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
ctx.onOpenRef(node.file);
}, [ctx, node.file]);
const { resolved, refetch } = useResolvedRef(node.file);
// Internal refs go through the resolved resource (which carries mime +
// file_extension from the database). External refs may already be URLs
// (http/https/file), in which case we use them as-is for rendering;
// bare paths from other tools can't be resolved here so we leave url null and fall
// back to the text branch.
const isInternal = isInternalRef(node.file);
const url = isInternal
? resourceUrlFor(resolved?.resource ?? null, ctx.resourceDirectory)
: (/^(https?:|file:)\/\//i.test(node.file) ? node.file : null);
const mime = resolved?.resource?.mime;
const isPdf = isInternal
? mime === 'application/pdf'
: /\.pdf(\?|$|#)/i.test(node.file);
const isImage = isInternal
? !!mime?.startsWith('image/')
: /\.(png|jpe?g|gif|webp|svg|bmp)(\?|$|#)/i.test(node.file);
// Render note bodies as compiled HTML, like the TextNode does. Resources
// linked from the note body need to be resolved separately — the editor's
// own resourceInfos only covers resources of the *whiteboard* note.
const [noteHtml, setNoteHtml] = useState<string>('');
useEffect(() => {
let cancelled = false;
if (resolved?.kind !== 'note' || !resolved.body) {
setNoteHtml('');
return undefined;
}
void (async () => {
try {
const linkedResources = await attachedResources(resolved.body);
if (cancelled) return;
const result = await ctx.markupToHtml(MarkupLanguage.Markdown, resolved.body, {
resourceInfos: linkedResources,
});
if (!cancelled) setNoteHtml(result?.html ?? '');
} catch {
if (!cancelled) setNoteHtml('');
}
})();
return () => { cancelled = true; };
}, [resolved, ctx]);
// Save the linked note's body when the user toggles a checkbox in its
// preview. We rely on the same reload-on-external-change path that lets
// other commands (e.g. addNoteToWhiteboard) update notes outside the
// editor's own state — once the body is saved, refetching `resolved`
// happens via `useResolvedRef` which is keyed on `node.file`.
const linkedNoteId = resolved?.kind === 'note' ? resolveFileRef(node.file).id : null;
const linkedNoteUserUpdatedTime = resolved?.kind === 'note' ? resolved.userUpdatedTime : undefined;
const linkedNoteDeletedTime = resolved?.kind === 'note' ? resolved.deletedTime : undefined;
// Per-card in-flight flag. Rapid checkbox toggles can otherwise overwrite
// each other because all clicks read from the same stale `resolved.body`
// until refetch completes. While a save is pending we drop further
// toggles; the user retries once the preview catches up.
const savingRef = useRef(false);
const onLinkedNoteBodyChange = useCallback(async (newBody: string) => {
if (!linkedNoteId) return;
// Don't write to deleted (in-trash) notes — Note.save would either
// fail or, worse, silently resurrect the note via the timestamp bump.
if (linkedNoteDeletedTime) {
logger.info(`Ignoring checkbox toggle on deleted note: ${linkedNoteId}`);
return;
}
if (savingRef.current) {
logger.info(`Dropped concurrent toggle on ${linkedNoteId} — a save is in flight`);
return;
}
savingRef.current = true;
try {
// Pass user_updated_time so the save layer can detect concurrent
// edits (e.g. the same note open in another window). changeSource
// is set explicitly so sync/telemetry can attribute the write.
await Note.save(
{
id: linkedNoteId,
body: newBody,
...(linkedNoteUserUpdatedTime ? { user_updated_time: linkedNoteUserUpdatedTime } : {}),
},
{ changeSource: ItemChange.SOURCE_UNSPECIFIED },
);
refetch();
} catch (error) {
// Read-only / shared-without-write-permission notes throw here.
// Log and leave the preview as-is — the next refetch will revert
// the visible checkbox state to match the on-disk body.
logger.warn(`Could not save linked note ${linkedNoteId}:`, error);
refetch();
} finally {
savingRef.current = false;
}
}, [linkedNoteId, linkedNoteUserUpdatedTime, linkedNoteDeletedTime, refetch]);
const checkboxRef = useCheckboxToggle({
body: resolved?.kind === 'note' ? (resolved.body ?? '') : '',
onChange: onLinkedNoteBodyChange,
});
const renderContent = () => {
// Image / PDF resource — render directly.
if (url && isImage) {
return <img src={url} style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain', alignSelf: 'center', flex: 1 }} alt={resolved?.title ?? ''} />;
}
if (url && isPdf) {
return <embed src={url} type="application/pdf" style={{ width: '100%', height: '100%' }} />;
}
// Internal note ref — show the note's title in the header and the body
// preview below.
if (resolved?.kind === 'note') {
return (
<>
<div style={noteHeaderStyle(colors.textColor, colors.dividerColor)} title={resolved.title}>{resolved.title}</div>
<div ref={checkboxRef} className="wb-card-md" style={bodyStyle(colors)} dangerouslySetInnerHTML={{ __html: noteHtml }} />
</>
);
}
// Internal resource (non-image / non-pdf) — show its title.
if (resolved?.kind === 'resource') {
return (
<>
<div style={headerStyle(colors)}>Resource</div>
<div style={bodyStyle(colors)}>{resolved.title}</div>
</>
);
}
// Loading or external file path.
return (
<>
<div style={headerStyle(colors)}>{node.file.startsWith(':/') ? 'Note / Resource' : 'File'}</div>
<div style={bodyStyle(colors)}>{resolved === null && node.file.startsWith(':/') ? 'Loading…' : node.file}</div>
</>
);
};
return (
<>
<NodeResizer minWidth={80} minHeight={40} isVisible={!!selected} />
{handlePositions.map(({ id: hid, position }) => (
<Handle key={hid} type="source" position={position} id={hid} style={{ background: colors.handleColor }} />
))}
<div style={cardStyle(colors, !!selected)} onDoubleClick={onDoubleClick}>
{renderContent()}
</div>
</>
);
};
export default FileNode;
@@ -0,0 +1,34 @@
import * as React from 'react';
import { useCallback, useMemo } from 'react';
import { Handle, NodeProps, NodeResizer } from '@xyflow/react';
import { LinkCanvasNode } from '@joplin/lib/services/whiteboard/jsoncanvas';
import { useWhiteboardContext } from '../WhiteboardContext';
import { WhiteboardNodeData } from '../canvasFlow';
import { whiteboardColors } from '../theme';
import { bodyStyle, cardStyle, handlePositions, headerStyle } from './sharedStyles';
const LinkNode = ({ data, selected }: NodeProps<{ id: string; type: 'wbLink'; data: WhiteboardNodeData; position: { x: number; y: number } }>) => {
const ctx = useWhiteboardContext();
const node = data.canvasNode as LinkCanvasNode;
const colors = useMemo(() => whiteboardColors(ctx.themeId), [ctx.themeId]);
const onDoubleClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
ctx.onOpenRef(node.url);
}, [ctx, node.url]);
return (
<>
<NodeResizer minWidth={80} minHeight={40} isVisible={!!selected} />
{handlePositions.map(({ id: hid, position }) => (
<Handle key={hid} type="source" position={position} id={hid} style={{ background: colors.handleColor }} />
))}
<div style={cardStyle(colors, !!selected)} onDoubleClick={onDoubleClick}>
<div style={headerStyle(colors)}>Link</div>
<div style={{ ...bodyStyle(colors), wordBreak: 'break-all' }}>{node.url}</div>
</div>
</>
);
};
export default LinkNode;
@@ -0,0 +1,228 @@
import * as React from 'react';
import { CSSProperties, KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Handle, NodeProps, NodeResizer } from '@xyflow/react';
import { MarkupLanguage } from '@joplin/renderer';
import { focus } from '@joplin/lib/utils/focusHandler';
import { _ } from '@joplin/lib/locale';
import { TextCanvasNode } from '@joplin/lib/services/whiteboard/jsoncanvas';
import { useWhiteboardContext, WhiteboardContextValue } from '../WhiteboardContext';
import { WhiteboardNodeData } from '../canvasFlow';
import useCheckboxToggle from '../useCheckboxToggle';
import { replaceStyle } from '../injectStyle';
import { SELECTION_COLOR, WhiteboardThemeColors, whiteboardColors } from '../theme';
import { cardStyle as baseCardStyle, handlePositions } from './sharedStyles';
// Text cards put content directly inside the card div (no inner body wrapper)
// so we extend the shared card style with text-content tokens.
const textCardStyle = (colors: WhiteboardThemeColors, selected: boolean): CSSProperties => ({
...baseCardStyle(colors, selected, 'auto'),
padding: 8,
fontSize: 13,
lineHeight: 1.4,
});
const editTextareaStyle = (textColor: string): CSSProperties => ({
width: '100%',
height: '100%',
border: 'none',
outline: 'none',
resize: 'none',
fontFamily: 'inherit',
fontSize: 13,
lineHeight: 1.4,
color: textColor,
background: 'transparent',
});
const renderedHtmlStyle: CSSProperties = {
flex: 1,
overflow: 'auto',
wordBreak: 'break-word',
fontSize: 13,
lineHeight: 1.4,
};
// Build the theme-dependent stylesheet for in-card markdown rendering. The
// renderer's HTML uses note-viewer CSS variables that don't exist outside the
// iframe, so we provide our own scoped overrides — and, crucially, keep them
// in sync with the active theme so dark mode looks dark.
const buildCardMdCss = (colors: WhiteboardThemeColors): string => `
.wb-card-md h1 { font-size: 16px; margin: 4px 0; }
.wb-card-md h2 { font-size: 15px; margin: 4px 0; }
.wb-card-md h3 { font-size: 14px; margin: 3px 0; }
.wb-card-md h4, .wb-card-md h5, .wb-card-md h6 { font-size: 13px; margin: 3px 0; }
.wb-card-md p { margin: 4px 0; }
.wb-card-md ul, .wb-card-md ol { margin: 4px 0; padding-left: 20px; }
.wb-card-md li { margin: 2px 0; }
.wb-card-md li.md-checkbox, .wb-card-md li.joplin-checkbox { list-style: none; margin-left: -20px; }
.wb-card-md li.md-checkbox input[type=checkbox] { margin-right: 6px; vertical-align: middle; }
.wb-card-md .checkbox-wrapper { display: inline; }
.wb-card-md pre { margin: 4px 0; padding: 6px; font-size: 12px; background: ${colors.codeBackground}; border: 1px solid ${colors.codeBorder}; border-radius: 4px; overflow: auto; color: ${colors.codeColor}; }
.wb-card-md code { font-size: 12px; background: ${colors.codeBackground}; color: ${colors.codeColor}; padding: 1px 4px; border-radius: 3px; }
.wb-card-md pre code { background: transparent; padding: 0; border: none; }
.wb-card-md blockquote { margin: 4px 0; padding-left: 8px; border-left: 3px solid ${colors.blockquoteBorder}; color: ${colors.blockquoteColor}; }
.wb-card-md img { max-width: 100%; height: auto; }
.wb-card-md table { font-size: 12px; border-collapse: collapse; }
.wb-card-md th, .wb-card-md td { padding: 2px 6px; border: 1px solid ${colors.tableBorder}; }
.wb-card-md hr { margin: 6px 0; border: none; border-top: 1px solid ${colors.dividerColor}; }
.wb-card-md a { color: ${colors.linkColor || SELECTION_COLOR}; }
`;
const useRenderedMarkdown = (md: string, ctx: WhiteboardContextValue) => {
const [html, setHtml] = useState<string>('');
useEffect(() => {
let cancelled = false;
if (!md) {
setHtml('');
return undefined;
}
void (async () => {
try {
const result = await ctx.markupToHtml(MarkupLanguage.Markdown, md, {
resourceInfos: ctx.resourceInfos,
});
if (!cancelled) setHtml(result?.html ?? '');
} catch {
if (!cancelled) setHtml('');
}
})();
return () => { cancelled = true; };
}, [md, ctx]);
return html;
};
const TextNode = ({ data, selected, id }: NodeProps<{ id: string; type: 'wbText'; data: WhiteboardNodeData; position: { x: number; y: number } }>) => {
const ctx = useWhiteboardContext();
const node = data.canvasNode as TextCanvasNode;
const colors = useMemo(() => whiteboardColors(ctx.themeId), [ctx.themeId]);
// Re-inject the in-card markdown stylesheet whenever the theme changes
// so dark mode swaps in dark code blocks, blockquote tints, etc.
useEffect(() => {
replaceStyle('wb-card-md-style', buildCardMdCss(colors));
}, [colors]);
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(node.text);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
useEffect(() => {
if (!editing) setDraft(node.text);
}, [editing, node.text]);
useEffect(() => {
if (editing && textareaRef.current) {
focus('WhiteboardTextNode::beginEdit', textareaRef.current);
textareaRef.current.select();
}
}, [editing]);
const html = useRenderedMarkdown(node.text, ctx);
const beginEdit = useCallback(() => {
setDraft(node.text);
setEditing(true);
}, [node.text]);
const commit = useCallback(() => {
if (!editing) return;
if (draft !== node.text) ctx.onUpdateNode(id, { text: draft });
setEditing(false);
}, [editing, draft, node.text, ctx, id]);
const cancel = useCallback(() => setEditing(false), []);
const onKeyDown = useCallback((e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Escape') {
e.preventDefault();
cancel();
} else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
commit();
}
}, [commit, cancel]);
const onDoubleClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
beginEdit();
}, [beginEdit]);
// Keyboard equivalent of double-click: when the card is focused (React
// Flow makes nodes focusable for tab navigation), Enter or F2 opens the
// editor. Skip when already editing — the textarea has its own handler.
const onCardKeyDown = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
if (editing) return;
if (e.key === 'Enter' || e.key === 'F2') {
e.preventDefault();
e.stopPropagation();
beginEdit();
}
}, [editing, beginEdit]);
const onPromote = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
ctx.onPromoteTextNode(id);
}, [ctx, id]);
const onCheckboxToggleBody = useCallback((newBody: string) => {
ctx.onUpdateNode(id, { text: newBody });
}, [ctx, id]);
const checkboxRef = useCheckboxToggle({
body: node.text,
onChange: onCheckboxToggleBody,
});
return (
<>
<NodeResizer minWidth={80} minHeight={40} isVisible={selected && !editing} />
{handlePositions.map(({ id: hid, position }) => (
<Handle key={hid} type="source" position={position} id={hid} style={{ background: colors.handleColor }} />
))}
<div style={textCardStyle(colors, !!selected)} onDoubleClick={onDoubleClick} onKeyDown={onCardKeyDown}>
{editing ? (
<textarea
ref={textareaRef}
style={editTextareaStyle(colors.textColor)}
value={draft}
onChange={e => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={onKeyDown}
className="nodrag"
/>
) : (
node.text
? <div ref={checkboxRef} className="wb-card-md" style={renderedHtmlStyle} dangerouslySetInnerHTML={{ __html: html }} />
: <div style={{ color: colors.mutedColor }}>{_('(empty — double-click to edit)')}</div>
)}
</div>
{selected && !editing && node.text ? (
<button
type="button"
onClick={onPromote}
className="nodrag"
title={_('Convert this card into a Joplin note')}
style={{
position: 'absolute',
top: -10,
right: -10,
padding: '2px 8px',
fontSize: 11,
border: `1px solid ${SELECTION_COLOR}`,
borderRadius: 10,
background: colors.cardBackground,
color: SELECTION_COLOR,
cursor: 'pointer',
zIndex: 5,
}}
>
{_('Promote to note')}
</button>
) : null}
</>
);
};
export default TextNode;
@@ -0,0 +1,53 @@
import { CSSProperties } from 'react';
import { Position } from '@xyflow/react';
import { WhiteboardThemeColors } from '../theme';
// Common card styling shared by Text/File/Link nodes. The `overflow` field
// varies between cards (auto for scrollable text, hidden for media/link
// previews) so it's set per-call.
export const cardStyle = (
colors: WhiteboardThemeColors,
selected: boolean,
overflow: CSSProperties['overflow'] = 'hidden',
): CSSProperties => ({
width: '100%',
height: '100%',
border: selected ? `2px solid ${colors.cardBorderSelected}` : `1px solid ${colors.cardBorder}`,
borderRadius: 6,
background: colors.cardBackground,
overflow,
boxShadow: selected ? colors.cardShadowSelected : colors.cardShadow,
boxSizing: 'border-box',
display: 'flex',
flexDirection: 'column',
color: colors.textColor,
});
export const headerStyle = (colors: WhiteboardThemeColors): CSSProperties => ({
fontSize: 11,
color: colors.headerColor,
padding: '4px 8px',
borderBottom: `1px solid ${colors.dividerColor}`,
textTransform: 'uppercase',
letterSpacing: 0.5,
flexShrink: 0,
});
export const bodyStyle = (colors: WhiteboardThemeColors): CSSProperties => ({
flex: 1,
padding: 8,
overflow: 'auto',
wordBreak: 'break-word',
fontSize: 13,
lineHeight: 1.4,
color: colors.textColor,
});
// The four sides shared by all node types — used both for rendering source
// handles around the perimeter and for routing edges to the right anchor.
export const handlePositions: { id: string; position: Position }[] = [
{ id: 'top', position: Position.Top },
{ id: 'right', position: Position.Right },
{ id: 'bottom', position: Position.Bottom },
{ id: 'left', position: Position.Left },
];
@@ -0,0 +1,63 @@
import { themeStyle } from '@joplin/lib/theme';
// The blue accent for "selected" state is shared across selected cards and
// edges. Kept as our own constant rather than pulled from the theme so the
// selection cue stays consistent across light and dark modes.
export const SELECTION_COLOR = '#4a90e2';
export const SELECTION_SHADOW = 'rgba(74,144,226,0.25)';
export interface WhiteboardThemeColors {
// Card / panel surfaces.
cardBackground: string;
cardBorder: string;
cardBorderSelected: string;
cardShadow: string;
cardShadowSelected: string;
// Card text.
textColor: string;
mutedColor: string;
headerColor: string;
// Markdown content inside cards.
codeBackground: string;
codeColor: string;
codeBorder: string;
blockquoteBorder: string;
blockquoteColor: string;
tableBorder: string;
dividerColor: string;
linkColor: string;
// Surface itself (the canvas background) and React Flow handles.
surfaceBackground: string;
handleColor: string;
}
// Translate the active Joplin theme into the colour set our whiteboard uses.
export const whiteboardColors = (themeId: number): WhiteboardThemeColors => {
const theme = themeStyle(themeId);
return {
cardBackground: theme.backgroundColor,
cardBorder: theme.dividerColor,
cardBorderSelected: SELECTION_COLOR,
cardShadow: '0 1px 3px rgba(0,0,0,0.08)',
cardShadowSelected: `0 4px 12px ${SELECTION_SHADOW}`,
textColor: theme.color,
mutedColor: theme.colorFaded || theme.color3 || theme.color,
headerColor: theme.colorFaded || theme.color3 || theme.color,
codeBackground: theme.codeBackgroundColor,
codeColor: theme.codeColor,
codeBorder: theme.codeBorderColor,
blockquoteBorder: theme.dividerColor,
blockquoteColor: theme.colorFaded || theme.color,
tableBorder: theme.dividerColor,
dividerColor: theme.dividerColor,
linkColor: theme.urlColor,
surfaceBackground: theme.backgroundColor3 || theme.backgroundColor,
handleColor: theme.colorFaded || '#888',
};
};
@@ -0,0 +1,53 @@
import { flipNthCheckbox } from './useCheckboxToggle';
// Joplin's renderer only converts `- [ ]` / `- [x]` items inside bullet lists
// into clickable checkboxes — `*`, `+`, and numbered list markers are ignored.
// `flipNthCheckbox` must count exactly the same subset so the Nth rendered
// checkbox maps to the Nth source-text checkbox.
describe('flipNthCheckbox', () => {
test('flips an unchecked box to checked', () => {
expect(flipNthCheckbox('- [ ] todo', 0)).toBe('- [x] todo');
});
test('flips a checked box back to unchecked', () => {
expect(flipNthCheckbox('- [x] done', 0)).toBe('- [ ] done');
expect(flipNthCheckbox('- [X] done', 0)).toBe('- [ ] done');
});
test('targets the Nth checkbox by zero-based index', () => {
const body = '- [ ] one\n- [ ] two\n- [ ] three';
expect(flipNthCheckbox(body, 0)).toBe('- [x] one\n- [ ] two\n- [ ] three');
expect(flipNthCheckbox(body, 1)).toBe('- [ ] one\n- [x] two\n- [ ] three');
expect(flipNthCheckbox(body, 2)).toBe('- [ ] one\n- [ ] two\n- [x] three');
});
test('returns null when index is out of range', () => {
expect(flipNthCheckbox('- [ ] only one', 1)).toBeNull();
expect(flipNthCheckbox('no checkboxes here', 0)).toBeNull();
expect(flipNthCheckbox('', 0)).toBeNull();
});
test('ignores list markers other than `-` to match the renderer', () => {
// `*`, `+`, and numbered markers are not turned into checkboxes by
// Joplin's renderer, so they must not affect the index either.
const body = '* [ ] starred\n+ [ ] plused\n1. [ ] numbered\n- [ ] real';
// The only "real" checkbox is index 0 — it's the dash-prefixed one.
expect(flipNthCheckbox(body, 0)).toBe('* [ ] starred\n+ [ ] plused\n1. [ ] numbered\n- [x] real');
expect(flipNthCheckbox(body, 1)).toBeNull();
});
test('ignores `[ ]` not preceded by a list marker', () => {
expect(flipNthCheckbox('Standalone [ ] not a checkbox', 0)).toBeNull();
});
test('handles Windows line endings', () => {
const body = '- [ ] one\r\n- [ ] two';
expect(flipNthCheckbox(body, 1)).toBe('- [ ] one\r\n- [x] two');
});
test('preserves indentation and the surrounding text', () => {
const body = 'Intro paragraph.\n\n - [ ] indented\n - [x] also indented\n\nOutro.';
expect(flipNthCheckbox(body, 1)).toBe('Intro paragraph.\n\n - [ ] indented\n - [ ] also indented\n\nOutro.');
});
});
@@ -0,0 +1,117 @@
import { useCallback, useEffect, useRef } from 'react';
// Joplin's renderer emits `<input type="checkbox">` elements for `- [ ]` /
// `- [x]` markdown syntax, with an inline onclick that calls
// `ipcProxySendToHost(...)` — that handler doesn't exist outside the note
// viewer iframe, so clicks are no-ops in our card context.
//
// This hook returns a callback ref. Attach it to the container that holds
// the rendered HTML (`<div ref={checkboxRef} dangerouslySetInnerHTML={...} />`).
// On every change to that container's children, the hook rewires checkboxes
// inside it: it strips the broken inline onclick, makes them enabled, and
// installs a click listener that flips the corresponding `[ ]` / `[x]` in
// the source markdown by index.
interface Options {
body: string;
onChange: (newBody: string)=> void;
}
// Matches a markdown task-list checkbox. The capture group is the inner
// character (' ' or 'x' / 'X'). Joplin's renderer only treats `- [ ]` /
// `- [x]` (dash-prefixed list items) as checkboxes — `*` and `+` markers and
// numbered lists are ignored — so we match exactly that subset, including
// Windows line endings.
const checkboxRegex = /(?<=(?:^|\r?\n)[ \t]*-[ \t]+)\[([ xX])\]/g;
// Exported for tests. Returns the body with the Nth `- [ ]` / `- [x]`
// flipped, or null if there's no Nth checkbox.
export const flipNthCheckbox = (body: string, index: number): string | null => {
let count = 0;
let result = '';
let lastIndex = 0;
let mutated = false;
const regex = new RegExp(checkboxRegex.source, 'g');
let match: RegExpExecArray | null;
while ((match = regex.exec(body)) !== null) {
if (count === index) {
const newChar = match[1] === ' ' ? 'x' : ' ';
result += `${body.slice(lastIndex, match.index)}[${newChar}]`;
lastIndex = match.index + match[0].length;
mutated = true;
break;
}
count++;
}
if (!mutated) return null;
return result + body.slice(lastIndex);
};
const useCheckboxToggle = ({ body, onChange }: Options) => {
const bodyRef = useRef(body);
bodyRef.current = body;
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
const observerRef = useRef<MutationObserver | null>(null);
// Track which checkbox elements have already been wired up. WeakSet so
// removed elements can be GC'd without leaking entries.
const wiredRef = useRef<WeakSet<HTMLInputElement>>(new WeakSet());
const wireUp = useCallback((root: HTMLElement) => {
const checkboxes = root.querySelectorAll<HTMLInputElement>('input[type=checkbox]');
for (const cb of Array.from(checkboxes)) {
if (wiredRef.current.has(cb)) continue;
wiredRef.current.add(cb);
cb.disabled = false;
cb.removeAttribute('disabled');
// Strip the renderer's inline onclick — it tries to call
// ipcProxySendToHost, which we don't expose in this context.
cb.removeAttribute('onclick');
(cb as HTMLInputElement & { onclick: unknown }).onclick = null;
cb.addEventListener('click', (e) => {
e.stopPropagation();
// Recompute the checkbox's current position at click time —
// the wire-time index goes stale after DOM insertions or
// removals (e.g. an external edit added/removed a list item
// before this one).
const current = Array.from(root.querySelectorAll<HTMLInputElement>('input[type=checkbox]'));
const currentIndex = current.indexOf(cb);
if (currentIndex < 0) return;
const next = flipNthCheckbox(bodyRef.current, currentIndex);
if (next !== null) onChangeRef.current(next);
});
cb.addEventListener('mousedown', (e) => e.stopPropagation());
}
}, []);
// Callback ref: fires whenever the underlying element changes (mount,
// unmount, replacement). Using a useCallback identity-stable ref means
// React only invokes it when the element actually changes.
const refCallback = useCallback((el: HTMLDivElement | null) => {
// Tear down previous observer.
if (observerRef.current) {
observerRef.current.disconnect();
observerRef.current = null;
}
if (!el) return;
wireUp(el);
const observer = new MutationObserver(() => wireUp(el));
observer.observe(el, { childList: true, subtree: true });
observerRef.current = observer;
}, [wireUp]);
// Clean up on unmount.
useEffect(() => {
return () => {
if (observerRef.current) {
observerRef.current.disconnect();
observerRef.current = null;
}
};
}, []);
return refCallback;
};
export default useCheckboxToggle;
@@ -42,6 +42,8 @@ import ItemChange from '@joplin/lib/models/ItemChange';
import PlainEditor from './NoteBody/PlainEditor/PlainEditor';
import CodeMirror6 from './NoteBody/CodeMirror/v6/CodeMirror';
import CodeMirror5 from './NoteBody/CodeMirror/v5/CodeMirror';
import WhiteboardEditor from './NoteBody/WhiteboardEditor/WhiteboardEditor';
import { hasWhiteboardFence } from '@joplin/lib/services/whiteboard/parse';
import { openItemById } from './utils/contextMenu';
import { MarkupLanguage } from '@joplin/renderer';
import useScrollWhenReadyOptions from './utils/useScrollWhenReadyOptions';
@@ -479,7 +481,23 @@ function NoteEditorContent(props: NoteEditorProps) {
let editor = null;
if (builtInEditorVisible) {
const noteHasWhiteboardFence = markupLanguage === MarkupLanguage.Markdown
&& hasWhiteboardFence(formNote.body);
const useWhiteboardEditor = builtInEditorVisible
&& noteHasWhiteboardFence
&& !props.whiteboardForceMarkdown?.[formNote.id];
// Mirror "active note is a whiteboard" to redux so the NoteToolbar can
// show the editor toggle. We can't compute this from the redux note list
// because note bodies aren't in the preview fields.
useEffect(() => {
props.dispatch({ type: 'WHITEBOARD_ACTIVE_NOTE_SET', value: noteHasWhiteboardFence });
}, [noteHasWhiteboardFence, props.dispatch]);
if (useWhiteboardEditor) {
editor = <WhiteboardEditor {...editorProps}/>;
} else if (builtInEditorVisible) {
if (props.bodyEditor === 'TinyMCE') {
editor = <TinyMCE {...editorProps}/>;
} else if (props.bodyEditor === 'PlainText') {
@@ -769,6 +787,7 @@ const mapStateToProps = (state: AppState, ownProps: ConnectProps) => {
enableHtmlToMarkdownBanner: state.settings['editor.enableHtmlToMarkdownBanner'],
enableInEditorRendering: state.settings['editor.inlineRendering'],
showNoteLinkIcon: state.settings['notes.showNoteLinkIcon'],
whiteboardForceMarkdown: windowState.whiteboardForceMarkdown ?? {},
};
};
@@ -183,6 +183,31 @@ const declarations: CommandDeclaration[] = [
{
name: 'viewer.focus',
},
{
name: 'editor.textTable',
label: () => _('Insert table'),
iconName: 'fas fa-table',
},
{
name: 'editor.tableAddRow',
label: () => _('Table: Add row'),
iconName: 'fas fa-plus',
},
{
name: 'editor.tableAddColumn',
label: () => _('Table: Add column'),
iconName: 'fas fa-columns',
},
{
name: 'editor.tableDeleteRow',
label: () => _('Table: Delete row'),
iconName: 'fas fa-minus',
},
{
name: 'editor.tableDeleteColumn',
label: () => _('Table: Delete column'),
iconName: 'fas fa-times',
},
];
export default declarations;
@@ -76,6 +76,7 @@ export interface NoteEditorProps {
startupPluginsLoaded: boolean;
enableHtmlToMarkdownBanner: boolean;
showNoteLinkIcon: boolean;
whiteboardForceMarkdown: Record<string, boolean>;
}
export interface NoteBodyEditorRef {
@@ -8,7 +8,8 @@ import { connect } from 'react-redux';
import { buildStyle } from '@joplin/lib/theme';
import { _ } from '@joplin/lib/locale';
import getActivePluginEditorView from '@joplin/lib/services/plugins/utils/getActivePluginEditorView';
import { AppState } from '../../app.reducer';
import { stateUtils } from '@joplin/lib/reducer';
import { AppState, AppWindowState } from '../../app.reducer';
interface NoteToolbarProps {
themeId: number;
@@ -51,6 +52,7 @@ const mapStateToProps = (state: AppState, ownProps: ConnectProps) => {
const whenClauseContext = stateToWhenClauseContext(state, { windowId: ownProps.windowId });
const { editorPlugin } = getActivePluginEditorView(state.pluginService.plugins, ownProps.windowId);
const windowState = stateUtils.windowStateById(state, ownProps.windowId) as AppWindowState;
const commands = [
'showSpellCheckerMenu',
@@ -59,7 +61,10 @@ const mapStateToProps = (state: AppState, ownProps: ConnectProps) => {
'showNoteProperties',
];
if (editorPlugin) commands.push('toggleEditorPlugin');
// `toggleEditorPlugin` shows for plugin editors; we extend it to also
// toggle the core whiteboard editor on whiteboard notes (see the command's
// runtime). The button is the same eye icon either way.
if (editorPlugin || windowState.activeNoteIsWhiteboard) commands.push('toggleEditorPlugin');
return {
toolbarButtonInfos: toolbarButtonUtils.commandsToToolbarButtons(commands
+56 -10
View File
@@ -7,7 +7,7 @@ import { themeStyle } from '@joplin/lib/theme';
import bridge from '../services/bridge';
import dialogs from './dialogs';
import { Profile, ProfileConfig } from '@joplin/lib/services/profileConfig/types';
import { deleteProfileById, saveProfileConfig } from '@joplin/lib/services/profileConfig';
import { deleteProfileById, isSubProfile, saveProfileConfig } from '@joplin/lib/services/profileConfig';
import Setting from '@joplin/lib/models/Setting';
import shim from '@joplin/lib/shim';
import Logger from '@joplin/utils/Logger';
@@ -150,18 +150,64 @@ const ProfileEditorComponent: React.FC<Props> = props => {
});
if (!ok) return;
const subProfile = isSubProfile(profile);
const rootDir = Setting.value('rootProfileDir');
const profileDir = `${rootDir}/profile-${profile.id}`;
try {
await shim.fsDriver().remove(profileDir);
logger.info('Deleted profile directory: ', profileDir);
} catch (error) {
logger.error('Error deleting profile directory: ', error);
bridge().showErrorMessageBox(error.message);
// Deleting the default profile must be handled differently. We can't delete the whole directory because it contains other profiles and global settings
if (subProfile) {
const profileDir = `${rootDir}/profile-${profile.id}`;
try {
await shim.fsDriver().remove(profileDir);
logger.info('Deleted profile directory: ', profileDir);
} catch (error) {
logger.error('Error deleting profile directory: ', error);
bridge().showErrorMessageBox(error.message);
}
await saveNewProfileConfig(() => deleteProfileById(profileConfig, profile.id));
} else {
const dirsToDelete = ['cache', 'resources', 'tmp'];
const filesToDelete = ['database.sqlite', 'log.txt', 'keymap-desktop.json'];
// Reset settings for the default profile, but retain global settings
try {
await Setting.resetDefaultProfileSettings();
} catch (error) {
// If the first stage fails, nothing has happened, so throw an error. But if there is a failure in later steps, ignore errors but log them
logger.error('Error deleting the default profile: ', error);
bridge().showErrorMessageBox(error.message);
return;
}
// Delete directories
for (const dir of dirsToDelete) {
const fullPath = `${rootDir}/${dir}`;
try {
if (await shim.fsDriver().exists(fullPath)) {
await shim.fsDriver().remove(fullPath);
logger.info('Deleted directory: ', fullPath);
}
} catch (error) {
logger.error('Error deleting directory: ', fullPath, error);
}
}
// Delete files
for (const file of filesToDelete) {
const fullPath = `${rootDir}/${file}`;
try {
if (await shim.fsDriver().exists(fullPath)) {
await shim.fsDriver().unlink(fullPath);
logger.info('Deleted file: ', fullPath);
}
} catch (error) {
logger.error('Error deleting file: ', fullPath, error);
}
}
bridge().showMessageBox(_('The default profile has been reset.'));
}
await saveNewProfileConfig(() => deleteProfileById(profileConfig, profile.id));
};
return (
@@ -0,0 +1,97 @@
import CommandService, { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
import { _ } from '@joplin/lib/locale';
import { ModelType } from '@joplin/lib/BaseModel';
import Note from '@joplin/lib/models/Note';
import ItemChange from '@joplin/lib/models/ItemChange';
import Logger from '@joplin/utils/Logger';
import { Mode } from '../../../plugins/GotoAnything';
import { GotoAnythingOptions, UiType } from './gotoAnything';
import { parseWhiteboard } from '@joplin/lib/services/whiteboard/parse';
import { serializeWhiteboard } from '@joplin/lib/services/whiteboard/serialize';
import { CanvasNode } from '@joplin/lib/services/whiteboard/jsoncanvas';
import generateId from '@joplin/lib/services/whiteboard/generateId';
const logger = Logger.create('addNoteToWhiteboard');
export const declaration: CommandDeclaration = {
name: 'addNoteToWhiteboard',
label: () => _('Add note to whiteboard...'),
};
// Adds a note (chosen via Goto Anything) as a card on the currently open
// whiteboard. The whiteboard is the currently selected note — the command is
// only enabled when that note contains a jsoncanvas fence.
export const runtime = (): CommandRuntime => {
return {
execute: async (context: CommandContext) => {
const targetId = context.state.selectedNoteIds?.[0];
if (!targetId) return;
// Quick gate before opening the picker — if the active note isn't
// a whiteboard, bail out without disturbing the user.
const initial = await Note.load(targetId);
if (!initial) return;
if (!parseWhiteboard(initial.body || '').hasCanvas) {
logger.warn('Active note is not a whiteboard:', targetId);
return;
}
const options: GotoAnythingOptions = { mode: Mode.TitleOnly };
const result = await CommandService.instance().execute('gotoAnything', UiType.ControlledApi, options);
if (!result) return;
if (result.type !== ModelType.Note) {
logger.warn('Selected item is not a note:', result);
return;
}
// Reload the note after the picker resolves: between opening the
// picker and now, the whiteboard editor (or a sync) may have
// written a newer body, and we want to append onto the freshest
// persisted state — not the snapshot we read before the prompt.
const fresh = await Note.load(targetId);
if (!fresh) return;
const parsed = parseWhiteboard(fresh.body || '');
if (!parsed.hasCanvas) {
logger.warn('Active note is no longer a whiteboard:', targetId);
return;
}
// Place the new card near the centre of the existing layout, with a
// small offset for each subsequent add so cards don't stack exactly.
const xs = parsed.canvas.nodes.map(n => n.x + n.width / 2);
const ys = parsed.canvas.nodes.map(n => n.y + n.height / 2);
const cx = xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : 0;
const cy = ys.length ? ys.reduce((a, b) => a + b, 0) / ys.length : 0;
const offset = (parsed.canvas.nodes.length % 8) * 24;
const newNode: CanvasNode = {
id: generateId(),
type: 'file',
x: cx - 120 + offset,
y: cy - 80 + offset,
width: 240,
height: 160,
file: `:/${result.item.id}`,
};
const nextCanvas = {
...parsed.canvas,
nodes: [...parsed.canvas.nodes, newNode],
};
const newBody = serializeWhiteboard(fresh.body || '', nextCanvas);
// Pass user_updated_time so the save layer can detect concurrent
// edits (the whiteboard editor's own debounced save, or a sync
// write between Note.load above and Note.save here). Mirrors the
// linked-note write path in FileNode.tsx.
await Note.save(
{
id: targetId,
body: newBody,
...(fresh.user_updated_time ? { user_updated_time: fresh.user_updated_time } : {}),
},
{ changeSource: ItemChange.SOURCE_UNSPECIFIED },
);
},
enabledCondition: 'oneNoteSelected && activeNoteIsWhiteboard && !noteIsReadOnly',
};
};
@@ -1,4 +1,5 @@
// AUTO-GENERATED using `gulp buildScriptIndexes`
import * as addNoteToWhiteboard from './addNoteToWhiteboard';
import * as addProfile from './addProfile';
import * as commandPalette from './commandPalette';
import * as deleteFolder from './deleteFolder';
@@ -16,6 +17,7 @@ import * as newFolder from './newFolder';
import * as newNote from './newNote';
import * as newSubFolder from './newSubFolder';
import * as newTodo from './newTodo';
import * as newWhiteboard from './newWhiteboard';
import * as openFolder from './openFolder';
import * as openFolderDialog from './openFolderDialog';
import * as openItem from './openItem';
@@ -48,8 +50,10 @@ import * as toggleNotesSortOrderReverse from './toggleNotesSortOrderReverse';
import * as togglePerFolderSortOrder from './togglePerFolderSortOrder';
import * as toggleSideBar from './toggleSideBar';
import * as toggleVisiblePanes from './toggleVisiblePanes';
import * as toggleWhiteboardEditor from './toggleWhiteboardEditor';
const index: any[] = [
addNoteToWhiteboard,
addProfile,
commandPalette,
deleteFolder,
@@ -67,6 +71,7 @@ const index: any[] = [
newNote,
newSubFolder,
newTodo,
newWhiteboard,
openFolder,
openFolderDialog,
openItem,
@@ -99,6 +104,7 @@ const index: any[] = [
togglePerFolderSortOrder,
toggleSideBar,
toggleVisiblePanes,
toggleWhiteboardEditor,
];
export default index;
@@ -0,0 +1,38 @@
import { runtime } from './newNote';
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
import Note from '@joplin/lib/models/Note';
import Folder from '@joplin/lib/models/Folder';
import Setting from '@joplin/lib/models/Setting';
describe('newNote', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
});
test.each([
[null, null],
['order', true],
['order', false],
])('should create a new note', async (sortOrderField: string, sortOrderReverse: boolean) => {
// The command needs an active folder ID.
const activeFolder = await Folder.save({ title: 'folder' });
const initialNote = await Note.save({ title: 'test', parent_id: activeFolder.id });
Setting.setValue('activeFolderId', activeFolder.id);
Setting.setValue('notes.sortOrder.field', sortOrderField);
Setting.setValue('notes.sortOrder.reverse', sortOrderReverse);
await runtime().execute(null, 'test note', true);
// Correct note should have been created
const newNote = (await Note.loadByField('body', 'test note'));
expect(newNote.body).toEqual('test note');
expect(newNote.parent_id).toEqual(activeFolder.id);
if (sortOrderField === 'order' && !!sortOrderReverse) {
expect(newNote.order).toBeGreaterThanOrEqual(initialNote.order + Note.defaultIntevalBetweenNotes);
} else if (sortOrderField === 'order' && !sortOrderReverse) {
expect(newNote.order).toBeLessThanOrEqual(initialNote.order - Note.defaultIntevalBetweenNotes);
} else {
expect(newNote.order).toEqual(0);
}
});
});
@@ -2,9 +2,57 @@ import { utils, CommandRuntime, CommandDeclaration, CommandContext } from '@jopl
import { _ } from '@joplin/lib/locale';
import Note from '@joplin/lib/models/Note';
import Folder from '@joplin/lib/models/Folder';
import Setting from '@joplin/lib/models/Setting';
import { NoteEntity } from '@joplin/lib/services/database/types';
export const newNoteEnabledConditions = 'oneFolderSelected && selectedFolderIsValid && !inConflictFolder && !folderIsReadOnly && !folderIsTrash';
export interface CreateNoteOptions {
body?: string;
title?: string;
isTodo?: boolean;
updateGeolocation?: boolean;
}
// Shared helper used by both `newNote` and any sibling commands that create
// a fresh, provisional note in the currently-active folder (e.g. the
// whiteboard "New whiteboard" command). Returns null if there's no valid
// active folder. Caller is responsible for telling the user the command
// was a no-op in that case (it's already gated by `newNoteEnabledConditions`
// in practice).
export const createNoteInActiveFolder = async (options: CreateNoteOptions = {}): Promise<NoteEntity | null> => {
const folder = await Folder.getValidActiveFolder();
if (!folder) return null;
const defaultValues = Note.previewFieldsWithDefaultValues({ includeTimestamps: false });
let order;
if (Setting.value('notes.sortOrder.field') === 'order') {
order = await Note.getNextOrderValue(folder.id);
}
const note = await Note.save({
...defaultValues,
parent_id: folder.id,
is_todo: options.isTodo ? 1 : 0,
body: options.body ?? '',
...(options.title ? { title: options.title } : {}),
...(order !== undefined ? { order } : {}),
}, { provisional: true });
if (options.updateGeolocation !== false) {
void Note.updateGeolocation(note.id);
}
utils.store.dispatch({ type: 'NOTE_SELECT', id: note.id });
// Immediately sort the note list so that the new note is positioned
// correctly before scrolling to it.
utils.store.dispatch({ type: 'NOTE_SORT' });
return note;
};
export const declaration: CommandDeclaration = {
name: 'newNote',
label: () => _('New note'),
@@ -14,29 +62,7 @@ export const declaration: CommandDeclaration = {
export const runtime = (): CommandRuntime => {
return {
execute: async (_context: CommandContext, body = '', isTodo = false) => {
const folder = await Folder.getValidActiveFolder();
if (!folder) return;
const defaultValues = Note.previewFieldsWithDefaultValues({ includeTimestamps: false });
let newNote = { ...defaultValues, parent_id: folder.id,
is_todo: isTodo ? 1 : 0,
body: body };
newNote = await Note.save(newNote, { provisional: true });
void Note.updateGeolocation(newNote.id);
utils.store.dispatch({
type: 'NOTE_SELECT',
id: newNote.id,
});
// Immediately sort the note list so that the new note is positioned correctly before
// scrolling to it.
utils.store.dispatch({
type: 'NOTE_SORT',
});
await createNoteInActiveFolder({ body, isTodo });
},
enabledCondition: newNoteEnabledConditions,
};
@@ -0,0 +1,25 @@
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
import { _ } from '@joplin/lib/locale';
import { newWhiteboardBody } from '@joplin/lib/services/whiteboard/serialize';
import { createNoteInActiveFolder, newNoteEnabledConditions } from './newNote';
export const declaration: CommandDeclaration = {
name: 'newWhiteboard',
label: () => _('Create whiteboard'),
iconName: 'fa-th',
};
export const runtime = (): CommandRuntime => {
return {
execute: async (_context: CommandContext, title?: string) => {
await createNoteInActiveFolder({
title: title || _('Untitled whiteboard'),
body: newWhiteboardBody(),
// A whiteboard isn't a place-stamped capture; skip the
// reverse-geocode lookup that fires for ordinary new notes.
updateGeolocation: false,
});
},
enabledCondition: newNoteEnabledConditions,
};
};
@@ -0,0 +1,19 @@
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
import { _ } from '@joplin/lib/locale';
export const declaration: CommandDeclaration = {
name: 'toggleWhiteboardEditor',
label: () => _('Toggle whiteboard / Markdown view'),
iconName: 'fas fa-eye',
};
export const runtime = (): CommandRuntime => {
return {
execute: async (context: CommandContext, noteId?: string) => {
const id = noteId || context.state.selectedNoteIds?.[0];
if (!id) return;
context.dispatch({ type: 'WHITEBOARD_FORCE_MARKDOWN_TOGGLE', noteId: id });
},
enabledCondition: 'oneNoteSelected',
};
};
@@ -4,40 +4,6 @@ import CommandService from '@joplin/lib/services/CommandService';
import { _ } from '@joplin/lib/locale';
import StyledInput from '../../style/StyledInput';
const styled = require('styled-components').default;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
type StyleProps = any;
export const Root = styled.div`
position: relative;
display: flex;
width: 100%;
`;
export const SearchButton = styled.button`
position: absolute;
right: 0;
background: none;
border: none;
height: 100%;
opacity: ${(props: StyleProps) => props.disabled ? 0.5 : 1};
`;
export const SearchButtonIcon = styled.span`
font-size: ${(props: StyleProps) => props.theme.toolbarIconSize}px;
color: ${(props: StyleProps) => props.theme.color4};
`;
export const SearchInput = styled(StyledInput)`
padding-right: 20px;
flex: 1;
width: 10px;
&::-webkit-search-cancel-button {
display: none;
}
`;
interface Props {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
@@ -51,11 +17,13 @@ interface Props {
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
onKeyDown?: Function;
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
onSearchButtonClick: Function;
onSearchButtonClick: ()=> void;
searchStarted: boolean;
placeholder?: string;
disabled?: boolean;
inputClassName?: string;
'aria-controls'?: string;
iconButtonTabIndex?: number;
}
export interface OnChangeEvent {
@@ -71,10 +39,14 @@ export default function(props: Props) {
props.onChange({ value: event.currentTarget.value });
}, [props.onChange]);
const fieldClassName = ['field'];
if (props.inputClassName) fieldClassName.push(props.inputClassName);
return (
<Root>
<SearchInput
<div className='search-input'>
<StyledInput
ref={props.inputRef}
className={fieldClassName.join(' ')}
value={props.value}
type="search"
placeholder={props.placeholder || _('Search...')}
@@ -84,14 +56,19 @@ export default function(props: Props) {
onKeyDown={props.onKeyDown}
spellCheck={false}
disabled={props.disabled}
aria-label={props.placeholder || _('Search...')}
aria-controls={props['aria-controls']}
/>
<SearchButton
<button
type='button'
className='button'
aria-label={iconLabel}
disabled={props.disabled}
tabIndex={props.iconButtonTabIndex}
onClick={props.onSearchButtonClick}
>
<SearchButtonIcon className={iconName}/>
</SearchButton>
</Root>
<span className={`icon ${iconName}`}/>
</button>
</div>
);
}
@@ -0,0 +1,40 @@
.search-input {
position: relative;
display: flex;
width: 100%;
> .field {
padding-right: 20px;
flex: 1;
width: 10px;
&::placeholder {
opacity: 1;
}
&::-webkit-search-cancel-button {
display: none;
}
&.settings {
background-color: var(--joplin-background-color4);
}
}
> .button {
position: absolute;
right: 0;
background: none;
border: none;
height: 100%;
&:disabled {
opacity: 0.5;
}
> .icon {
font-size: var(--joplin-toolbar-icon-size);
color: var(--joplin-color4);
}
}
}
@@ -17,6 +17,9 @@ export default function() {
'newNote',
'newSubFolder',
'newTodo',
'newWhiteboard',
'toggleWhiteboardEditor',
'addNoteToWhiteboard',
'openProfileDirectory',
'print',
'setTags',
@@ -66,6 +69,10 @@ export default function() {
'editor.sortSelectedLines',
'editor.swapLineUp',
'editor.swapLineDown',
'editor.tableAddRow',
'editor.tableAddColumn',
'editor.tableDeleteRow',
'editor.tableDeleteColumn',
'linkToNote',
'exportDeletionLog',
'toggleSafeMode',
@@ -220,11 +220,15 @@ test.describe('main', () => {
await mainScreen.importHtmlDirectory(electronApp, join(__dirname, 'resources', 'html-import'));
const importedFolder = mainScreen.sidebar.container.getByText('html-import');
await importedFolder.click();
await mainScreen.noteList.focusContent(electronApp);
const importedNote1 = await mainScreen.noteList.getNoteItemByTitle('test-html-file-with-image');
await expect(importedNote1).toBeAttached();
const importedNote2 = await mainScreen.noteList.getNoteItemByTitle('test-html-file-2');
await expect(importedNote2).toBeAttached();
const importedNote1 = mainScreen.noteList.getNoteItemByTitle('test-html-file-with-image');
const importedNote2 = mainScreen.noteList.getNoteItemByTitle('test-html-file-2');
await expect.poll(async () => importedNote1.count(), { timeout: 60_000 }).toBeGreaterThan(0);
await expect.poll(async () => importedNote2.count(), { timeout: 60_000 }).toBeGreaterThan(0);
await expect(importedNote1).toBeVisible();
await expect(importedNote2).toBeVisible();
});
test('should import a single HTML file', async ({ mainWindow, electronApp }) => {
@@ -232,8 +236,10 @@ test.describe('main', () => {
await mainScreen.waitFor();
await mainScreen.importHtmlFile(electronApp, join(__dirname, 'resources', 'html-import', 'test-html-file-with-image.html'));
const importedNote = await mainScreen.noteList.getNoteItemByTitle('test-html-file-with-image');
await expect(importedNote).toBeAttached();
const importedNote = mainScreen.noteList.getNoteItemByTitle('test-html-file-with-image');
await expect.poll(async () => importedNote.count(), { timeout: 60_000 }).toBeGreaterThan(0);
await expect(importedNote).toBeVisible({ timeout: 60_000 });
});
});
@@ -26,6 +26,10 @@ export default class NoteList {
public async focusContent(electronApp: ElectronApplication) {
await activateMainMenuItem(electronApp, 'Note list', 'Focus');
await expect(this.container.locator(':focus')).toBeAttached();
// Wait for the list to have rendered a selected item before the caller
// tries to navigate with arrow keys. Without this, the aria-selected
// attribute may not be set yet on slow CI runners.
await expect(this.container.locator('[aria-selected="true"]')).toBeAttached();
}
// The resultant locator may fail to resolve if the item is not visible
@@ -34,6 +34,23 @@ const expectNoViolations = async (page: Page) => {
// random failure in CI.
await expect.poll(async () => {
const results = await scanner.analyze();
if (results.violations.length > 0) {
// Keep CI failures actionable with compact, structured rule/selector details.
const violationSummary = results.violations.map(violation => ({
rule: violation.id,
impact: violation.impact,
description: violation.description,
help: violation.help,
helpUrl: violation.helpUrl,
nodes: violation.nodes.map(node => ({
target: node.target,
failureSummary: node.failureSummary,
})),
}));
console.error('WCAG violations detected:');
console.error(JSON.stringify(violationSummary, null, 2));
}
return results.violations;
}).toEqual([]);
};
+3 -3
View File
@@ -286,18 +286,18 @@ Component-specific classes
padding-bottom: 20px;
}
.master-password-dialog .dialog-root, .enable-encryption-dialog .dialog-root {
.master-password-dialog .dialog-root, .enable-encryption-dialog .dialog-root, .disable-encryption-dialog .dialog-root {
min-width: 500px;
max-width: 600px;
}
.master-password-dialog .dialog-content, .enable-encryption-dialog .dialog-content {
.master-password-dialog .dialog-content, .enable-encryption-dialog .dialog-content, .disable-encryption-dialog .dialog-content {
background-color: var(--joplin-background-color3);
padding: 1em;
padding-bottom: 1px;
}
.master-password-dialog .current-password-wrapper, .enable-encryption-dialog .current-password-wrapper {
.master-password-dialog .current-password-wrapper, .enable-encryption-dialog .current-password-wrapper, .disable-encryption-dialog .current-password-wrapper {
display: flex;
flex-direction: row;
align-items: center;
+6 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@joplin/app-desktop",
"version": "3.6.10",
"version": "3.6.11",
"description": "Joplin for Desktop",
"main": "main.bundle.js",
"private": true,
@@ -140,7 +140,7 @@
"homepage": "https://github.com/laurent22/joplin#readme",
"devDependencies": {
"7zip-bin": "5.2.0",
"@axe-core/playwright": "4.11.0",
"@axe-core/playwright": "4.11.1",
"@electron/notarize": "2.5.0",
"@electron/rebuild": "3.7.2",
"@fortawesome/fontawesome-free": "5.15.4",
@@ -168,7 +168,7 @@
"color": "3.2.1",
"compare-versions": "6.1.1",
"debounce": "1.2.1",
"electron": "40.8.3",
"electron": "40.9.2",
"electron-builder": "24.13.3",
"electron-updater": "6.6.8",
"electron-window-state": "5.0.3",
@@ -208,13 +208,14 @@
"taboverride": "4.0.3",
"tesseract.js": "6.0.1",
"tinymce": "6.8.5",
"ts-jest": "29.4.1",
"ts-jest": "29.4.6",
"ts-node": "10.9.2",
"typescript": "5.8.3"
"typescript": "5.9.3"
},
"dependencies": {
"@electron/remote": "2.1.3",
"@joplin/onenote-converter": "~3.6",
"@xyflow/react": "12.10.2",
"fs-extra": "11.3.3",
"keytar": "7.9.0",
"node-fetch": "2.6.7",
@@ -29,6 +29,12 @@ export default defineConfig({
// The CI machines can sometimes be very slow. Increase per-test timeout in CI.
timeout: process.env.CI ? 70_000 : 60_000, // milliseconds
// Raise the default assertion timeout on CI — imports and React state updates
// can take longer on slow Ubuntu runners than the built-in 5 s default.
expect: {
timeout: process.env.CI ? 15_000 : 5_000,
},
// Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions.
use: {
// Base URL to use in actions like `await page.goto('/')`.
@@ -28,6 +28,7 @@ export default function stateToWhenClauseContext(state: AppState, options: WhenC
sidebarVisible: isMainWindow && !!state.mainLayout && layoutItemProp(state.mainLayout, 'sideBar', 'visible'),
noteListHasNotes: !!windowState.notes.length,
isAltInstance,
activeNoteIsWhiteboard: !!windowState.activeNoteIsWhiteboard,
// Deprecated
sideBarVisible: !!state.mainLayout && layoutItemProp(state.mainLayout, 'sideBar', 'visible'),
+1
View File
@@ -2,6 +2,7 @@
@use 'gui/EditFolderDialog/style.scss' as edit-folder-dialog;
@use 'gui/EncryptionConfigScreen/style.scss' as encryption-config-screen;
@use 'gui/PasswordInput/style.scss' as password-input;
@use 'gui/lib/SearchInput/style.scss' as search-input;
@use 'gui/JoplinCloudConfigScreen.scss' as joplin-cloud-config-screen;
@use 'gui/Dropdown/style.scss' as dropdown-control;
@use 'gui/ShareFolderDialog/style.scss' as share-folder-dialog;
+1 -1
View File
@@ -1 +1 @@
18
22
+2 -2
View File
@@ -83,8 +83,8 @@ android {
applicationId "net.cozic.joplin"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 2097805
versionName "3.6.17"
versionCode 2097806
versionName "3.6.18"
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
+18 -2
View File
@@ -10,20 +10,36 @@ describe('newNote', () => {
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
});
test('should create and navigate to a new note', async () => {
test.each([
[null, null],
['order', true],
['order', false],
])('should create and navigate to a new note', async (sortOrderField: string, sortOrderReverse: boolean) => {
const dispatchMock = jest.fn();
NavService.dispatch = dispatchMock;
// The command needs an active folder ID.
const activeFolder = await Folder.save({ title: 'folder' });
const initialNote = await Note.save({ title: 'test', parent_id: activeFolder.id });
Setting.setValue('activeFolderId', activeFolder.id);
Setting.setValue('notes.sortOrder.field', sortOrderField);
Setting.setValue('notes.sortOrder.reverse', sortOrderReverse);
await runtime().execute(null, 'test note', true);
expect(dispatchMock).toHaveBeenCalledTimes(1);
// Correct note should have been created
const noteId = dispatchMock.mock.lastCall[0].noteId;
expect(await Note.load(noteId)).toMatchObject({ body: 'test note', parent_id: activeFolder.id });
const newNote = await Note.load(noteId);
expect(newNote.body).toEqual('test note');
expect(newNote.parent_id).toEqual(activeFolder.id);
if (sortOrderField === 'order' && !!sortOrderReverse) {
expect(newNote.order).toBeGreaterThanOrEqual(initialNote.order + Note.defaultIntevalBetweenNotes);
} else if (sortOrderField === 'order' && !sortOrderReverse) {
expect(newNote.order).toBeLessThanOrEqual(initialNote.order - Note.defaultIntevalBetweenNotes);
} else {
expect(newNote.order).toBeLessThan(initialNote.order + Note.defaultIntevalBetweenNotes);
}
// Should have tried to navigate to the note.
expect(dispatchMock.mock.lastCall).toMatchObject([
+7
View File
@@ -3,6 +3,7 @@ import Logger from '@joplin/utils/Logger';
import goToNote, { GotoNoteOptions } from './util/goToNote';
import Note from '@joplin/lib/models/Note';
import Folder from '@joplin/lib/models/Folder';
import Setting from '@joplin/lib/models/Setting';
const logger = Logger.create('newNoteCommand');
@@ -19,10 +20,16 @@ export const runtime = (): CommandRuntime => {
return;
}
let order;
if (Setting.value('notes.sortOrder.field') === 'order') {
order = await Note.getNextOrderValue(folder.id);
}
const note = await Note.save({
body,
parent_id: folder.id,
is_todo: todo ? 1 : 0,
...(order !== undefined ? { order } : {}),
}, { provisional: true });
logger.info(`Navigating to note ${note.id}`);
@@ -65,6 +65,7 @@ interface Props {
plugins: PluginStates;
noteResources: ResourceInfos;
editorImageRendering: boolean;
editorTableEditing: boolean;
editorInlineRendering: boolean;
onScroll: OnScroll;
@@ -283,6 +284,7 @@ const useEditorSettings = (props: Props) => {
katexEnabled: Setting.value('markdown.plugin.katex'),
spellcheckEnabled: Setting.value('editor.mobile.spellcheckEnabled'),
inlineRenderingEnabled,
tableEditingEnabled: props.editorTableEditing,
imageRenderingEnabled: props.editorImageRendering,
language: props.markupLanguage === MarkupLanguage.Html ? EditorLanguageType.Html : EditorLanguageType.Markdown,
useExternalSearch: true,
@@ -301,7 +303,7 @@ const useEditorSettings = (props: Props) => {
indentWithTabs: true,
editorLabel: _('Markdown editor'),
}), [props.themeId, props.readOnly, props.markupLanguage, highlightActiveLine, inlineRenderingEnabled, props.editorImageRendering]);
}), [props.themeId, props.readOnly, props.markupLanguage, highlightActiveLine, inlineRenderingEnabled, props.editorImageRendering, props.editorTableEditing]);
return editorSettings;
};
@@ -512,6 +514,7 @@ export default connect((state: AppState) => {
return {
themeId: state.settings.theme,
editorInlineRendering: state.settings['editor.inlineRendering'],
editorTableEditing: state.settings['editor.tableEditing'],
editorImageRendering: state.settings['editor.imageRendering'],
};
}, null, null, { forwardRef: true })(NoteEditor);
@@ -12,7 +12,6 @@ const markdownEditorOnlyCommands = [
const richTextEditorOnlyCommands = [
EditorCommandType.InsertTable,
EditorCommandType.InsertCodeBlock,
].map(command => `editor.${command}`);
@@ -56,33 +56,4 @@ describe('deleteProfile', () => {
expect(await pathExists(resourceDir)).toBe(false);
expect(await pathExists(pluginDataDir)).toBe(false);
});
it('should refuse to delete the default profile', async () => {
const config: ProfileConfig = {
version: CurrentProfileVersion,
currentProfileId: 'test',
profiles: [
{
name: 'Testing',
id: DefaultProfileId,
},
{
name: 'Another test',
id: 'test',
},
],
};
try {
await deleteProfile({
profileConfig: config,
toDelete: config.profiles[0],
databaseDriver: new MockDatabaseDriver(),
});
expect('did not throw').toBe('threw');
} catch (error) {
expect(String(error)).toMatch(/The default profile cannot be deleted/);
}
});
});
@@ -2,10 +2,11 @@ import { Profile, ProfileConfig } from '@joplin/lib/services/profileConfig/types
import { getDatabaseName, getPluginDataDir, getResourceDir, saveProfileConfig } from '../../../services/profiles';
import { deleteProfileById, getCurrentProfile, isSubProfile } from '@joplin/lib/services/profileConfig';
import Setting from '@joplin/lib/models/Setting';
import shim from '@joplin/lib/shim';
import shim, { MessageBoxType } from '@joplin/lib/shim';
import Logger from '@joplin/utils/Logger';
import resolvePathWithinDir from '@joplin/lib/utils/resolvePathWithinDir';
import DatabaseDriver from '@joplin/lib/database-driver';
import { _ } from '@joplin/lib/locale';
const logger = Logger.create('deleteProfile');
@@ -17,14 +18,17 @@ interface DeleteProfileOptions {
const deleteProfile = async (options: DeleteProfileOptions) => {
logger.info('Deleting profile config', options.toDelete.id);
// This step also verifies that the to-be-deleted profile is not the default profile, etc.
const newConfig = deleteProfileById(options.profileConfig, options.toDelete.id);
// Save the profile config early. If the later deletion steps fail, this prevents the user from
// opening a partially-deleted profile:
await saveProfileConfig(newConfig);
if (options.toDelete.id === options.profileConfig.currentProfileId) throw new Error(_('The active profile cannot be deleted. Switch to a different profile and try again.'));
const subProfile = isSubProfile(options.toDelete);
if (!subProfile) throw new Error('Deleting a sub-profile is not supported');
// Deleting the default profile must be handled differently. We can't delete the whole directory because it contains other profiles and global settings
if (subProfile) {
const newConfig = deleteProfileById(options.profileConfig, options.toDelete.id);
// Save the profile config early. If the later deletion steps fail, this prevents the user from
// opening a partially-deleted profile. The default profile does not get deleted from the list,
// but the data will be cleared
await saveProfileConfig(newConfig);
}
// Retrieve and validate both the database name and resources directory
// **before** doing any deletion.
@@ -42,11 +46,38 @@ const deleteProfile = async (options: DeleteProfileOptions) => {
logger.warn('Failed to delete database: ', error, '. Was the profile initialized?');
}
logger.info('Deleting resources directory', resourcesDir);
await shim.fsDriver().remove(resourcesDir);
if (subProfile) {
logger.info('Deleting resources directory', resourcesDir);
await shim.fsDriver().remove(resourcesDir);
} else {
try {
const items = await shim.fsDriver().readDirStats(resourcesDir);
for (const item of items) {
if (item.isDirectory()) continue;
const fileName = item.path;
if (/^[a-f0-9]{32}\./.test(fileName)) {
const fullPath = `${resourcesDir}/${fileName}`;
try {
await shim.fsDriver().unlink(fullPath);
logger.info('Deleted resource file: ', fullPath);
} catch (error) {
logger.error('Error deleting resource file: ', fullPath, error);
}
}
}
} catch (error) {
logger.error('Error reading resources directory: ', resourcesDir, error);
}
}
logger.info('Deleting plugin data directory', pluginDataDir);
await shim.fsDriver().remove(pluginDataDir);
if (!subProfile) {
await shim.showMessageBox(_('The default profile has been reset.'), { type: MessageBoxType.Info });
}
};
export default deleteProfile;
@@ -70,7 +101,7 @@ const getTargetResourceDirectory = ({ toDelete: target }: DeleteProfileOptions)
// Add an extra check here to verify that deleting the other profile's resource directory
// doesn't also delete **the active** profile's resource directory. On mobile, the resources
// directory can sometimes contain other profile directories (e.g. in the case of the default profile).
if (resolvePathWithinDir(resourcesDir, Setting.value('resourceDir')) !== null) {
if (isSubProfile(target) && resolvePathWithinDir(resourcesDir, Setting.value('resourceDir')) !== null) {
throw new Error('Refusing to delete a directory that contains the active profile\'s resource directory.');
}
return resourcesDir;
@@ -79,7 +110,7 @@ const getTargetResourceDirectory = ({ toDelete: target }: DeleteProfileOptions)
const getTargetPluginDataDirectory = ({ toDelete: target }: DeleteProfileOptions) => {
const pluginDataDir = getPluginDataDir(target, isSubProfile(target));
if (resolvePathWithinDir(pluginDataDir, Setting.value('pluginDataDir')) !== null) {
if (isSubProfile(target) && resolvePathWithinDir(pluginDataDir, Setting.value('pluginDataDir')) !== null) {
throw new Error('Refusing to delete a directory that contains the active profile\'s plugin data directory.');
}
return pluginDataDir;
@@ -46,12 +46,18 @@ export default class PluginRunner extends BasePluginRunner {
return false;
});
// On native mobile, pass a file path so the WebView can load the
// script directly from the filesystem (avoids transferring the full
// script text across the React Native bridge). On web, file:// URLs
// are blocked by CSP so we pass the script text directly.
const scriptFilePath = plugin.scriptText ? '' : `${plugin.baseDir}/index.js`;
this.webviewRef.current.injectJS(`
pluginBackgroundPage.runPlugin(
${JSON.stringify(shim.injectedJs('pluginBackgroundPage'))},
${JSON.stringify(plugin.scriptText)},
${JSON.stringify(scriptFilePath)},
${JSON.stringify(messageChannelId)},
${JSON.stringify(plugin.id)},
${JSON.stringify(plugin.scriptText)},
);
`);
@@ -186,6 +186,7 @@ const PluginRunnerWebViewComponent: React.FC<Props> = props => {
html={html}
injectedJavaScript={injectedJs}
hasPluginScripts={true}
allowFileAccessFromJs={true}
onMessage={pluginRunner.onWebviewMessage}
onLoadEnd={onLoadEnd}
onLoadStart={onLoadStart}
@@ -26,14 +26,29 @@ export const stopPlugin = async (pluginId: string) => {
delete loadedPlugins[pluginId];
};
export const runPlugin = (
pluginBackgroundScript: string, pluginScript: string, messageChannelId: string, pluginId: string,
export const runPlugin = async (
pluginBackgroundScript: string, scriptFilePath: string, messageChannelId: string, pluginId: string, scriptText = '',
) => {
if (loadedPlugins[pluginId]) {
console.warn(`Plugin already running ${pluginId}`);
return;
}
// When scriptText is provided (web), use it directly. Otherwise load
// the plugin script from the filesystem (native mobile). We use
// XMLHttpRequest because fetch() doesn't support file:// URLs on
// Android WebView.
let pluginScript = scriptText;
if (!pluginScript) {
pluginScript = await new Promise<string>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', `file://${scriptFilePath}`, true);
xhr.onload = () => resolve(xhr.responseText);
xhr.onerror = () => reject(new Error(`Failed to load plugin script: ${scriptFilePath}`));
xhr.send();
});
}
const bodyHtml = '';
const initialJavaScript = `
"use strict";
@@ -14,6 +14,7 @@ import ScreenHeader from '../../ScreenHeader';
import { _ } from '@joplin/lib/locale';
import BaseScreenComponent from '../../base-screen';
import * as shared from '@joplin/lib/components/shared/config/config-shared';
import { shouldShowBySearch, hasNormalizedQuery } from '@joplin/lib/components/shared/config/config-search-text';
import SyncTargetRegistry from '@joplin/lib/SyncTargetRegistry';
import biometricAuthenticate from '../../biometrics/biometricAuthenticate';
import configScreenStyles, { ConfigScreenStyles } from './configScreenStyles';
@@ -48,6 +49,7 @@ interface ConfigScreenState {
changedSettingKeys: string[];
searchQuery: string;
searchSectionFilter: string|null;
searching: boolean;
fixingSearchIndex: boolean;
@@ -398,22 +400,7 @@ class ConfigScreenComponent extends BaseScreenComponent<ConfigScreenProps, Confi
}
const matchesSearchQuery = (relatedText: string|string[]) => {
let searchThrough;
if (Array.isArray(relatedText)) {
searchThrough = relatedText.join('\n');
} else {
searchThrough = relatedText;
}
searchThrough = searchThrough.toLocaleLowerCase();
const searchQuery = this.state.searchQuery.toLocaleLowerCase().trim();
const hasSearchMatches =
headerTitle.toLocaleLowerCase() === searchQuery
|| searchThrough.includes(searchQuery);
// Don't show results when the search input is empty
return this.state.searchQuery.length > 0 && hasSearchMatches;
return shouldShowBySearch(this.state.searchQuery, headerTitle, relatedText);
};
const addSettingComponent = (
@@ -421,7 +408,7 @@ class ConfigScreenComponent extends BaseScreenComponent<ConfigScreenProps, Confi
relatedText: string|string[],
settingMetadata?: { advanced?: boolean },
) => {
const hiddenBySearch = this.state.searching && !matchesSearchQuery(relatedText);
const hiddenBySearch = this.state.searching && hasNormalizedQuery(this.state.searchQuery) && !matchesSearchQuery(relatedText);
if (component && !hiddenBySearch) {
if (settingMetadata?.advanced) {
advancedSettingComps.push(component);
@@ -163,6 +163,7 @@ class LogScreenComponent extends BaseScreenComponent<Props, State> {
private async refreshLogEntries(showErrorsOnly: boolean = null) {
if (showErrorsOnly === null) showErrorsOnly = this.state.showErrorsOnly;
const prevShowErrorsOnly = this.state.showErrorsOnly;
const limit = 1000;
const logEntries = await this.getLogEntries(showErrorsOnly, limit);
@@ -171,7 +172,7 @@ class LogScreenComponent extends BaseScreenComponent<Props, State> {
logEntries: logEntries,
showErrorsOnly: showErrorsOnly,
}, () => {
if (this.state.filter !== undefined) {
if (this.state.filter !== undefined || prevShowErrorsOnly !== showErrorsOnly) {
this.logListRef_.current?.scrollToOffset({ offset: 0, animated: false });
}
});
@@ -204,7 +204,7 @@ class StatusScreenComponent extends BaseScreenComponent<Props, State> {
</View>
) : null;
const textComponent = text ? <Text style={style} role={textRole}>{text}</Text> : null;
const textComponent = text ? <Text style={style} role={textRole} numberOfLines={2} ellipsizeMode='tail'>{text}</Text> : null;
if (item.isDivider) {
return <View style={styles.divider} role='separator' key={item.key} />;
} else if (item.listItems) {
@@ -520,7 +520,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
CURRENT_PROJECT_VERSION = 153;
CURRENT_PROJECT_VERSION = 154;
DEVELOPMENT_TEAM = A9BXAFS6CT;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Joplin/Info.plist;
@@ -529,7 +529,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 13.6.4;
MARKETING_VERSION = 13.6.5;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@@ -555,7 +555,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
CURRENT_PROJECT_VERSION = 153;
CURRENT_PROJECT_VERSION = 154;
DEVELOPMENT_TEAM = A9BXAFS6CT;
INFOPLIST_FILE = Joplin/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
@@ -563,7 +563,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 13.6.4;
MARKETING_VERSION = 13.6.5;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@@ -758,7 +758,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 153;
CURRENT_PROJECT_VERSION = 154;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = A9BXAFS6CT;
GCC_C_LANGUAGE_STANDARD = gnu11;
@@ -769,7 +769,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 13.6.4;
MARKETING_VERSION = 13.6.5;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
OTHER_LDFLAGS = (
@@ -801,7 +801,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 153;
CURRENT_PROJECT_VERSION = 154;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = A9BXAFS6CT;
GCC_C_LANGUAGE_STANDARD = gnu11;
@@ -812,7 +812,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 13.6.4;
MARKETING_VERSION = 13.6.5;
MTL_FAST_MATH = YES;
OTHER_LDFLAGS = (
"$(inherited)",
+8 -8
View File
@@ -49,7 +49,7 @@
"crypto-browserify": "3.12.1",
"deprecated-react-native-prop-types": "5.0.0",
"events": "3.3.0",
"expo": "54.0.32",
"expo": "54.0.33",
"expo-audio": "1.1.1",
"expo-camera": "17.0.10",
"expo-image-manipulator": "14.0.8",
@@ -69,7 +69,7 @@
"react-native-image-picker": "8.2.1",
"react-native-localize": "3.6.1",
"react-native-modal-datetime-picker": "18.0.0",
"react-native-nitro-modules": "0.33.2",
"react-native-nitro-modules": "0.33.7",
"react-native-paper": "5.14.5",
"react-native-popup-menu": "0.17.0",
"react-native-quick-actions": "0.3.13",
@@ -78,9 +78,9 @@
"react-native-rsa-native": "2.0.5",
"react-native-safe-area-context": "5.6.2",
"react-native-securerandom": "1.0.1",
"react-native-share": "12.2.4",
"react-native-share": "12.2.5",
"react-native-sqlite-storage": "6.0.1",
"react-native-svg": "15.15.1",
"react-native-svg": "15.15.2",
"react-native-url-polyfill": "2.0.0",
"react-native-version-info": "1.1.1",
"react-native-webview": "13.16.0",
@@ -109,7 +109,7 @@
"@react-native-community/cli-platform-ios": "20.0.0",
"@react-native/babel-preset": "0.81.6",
"@react-native/metro-config": "0.81.6",
"@react-native/typescript-config": "0.81.6",
"@react-native/typescript-config": "0.83.1",
"@sqlite.org/sqlite-wasm": "3.46.0-build2",
"@testing-library/react-native": "13.2.0",
"@types/fs-extra": "11.0.4",
@@ -117,7 +117,7 @@
"@types/node": "18.19.130",
"@types/react": "19.1.10",
"@types/react-redux": "7.1.34",
"@types/serviceworker": "0.0.179",
"@types/serviceworker": "0.0.183",
"@types/tar-stream": "3.1.4",
"babel-jest": "29.7.0",
"babel-loader": "9.1.3",
@@ -140,10 +140,10 @@
"sharp": "0.34.5",
"sqlite3": "5.1.6",
"timers-browserify": "2.0.12",
"ts-jest": "29.4.1",
"ts-jest": "29.4.6",
"ts-loader": "9.5.4",
"ts-node": "10.9.2",
"typescript": "5.8.3",
"typescript": "5.9.3",
"url-loader": "4.1.1",
"webpack": "5.97.1",
"webpack-cli": "5.1.4",
+11 -3
View File
@@ -153,7 +153,7 @@ const generalMiddleware = (store: any) => (next: any) => async (action: any) =>
const result = next(action);
const newState: AppState = store.getState();
let doRefreshFolders = false;
let doRefreshFolders: boolean | string = false;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
await reduxSharedMiddleware(store, next, action, storeDispatch as any);
@@ -248,9 +248,17 @@ const generalMiddleware = (store: any) => (next: any) => async (action: any) =>
Setting.setValue('noteVisiblePanes', newState.noteVisiblePanes);
}
if (action.type === 'SETTING_UPDATE_ONE' && action.key.indexOf('folders.sortOrder') === 0) {
doRefreshFolders = 'now';
}
if (doRefreshFolders) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
await scheduleRefreshFolders((action: any) => storeDispatch(action), newState.selectedFolderId);
if (doRefreshFolders === 'now') {
await refreshFolders(storeDispatch, newState.selectedFolderId);
} else {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
await scheduleRefreshFolders((action: any) => storeDispatch(action), newState.selectedFolderId);
}
}
return result;
@@ -312,12 +312,6 @@ const buildStartupTasks = (
Setting.setValue('welcome.enabled', false);
}
// Note: for now we hard-code the folder sort order as we need to
// create a UI to allow customisation (started in branch mobile_add_sidebar_buttons)
Setting.setValue('folders.sortOrder.field', 'title');
Setting.setValue('folders.sortOrder.reverse', false);
reg.logger().info(`Sync target: ${Setting.value('sync.target')}`);
setLocale(Setting.value('locale'));
@@ -5,6 +5,9 @@ import { WebViewControl } from '../../components/ExtendedWebView/types';
import { RefObject } from 'react';
import { OnMessageEvent } from '../../components/ExtendedWebView/types';
import { Platform } from 'react-native';
import Logger from '@joplin/utils/Logger';
const logger = Logger.create('RNToWebViewMessenger');
const canUseOptimizedPostMessage = Platform.OS === 'web';
@@ -41,10 +44,22 @@ export default class RNToWebViewMessenger<LocalInterface, RemoteInterface> exten
public onWebViewMessage = (event: OnMessageEvent) => {
if (!this.hasBeenClosed()) {
let data;
if (canUseOptimizedPostMessage) {
void this.onMessage(event.nativeEvent.data);
data = event.nativeEvent.data;
} else {
void this.onMessage(JSON.parse(event.nativeEvent.data));
try {
data = JSON.parse(event.nativeEvent.data);
} catch {
logger.warn('Failed to parse message:', event.nativeEvent.data);
return;
}
}
if (typeof data === 'object' && data !== null && typeof data.kind === 'string') {
void this.onMessage(data);
} else {
logger.info('Unknown message format:', event.nativeEvent.data);
}
}
};
+1 -1
View File
@@ -16,7 +16,7 @@
"@types/yargs": "17.0.35",
"joplin-plugin-freehand-drawing": "4.3.0",
"ts-node": "10.9.2",
"typescript": "5.8.3"
"typescript": "5.9.3"
},
"dependencies": {
"@joplin/utils": "~3.6",
@@ -15,6 +15,7 @@ import { vim } from '@replit/codemirror-vim';
import { indentUnit } from '@codemirror/language';
import insertNewlineContinueMarkup from './editorCommands/insertNewlineContinueMarkup';
import renderingExtension from './extensions/rendering/renderingExtension';
import renderTables from './extensions/rendering/renderTables';
import { RenderedContentContext } from './extensions/rendering/types';
import highlightActiveLineExtension from './extensions/highlightActiveLineExtension';
import renderBlockImages from './extensions/rendering/renderBlockImages';
@@ -112,7 +113,11 @@ const configFromSettings = (settings: EditorSettings, context: RenderedContentCo
// Only enable in-editor rendering for Markdown notes. In-editor rendering can result in
// confusing output in HTML notes (e.g. some, but not most, tags hidden).
if (settings.inlineRenderingEnabled && settings.language === EditorLanguageType.Markdown) {
extensions.push(renderingExtension());
extensions.push(renderingExtension(settings.tableEditingEnabled));
} else if (settings.tableEditingEnabled && settings.language === EditorLanguageType.Markdown) {
// Table editing can work independently of inline rendering so users
// who disable inline rendering can still use the interactive widget.
extensions.push(renderTables);
}
if (settings.imageRenderingEnabled) {
@@ -22,6 +22,7 @@ import {
toggleBolded, toggleCode,
toggleItalicized, toggleMath,
} from './editorCommands/markdownCommands';
import { tableNextCell, tablePreviousCell } from './editorCommands/tableCommands';
import decoratorExtension from './extensions/markdownDecorationExtension';
import computeSelectionFormatting from './utils/formatting/computeSelectionFormatting';
import { selectionFormattingEqual } from '../SelectionFormatting';
@@ -203,6 +204,11 @@ const createEditor = (
return false;
}
// Try table cell navigation first
if (tableNextCell(view)) {
return true;
}
if (settings.autocompleteMarkup) {
return insertOrIncreaseIndent(view);
}
@@ -214,6 +220,11 @@ const createEditor = (
return false;
}
// Try table cell navigation first
if (tablePreviousCell(view)) {
return true;
}
// When at the beginning of the editor, allow shift-tab to act
// normally.
if (isCursorAtBeginning(view.state)) {
@@ -14,6 +14,8 @@ import { closeSearchPanel, findNext, findPrevious, openSearchPanel, replaceAll,
import { focus } from '@joplin/lib/utils/focusHandler';
import { showLinkEditor } from '../utils/handleLinkEditRequests';
import jumpToHash from './jumpToHash';
import { tableAddRow, tableAddColumn, tableDeleteRow, tableDeleteColumn } from './tableCommands';
import { generateTable } from '../utils/markdown/tableUtils';
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Commands have varying argument types
export type EditorCommandFunction = (editor: EditorView, ...args: any[])=> any;
@@ -46,13 +48,7 @@ const editorCommands: Record<EditorCommandType, EditorCommandFunction> = {
[EditorCommandType.ToggleHeading5]: toggleHeaderLevel(5),
[EditorCommandType.InsertHorizontalRule]: insertHorizontalRule,
[EditorCommandType.InsertTable]: editor => {
replaceSelectionCommand(editor, [
'',
'| | |',
'|----|----|',
'| | |',
'',
].join('\n'));
replaceSelectionCommand(editor, `\n${generateTable(1, 2)}\n\n`);
},
[EditorCommandType.InsertCodeBlock]: editor => {
replaceSelectionCommand(editor, [
@@ -128,6 +124,12 @@ const editorCommands: Record<EditorCommandType, EditorCommandFunction> = {
[EditorCommandType.JumpToHash]: (editor, hash: string) => {
return jumpToHash(editor, hash);
},
// Table editing commands
[EditorCommandType.TableAddRow]: tableAddRow,
[EditorCommandType.TableAddColumn]: tableAddColumn,
[EditorCommandType.TableDeleteRow]: tableDeleteRow,
[EditorCommandType.TableDeleteColumn]: tableDeleteColumn,
};
export default editorCommands;

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