Compare commits

...
Author SHA1 Message Date
Laurent Cozic 87211d1996 update 2026-07-24 13:16:00 +01:00
Laurent Cozic 26f7166200 update 2026-07-24 13:06:20 +01:00
Laurent Cozic 09f6a209dd Chore: Retry "Install yarn" CI step on failure 2026-07-22 11:34:47 +01:00
renovate[bot] e983444b96 fix(deps): update dependency @react-native-documents/picker to v12 (#16008)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-22 11:29:33 +01:00
Henry Heino aed692d5c6 Chore: Server: Prevent regenerating database types when there are migrations left over from switching branches (#16006) 2026-07-22 11:29:18 +01:00
Laurent Cozic 96e78ad023 Desktop: Sync Wizard: Make Joplin Cloud the primary sync option (#16004) 2026-07-22 11:29:05 +01:00
Laurent Cozic b598fa2911 Doc: Fixed price of JSB license 2026-07-21 19:22:40 +01:00
Laurent Cozic 248ef10ab2 Doc: enable self-hosting self-service 2026-07-21 18:29:05 +01:00
Henry Heino 9395c9fa87 Desktop: Resolves #15880: Avoid including unused onnxruntime-node files in the built application (#15997) 2026-07-21 17:16:31 +01:00
Henry Heino 151faf9f88 Mobile: Note actions menu: Decrease padding size (#15998) 2026-07-21 10:15:40 +01:00
Henry Heino 4246e09f89 Mobile: Note action menu: Vertically align icons (#15996) 2026-07-21 00:17:53 +01:00
renovate[bot] 75cdbafba9 chore(deps): update nvidia/cuda docker tag to v13.2.1 (#15992)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-20 00:04:48 +01:00
renovate[bot] 341948e0ba fix(deps): update dependency @react-native-vector-icons/material-design-icons to v12.5.0 (#15980)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-19 09:04:26 +01:00
renovate[bot] e3b51d850f fix(deps): update dependency @fortawesome/react-fontawesome to v3.3.1 (#15989)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-19 09:04:12 +01:00
renovate[bot] e6a32029b4 chore(deps): update dependency @types/serviceworker to v0.0.195 (#15988)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-19 09:03:54 +01:00
renovate[bot] 7798a33739 fix(deps): update dependency @react-native-vector-icons/material-icons to v12.5.0 (#15984)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-19 00:15:58 +01:00
mrjo118 f5941b28d4 Desktop, Mobile, Cli: Fixes #15975: Fix WebDAV support for Koofr (#15983) 2026-07-18 21:05:39 +01:00
Laurent Cozic 70846df6b1 Desktop: Make links in the table editor clickable with Ctrl/Cmd+click (#15977) 2026-07-18 20:52:51 +01:00
renovate[bot] 1321b154da fix(deps): update dependency @react-native-vector-icons/ionicons to v12.5.0 (#15979)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-18 20:51:28 +01:00
renovate[bot] 6a21ec6cc1 fix(deps): update dependency @react-native-vector-icons/fontawesome5 to v12.4.0 (#15978)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-18 20:51:24 +01:00
Laurent Cozic b5ed2c944e Desktop, Server: Record which app screen a Joplin Cloud sign-up started from
Adds an optional "source" label to the Joplin Cloud sign-up link (e.g.
"desktop-sync-wizard") so we can see which screens lead people to subscribe.
The label is a fixed string naming the button that was clicked - it carries
no personal or device information. It travels with the checkout as Stripe
metadata and is saved on the subscription record.
2026-07-18 16:05:00 +01:00
mrjo118andLaurent Cozic fa6d4a259b Mobile: Fixes #15914: Fix auto navigate to latest notebook shortly after app start, when the selected notebook was previously deleted (#15950)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-07-18 11:03:52 +01:00
Jonatan NybergandLaurent Cozic 32c29dd2f6 Improve Swedish PO localisation (#15936)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-07-18 11:03:21 +01:00
Stephan Paternotte de93ebed45 Update nl_NL.po (#15921) 2026-07-18 11:02:58 +01:00
Aissa BenfoddaandLaurent Cozic 154f12744d Desktop, Mobile: Resolves #15828:Provide a clear message on what needs to be done when sync fails due to wrong version (#15837)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-07-18 11:01:46 +01:00
Aissa Benfodda 2c11cbbb6b Desktop: Publish a notebook (#15276) 2026-07-18 10:58:57 +01:00
Henry Heino fca026f0d9 Android: Fixes #15928: Fix menu animations and drag-to-dismiss (#15971) 2026-07-18 10:58:16 +01:00
renovate[bot] e51d9f2670 fix(deps): update dependency @fortawesome/react-fontawesome to v3.3.0 (#15976)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-18 10:57:49 +01:00
Henry Heino 4c2c38f43e Android, Web: Fixes #15959: Increase minimum prompt button width (#15966) 2026-07-17 23:52:20 +01:00
Will Hanstedt dafe4cd010 Doc: Fixes 15953: dead link to pandoc deflist documentation (#15967) 2026-07-17 23:51:06 +01:00
Laurent Cozic 6aa4040762 Desktop: Fixes #15918: Keep trailing spaces in table cells while editing (#15964) 2026-07-17 23:50:33 +01:00
Laurent Cozic 22689817c7 Desktop: Whiteboard: Fix edge keeping the blue selection colour after reconnecting 2026-07-17 12:42:27 +01:00
Laurent Cozic 3515c6aee1 Desktop: Whiteboard: Allow selecting which arrow to move when two arrows share an anchor 2026-07-17 12:26:38 +01:00
mrjo118 a180f9151a Mobile: Fixes #15915: Add file extension validation to import actions (#15960) 2026-07-17 11:22:01 +01:00
Laurent Cozic 15207dc931 Desktop: Fix layout divider behaviour in nested layouts and move mode (#15955) 2026-07-17 09:39:35 +01:00
mrjo118 193a17274a Desktop: Fixes #15867: Fix cycling of the active note when other notes in the notebook are moved (#15868) 2026-07-16 23:29:35 +01:00
Keshav b0c977b2b6 Desktop: Resolves #15786: Add note lock configuration screen (#15852) 2026-07-16 23:22:27 +01:00
Henry Heino 45309e1d0a Desktop: AI chat panel: Improve accessibility (#15956) 2026-07-16 23:16:44 +01:00
Laurent Cozic f410aced1e Desktop: Whiteboard: Fixed newline handling within cards 2026-07-16 17:58:59 +01:00
renovate[bot] 6d5444565b chore(deps): update dependency @axe-core/playwright to v4.11.2 (#15952)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-16 16:42:02 +01:00
Henry Heino 54b8e69750 Chore: Fix CI (#15949) 2026-07-16 01:37:55 +01:00
Henry Heino 2e99bd00ae Desktop: AI chat panel: Fix failure when tool responses are invalid JSON (#15886) 2026-07-15 22:33:57 +01:00
Henry Heino 7524c73526 Desktop: AI chat panel: Stop retry loop after several repeated failures (#15944) 2026-07-15 22:29:31 +01:00
Henry Heino 655806e79c Chore: Server: Add Joplin Server Business-related config logic (#15945) 2026-07-15 22:29:15 +01:00
Henry Heino 68ef1623ab Desktop: AI chat panel: Support cancelling in-progress responses (#15946) 2026-07-15 22:28:48 +01:00
renovate[bot] b0f27acaf7 chore(deps): update dependency nan to v2.26.0 (#15938)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-15 18:15:56 +01:00
renovate[bot] ce475ea22e chore(deps): update nvidia/cuda docker tag to v13.2.0 (#15929)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-15 12:55:34 +01:00
Henry Heino 0faa20d827 Chore: Server: Store Stripe period start/end in the database (#15925) 2026-07-14 22:03:54 +01:00
renovate[bot] a7d1e41c09 fix(deps): update dependency nanoid to v5.1.9 (#15926)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-14 20:56:23 +01:00
Joplin Bot 4dbbf2c3f8 Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-07-14 13:52:11 +00:00
Henry Heino 24ab23045b Chore: Build: Add build-time LICENSE_REQUIRED config flag to Joplin Server (#15913) 2026-07-14 11:24:48 +01:00
Henry Heino bbc8999a45 Chore: Server: Add logic to customize the home screen for external users (#15912) 2026-07-14 11:24:37 +01:00
Henry Heino 0ef2649edd Chore: Server: Add logic for displaying Joplin Server Business status (#15909) 2026-07-14 11:24:26 +01:00
Aissa Benfodda 974198b3a6 Mobile: Update Search screen (#15736) 2026-07-14 10:26:04 +01:00
Laurent Cozic d58a0bb461 Desktop release v3.7.9 2026-07-14 09:54:16 +01:00
Laurent Cozic 5480a56ff5 Revert "Web: Fixes #15601: Fix S3 sync failing with getReader TypeError (#15851)"
This reverts commit c2b04f26df.

May have caused https://github.com/laurent22/joplin/issues/15916
2026-07-14 09:53:36 +01:00
Joplin Bot 7b2ee03091 Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-07-14 02:38:33 +00:00
Laurent Cozic 66cbacca6e Revert "Chore: Set IPHONEOS_DEPLOYMENT_TARGET to 15.6 for tests to match application version"
It appears we need v18.x for the test units

This reverts commit 1f9cec33d8.
2026-07-14 00:12:54 +01:00
Laurent Cozic 6baede235e Chore: Update ignoreDeps in renovate.json5
Added 'node-gyp', 'nitrogen', and 'react-native-nitro-modules' to ignoreDeps in renovate.json5.
2026-07-13 21:39:00 +01:00
Henry Heino 84035e579b Desktop: Fix dependencies used for semantic search (#15891) 2026-07-13 21:33:14 +01:00
Henry Heino ca7cc0fd31 Doc: Resolves #15823: Docker compose template: Don't expose PostgreSQL container port (#15908) 2026-07-13 21:32:41 +01:00
Henry Heino 34ea6c7d8e Chore: Server: Add a utility function for determining the number of enabled non-admin users (#15907) 2026-07-13 20:49:34 +01:00
Joplin Bot ac4d934ebb Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-07-13 19:33:13 +00:00
Laurent Cozic 06a49e6988 iOS 13.7.1 2026-07-13 20:31:46 +01:00
Laurent Cozic 1f9cec33d8 Chore: Set IPHONEOS_DEPLOYMENT_TARGET to 15.6 for tests to match application version 2026-07-13 20:31:00 +01:00
renovate[bot] 39aefb1ad0 fix(deps): update dependency react-native-paper to v5.15.1 (#15905)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-13 20:09:37 +01:00
Laurent Cozic 103ab78b09 Android 3.7.3 2026-07-13 16:07:54 +01:00
Laurent Cozic 674f45d06b Desktop release v3.7.8 2026-07-13 15:52:47 +01:00
Aissa Benfodda 9367ec21f0 Desktop: Resolves #15842: Unable to select text within links (#15871) 2026-07-13 15:52:34 +01:00
Laurent Cozic 5178633558 Desktop: Whiteboard: Place new groups in empty space instead of viewport centre (#15901) 2026-07-13 13:54:29 +01:00
Laurent Cozic 8949fa25f7 Chore: Desktop: Fixed action dispatching error on startup 2026-07-13 00:02:27 +01:00
Laurent Cozic 2ab3b6e00a Desktop release v3.7.7 2026-07-12 23:54:00 +01:00
Laurent Cozic e94a3677f3 Desktop: Surface Joplin Cloud AI degraded state and map error codes (#15849) 2026-07-12 23:53:27 +01:00
Joplin Bot 194ff99911 Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-07-12 19:16:00 +00:00
Laurent Cozic 47a3dbb3f3 Doc: Update sponsors 2026-07-12 17:16:21 +01:00
renovate[bot] df770ba38f chore(deps): update dependency lint-staged to v16.4.0 (#15898)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-12 15:41:53 +01:00
Henry Heino 01bcd2b52a Desktop: AI chat panel: Improve reliability of the Joplin Cloud AI provider (#15885) 2026-07-12 15:39:03 +01:00
mrjo118 5de4777ae0 Mobile: Fixes #12962: Improve sidebar swipe gestures and reduce the likelihood of gesture navigation conflicts (#15875) 2026-07-12 15:38:24 +01:00
Henry Heino d7fb11789e Mobile: Implement redesigned note action menu (#15873) 2026-07-12 15:35:25 +01:00
Martino Dell'Ambrogio 526a0037ee Server: Fixes #15857: Fixed LDAP connection leak when login exits early (#15855) 2026-07-12 14:46:27 +01:00
Aissa Benfodda 4efbc6c3fa Desktop: Resolves #15723: Notes issued from an imported .one file show a double title on the published page (#15735) 2026-07-12 14:22:06 +01:00
Joplin Bot fb8628b687 Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-07-12 02:54:16 +00:00
Laurent Cozic 71693142df Desktop: Whiteboard: Also color the background of cards and groups 2026-07-10 20:02:55 +01:00
Laurent Cozic 90c1d1ec85 Desktop: Whiteboard: keep wheel scrolling inside scrollable cards 2026-07-10 19:37:18 +01:00
Laurent Cozic 0d4534e546 Desktop: Whiteboard: Improve card content styling 2026-07-10 19:17:07 +01:00
Laurent Cozic affb456b46 Desktop: Whiteboard: Add support for colors 2026-07-10 18:29:57 +01:00
Laurent Cozic d17d84590d Desktop: Whiteboard: Double-click an edge label to edit it 2026-07-10 18:09:10 +01:00
Laurent Cozic a8100e3467 Desktop: Whiteboard: Allow changing the origin of an edge by dragging it 2026-07-10 17:55:58 +01:00
Henry Heino d05a24ef7a Desktop: Resolves #15862: AI chat panel: Change APIs used to expose note edit operations (#15881) 2026-07-10 09:30:35 +01:00
Antonio VargasandLaurent Cozic c2b04f26df Web: Fixes #15601: Fix S3 sync failing with getReader TypeError (#15851)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-07-09 18:22:31 +01:00
renovate[bot] 0f52c0cfb5 chore(deps): update dependency sass to v1.98.0 (#15876)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-09 17:30:46 +01:00
Henry Heino 845a92eae8 Chore: Server: Allow specifying an image name during build and fix license specifiers (#15861) 2026-07-08 22:46:49 +01:00
Henry Heino 081444cd2c Desktop: Resolves #15834: AI chat panel: Render markdown (#15836) 2026-07-08 22:44:13 +01:00
Henry Heino 74b6545689 Chore: Fixes #15864: Fix CI (#15866) 2026-07-07 08:59:16 +01:00
Laurent Cozic 97fff14326 Doc: Add AI Sidebar tools spec 2026-07-06 18:34:23 +01:00
Laurent Cozic 694cbd957d Plugins: Change joplin.ai.chat to return an object with a text property 2026-07-05 00:39:17 +01:00
Keshav 4dad8a7fb6 Mobile, Desktop, Cli: Resolves #15772: Update note lock session state (#15773) 2026-07-04 22:29:06 +01:00
Henry Heino b64d970b5c Chore: Server package refactoring (#15843) 2026-07-04 22:26:02 +01:00
Henry Heino ebaac5c216 Desktop: Fix secondary widnows have visible scrollbars while/after resizing (#15844) 2026-07-04 22:25:52 +01:00
Henry Heino 6fdc78aa1e Desktop: AI chat panel: Make edit operations more likely to succeed (#15845) 2026-07-04 22:25:37 +01:00
Laurent Cozic 52e56deb61 Mobile: Fixes #13901: Fix auto-derived title being cleared after saving a new note (#15846) 2026-07-04 22:25:05 +01:00
Henry Heino 418e8657fe Server: Fixes #15725: Fix LDAP user auto-creation (#15847) 2026-07-04 22:23:28 +01:00
Laurent Cozic f7f7ba10e2 Chore: Desktop: Do no display tooltips over links on desktop 2026-07-03 19:40:41 +01:00
Laurent Cozic 7979cf8702 Mobile: Editor: Display tooltip above links to allow opening the link 2026-07-03 19:19:49 +01:00
Laurent Cozic c6cc90a509 Desktop, Mobile: Editor: Ensure checkbox size is proportional to font size 2026-07-03 18:57:50 +01:00
Laurent Cozic 89884e941e Desktop, Mobile: Editor: Fixed vertical alignement of checkboxes 2026-07-03 18:56:18 +01:00
Laurent Cozic 8d90057453 Server: Allow searching emails by full name or email 2026-07-03 18:50:28 +01:00
Laurent Cozic 8e5d29f058 Desktop: Hide "Sign up to Joplin Cloud" button once the user is logged in 2026-07-03 18:44:31 +01:00
Laurent Cozic 380401c8fc Server: Give focus to MFA field when it is displayed 2026-07-03 18:37:48 +01:00
Laurent Cozic 221b2593ff Server: Silence Katex warnings (#15839) 2026-07-03 14:15:54 +01:00
Laurent Cozic c227b66bb2 Desktop: AI Chat: Added warning in console when structured output schema is not accepted by the model 2026-07-03 12:12:30 +01:00
Henry Heino b3d467839c Desktop: AI chat panel: Make model responses more likely to be valid (#15801) 2026-07-03 12:04:59 +01:00
renovate[bot] 99d51ccecb fix(deps): update dependency pg to v8.20.0 (#15835)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-03 12:04:30 +01:00
renovate[bot] 894b29c9d3 chore(deps): update dependency tesseract.js to v7 (#15838)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-03 12:01:16 +01:00
renovate[bot] 3c6a500889 chore(deps): update dependency turndown to v7.2.4 (#15832)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-02 18:31:53 +00:00
Daniel Nylander 05c7b2f032 All: Translation: Update sv.po (#15833) 2026-07-02 14:29:22 -04:00
Laurent Cozic 7160fbfe20 Doc: Mention that Joplin Cloud AI is beta and added it to the feature list 2026-07-02 16:44:18 +01:00
mrjo118 0c70b1b1eb Mobile: Fixes #15663: Prevent data loss when toggling the checkbox on a todo immediately after changing note data (#15815) 2026-07-02 16:25:41 +01:00
mrjo118 2c71aee90b Desktop, Mobile, Cli: Fixes #15811: Add safeguard to prevent revision chains being broken by revision cleaning, where old revisions still exist unexpectedly (#15812) 2026-07-02 16:23:05 +01:00
mrjo118 2fa1ecbd98 Mobile: Fixes #11479: Harden share to Joplin logic, to prevent overwriting existing notes in certain cases (#15794) 2026-07-02 16:21:28 +01:00
Henry Heino 5cd3445cae Desktop: Markdown editor: Fix certain types of note edits can't be undone with ctrl-z (#15806) 2026-07-02 16:19:11 +01:00
Henry Heino e67b50764f Desktop: AI chat panel: Support multiple windows (#15803) 2026-07-02 16:19:03 +01:00
Aissa Benfodda 2c3fac7b12 Desktop: Resolves #15763: Reveal markup on mouseup instead of mousedown (#15778) 2026-07-02 16:18:17 +01:00
renovate[bot] c5390d4f80 chore(deps): update dependency turndown to v7.2.3 (#15829)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-02 14:59:56 +01:00
Aissa Benfodda f7675413ec Desktop: Resolves #15716: Published note issued from an imported .one file breaks UI (#15734) 2026-07-02 14:57:23 +01:00
Henry Heino e63b96be5c Mobile: Update appearance of dialogs (#15706) 2026-07-02 14:55:51 +01:00
Laurent Cozic d97a5091a3 Server: Validate transcribe job ID to prevent path traversal in proxy endpoint 2026-07-01 22:39:35 +01:00
Laurent Cozic dff533ccbd Server: Prevent pending share recipients from writing items into shared folders before accepting the invitation 2026-07-01 22:31:25 +01:00
renovate[bot] a5d7dbdd17 chore(deps): update actions/upload-artifact action to v6 (#15809)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-01 15:16:18 +01:00
Laurent Cozic c7841670cd Server: Fixes #15802: Allow SVG resources to be served inline (#15814) 2026-07-01 12:28:51 +01:00
renovate[bot] 5101b72a3c fix(deps): update dependency slugify to v1.6.9 (#15817)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-01 12:28:41 +01:00
Joplin Bot 658e741029 Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-07-01 04:03:54 +00:00
renovate[bot] 9884e7f0ca chore(deps): update dependency esbuild to v0.27.5 (#15816)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-01 03:12:32 +00:00
Laurent Cozic 1a2501e85f Desktop: Fixes #4238: Make the editor absorb space on window resize, regardless of layout position (#15774) 2026-06-30 18:18:48 +01:00
renovate[bot] 600c8d0f8f fix(deps): update dependency adm-zip to v0.5.17 (#15805)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-30 18:05:24 +01:00
Laurent Cozic a67f1bf1fe Desktop: Add joplin.ai.getEmbeddings() plugin API for raw vector access (#15799) 2026-06-30 09:58:46 +01:00
Laurent Cozic 952ae75ef4 Desktop: Add joplin.ai.getIndexStatus() plugin API (#15800) 2026-06-30 00:14:46 +01:00
Laurent Cozic 0065760e88 Chore: Mobile: Add perf logging for NoteBodyViewer WebView load and renderer RPC (#15796) 2026-06-29 15:43:53 +01:00
Laurent Cozic 31be269339 Chore: Desktop: Check that vectors are all numeric before saving them (#15792) 2026-06-29 09:26:27 +01:00
Laurent Cozic 1f612b398e Chore: Update lockfile 2026-06-28 23:44:26 +01:00
renovate[bot] 4e4d48e28e chore(deps): update dependency lint-staged to v16.3.4 (#15791)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-28 17:56:25 +00:00
renovate[bot] 1449b5faf5 chore(deps): update dependency lint-staged to v16.3.0 (#15790)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-28 15:03:05 +01:00
Laurent Cozic 920cd8f463 Server: Harden user-content responses against XSS via uploaded resources (#15787) 2026-06-28 10:11:17 +01:00
Joplin Bot 1cd7af30e0 Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-06-27 19:30:20 +00:00
Laurent Cozic ee10ebf76b Chore: Desktop: Added helper functions to check AI availability (#15785) 2026-06-27 19:08:32 +01:00
Laurent Cozic 2887e3f29e Doc: Update sponsors 2026-06-27 18:07:32 +01:00
Laurent Cozic cd75b3e9c8 Desktop: Add AI chat panel for the active note (#15771) 2026-06-27 15:05:10 +01:00
renovate[bot] 2be2c152af fix(deps): update dependency @react-native-vector-icons/ionicons to v12.4.3 (#15782)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-27 12:46:39 +00:00
Laurent Cozic befc5686dc Desktop: Resolves #1449: Improve usability of Link to note command 2026-06-27 12:56:45 +01:00
renovate[bot] 24e5354001 fix(deps): update dependency @react-native-vector-icons/ionicons to v12.4.1 (#15777)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-27 11:46:55 +01:00
Laurent Cozic 22d0894b08 Desktop: Fixes #15779: Update GotoAnything selection on mouse hover 2026-06-27 11:45:55 +01:00
Laurent Cozic 4742fdfc8d Desktop: Fixes #15780: Fix "Link to note" command not working from command palette 2026-06-27 10:37:45 +01:00
Laurent Cozic 34b5e8cbe1 Desktop: Fix crash on startup when layout references a component that no longer exists 2026-06-27 10:28:43 +01:00
Laurent Cozic 7fa42e06f3 Desktop: Fix issue with Chat API not working with max_completion_tokens parameter of OpenAI 2026-06-26 15:40:08 +01:00
Henry Heino c444bfd4f4 Desktop, Mobile: Slightly adjust button colors and add new theme variable (#15764) 2026-06-26 12:22:59 +01:00
Henry Heino aa39146662 Chore: Server: Update Stripe config to match new backend products (#15769) 2026-06-26 12:19:19 +01:00
renovate[bot] 7c0f6ba93f fix(deps): update dependency pg to v8.19.0 (#15768)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-25 20:46:20 +01:00
Henry Heino 6396caaa4c Chore: Server: Retry Stripe events interrupted by server shutdown (#15755) 2026-06-25 11:09:24 +01:00
Henry Heino 24c6fa7194 Server: Hardening: Disable the __proto__ property (#15765) 2026-06-25 11:00:04 +01:00
renovate[bot] b9ca2a2ffd fix(deps): update dependency react-native-safe-area-context to v5.7.0 (#15766)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-25 10:33:20 +01:00
Aissa Benfodda 92b46ad541 Desktop: Resolves #15720: Tests in lib package print OneNote related warnings (#15767) 2026-06-25 10:30:12 +01:00
Joplin Bot a114f7355a Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-06-25 03:43:39 +00:00
Laurent Cozic 3eb2260b60 Desktop release v3.7.6 2026-06-24 21:05:11 +01:00
Laurent Cozic 6668375ae5 Desktop: Speed up embedding indexer during initial scan 2026-06-24 21:02:07 +01:00
Laurent Cozic 4a6da2c0d2 Desktop: Wipe orphan vec rows before insert in NoteEmbedding.saveChunks 2026-06-24 20:53:08 +01:00
Laurent Cozic 109d1b1114 Desktop: Cap ONNX embedding batch size to prevent renderer OOM 2026-06-24 20:51:58 +01:00
Laurent Cozic 9de4a11e35 Desktop: Fix sqlite-vec extension path inside packaged Electron app 2026-06-24 20:50:33 +01:00
Laurent Cozic 821ebbbb78 Desktop release v3.7.5 2026-06-24 18:02:41 +01:00
Keshav a8291bb4c2 Chore: Mobile, Desktop, Cli: Resolves #15737: Exclude locked notes from search indexes (#15756) 2026-06-24 16:52:13 +01:00
Laurent Cozic 7f1d39b75a Linux: Fixed loading of note embeddings 2026-06-24 16:44:06 +01:00
Joplin Bot be41fecb23 Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-06-24 14:39:48 +00:00
Laurent Cozic cd9d5c1255 Desktop release v3.7.4 2026-06-24 11:52:39 +01:00
Laurent Cozic f6d97e4871 Desktop: Fixes #15761: Fix renderer crash loop when sqlite-vec is unavailable (#15762) 2026-06-24 11:52:19 +01:00
Henry Heino c1d60df234 Server: Fix "password has not been changed" warning not shown in some cases where it should be (#15747) 2026-06-23 15:16:03 +01:00
mrjo118 1346d29e25 Mobile: Fixes #15618: Fix sharing to Joplin when opening in edit mode with the RTE enabled (#15746) 2026-06-23 15:15:18 +01:00
Henry Heino e56c997554 Mobile: Adjust UI padding to match the mobile app redesign (#15741) 2026-06-23 15:13:36 +01:00
Keshav 86e07b64e3 Mobile, Desktop, Cli: Resolves #15714: Index locked note resources (#15717) 2026-06-23 15:10:21 +01:00
Keshav bdc1b64f48 Mobile: Fixes #15729: Hide private feature flags (#15730) 2026-06-23 14:47:40 +01:00
renovate[bot] df740e68ff fix(deps): update dependency @react-native-documents/picker to v11.0.4 (#15754)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-23 11:39:33 +01:00
Joplin Bot 578e7fae8e Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-06-23 09:48:02 +00:00
renovate[bot] e92af6ac23 fix(deps): update dependency @react-native-documents/picker to v11 (#15752)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-23 09:53:54 +01:00
Henry Heino 25a93ff554 Cli: Fixes #15738: Fix version command when published to NPM (#15740) 2026-06-23 06:33:05 +01:00
Laurent Cozic 49c083f995 Chore: Removed unused module short-uuid 2026-06-23 06:13:49 +01:00
Laurent Cozic f18ef2f0c1 Chore: Improved date handling when signing canary 2026-06-23 05:50:13 +01:00
Laurent Cozic 483e63474e Doc: Fix warrant canary signature by restoring valid-until date
The date had been hand-edited from 18 to 19 August after signing,
which invalidated the PGP signature. Restore the original signed
value so the signature verifies.
2026-06-23 05:19:02 +01:00
renovate[bot] 9ba9665476 Update dependency react-native-localize to v3.7.0 (#15742)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-23 01:33:46 +01:00
renovate[bot] 60c9c4d275 Update dependency nitrogen to v0.33.9 (#15743)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-23 01:33:37 +01:00
Joplin Bot bc1d468c81 Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-06-22 20:58:25 +00:00
Laurent Cozic a0bed69a77 Android 3.7.2 2026-06-22 21:31:27 +01:00
Laurent Cozic 08bdff221f Desktop release v3.7.3 2026-06-22 21:13:05 +01:00
renovate[bot] cce0dd864a fix(deps): update dependency tar to v7.5.13 (#15733)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-21 23:11:20 +01:00
Laurent Cozic dc21be248d Desktop: Add tooltip on note title in note list (#15732) 2026-06-21 18:36:53 +01:00
Laurent Cozic d85f4305b6 Desktop, Cli, Mobile: Fixed crash when the profile database is updated 2026-06-21 15:57:29 +01:00
Joplin Bot 945de8d17b Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-06-21 04:23:29 +00:00
Laurent Cozic a0b6f2a937 Mobile, Desktop, Cli: Preserve customised note history settings when syncing across clients (#15728) 2026-06-20 22:36:33 +01:00
Joplin Bot c3a71ba203 Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-06-20 14:14:00 +00:00
Laurent Cozic 4072194e9a Android 3.7.1 2026-06-20 14:21:06 +01:00
Laurent Cozic d9767bd2c6 Desktop release v3.7.2 2026-06-20 13:11:24 +01:00
Laurent Cozic 3f2000a9dd Merge branch 'release-3.6' into dev 2026-06-20 13:10:09 +01:00
Laurent Cozic fb82b929d9 iOS: Set ITSAppUsesNonExemptEncryption key to false 2026-06-20 12:59:58 +01:00
Laurent Cozic 45c8501b7e Chore: build file 2026-06-20 12:59:58 +01:00
Sriram Varun Kumar 045b2d8c2b Mobile, Desktop, Cli: Resolves #15722: Give conflict_note_states table a numeric primary key and set note_id as unique (#15724) 2026-06-20 12:45:05 +01:00
Laurent Cozic dd7acaff31 iOS 13.6.10 2026-06-20 12:30:40 +01:00
Laurent Cozic f251427d34 Android 3.6.21 2026-06-20 12:04:32 +01:00
Laurent Cozic 18e0b08c28 All: Reject malformed item IDs during sync (#15540) 2026-06-20 11:29:13 +01:00
Laurent Cozic c615726603 Desktop release v3.6.15 2026-06-20 11:27:46 +01:00
Laurent Cozic a658e68276 Chore: Clarify when warrant canary is udpated 2026-06-19 12:37:19 +01:00
Laurent Cozic f2d8f4fb18 Doc: Update warrant canary 2026-06-19 12:29:54 +01:00
Joplin Bot 34aebb46f2 Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-06-19 10:35:36 +00:00
Laurent Cozic d6d50ce650 Chore: Silence MCP server warnings in tests 2026-06-19 11:25:27 +01:00
renovate[bot] 38c94f318e fix(deps): update dependency short-uuid to v6 (#15713)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-19 09:39:16 +01:00
Henry Heino 9578125b22 Web: Display fewer sync options when hosted on app.joplincloud.com (#15715) 2026-06-19 09:39:00 +01:00
Sriram Varun Kumar 871178fa7d Chore: Desktop, Mobile: Resolves #15570: Populate base and conflict note state during sync (#15669) 2026-06-19 08:22:30 +01:00
Henry Heino fa2900709e Chore: Server: Allow the tagServerLatest script to push to other repositories (#15712) 2026-06-18 20:22:31 +01:00
Aissa Benfodda 7b219b7ab2 Desktop: Resolves #15578: OneNote Import: Handwriting/pen strokes imported incorrectly (#15681) 2026-06-18 19:54:18 +01:00
Akshaj Rawat 98eeb4bd01 Plugins: Fixes #15710: removed semver and used regex for version check (#15711) 2026-06-18 19:54:05 +01:00
Laurent Cozic 1cc68739fb Chore: Update supported prefixes in check-pr-title 2026-06-18 18:53:03 +01:00
Laurent Cozic f7812d283e Server: Add OrphanTrace logging to investigate orphaned items bug (#15709) 2026-06-18 11:32:01 +01:00
Aissa Benfodda d4030c4225 Desktop: Resolves #15690: Padding between bottom of setting screen and button bar doesn't appear (#15698) 2026-06-18 11:31:53 +01:00
Akshaj Rawat 364b22c5b6 Plugin-generator: Resolves #15595: Added local validations for new publish workflow (#15596) 2026-06-18 10:02:20 +01:00
renovate[bot] d37b6f3751 fix(deps): update dependency tar to v7.5.12 (#15708)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-18 09:18:03 +01:00
Henry Heino 7c36f326ac Server: Make LDAP login logic safer (#15707) 2026-06-18 08:54:11 +01:00
Keshav d7594a3bb0 Chore: Resolves #15597: Add note lock backend path (#15673) 2026-06-18 00:16:12 +01:00
Laurent Cozic 4e737f397a Server: Fix orphaned items task timing out on large databases (#15704) 2026-06-17 23:50:47 +01:00
Laurent Cozic 42af5e7339 Server: Speed up batch deletes by using whereIn instead of OR chains (#15705) 2026-06-17 23:48:57 +01:00
Henry Heino 6701668e69 Chore: Doc: Add (currently disabled) website/checkout logic for a self-hosted plan (#15675) 2026-06-17 22:22:01 +01:00
renovate[bot]andLaurent Cozic dafb6b05f1 fix(deps): update dependency rate-limiter-flexible to v9.1.1 (#15703)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-06-17 19:18:56 +01:00
Laurent Cozic 6b8b8d0694 Doc: Fixed docu build 2026-06-17 13:47:36 +01:00
Laurent Cozic 9853882787 Doc: Improve AI-related documentation 2026-06-17 13:18:43 +01:00
renovate[bot] 324fa333d4 fix(deps): update dependency rate-limiter-flexible to v9 (#15702)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-17 13:15:10 +01:00
renovate[bot] 0818e7856e fix(deps): update dependency react-native-svg to v15.15.4 (#15697)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-17 10:37:33 +00:00
Laurent Cozic 4020986f29 Desktop: Add MCP server (#15699) 2026-06-17 11:31:51 +01:00
renovate[bot] 68543655ce chore(deps): update dependency @types/serviceworker to v0.0.194 (#15700)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-17 09:04:36 +01:00
Henry Heino 5051807374 All: Improve how resource filenames are determined (#15672) 2026-06-12 18:28:20 +01:00
mrjo118andLaurent Cozic 62bab6ece5 All: Fixes #15608: Fix duplicate tag creation for special unicode characters (#15613)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-06-12 16:19:55 +01:00
Henry Heino 8e0812de9d Desktop,Mobile,Server: Fix Fountain rendering issue (#15659) 2026-06-12 09:19:32 +01:00
Henry Heino 5216b47b0d Chore: Fix CI on the release-3.6 branch (#15644) 2026-06-11 13:34:15 +01:00
416 changed files with 17218 additions and 3130 deletions

No files matched your search

+2
View File
@@ -17,4 +17,6 @@ packages/server/db-*.sqlite
packages/server/dist/
packages/server/logs/
packages/server/temp/
packages/server/assets/license/signingKey.private.*.pem
packages/server/readme/
packages/transcribe/.env
+7 -4
View File
@@ -18,26 +18,29 @@ 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
- Mobile, Desktop, Cli: Resolves #777: Improved note search performance
Valid prefixes:
- `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
- `Windows` — Windows-specific changes
- `Linux` — Linux-specific changes
- `macOS` — macOS-specific changes
- `Cli` — the command line app
- `Server` — the Joplin Server
- `Clipper` — the web clipper browser extension
- `Transcribe` — the Transcribe server
- `Plugins` — the plugin API or built-in plugins
- `Plugin Repo` — the plugin repository
- `Api` — the data API
- `Cloud` — Joplin Cloud
- `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 the change targets two areas, separate them with commas — for example "Desktop, Mobile". If it applies to all client apps, use "All".
If the change targets several areas, separate them with commas — for example "Mobile, Desktop, Cli: Resolves #777: Improved note search performance".
-->
+8 -3
View File
@@ -20,7 +20,12 @@ 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|Web|Transcribe|Plugins|CI|Plugin Repo|Doc)';
// Product-area prefixes are kept in sync with the list recognised by
// packages/tools/git-changelog.ts so that valid titles are picked up by
// the changelog generator. Non-product prefixes (Tools, Chore, CI, Doc,
// Plugin Repo) are administrative categories for changes that do not
// ship to users and so do not appear in the changelog.
const prefix = '(Desktop|Mobile|Android|iOS|Windows|Linux|macOS|Cli|Clipper|Server|Transcribe|Plugins|Api|Cloud|Tools|Chore|CI|Doc)';
const prefixList = `${prefix}(,\\s*${prefix})*`;
const strictRegex = new RegExp(`^${prefixList}: (Fixes|Resolves) #[0-9]+: .+`);
const softRegex = new RegExp(`^${prefixList}: ((Fixes|Resolves) #[0-9]+: )?.+`);
@@ -107,11 +112,11 @@ module.exports = async ({ github, context, core }) => {
commentMarker,
`@${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:',
'Please prefix the title with the area you are targeting, then add the issue you are addressing. If the change targets several areas, separate them with commas. 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`',
'- `Mobile, Desktop, Cli: Resolves #777: Improved note search performance`',
'',
'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.',
'',
+1 -3
View File
@@ -42,9 +42,7 @@ jobs:
node-version: '24'
cache: 'yarn'
- name: Install Yarn
run: |
corepack enable
- uses: ./.github/workflows/shared/install-yarn
- name: Install
run: yarn install
+1 -4
View File
@@ -15,10 +15,7 @@ jobs:
node-version: '24'
cache: 'yarn'
- name: Install Yarn
run: |
# https://yarnpkg.com/getting-started/install
corepack enable
- uses: ./.github/workflows/shared/install-yarn
- name: Install macOs dependencies
if: runner.os == 'macOS'
+3 -6
View File
@@ -164,10 +164,7 @@ jobs:
docker builder prune -af || true
sudo rm -rf /var/lib/docker/tmp/* || true
- name: Install Yarn
run: |
# https://yarnpkg.com/getting-started/install
corepack enable
- uses: ./.github/workflows/shared/install-yarn
- name: Build Docker Image
env:
@@ -186,12 +183,12 @@ jobs:
# Basic test to ensure that the created build is valid. It should exit with
# code 0 if it works.
docker run joplin/server:$(dpkg --print-architecture)-0.0.0 node dist/app.js migrate list
docker run joplin/server:$(dpkg --print-architecture)-0.0.0 node dist/index.js migrate list
- name: Check HTTP request
run: |
# Need to pass environment variables:
docker run --env MAX_TIME_DRIFT=0 --publish 22300:22300 joplin/server:$(dpkg --print-architecture)-0.0.0 node dist/app.js --env dev &
docker run --env MAX_TIME_DRIFT=0 --publish 22300:22300 joplin/server:$(dpkg --print-architecture)-0.0.0 node dist/index.js --env dev &
# Wait for server to start
sleep 120
@@ -0,0 +1,22 @@
name: 'Install Yarn'
description: 'Enable Corepack and install the pinned Yarn version, with retries'
runs:
using: 'composite'
steps:
- name: Install Yarn
shell: bash
# https://yarnpkg.com/getting-started/install
#
# `corepack enable` only sets up the shims - Corepack downloads the
# pinned Yarn version from repo.yarnpkg.com on first use. That fetch has
# a short timeout and no built-in retry, so a transient network blip on
# CI fails the whole job. Prepare it eagerly with a retry loop.
env:
COREPACK_NETWORK_TIMEOUT: '60000'
run: |
corepack enable
for i in 1 2 3; do
corepack prepare --activate && break
echo "corepack prepare failed (attempt $i), retrying in 15s..."
sleep 15
done
@@ -54,17 +54,13 @@ runs:
- uses: actions/setup-node@v6
with:
node-version: '24'
node-version: '24.15'
# Disable the cache on ARM runners. For now, we don't run "yarn install" on these
# environments and this breaks actions/setup-node.
# See https://github.com/laurent22/joplin/commit/47d0d3eb9e89153a609fb5441344da10904c6308#commitcomment-159577783.
# cache: ${{ (!contains(runner.os, 'arm') && 'yarn') || '' }}
- name: Install Yarn
shell: bash
run: |
# https://yarnpkg.com/getting-started/install
corepack enable
- uses: ./.github/workflows/shared/install-yarn
# macos-latest ships with Python 3.12 by default, but this removes a
# utility that's used by electron-builder (distutils) so we need to pin
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
cd ${GITHUB_WORKSPACE}/packages/app-desktop/
bash ./integration-tests/run-ci.sh
# See https://playwright.dev/docs/ci-intro#setting-up-github-actions
- uses: actions/upload-artifact@v5
- uses: actions/upload-artifact@v6
if: ${{ !cancelled() }}
with:
name: playwright-report-${{ matrix.os }}
+65
View File
@@ -189,17 +189,22 @@ packages/app-desktop/commands/switchProfile3.js
packages/app-desktop/commands/toggleExternalEditing.js
packages/app-desktop/commands/toggleSafeMode.js
packages/app-desktop/commands/toggleTabMovesFocus.js
packages/app-desktop/gui/AiDegradedNotice.js
packages/app-desktop/gui/Button/Button.js
packages/app-desktop/gui/ChatPanel/ChatMessageItem.js
packages/app-desktop/gui/ChatPanel/ChatPanel.js
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/AiIndexStatus.js
packages/app-desktop/gui/ConfigScreen/controls/AiStatus.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
packages/app-desktop/gui/ConfigScreen/controls/MissingPasswordHelpLink.js
packages/app-desktop/gui/ConfigScreen/controls/NoteLockSettings.js
packages/app-desktop/gui/ConfigScreen/controls/SettingComponent.js
packages/app-desktop/gui/ConfigScreen/controls/SettingDescription.js
packages/app-desktop/gui/ConfigScreen/controls/SettingHeader.js
@@ -208,6 +213,7 @@ packages/app-desktop/gui/ConfigScreen/controls/ToggleAdvancedSettingsButton.js
packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.js
packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.js
packages/app-desktop/gui/ConfigScreen/controls/plugins/SearchPlugins.js
packages/app-desktop/gui/ConfigScreen/controls/useNoteLockMode.js
packages/app-desktop/gui/ConfigScreen/searchHighlight.test.js
packages/app-desktop/gui/ConfigScreen/searchHighlight.js
packages/app-desktop/gui/DialogButtonRow.js
@@ -228,6 +234,7 @@ packages/app-desktop/gui/HelpButton.js
packages/app-desktop/gui/IconButton.js
packages/app-desktop/gui/ImportScreen.js
packages/app-desktop/gui/InlineCombobox.js
packages/app-desktop/gui/InlineMarkdownDisplay.js
packages/app-desktop/gui/ItemList.js
packages/app-desktop/gui/JoplinCloudConfigScreen.js
packages/app-desktop/gui/JoplinCloudLoginScreen.js
@@ -238,6 +245,7 @@ packages/app-desktop/gui/KeymapConfig/styles/index.js
packages/app-desktop/gui/KeymapConfig/utils/getLabel.js
packages/app-desktop/gui/KeymapConfig/utils/useCommandStatus.js
packages/app-desktop/gui/KeymapConfig/utils/useKeymap.js
packages/app-desktop/gui/MainLayoutPane.js
packages/app-desktop/gui/MainScreen.js
packages/app-desktop/gui/MasterPasswordDialog/Dialog.js
packages/app-desktop/gui/MenuBar.js
@@ -307,6 +315,7 @@ packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/GroupNode.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/handlePositions.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/useCardWheel.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
@@ -388,6 +397,7 @@ packages/app-desktop/gui/NoteListHeader/utils/validateColumns.js
packages/app-desktop/gui/NoteListItem/NoteListItem.js
packages/app-desktop/gui/NoteListItem/utils/getNoteElementIdFromJoplinId.js
packages/app-desktop/gui/NoteListItem/utils/getNoteTitleHtml.js
packages/app-desktop/gui/NoteListItem/utils/noteTitleTooltip.js
packages/app-desktop/gui/NoteListItem/utils/prepareViewProps.test.js
packages/app-desktop/gui/NoteListItem/utils/prepareViewProps.js
packages/app-desktop/gui/NoteListItem/utils/types.js
@@ -417,6 +427,7 @@ packages/app-desktop/gui/PopupNotification/types.js
packages/app-desktop/gui/ProfileEditor.js
packages/app-desktop/gui/PromptDialog.test.js
packages/app-desktop/gui/PromptDialog.js
packages/app-desktop/gui/PublishFolderDialog.js
packages/app-desktop/gui/QuitSyncDialog.js
packages/app-desktop/gui/ResizableLayout/LayoutItemContainer.js
packages/app-desktop/gui/ResizableLayout/MoveButtons.js
@@ -431,6 +442,8 @@ packages/app-desktop/gui/ResizableLayout/utils/persist.test.js
packages/app-desktop/gui/ResizableLayout/utils/persist.js
packages/app-desktop/gui/ResizableLayout/utils/removeItem.js
packages/app-desktop/gui/ResizableLayout/utils/removeKeylessItems.js
packages/app-desktop/gui/ResizableLayout/utils/resizeLogic.test.js
packages/app-desktop/gui/ResizableLayout/utils/resizeLogic.js
packages/app-desktop/gui/ResizableLayout/utils/setLayoutItemProps.js
packages/app-desktop/gui/ResizableLayout/utils/style.js
packages/app-desktop/gui/ResizableLayout/utils/types.js
@@ -489,6 +502,7 @@ 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/copyNoteExternalLink.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/deleteFolder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/duplicateNote.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/editAlarm.js
@@ -526,10 +540,12 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showModalMessage.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showNoteContentProperties.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showNoteProperties.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showPrompt.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showPublishFolderDialog.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showShareFolderDialog.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showShareNoteDialog.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showSpellCheckerMenu.test.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showSpellCheckerMenu.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleAiChat.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleEditors.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleLayoutMoveMode.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleMenuBar.js
@@ -575,6 +591,7 @@ packages/app-desktop/integration-tests/goToAnything.spec.js
packages/app-desktop/integration-tests/main.spec.js
packages/app-desktop/integration-tests/markdownEditor.spec.js
packages/app-desktop/integration-tests/models/ChangeAppLayoutScreen.js
packages/app-desktop/integration-tests/models/ChatPanel.js
packages/app-desktop/integration-tests/models/EditorCodeDialog.js
packages/app-desktop/integration-tests/models/GoToAnything.js
packages/app-desktop/integration-tests/models/MainScreen.js
@@ -611,6 +628,8 @@ packages/app-desktop/main-html.js
packages/app-desktop/main.js
packages/app-desktop/playwright.config.js
packages/app-desktop/plugins/GotoAnything.js
packages/app-desktop/services/aiStatusBridge.test.js
packages/app-desktop/services/aiStatusBridge.js
packages/app-desktop/services/autoUpdater/AutoUpdaterService.test.js
packages/app-desktop/services/autoUpdater/AutoUpdaterService.js
packages/app-desktop/services/bridge.js
@@ -636,6 +655,7 @@ packages/app-desktop/services/plugins/types.js
packages/app-desktop/services/restart.js
packages/app-desktop/services/spellChecker/SpellCheckerServiceDriverNative.js
packages/app-desktop/tools/bundleJs.js
packages/app-desktop/tools/cleanOnnxRuntime.js
packages/app-desktop/tools/copy7Zip.js
packages/app-desktop/tools/generateLatestArm64Yml.js
packages/app-desktop/tools/githubReleasesUtils.js
@@ -657,6 +677,7 @@ packages/app-desktop/utils/initReact.js
packages/app-desktop/utils/initializeCommandService.js
packages/app-desktop/utils/isSafeToOpen.test.js
packages/app-desktop/utils/isSafeToOpen.js
packages/app-desktop/utils/layout/layoutKeyToLabel.js
packages/app-desktop/utils/restartInSafeModeFromMain.test.js
packages/app-desktop/utils/restartInSafeModeFromMain.js
packages/app-desktop/utils/sourceMapSetup.js
@@ -1269,6 +1290,9 @@ packages/generator-joplin/generators/app/templates/api/index.js
packages/generator-joplin/generators/app/templates/api/noteListType.js
packages/generator-joplin/generators/app/templates/api/types.js
packages/generator-joplin/generators/app/templates/api_index.js
packages/generator-joplin/generators/app/templates/script/publish/steps/verifyBuild.js
packages/generator-joplin/generators/app/templates/script/publish/steps/verifyGitState.js
packages/generator-joplin/generators/app/templates/script/publish/utils/logger.js
packages/generator-joplin/generators/app/templates/src/index.js
packages/generator-joplin/tools/updateCategories.js
packages/htmlpack/index.test.js
@@ -1351,6 +1375,7 @@ packages/lib/components/shared/NoteEditor/WarningBanner/useEditorTypeMigrationBa
packages/lib/components/shared/NoteList/getEmptyFolderMessage.js
packages/lib/components/shared/NoteRevisionViewer/getHelpMessage.js
packages/lib/components/shared/NoteRevisionViewer/useDeleteHistoryClick.js
packages/lib/components/shared/PublishFolderDialog/useOnPublishFolderLinkClick.js
packages/lib/components/shared/SamlShared.js
packages/lib/components/shared/ShareNoteDialog/onUnshareNoteClick.js
packages/lib/components/shared/ShareNoteDialog/types.js
@@ -1360,6 +1385,7 @@ 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.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
@@ -1442,6 +1468,7 @@ packages/lib/mimeUtils.test.js
packages/lib/models/Alarm.js
packages/lib/models/BaseItem.test.js
packages/lib/models/BaseItem.js
packages/lib/models/ConflictNoteState.js
packages/lib/models/Folder.sharing.test.js
packages/lib/models/Folder.test.js
packages/lib/models/Folder.js
@@ -1489,6 +1516,7 @@ packages/lib/models/utils/paginatedFeed.js
packages/lib/models/utils/paginationToSql.js
packages/lib/models/utils/readOnly.test.js
packages/lib/models/utils/readOnly.js
packages/lib/models/utils/resourceUtils.test.js
packages/lib/models/utils/resourceUtils.js
packages/lib/models/utils/types.js
packages/lib/models/utils/userData.test.js
@@ -1558,16 +1586,25 @@ packages/lib/services/ai/SearchService.js
packages/lib/services/ai/SqliteVec.test.js
packages/lib/services/ai/aiSettingsTransition.test.js
packages/lib/services/ai/aiSettingsTransition.js
packages/lib/services/ai/applyNoteEdits.js
packages/lib/services/ai/availability.test.js
packages/lib/services/ai/availability.js
packages/lib/services/ai/chunker.test.js
packages/lib/services/ai/chunker.js
packages/lib/services/ai/classification.js
packages/lib/services/ai/noteChat.test.js
packages/lib/services/ai/noteChat.js
packages/lib/services/ai/providers/Anthropic.test.js
packages/lib/services/ai/providers/Anthropic.js
packages/lib/services/ai/providers/ChatProviderBase.js
packages/lib/services/ai/providers/JoplinCloud.test.js
packages/lib/services/ai/providers/JoplinCloud.js
packages/lib/services/ai/providers/OpenAiCompatible.js
packages/lib/services/ai/providers/TestProvider.js
packages/lib/services/ai/testing/TestEmbeddingProvider.test.js
packages/lib/services/ai/testing/TestEmbeddingProvider.js
packages/lib/services/ai/types.js
packages/lib/services/ai/utils/findFencedBlock.js
packages/lib/services/commands/MenuUtils.js
packages/lib/services/commands/ToolbarButtonUtils.test.js
packages/lib/services/commands/ToolbarButtonUtils.js
@@ -1591,6 +1628,7 @@ packages/lib/services/database/migrations/49.js
packages/lib/services/database/migrations/50.js
packages/lib/services/database/migrations/51.js
packages/lib/services/database/migrations/52.js
packages/lib/services/database/migrations/53.js
packages/lib/services/database/migrations/index.js
packages/lib/services/database/sqlStringToLines.js
packages/lib/services/database/types.js
@@ -1653,6 +1691,20 @@ packages/lib/services/keychain/KeychainServiceDriver.dummy.js
packages/lib/services/keychain/KeychainServiceDriver.electron.js
packages/lib/services/keychain/KeychainServiceDriver.node.js
packages/lib/services/keychain/KeychainServiceDriverBase.js
packages/lib/services/mcp/McpServer.test.js
packages/lib/services/mcp/McpServer.js
packages/lib/services/mcp/registry.js
packages/lib/services/mcp/tools/createNote.js
packages/lib/services/mcp/tools/createNotebook.js
packages/lib/services/mcp/tools/deleteNote.js
packages/lib/services/mcp/tools/listNotebooks.js
packages/lib/services/mcp/tools/listTags.js
packages/lib/services/mcp/tools/manageTags.js
packages/lib/services/mcp/tools/readNote.js
packages/lib/services/mcp/tools/searchNotes.js
packages/lib/services/mcp/tools/semanticSearchNotes.js
packages/lib/services/mcp/tools/updateNote.js
packages/lib/services/mcp/types.js
packages/lib/services/noteList/checkboxPieCss.js
packages/lib/services/noteList/defaultLeftToRightListRenderer.js
packages/lib/services/noteList/defaultListRenderer.js
@@ -1663,6 +1715,13 @@ packages/lib/services/noteList/renderTemplate.js
packages/lib/services/noteList/renderViewProps.test.js
packages/lib/services/noteList/renderViewProps.js
packages/lib/services/noteList/renderers.js
packages/lib/services/noteLock/NoteLockKey.js
packages/lib/services/noteLock/NoteLockNote.js
packages/lib/services/noteLock/NoteLockService.test.js
packages/lib/services/noteLock/NoteLockService.js
packages/lib/services/noteLock/NoteLockSession.test.js
packages/lib/services/noteLock/NoteLockSession.js
packages/lib/services/noteLock/isNoteLockEnabled.js
packages/lib/services/ocr/OcrDriverBase.js
packages/lib/services/ocr/OcrService.test.js
packages/lib/services/ocr/OcrService.js
@@ -1760,6 +1819,7 @@ packages/lib/services/rest/routes/events.js
packages/lib/services/rest/routes/folders.test.js
packages/lib/services/rest/routes/folders.js
packages/lib/services/rest/routes/master_keys.js
packages/lib/services/rest/routes/mcp.js
packages/lib/services/rest/routes/notes.test.js
packages/lib/services/rest/routes/notes.js
packages/lib/services/rest/routes/ping.js
@@ -1806,6 +1866,7 @@ packages/lib/services/synchronizer/ItemUploader.test.js
packages/lib/services/synchronizer/ItemUploader.js
packages/lib/services/synchronizer/LockHandler.js
packages/lib/services/synchronizer/MigrationHandler.js
packages/lib/services/synchronizer/Synchronizer.baseFields.test.js
packages/lib/services/synchronizer/Synchronizer.basics.test.js
packages/lib/services/synchronizer/Synchronizer.conflicts.test.js
packages/lib/services/synchronizer/Synchronizer.e2ee.test.js
@@ -1842,9 +1903,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/findEmptySpot.test.js
packages/lib/services/whiteboard/findEmptySpot.js
packages/lib/services/whiteboard/generateId.js
packages/lib/services/whiteboard/jsoncanvas.js
packages/lib/services/whiteboard/parse.js
packages/lib/services/whiteboard/presetColors.js
packages/lib/services/whiteboard/resolveRef.js
packages/lib/services/whiteboard/serialize.js
packages/lib/services/whiteboard/whiteboard.test.js
@@ -1972,6 +2036,7 @@ packages/renderer/MdToHtml/rules/frontmatter.js
packages/renderer/MdToHtml/rules/highlight_keywords.js
packages/renderer/MdToHtml/rules/html_image.js
packages/renderer/MdToHtml/rules/image.js
packages/renderer/MdToHtml/rules/katex.test.js
packages/renderer/MdToHtml/rules/katex.js
packages/renderer/MdToHtml/rules/link_close.js
packages/renderer/MdToHtml/rules/link_open.js
+65
View File
@@ -215,17 +215,22 @@ packages/app-desktop/commands/switchProfile3.js
packages/app-desktop/commands/toggleExternalEditing.js
packages/app-desktop/commands/toggleSafeMode.js
packages/app-desktop/commands/toggleTabMovesFocus.js
packages/app-desktop/gui/AiDegradedNotice.js
packages/app-desktop/gui/Button/Button.js
packages/app-desktop/gui/ChatPanel/ChatMessageItem.js
packages/app-desktop/gui/ChatPanel/ChatPanel.js
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/AiIndexStatus.js
packages/app-desktop/gui/ConfigScreen/controls/AiStatus.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
packages/app-desktop/gui/ConfigScreen/controls/MissingPasswordHelpLink.js
packages/app-desktop/gui/ConfigScreen/controls/NoteLockSettings.js
packages/app-desktop/gui/ConfigScreen/controls/SettingComponent.js
packages/app-desktop/gui/ConfigScreen/controls/SettingDescription.js
packages/app-desktop/gui/ConfigScreen/controls/SettingHeader.js
@@ -234,6 +239,7 @@ packages/app-desktop/gui/ConfigScreen/controls/ToggleAdvancedSettingsButton.js
packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.js
packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.js
packages/app-desktop/gui/ConfigScreen/controls/plugins/SearchPlugins.js
packages/app-desktop/gui/ConfigScreen/controls/useNoteLockMode.js
packages/app-desktop/gui/ConfigScreen/searchHighlight.test.js
packages/app-desktop/gui/ConfigScreen/searchHighlight.js
packages/app-desktop/gui/DialogButtonRow.js
@@ -254,6 +260,7 @@ packages/app-desktop/gui/HelpButton.js
packages/app-desktop/gui/IconButton.js
packages/app-desktop/gui/ImportScreen.js
packages/app-desktop/gui/InlineCombobox.js
packages/app-desktop/gui/InlineMarkdownDisplay.js
packages/app-desktop/gui/ItemList.js
packages/app-desktop/gui/JoplinCloudConfigScreen.js
packages/app-desktop/gui/JoplinCloudLoginScreen.js
@@ -264,6 +271,7 @@ packages/app-desktop/gui/KeymapConfig/styles/index.js
packages/app-desktop/gui/KeymapConfig/utils/getLabel.js
packages/app-desktop/gui/KeymapConfig/utils/useCommandStatus.js
packages/app-desktop/gui/KeymapConfig/utils/useKeymap.js
packages/app-desktop/gui/MainLayoutPane.js
packages/app-desktop/gui/MainScreen.js
packages/app-desktop/gui/MasterPasswordDialog/Dialog.js
packages/app-desktop/gui/MenuBar.js
@@ -333,6 +341,7 @@ packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/GroupNode.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/handlePositions.js
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/useCardWheel.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
@@ -414,6 +423,7 @@ packages/app-desktop/gui/NoteListHeader/utils/validateColumns.js
packages/app-desktop/gui/NoteListItem/NoteListItem.js
packages/app-desktop/gui/NoteListItem/utils/getNoteElementIdFromJoplinId.js
packages/app-desktop/gui/NoteListItem/utils/getNoteTitleHtml.js
packages/app-desktop/gui/NoteListItem/utils/noteTitleTooltip.js
packages/app-desktop/gui/NoteListItem/utils/prepareViewProps.test.js
packages/app-desktop/gui/NoteListItem/utils/prepareViewProps.js
packages/app-desktop/gui/NoteListItem/utils/types.js
@@ -443,6 +453,7 @@ packages/app-desktop/gui/PopupNotification/types.js
packages/app-desktop/gui/ProfileEditor.js
packages/app-desktop/gui/PromptDialog.test.js
packages/app-desktop/gui/PromptDialog.js
packages/app-desktop/gui/PublishFolderDialog.js
packages/app-desktop/gui/QuitSyncDialog.js
packages/app-desktop/gui/ResizableLayout/LayoutItemContainer.js
packages/app-desktop/gui/ResizableLayout/MoveButtons.js
@@ -457,6 +468,8 @@ packages/app-desktop/gui/ResizableLayout/utils/persist.test.js
packages/app-desktop/gui/ResizableLayout/utils/persist.js
packages/app-desktop/gui/ResizableLayout/utils/removeItem.js
packages/app-desktop/gui/ResizableLayout/utils/removeKeylessItems.js
packages/app-desktop/gui/ResizableLayout/utils/resizeLogic.test.js
packages/app-desktop/gui/ResizableLayout/utils/resizeLogic.js
packages/app-desktop/gui/ResizableLayout/utils/setLayoutItemProps.js
packages/app-desktop/gui/ResizableLayout/utils/style.js
packages/app-desktop/gui/ResizableLayout/utils/types.js
@@ -515,6 +528,7 @@ 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/copyNoteExternalLink.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/deleteFolder.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/duplicateNote.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/editAlarm.js
@@ -552,10 +566,12 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showModalMessage.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showNoteContentProperties.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showNoteProperties.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showPrompt.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showPublishFolderDialog.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showShareFolderDialog.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showShareNoteDialog.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showSpellCheckerMenu.test.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showSpellCheckerMenu.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleAiChat.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleEditors.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleLayoutMoveMode.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleMenuBar.js
@@ -601,6 +617,7 @@ packages/app-desktop/integration-tests/goToAnything.spec.js
packages/app-desktop/integration-tests/main.spec.js
packages/app-desktop/integration-tests/markdownEditor.spec.js
packages/app-desktop/integration-tests/models/ChangeAppLayoutScreen.js
packages/app-desktop/integration-tests/models/ChatPanel.js
packages/app-desktop/integration-tests/models/EditorCodeDialog.js
packages/app-desktop/integration-tests/models/GoToAnything.js
packages/app-desktop/integration-tests/models/MainScreen.js
@@ -637,6 +654,8 @@ packages/app-desktop/main-html.js
packages/app-desktop/main.js
packages/app-desktop/playwright.config.js
packages/app-desktop/plugins/GotoAnything.js
packages/app-desktop/services/aiStatusBridge.test.js
packages/app-desktop/services/aiStatusBridge.js
packages/app-desktop/services/autoUpdater/AutoUpdaterService.test.js
packages/app-desktop/services/autoUpdater/AutoUpdaterService.js
packages/app-desktop/services/bridge.js
@@ -662,6 +681,7 @@ packages/app-desktop/services/plugins/types.js
packages/app-desktop/services/restart.js
packages/app-desktop/services/spellChecker/SpellCheckerServiceDriverNative.js
packages/app-desktop/tools/bundleJs.js
packages/app-desktop/tools/cleanOnnxRuntime.js
packages/app-desktop/tools/copy7Zip.js
packages/app-desktop/tools/generateLatestArm64Yml.js
packages/app-desktop/tools/githubReleasesUtils.js
@@ -683,6 +703,7 @@ packages/app-desktop/utils/initReact.js
packages/app-desktop/utils/initializeCommandService.js
packages/app-desktop/utils/isSafeToOpen.test.js
packages/app-desktop/utils/isSafeToOpen.js
packages/app-desktop/utils/layout/layoutKeyToLabel.js
packages/app-desktop/utils/restartInSafeModeFromMain.test.js
packages/app-desktop/utils/restartInSafeModeFromMain.js
packages/app-desktop/utils/sourceMapSetup.js
@@ -1295,6 +1316,9 @@ packages/generator-joplin/generators/app/templates/api/index.js
packages/generator-joplin/generators/app/templates/api/noteListType.js
packages/generator-joplin/generators/app/templates/api/types.js
packages/generator-joplin/generators/app/templates/api_index.js
packages/generator-joplin/generators/app/templates/script/publish/steps/verifyBuild.js
packages/generator-joplin/generators/app/templates/script/publish/steps/verifyGitState.js
packages/generator-joplin/generators/app/templates/script/publish/utils/logger.js
packages/generator-joplin/generators/app/templates/src/index.js
packages/generator-joplin/tools/updateCategories.js
packages/htmlpack/index.test.js
@@ -1377,6 +1401,7 @@ packages/lib/components/shared/NoteEditor/WarningBanner/useEditorTypeMigrationBa
packages/lib/components/shared/NoteList/getEmptyFolderMessage.js
packages/lib/components/shared/NoteRevisionViewer/getHelpMessage.js
packages/lib/components/shared/NoteRevisionViewer/useDeleteHistoryClick.js
packages/lib/components/shared/PublishFolderDialog/useOnPublishFolderLinkClick.js
packages/lib/components/shared/SamlShared.js
packages/lib/components/shared/ShareNoteDialog/onUnshareNoteClick.js
packages/lib/components/shared/ShareNoteDialog/types.js
@@ -1386,6 +1411,7 @@ 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.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
@@ -1468,6 +1494,7 @@ packages/lib/mimeUtils.test.js
packages/lib/models/Alarm.js
packages/lib/models/BaseItem.test.js
packages/lib/models/BaseItem.js
packages/lib/models/ConflictNoteState.js
packages/lib/models/Folder.sharing.test.js
packages/lib/models/Folder.test.js
packages/lib/models/Folder.js
@@ -1515,6 +1542,7 @@ packages/lib/models/utils/paginatedFeed.js
packages/lib/models/utils/paginationToSql.js
packages/lib/models/utils/readOnly.test.js
packages/lib/models/utils/readOnly.js
packages/lib/models/utils/resourceUtils.test.js
packages/lib/models/utils/resourceUtils.js
packages/lib/models/utils/types.js
packages/lib/models/utils/userData.test.js
@@ -1584,16 +1612,25 @@ packages/lib/services/ai/SearchService.js
packages/lib/services/ai/SqliteVec.test.js
packages/lib/services/ai/aiSettingsTransition.test.js
packages/lib/services/ai/aiSettingsTransition.js
packages/lib/services/ai/applyNoteEdits.js
packages/lib/services/ai/availability.test.js
packages/lib/services/ai/availability.js
packages/lib/services/ai/chunker.test.js
packages/lib/services/ai/chunker.js
packages/lib/services/ai/classification.js
packages/lib/services/ai/noteChat.test.js
packages/lib/services/ai/noteChat.js
packages/lib/services/ai/providers/Anthropic.test.js
packages/lib/services/ai/providers/Anthropic.js
packages/lib/services/ai/providers/ChatProviderBase.js
packages/lib/services/ai/providers/JoplinCloud.test.js
packages/lib/services/ai/providers/JoplinCloud.js
packages/lib/services/ai/providers/OpenAiCompatible.js
packages/lib/services/ai/providers/TestProvider.js
packages/lib/services/ai/testing/TestEmbeddingProvider.test.js
packages/lib/services/ai/testing/TestEmbeddingProvider.js
packages/lib/services/ai/types.js
packages/lib/services/ai/utils/findFencedBlock.js
packages/lib/services/commands/MenuUtils.js
packages/lib/services/commands/ToolbarButtonUtils.test.js
packages/lib/services/commands/ToolbarButtonUtils.js
@@ -1617,6 +1654,7 @@ packages/lib/services/database/migrations/49.js
packages/lib/services/database/migrations/50.js
packages/lib/services/database/migrations/51.js
packages/lib/services/database/migrations/52.js
packages/lib/services/database/migrations/53.js
packages/lib/services/database/migrations/index.js
packages/lib/services/database/sqlStringToLines.js
packages/lib/services/database/types.js
@@ -1679,6 +1717,20 @@ packages/lib/services/keychain/KeychainServiceDriver.dummy.js
packages/lib/services/keychain/KeychainServiceDriver.electron.js
packages/lib/services/keychain/KeychainServiceDriver.node.js
packages/lib/services/keychain/KeychainServiceDriverBase.js
packages/lib/services/mcp/McpServer.test.js
packages/lib/services/mcp/McpServer.js
packages/lib/services/mcp/registry.js
packages/lib/services/mcp/tools/createNote.js
packages/lib/services/mcp/tools/createNotebook.js
packages/lib/services/mcp/tools/deleteNote.js
packages/lib/services/mcp/tools/listNotebooks.js
packages/lib/services/mcp/tools/listTags.js
packages/lib/services/mcp/tools/manageTags.js
packages/lib/services/mcp/tools/readNote.js
packages/lib/services/mcp/tools/searchNotes.js
packages/lib/services/mcp/tools/semanticSearchNotes.js
packages/lib/services/mcp/tools/updateNote.js
packages/lib/services/mcp/types.js
packages/lib/services/noteList/checkboxPieCss.js
packages/lib/services/noteList/defaultLeftToRightListRenderer.js
packages/lib/services/noteList/defaultListRenderer.js
@@ -1689,6 +1741,13 @@ packages/lib/services/noteList/renderTemplate.js
packages/lib/services/noteList/renderViewProps.test.js
packages/lib/services/noteList/renderViewProps.js
packages/lib/services/noteList/renderers.js
packages/lib/services/noteLock/NoteLockKey.js
packages/lib/services/noteLock/NoteLockNote.js
packages/lib/services/noteLock/NoteLockService.test.js
packages/lib/services/noteLock/NoteLockService.js
packages/lib/services/noteLock/NoteLockSession.test.js
packages/lib/services/noteLock/NoteLockSession.js
packages/lib/services/noteLock/isNoteLockEnabled.js
packages/lib/services/ocr/OcrDriverBase.js
packages/lib/services/ocr/OcrService.test.js
packages/lib/services/ocr/OcrService.js
@@ -1786,6 +1845,7 @@ packages/lib/services/rest/routes/events.js
packages/lib/services/rest/routes/folders.test.js
packages/lib/services/rest/routes/folders.js
packages/lib/services/rest/routes/master_keys.js
packages/lib/services/rest/routes/mcp.js
packages/lib/services/rest/routes/notes.test.js
packages/lib/services/rest/routes/notes.js
packages/lib/services/rest/routes/ping.js
@@ -1832,6 +1892,7 @@ packages/lib/services/synchronizer/ItemUploader.test.js
packages/lib/services/synchronizer/ItemUploader.js
packages/lib/services/synchronizer/LockHandler.js
packages/lib/services/synchronizer/MigrationHandler.js
packages/lib/services/synchronizer/Synchronizer.baseFields.test.js
packages/lib/services/synchronizer/Synchronizer.basics.test.js
packages/lib/services/synchronizer/Synchronizer.conflicts.test.js
packages/lib/services/synchronizer/Synchronizer.e2ee.test.js
@@ -1868,9 +1929,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/findEmptySpot.test.js
packages/lib/services/whiteboard/findEmptySpot.js
packages/lib/services/whiteboard/generateId.js
packages/lib/services/whiteboard/jsoncanvas.js
packages/lib/services/whiteboard/parse.js
packages/lib/services/whiteboard/presetColors.js
packages/lib/services/whiteboard/resolveRef.js
packages/lib/services/whiteboard/serialize.js
packages/lib/services/whiteboard/whiteboard.test.js
@@ -1998,6 +2062,7 @@ packages/renderer/MdToHtml/rules/frontmatter.js
packages/renderer/MdToHtml/rules/highlight_keywords.js
packages/renderer/MdToHtml/rules/html_image.js
packages/renderer/MdToHtml/rules/image.js
packages/renderer/MdToHtml/rules/katex.test.js
packages/renderer/MdToHtml/rules/katex.js
packages/renderer/MdToHtml/rules/link_close.js
packages/renderer/MdToHtml/rules/link_open.js
Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

@@ -36,7 +36,18 @@
{{#featureLabelsOff}}
<p class="unchecked-text"><i class="fas fa-times feature feature-off"></i>{{label}}</p>
{{/featureLabelsOff}}
{{#pricingTable}}
<strong translate>Pricing</strong>
<table class="table">
<tbody>
{{#rows}}
<tr><td>{{condition}}</td><td>{{priceYearly}}</td></tr>
{{/rows}}
</tbody>
</table>
{{/pricingTable}}
<p class="text-center subscribe-wrapper">
<a id="subscribeButton-{{name}}" href="{{cfaUrl}}" class="button-link btn-white subscribeButton cfa-button">{{cfaLabel}}</a>
@@ -58,29 +69,27 @@
const buttonId = 'subscribeButton-' + planName;
const buttonElement = document.getElementById(buttonId);
if (stripePricesIds.monthly) {
function handleResult() {
console.info('Redirected to checkout');
function handleResult() {
console.info('Redirected to checkout');
}
buttonElement.addEventListener("click", function(evt) {
const priceId = stripePricesIds[subscriptionPeriod];
if (!priceId) {
console.error('Invalid period: ' + subscriptionPeriod);
return;
}
buttonElement.addEventListener("click", function(evt) {
evt.preventDefault();
evt.preventDefault();
const priceId = stripePricesIds[subscriptionPeriod];
if (!priceId) {
console.error('Invalid period: ' + subscriptionPeriod);
return;
}
createCheckoutSession(priceId).then(function(data) {
stripe.redirectToCheckout({
sessionId: data.sessionId
})
.then(handleResult);
});
createCheckoutSession(priceId).then(function(data) {
stripe.redirectToCheckout({
sessionId: data.sessionId
})
.then(handleResult);
});
}
});
for (const button of document.querySelectorAll('button.action-toggleIncreaseStorage')) {
button.onclick = () => {
@@ -216,6 +216,11 @@
applyPeriod(period);
});
// Keep the ?source= label so it can be sent to Stripe at checkout.
// Only overwrite when the param is present, to preserve an earlier one.
const sourceParam = urlQuery.get('source');
if (sourceParam) localStorage.setItem('source', sourceParam);
setupBetaHandling(urlQuery);
if (urlQuery.get('period') === 'monthly') {
// Nothing - this is the default
@@ -250,6 +255,8 @@
$('.toggle-button-self').click((event) => {
event.preventDefault();
// Self-hosting is currently yearly-only
applyPeriod('yearly');
setHostingType('self');
});
+1
View File
@@ -5,6 +5,7 @@
- Tabs for indentation
- Single quotes for strings
- Proper TypeScript types (avoid `any`)
- Don't annotate return types or const types when TypeScript can infer them. Annotate only when TypeScript would otherwise infer `any`.
- Comments should be only with `//` and should not contain jsdoc syntax
- If you duplicate a substantial block of code, add a comment above it noting the duplication and referencing the original location.
- When creating Jest tests, there should be only one `describe()` statement in the file.
+8 -1
View File
@@ -40,6 +40,9 @@ COPY packages/server ./packages/server
# We don't want to build onenote-converter since it is not used by the server
RUN sed --in-place '/onenote-converter/d' ./packages/lib/package.json
# Joplin Server -- and the Joplin packages, when included with it -- are licensed under their own license.
RUN sed --in-place 's/"license": "AGPL-3.0-or-later"/"license": "UNLICENSED"/' ./package.json ./packages/*/package.json
# For some reason there's both a .yarn/cache and .yarn/berry/cache that are
# being generated, and both have the same content. Not clear why it does this
# but we can delete it anyway. We can delete the cache because we use
@@ -86,6 +89,9 @@ RUN pm2 install https://registry.npmjs.org/pm2-logrotate/-/pm2-logrotate-3.0.0.t
&& pm2 set pm2-logrotate:retain 5 \
&& pm2 set pm2-logrotate:compress true
ARG LICENSE_REQUIRED="0"
ENV LICENSE_REQUIRED=${LICENSE_REQUIRED}
ENV NODE_ENV=production
ENV RUNNING_IN_DOCKER=1
EXPOSE ${APP_PORT}
@@ -102,8 +108,9 @@ ARG BUILD_DATE
ARG REVISION
ARG VERSION
ARG SOURCE
ARG IMAGE_NAME="Joplin Server"
LABEL org.opencontainers.image.created="$BUILD_DATE" \
org.opencontainers.image.title="Joplin Server" \
org.opencontainers.image.title="$IMAGE_NAME" \
org.opencontainers.image.description="Docker image for Joplin Server" \
org.opencontainers.image.url="https://joplinapp.org/" \
org.opencontainers.image.revision="$REVISION" \
+1 -1
View File
@@ -23,7 +23,7 @@
FROM ghcr.io/ggml-org/llama.cpp:full-cuda-b5449 AS llama-cuda
# Stage 2: our runtime image.
FROM nvidia/cuda:13.1.1-cudnn-runtime-ubuntu22.04
FROM nvidia/cuda:13.2.1-cudnn-runtime-ubuntu22.04
RUN apt-get update \
&& apt-get install -y \
+1 -1
View File
@@ -31,7 +31,7 @@ Please see the [donation page](https://github.com/laurent22/joplin/blob/dev/read
# Sponsors
<!-- SPONSORS-ORG -->
<a href="https://seirei.ne.jp"><img title="Serei Network" width="256" src="https://joplinapp.org/images/sponsors/SeireiNetwork.png"/></a> <a href="https://citricsheep.com"><img title="Citric Sheep" width="256" src="https://joplinapp.org/images/sponsors/CitricSheep.png"/></a> <a href="https://sorted.travel/?utm_source=joplinapp"><img title="Sorted Travel" width="256" src="https://joplinapp.org/images/sponsors/SortedTravel.png"/></a> <a href="https://celebian.com"><img title="Celebian" width="256" src="https://joplinapp.org/images/sponsors/Celebian.png"/></a> <a href="https://bestkru.com"><img title="BestKru" width="256" src="https://joplinapp.org/images/sponsors/BestKru.png"/></a> <a href="https://www.socialfollowers.uk/buy-tiktok-followers/"><img title="Social Followers" width="256" src="https://joplinapp.org/images/sponsors/SocialFollowers.png"/></a> <a href="https://stormlikes.com/"><img title="Stormlikes" width="256" src="https://joplinapp.org/images/sponsors/Stormlikes.png"/></a> <a href="https://route4me.com"><img title="Route4Me" width="256" src="https://joplinapp.org/images/sponsors/Route4Me.png"/></a> <a href="https://topagency.webflow.io"><img title="WebDesignAgency" width="256" src="https://joplinapp.org/images/sponsors/WebDesignAgency.png" alt="topagency"/></a> <a href="https://essayshark.com"><img title="EssayShark - essay writers for hire" width="256" src="https://joplinapp.org/images/sponsors/EssayShark.png" alt="EssayShark - essay writers for hire"/></a> <a href="https://pokieslab1.com/real-money-pokies/"><img title="Australian Real Money Pokies" width="256" src="https://joplinapp.org/images/sponsors/PokiesLab.png" alt="Australian Real Money Pokies"/></a> <a href="https://pokiesman1.net/real-money-pokies/"><img title="Australian Real Money Pokies" width="256" src="https://joplinapp.org/images/sponsors/Pokiesman.png" alt="Australian Real Money Pokies"/></a> <a href="https://socialkings.online"><img title="Boost your reach and buy real followers" width="256" src="https://joplinapp.org/images/sponsors/SocialKings.png" alt="Boost your reach and buy real followers"/></a> <a href="https://essayservice.com/"><img title="For those in need of immediate academic assistance, EssayService offers a fast and reliable service to write my essay for me now, ensuring high-quality results within tight deadlines" width="256" src="https://joplinapp.org/images/sponsors/EssayService.png" alt="For those in need of immediate academic assistance, EssayService offers a fast and reliable service to write my essay for me now, ensuring high-quality results within tight deadlines"/></a> <a href="https://thenationonlineng.net/casino-en-ligne/casino-en-ligne-payant-au-canada/"><img title="casino en ligne le plus payant" width="256" src="https://joplinapp.org/images/sponsors/TheNationOnline.jpg" alt="casino en ligne le plus payant"/></a>
<a href="https://seirei.ne.jp"><img title="Serei Network" width="256" src="https://joplinapp.org/images/sponsors/SeireiNetwork.png"/></a> <a href="https://citricsheep.com"><img title="Citric Sheep" width="256" src="https://joplinapp.org/images/sponsors/CitricSheep.png"/></a> <a href="https://sorted.travel/?utm_source=joplinapp"><img title="Sorted Travel" width="256" src="https://joplinapp.org/images/sponsors/SortedTravel.png"/></a> <a href="https://celebian.com"><img title="Celebian" width="256" src="https://joplinapp.org/images/sponsors/Celebian.png"/></a> <a href="https://bestkru.com"><img title="BestKru" width="256" src="https://joplinapp.org/images/sponsors/BestKru.png"/></a> <a href="https://route4me.com"><img title="Route4Me" width="256" src="https://joplinapp.org/images/sponsors/Route4Me.png"/></a> <a href="https://socialboom.io/"><img title="Buy YouTube, Instagram, TikTok &amp; Facebook Growth Services at Socialboom" width="256" src="https://joplinapp.org/images/sponsors/Socialboom.png" alt="Buy YouTube, Instagram, TikTok &amp; Facebook Growth Services at Socialboom"/></a>
<!-- SPONSORS-ORG -->
* * *
+2 -2
View File
@@ -30,8 +30,8 @@ services:
- ./data/postgres:/var/lib/postgresql/data
networks:
- app-network
ports:
- "5432:5432"
# (Optional): Allow accessing the database using an external tool (e.g. `psql`) from the host:
# ports: [ "127.0.0.1:5432:5432" ]
restart: unless-stopped
environment:
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
+3 -2
View File
@@ -93,7 +93,7 @@
"gulp": "4.0.2",
"husky": "9.1.7",
"lerna": "3.22.1",
"lint-staged": "16.2.7",
"lint-staged": "16.4.0",
"madge": "8.0.0",
"npm-package-json-lint": "9.1.0",
"typescript": "5.9.3"
@@ -112,6 +112,7 @@
"@codemirror/language": "6.12.3",
"@codemirror/lint": "6.9.5",
"@codemirror/autocomplete": "6.20.1",
"onnxruntime-node": "1.24.3",
"eslint": "patch:eslint@9.39.4#./.yarn/patches/eslint-npm-9.39.4-81a84865c0.patch",
"yauzl": "3.3.1",
"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",
@@ -130,7 +131,7 @@
"pdfjs-dist@*": "patch:pdfjs-dist@npm%3A3.11.174#./.yarn/patches/pdfjs-dist-npm-3.11.174-67f2fee6d6.patch",
"pdfjs-dist@3.11.174": "patch:pdfjs-dist@npm%3A3.11.174#./.yarn/patches/pdfjs-dist-npm-3.11.174-67f2fee6d6.patch",
"canvas@npm:^2.11.2": "link:./.yarn/joplin-empty-package/",
"@xenova/transformers/sharp": "link:./.yarn/joplin-empty-package/",
"@huggingface/transformers/sharp": "link:./.yarn/joplin-empty-package/",
"node-gyp@npm:^9.0.0": "11.2.0",
"node-forge@npm:^1": "1.3.2",
"node-forge@npm:^1.3.0": "1.3.2",
@@ -14,7 +14,7 @@ import { loadKeychainServiceAndSettings } from '@joplin/lib/services/SettingUtil
import shimInitCli from './utils/shimInitCli';
const baseDir = `${dirname(__dirname)}/tests/cli-integration`;
const joplinAppPath = `${__dirname}/main.js`;
const joplinAppPath = `${__dirname}/build/main.js`;
shimInitCli({ nodeSqlite, appVersion: () => require('../package.json').version, keytar: null, sharp: null, React: null, electronBridge: null, pdfJs: null });
require('@joplin/lib/testing/test-utils');
+3 -1
View File
@@ -12,7 +12,9 @@ class Command extends BaseCommand {
}
public override async action() {
this.stdout(versionInfo(require('../package.json'), {}).message);
// The relative path to package.json is from the built version of this command
// in app-cli/build/command-version.js (see https://github.com/laurent22/joplin/issues/15738):
this.stdout(versionInfo(require('./package.json'), {}).message);
}
}
@@ -0,0 +1,11 @@
<!-- joplin-metadata-print-title = false -->
<div class="fountain joplin-editable">
<pre class="joplin-source" hidden data-joplin-language="fountain" data-joplin-source-open="```fountain&#10;" data-joplin-source-close="&#10;```&#10;">&lt;img src=x onerror=&quot;something()&quot;/&gt;...
</pre>
<div class="page">
<p><img src="x"/>...</p>
</div>
</div>
@@ -0,0 +1,3 @@
```fountain
<img src=x onerror="something()"/>...
```
@@ -47,7 +47,8 @@ joplin.plugins.register({
// must reach .chat from `joplin` in a single chain and call it
// immediately. Storing `joplin.ai` or `joplin.ai.chat` first and
// then invoking it later corrupts the path tracking.
summary = await (joplin as any).ai.chat(messages);
const result = await (joplin as any).ai.chat(messages);
summary = result.text;
} catch (error) {
alert(`AI call failed: ${error.message}`);
return;
+93 -1
View File
@@ -1,5 +1,7 @@
import { AppState, createAppDefaultWindowState } from './app.reducer';
import { ChatRole, ChatToolCall, ChatToolMessage } from '@joplin/lib/services/ai/types';
import { AiChatMessage, AppState, createAppDefaultWindowState } from './app.reducer';
import appReducer, { createAppDefaultState } from './app.reducer';
import { defaultWindowId } from '@joplin/lib/reducer';
describe('app.reducer', () => {
@@ -47,6 +49,37 @@ describe('app.reducer', () => {
]);
});
it('aI_STATUS_UPDATE merges a partial payload without clobbering unrelated fields', () => {
const state: AppState = {
...createAppDefaultState({}),
aiStatus: { degraded: true, tokensUsed: 500, tokensBudget: 1000, lastToastShownAt: 12345 },
};
const afterUsage = appReducer(state, {
type: 'AI_STATUS_UPDATE',
payload: { tokensUsed: 600 },
});
expect(afterUsage.aiStatus).toEqual({
degraded: true,
tokensUsed: 600,
tokensBudget: 1000,
lastToastShownAt: 12345,
});
const afterToast = appReducer(afterUsage, {
type: 'AI_STATUS_UPDATE',
payload: { lastToastShownAt: 67890 },
});
expect(afterToast.aiStatus).toEqual({
degraded: true,
tokensUsed: 600,
tokensBudget: 1000,
lastToastShownAt: 67890,
});
});
it('showing a dialog in one window should hide dialogs with the same ID in background windows', () => {
const state: AppState = {
...createAppDefaultState({}),
@@ -71,4 +104,63 @@ describe('app.reducer', () => {
expect(newState.visibleDialogs).toEqual({ testDialog: true });
});
it('should build AI chat history', () => {
let state: AppState = {
...createAppDefaultState({}),
};
let idCounter = 0;
const buildMessage = (role: ChatRole.User | ChatRole.Assistant, content: string, toolCalls: ChatToolCall[]) => ({
id: `id-${idCounter++}`,
role,
text: content,
raw: [
{
role,
content: content,
...(toolCalls.length ? { toolCalls } : {}),
},
],
} satisfies AiChatMessage);
state = appReducer(state, {
type: 'AI_CHAT_APPEND',
windowId: defaultWindowId,
message: buildMessage(ChatRole.User, 'Test', []),
});
state = appReducer(state, {
type: 'AI_CHAT_APPEND',
windowId: defaultWindowId,
message: buildMessage(
ChatRole.Assistant,
'Testing',
[{ toolName: 'testTool', callId: 'call-1', arguments: { arg: 1 }, parseError: null }],
),
});
state = appReducer(state, {
type: 'AI_CHAT_ADD_TOOL_RESULT',
windowId: defaultWindowId,
toolCall: ({
role: ChatRole.Tool,
toolName: 'testTool',
toolCallId: 'call-1',
userDescription: '',
isError: false,
content: 'Result',
} satisfies ChatToolMessage),
});
expect(state.aiChatMessages).toMatchObject([
{ id: 'id-0', role: 'user', text: 'Test', raw: [{ role: 'user' }] },
{ id: 'id-1', role: 'assistant', text: 'Testing', editsApplied: 1, editsMissed: 0 },
]);
state = appReducer(state, {
type: 'AI_CHAT_RESET',
windowId: defaultWindowId,
});
expect(state.aiChatMessages).toEqual([]);
});
});
+155 -29
View File
@@ -1,13 +1,42 @@
import { produce } from 'immer';
import Setting from '@joplin/lib/models/Setting';
import { defaultState, defaultWindowState, State, WindowState } from '@joplin/lib/reducer';
import { defaultState, defaultWindowId, defaultWindowState, State, stateUtils, WindowState } from '@joplin/lib/reducer';
import iterateItems from './gui/ResizableLayout/utils/iterateItems';
import { LayoutItem } from './gui/ResizableLayout/utils/types';
import validateLayout from './gui/ResizableLayout/utils/validateLayout';
import Logger from '@joplin/utils/Logger';
import { ChatMessage } from '@joplin/lib/services/ai/types';
const logger = Logger.create('app.reducer');
export interface AiChatMessage {
id: string;
role: 'user' | 'assistant' | 'error' | 'separator';
text: string;
editsApplied?: number;
editsMissed?: number;
// The raw message(s) corresponding to this event
raw: ChatMessage[];
}
// Joplin Cloud degradation / budget snapshot. Populated from the provider's
// InternalChatResult after each chat() call, and persisted to Setting so it
// survives restarts and reflects plugin-driven calls even when no UI was open.
export interface AiStatus {
degraded: boolean;
tokensUsed: number;
tokensBudget: number;
lastToastShownAt: number | null;
}
export const defaultAiStatus = (): AiStatus => ({
degraded: false,
tokensUsed: 0,
tokensBudget: 0,
lastToastShownAt: null,
});
export interface AppStateRoute {
type: string;
routeName: string;
@@ -47,6 +76,11 @@ export interface AppWindowState extends WindowState {
// 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;
// In window state so the conversation survives panel hide/show (the
// layout container can swap component types and unmount the panel).
aiChatMessages: AiChatMessage[];
// Layout for secondary windows
secondaryWindowLayout: LayoutItem|null;
}
interface BackgroundWindowStates {
@@ -67,6 +101,7 @@ export interface AppState extends State, AppWindowState {
// Extra reducer keys go here
mainLayout: LayoutItem;
isResettingLayout: boolean;
aiStatus: AiStatus;
}
export const createAppDefaultWindowState = (): AppWindowState => {
@@ -80,6 +115,8 @@ export const createAppDefaultWindowState = (): AppWindowState => {
watchedResources: {},
whiteboardForceMarkdown: {},
activeNoteIsWhiteboard: false,
aiChatMessages: [],
secondaryWindowLayout: null,
};
};
@@ -102,6 +139,7 @@ export function createAppDefaultState(resourceEditWatcherDefaultState: Partial<A
startupPluginsLoaded: false,
isResettingLayout: false,
modalOverlayMessage: null,
aiStatus: defaultAiStatus(),
...resourceEditWatcherDefaultState,
};
}
@@ -115,6 +153,42 @@ const hideBackgroundDialogsWithId = produce((state: AppState, id: string) => {
}
});
const withWindowStateUpdated = <Key extends keyof AppWindowState> (
state: AppState, windowId: string, stateKey: Key, value: (oldValue: AppWindowState[Key])=> AppWindowState[Key],
) => {
return produce((state: AppState) => {
const windowState = stateUtils.windowStateById(state, windowId);
windowState[stateKey] = value(windowState[stateKey]);
})(state);
};
interface SetLayoutPropOptions {
key: string;
prop: string;
value: string;
}
const setLayoutProp = (layout: LayoutItem, { key, prop, value }: SetLayoutPropOptions) => {
let newLayout = produce(layout, (draftLayout: LayoutItem) => {
iterateItems(draftLayout, (_itemIndex: number, item: LayoutItem, _parent: LayoutItem) => {
if (!item) {
logger.warn('MAIN_LAYOUT_SET_ITEM_PROP: Found an empty item in layout: ', JSON.stringify(layout));
} else {
if (item.key === key) {
(item as unknown as Record<string, unknown>)[prop] = value;
return false;
}
}
return true;
});
});
if (newLayout !== layout) newLayout = validateLayout(newLayout);
return newLayout;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Redux actions are heterogeneous; typing this would require an action-type union and many narrowing casts inside the switch
export default function(state: AppState, action: any) {
let newState = state;
@@ -232,45 +306,97 @@ export default function(state: AppState, action: any) {
};
break;
case 'MAIN_LAYOUT_SET':
case 'AI_CHAT_APPEND':
newState = withWindowStateUpdated(
state, action.windowId, 'aiChatMessages', messages => [...messages, action.message as AiChatMessage],
);
break;
case 'AI_CHAT_ADD_TOOL_RESULT':
newState = withWindowStateUpdated(
state, action.windowId, 'aiChatMessages', messages => {
let lastMessage = messages[messages.length - 1];
if (lastMessage) {
const toolCall = action.toolCall;
const error = toolCall.isError;
const editsApplied = (lastMessage.editsApplied ?? 0) + (error ? 0 : 1);
const editsMissed = (lastMessage.editsMissed ?? 0) + (error ? 1 : 0);
lastMessage = {
...lastMessage,
editsApplied,
editsMissed,
raw: [
...lastMessage.raw,
action.toolCall,
],
};
return [...messages.slice(0, messages.length - 1), lastMessage];
}
return messages;
},
);
break;
case 'AI_CHAT_REMOVE':
newState = withWindowStateUpdated(
state, action.windowId, 'aiChatMessages', messages => messages.filter(m => m.id !== action.id),
);
break;
case 'AI_CHAT_RESET':
newState = withWindowStateUpdated(
state, action.windowId, 'aiChatMessages', (): AiChatMessage[] => [],
);
break;
case 'AI_STATUS_UPDATE':
// Partial merge — callers can bump `lastToastShownAt` alone after
// firing the toast without clobbering the degraded/usage numbers.
newState = {
...state,
mainLayout: action.value,
aiStatus: {
...(state.aiStatus ?? defaultAiStatus()),
...(action.payload as Partial<AiStatus>),
},
};
break;
case 'MAIN_LAYOUT_SET_ITEM_PROP':
case 'WINDOW_LAYOUT_SET':
case 'MAIN_LAYOUT_SET':
if ((action.windowId ?? defaultWindowId) === defaultWindowId) {
newState = {
...state,
mainLayout: action.value,
};
} else {
newState = withWindowStateUpdated(
state, action.windowId, 'secondaryWindowLayout', () => action.value,
);
}
break;
{
if (!state.mainLayout) {
logger.warn('MAIN_LAYOUT_SET_ITEM_PROP: Trying to set an item prop on the layout, but layout is empty: ', JSON.stringify(action));
} else {
let newLayout = produce(state.mainLayout, (draftLayout: LayoutItem) => {
iterateItems(draftLayout, (_itemIndex: number, item: LayoutItem, _parent: LayoutItem) => {
if (!item) {
logger.warn('MAIN_LAYOUT_SET_ITEM_PROP: Found an empty item in layout: ', JSON.stringify(state.mainLayout));
} else {
if (item.key === action.itemKey) {
(item as unknown as Record<string, unknown>)[action.propName] = action.propValue;
return false;
}
}
case 'WINDOW_LAYOUT_SET_ITEM_PROP':
case 'MAIN_LAYOUT_SET_ITEM_PROP': {
const updateOption = { key: action.itemKey, prop: action.propName, value: action.propValue };
return true;
});
});
if (newLayout !== state.mainLayout) newLayout = validateLayout(newLayout);
newState = {
...state,
mainLayout: newLayout,
};
}
if ((action.windowId ?? defaultWindowId) !== defaultWindowId) {
newState = withWindowStateUpdated(
state, action.windowId, 'secondaryWindowLayout', oldLayout => (
setLayoutProp(oldLayout, updateOption)
),
);
} else {
newState = {
...state,
mainLayout: setLayoutProp(state.mainLayout, updateOption),
};
}
break;
}
case 'SHOW_MODAL_MESSAGE':
newState = { ...newState, modalOverlayMessage: action.message };
+6
View File
@@ -68,6 +68,7 @@ import Note from '@joplin/lib/models/Note';
import Resource from '@joplin/lib/models/Resource';
import AiService from '@joplin/lib/services/ai/AiService';
import LocalEmbeddingProvider from '@joplin/lib/services/ai/LocalEmbeddingProvider';
import { installAiStatusBridge, AiStatusStore } from './services/aiStatusBridge';
const perfLogger = PerformanceLogger.create();
@@ -506,6 +507,11 @@ class Application extends BaseApplication {
this.initRedux();
// BaseApplication.store() is typed against the shared State; the
// runtime store carries AppState. The bridge only needs dispatch and
// getState, so narrow through unknown.
installAiStatusBridge(this.store() as unknown as AiStatusStore);
initializeCommandService(this.store(), Setting.value('env') === 'dev');
const keymapService = KeymapService.instance();
+6
View File
@@ -36,6 +36,12 @@ async function fetchLatestReleases() {
return (await response.json()) as GitHubRelease[];
}
export async function isReleaseVersion(version: string): Promise<boolean | null> {
const releases = await fetchLatestReleases();
const release = releases.find(release => release.tag_name === `v${version}`);
return release ? !release.prerelease : null;
}
function truncateText(text: string, length: number) {
let truncated = text.substring(0, length);
const lastNewLine = truncated.lastIndexOf('\n');
@@ -0,0 +1,25 @@
import * as React from 'react';
import { _ } from '@joplin/lib/locale';
import bridge from '../services/bridge';
const joplinCloudCreditsUrl = 'https://joplincloud.com/users/me';
interface Props {
// Container class — each caller owns its own SCSS scope
// (chat-panel > .degraded-status vs. .ai-status.-degraded).
className: string;
}
const AiDegradedNotice: React.FC<Props> = ({ className }) => (
<div className={className} role='status'>
{_('AI is running in reduced-quality mode — monthly allowance exceeded. Credits replenish on a rolling 30-day basis, or you can')}
{' '}
<a
href='#'
onClick={(e) => { e.preventDefault(); void bridge().openExternal(joplinCloudCreditsUrl); }}
>{_('buy more credits')}</a>
{'.'}
</div>
);
export default AiDegradedNotice;
@@ -0,0 +1,56 @@
import * as React from 'react';
import { _ } from '@joplin/lib/locale';
import { AiChatMessage } from '../../app.reducer';
import InlineMarkdownDisplay from '../InlineMarkdownDisplay';
import { ChatMessage, ChatRole } from '@joplin/lib/services/ai/types';
const editsSummary = (actions: ChatMessage[], applied: number, missed: number) => {
if (applied + missed === 0) return '';
if (missed === 0 && applied > 1) return _('%d edit(s) applied.', applied);
if (missed === 0) {
const toolResults = actions.filter(action => action.role === ChatRole.Tool);
return toolResults.map(result => result.userDescription).join('\n');
}
return _('%d edit(s) applied, %d could not be placed automatically.', applied, missed);
};
interface Props {
message: AiChatMessage;
}
const ChatMessageItem: React.FC<Props> = ({ message }) => {
if (message.role === 'separator') {
return <div className='separator'>{message.text}</div>;
}
if (message.role === 'error') {
return <div className='error'>{message.text}</div>;
}
const summary = message.role === 'assistant' ? editsSummary(message.raw, message.editsApplied ?? 0, message.editsMissed ?? 0) : '';
// Always show something in the message box:
const textContent = !message.text && !summary ? _('(no message)') : message.text;
const renderMarkdown = message.role === 'assistant';
const content = renderMarkdown
? <InlineMarkdownDisplay
className='content'
markdown={textContent}
allowLinks={false} />
: <div className='content'>{textContent}</div>;
return (
<div className={`chat-message turn -${message.role}`}>
{content}
{summary && (
<div className='meta'>
{(message.editsMissed ?? 0) > 0
? <span className='warning'>{summary}</span>
: <span>{summary}</span>}
</div>
)}
</div>
);
};
export default ChatMessageItem;
@@ -0,0 +1,436 @@
import * as React from 'react';
import { useCallback, useContext, useEffect, useId, useMemo, useRef, useState } from 'react';
import { connect } from 'react-redux';
import { Dispatch } from 'redux';
import { _ } from '@joplin/lib/locale';
import Setting from '@joplin/lib/models/Setting';
import Note from '@joplin/lib/models/Note';
import CommandService from '@joplin/lib/services/CommandService';
import Logger from '@joplin/utils/Logger';
import { stateUtils } from '@joplin/lib/reducer';
import { AiChatMessage, AppState } from '../../app.reducer';
import { runNoteChat } from '@joplin/lib/services/ai/noteChat';
import { chatAvailability } from '@joplin/lib/services/ai/availability';
import { WindowIdContext } from '../NewWindowOrIFrame';
import AiDegradedNotice from '../AiDegradedNotice';
import { ChatMessage, ChatRole, ChatToolMessage } from '@joplin/lib/services/ai/types';
import JoplinError from '@joplin/lib/JoplinError';
import eventManager, { EventName, ItemChangeEvent } from '@joplin/lib/eventManager';
import { Second } from '@joplin/utils/time';
import ChatMessageItem from './ChatMessageItem';
const logger = Logger.create('ChatPanel');
interface Props {
themeId: number;
available: boolean;
unavailableHint: string;
providerType: string;
noteId: string | null;
noteTitle: string;
noteIsEncrypted: boolean;
messages: AiChatMessage[];
aiDegraded: boolean;
dispatch: Dispatch;
}
const disclosureSetting = 'ai.chat.disclosureAcknowledged';
let nextMessageId = 0;
const makeId = () => `m-${Date.now()}-${++nextMessageId}`;
const waitForNextNoteChangeOrTimeout = (noteId: string, timeout: number) => {
return new Promise<void>((resolve) => {
const listener = (event: ItemChangeEvent) => {
if (event.itemId === noteId) {
onComplete();
}
};
const onComplete = () => {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
eventManager.off(EventName.ItemChange, listener);
resolve();
};
eventManager.on(EventName.ItemChange, listener);
let timeoutId = setTimeout(() => {
onComplete();
}, timeout);
});
};
const useCancelCallback = () => {
const abortControllerRef = useRef(new AbortController());
const cancelRequest = useCallback(() => {
abortControllerRef.current.abort();
abortControllerRef.current = new AbortController();
}, []);
// Cancel on Reset / unmount so an in-flight reply can detect it should
// abort instead of landing in a cleared or destroyed conversation.
const cancelRequestRef = useRef(cancelRequest);
cancelRequestRef.current = cancelRequest;
useEffect(() => () => {
cancelRequestRef.current();
}, []);
return { abortControllerRef, cancelRequest };
};
const useHasFocus = () => {
const [hasFocus, setHasFocus] = useState(false);
const onFocus = useCallback(() => setHasFocus(true), []);
const onBlur: React.FocusEventHandler<HTMLDivElement> = useCallback((event) => {
const chatPanelContainer = event.currentTarget;
const newElementWithFocus = event.relatedTarget;
const movingFocusOut = !chatPanelContainer.contains(newElementWithFocus);
// Avoid quickly toggling hasFocus: onBlur/onFocus are emitted when moving focus between
// elements within the container.
if (movingFocusOut) {
setHasFocus(false);
}
}, []);
return { hasFocus, onFocus, onBlur };
};
// Single-window for v1: mapStateToProps hard-codes defaultWindowId and the
// toggle writes to the app-wide layout. A second window would mirror the main.
const ChatPanel: React.FC<Props> = (props) => {
const { dispatch, messages } = props;
const [input, setInput] = useState('');
const [sending, setSending] = useState(false);
const [disclosureShown, setDisclosureShown] = useState<boolean>(() => {
try {
return !!Setting.value(disclosureSetting);
} catch {
return false;
}
});
const lastNoteIdRef = useRef<string | null>(props.noteId);
const messagesLengthRef = useRef(messages.length);
messagesLengthRef.current = messages.length;
// Lets async work detect note switches without re-running its closure.
const noteIdRef = useRef(props.noteId);
noteIdRef.current = props.noteId;
const messagesEndRef = useRef<HTMLDivElement>(null);
const { hasFocus, onFocus, onBlur } = useHasFocus();
const { abortControllerRef, cancelRequest } = useCancelCallback();
const windowId = useContext(WindowIdContext);
const appendMessage = useCallback((message: AiChatMessage) => {
dispatch({ type: 'AI_CHAT_APPEND', windowId, message });
}, [dispatch, windowId]);
const addToolResult = useCallback((result: ChatToolMessage) => {
dispatch({ type: 'AI_CHAT_ADD_TOOL_RESULT', windowId, toolCall: result });
}, [dispatch, windowId]);
// Drop a separator when the active note changes mid-conversation. Skip
// the first ever opened note (no prior context to separate from).
useEffect(() => {
const prev = lastNoteIdRef.current;
lastNoteIdRef.current = props.noteId;
if (prev === null || prev === props.noteId || !props.noteId) return;
if (messagesLengthRef.current === 0) return;
appendMessage({
id: makeId(),
role: 'separator',
text: _('— now viewing: %s —', props.noteTitle || _('(untitled)')),
raw: [],
});
}, [props.noteId, props.noteTitle, appendMessage]);
useEffect(() => {
if (messages.length === 0) return;
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const conversationTurns = useMemo<ChatMessage[]>(() => {
return messages
.map(m => m.raw)
.flat();
}, [messages]);
// Joplin Cloud is remote but the user already consented via sync setup.
const requiresDisclosure = props.providerType !== 'joplin-cloud';
const showDisclosure = requiresDisclosure && !disclosureShown && messages.length === 0;
const handleCancel = useCallback(() => {
cancelRequest();
appendMessage({ id: makeId(), role: 'assistant', text: _('(Cancelled)'), raw: [] });
}, [cancelRequest, appendMessage]);
const handleSend = useCallback(async () => {
const text = input.trim();
if (!text || sending) return;
if (!props.noteId) {
appendMessage({
id: makeId(), role: 'error', text: _('Open a note to start chatting.'), raw: [],
});
return;
}
const abortController = abortControllerRef.current;
const noteIdAtStart = props.noteId;
setSending(true);
setInput('');
// Captured so we can roll it back on failure — otherwise a retry would
// send the prior user turn as history alongside the new prompt.
const userTurnId = makeId();
let hadSuccessfulResponse = false;
appendMessage({
id: userTurnId,
role: 'user',
text,
raw: [{ role: ChatRole.User, content: text }],
});
try {
const note = await Note.load(props.noteId);
if (!note) throw new Error(`Note not found: ${props.noteId}`);
const getContext = async () => {
let selection = '';
try {
selection = await CommandService.instance().executeInWindow(
'selectedText', { windowId, args: [] },
) as string || '';
} catch {
// Editor may not be ready; treat as no selection.
}
const note = await Note.load(props.noteId);
if (!note) throw new Error(`Note not found: ${props.noteId}`);
return {
body: note.body,
title: note.title,
selection,
};
};
let lastHistory = [
{ role: ChatRole.System, content: 'placeholder' },
...conversationTurns,
];
const onHistoryChanged = (history: ChatMessage[]) => {
if (abortController.signal.aborted) return;
for (let i = lastHistory.length; i < history.length; i++) {
const entry = history[i];
// User messages are added elsewhere, when the user submits the chat
if (entry.role === ChatRole.User) continue;
// System messages are not shown in the UI
if (entry.role === ChatRole.System) continue;
hadSuccessfulResponse = true;
if (entry.role === ChatRole.Tool) {
addToolResult(entry);
} else {
appendMessage({
id: makeId(),
role: entry.role,
text: entry.content,
raw: [entry],
});
}
}
lastHistory = history;
};
const assertSameNote = () => {
if (noteIdAtStart !== noteIdRef.current) {
cancelRequest();
throw new JoplinError(
_('You switched notes while the request was running; edits were not applied. Try again.'), 'aiNoteChanged',
);
}
};
await runNoteChat(
getContext, conversationTurns, text, {
replaceSelection: async (text, oldText) => {
if (text === oldText) return;
assertSameNote();
const changeListener = waitForNextNoteChangeOrTimeout(props.noteId, Second);
await CommandService.instance().executeInWindow('replaceSelection', {
windowId,
args: [text],
});
await changeListener;
},
updateNoteBody: async (newBody, oldBody) => {
if (newBody === oldBody) return;
assertSameNote();
const changeListener = waitForNextNoteChangeOrTimeout(props.noteId, Second);
await CommandService.instance().executeInWindow('editor.setText', {
windowId,
args: [newBody],
});
await changeListener;
},
displayError: (message) => {
appendMessage({ id: makeId(), role: 'error', text: message, raw: [] });
},
}, onHistoryChanged, abortController.signal,
);
} catch (error) {
logger.warn('Chat failed:', error);
if (abortController.signal.aborted) return;
if (!hadSuccessfulResponse) {
dispatch({ type: 'AI_CHAT_REMOVE', windowId, id: userTurnId });
setInput(text);
}
appendMessage({ id: makeId(), role: 'error', text: error.message || _('Something went wrong.'), raw: [] });
} finally {
setSending(false);
}
}, [input, sending, props.noteId, conversationTurns, windowId, addToolResult, appendMessage, dispatch, cancelRequest, abortControllerRef]);
const handleAcknowledgeDisclosure = useCallback(() => {
Setting.setValue(disclosureSetting, true);
setDisclosureShown(true);
}, []);
const handleReset = useCallback(() => {
cancelRequest();
dispatch({ type: 'AI_CHAT_RESET', windowId: windowId });
}, [dispatch, windowId, cancelRequest]);
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Don't send while an IME composition is in flight — Enter commits
// the composition for CJK / accented input.
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
if (!sending) void handleSend();
}
}, [handleSend, sending]);
const renderContent = () => {
if (!props.available) {
const content = (
<div className='disabled-message'>
{props.unavailableHint}
</div>
);
return { content, showingMessages: false };
}
if (props.noteIsEncrypted) {
const content = (
<div className='disabled-message'>
{_('This note is encrypted and cannot be used with AI Chat.')}
</div>
);
return { content, showingMessages: false };
}
const content = <>
{props.aiDegraded && <AiDegradedNotice className='degraded-status' />}
<div className='messages' aria-live={hasFocus ? 'polite' : undefined}>
{messages.length === 0 && (
<div className='empty'>
{_('Ask about this note, or request changes. Select text in the editor first to scope the request to that selection.')}
</div>
)}
{messages.map(m => <ChatMessageItem key={m.id} message={m}/>)}
<div ref={messagesEndRef} />
</div>
<div className='composer'>
{showDisclosure && (
<div className='disclosure'>
{_('Your note will be sent to the configured AI provider (%s).', props.providerType)}
{' '}
<a href='#' onClick={(e) => { e.preventDefault(); handleAcknowledgeDisclosure(); }}>{_('Don\'t show again')}</a>
</div>
)}
<div className='input-wrapper'>
<textarea
className='input'
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={_('Ask about this note, or request a change…')}
aria-label={_('Chat message')}
/>
<button
type='button'
className='send'
onClick={sending ? handleCancel : handleSend}
disabled={!sending && !input.trim()}
aria-label={sendButtonLabel}
title={sendButtonLabel}
>
<i className={sending ? 'fas fa-stop' : 'fas fa-paper-plane'} aria-hidden='true' />
</button>
</div>
</div>
</>;
return { content, showingMessages: messages.length > 0 };
};
const sendButtonLabel = sending ? _('Stop generating') : _('Send');
const headerId = useId();
const { content, showingMessages } = renderContent();
return (
<div
className='chat-panel'
role='region'
aria-labelledby={headerId}
onFocus={onFocus}
onBlur={onBlur}
>
<div className='header'>
<h1 className='title' id={headerId}>{_('AI Chat')}</h1>
{showingMessages && (
<button type='button' className='reset' onClick={handleReset}>{_('Reset')}</button>
)}
</div>
{content}
</div>
);
};
interface OwnProps {
windowId: string;
}
const mapStateToProps = (state: AppState, ownProps: OwnProps) => {
const windowState = stateUtils.windowStateById(state, ownProps.windowId);
const noteId = stateUtils.selectedNoteId(windowState);
const note = noteId ? windowState.notes.find(n => n.id === noteId) : null;
const availability = chatAvailability();
return {
themeId: state.settings.theme,
available: availability.available,
unavailableHint: availability.hint ?? '',
providerType: state.settings['ai.chat.providerType'] || 'openai-compatible',
noteId,
noteTitle: note?.title || '',
noteIsEncrypted: !!note?.encryption_applied,
messages: windowState.aiChatMessages || [],
aiDegraded: !!state.aiStatus?.degraded,
};
};
export default connect(mapStateToProps)(ChatPanel);
@@ -0,0 +1,222 @@
.chat-panel {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
box-sizing: border-box;
background-color: var(--joplin-background-color);
color: var(--joplin-color);
border-left: 1px solid var(--joplin-divider-color);
font-size: var(--joplin-font-size);
}
.chat-panel > .header {
display: flex;
flex-direction: row;
align-items: center;
padding: 6px 10px;
border-bottom: 1px solid var(--joplin-divider-color);
font-weight: 600;
}
.chat-panel > .header > .title {
flex: 1;
font-size: inherit;
margin: 0;
}
.chat-panel > .header > .reset {
background: none;
border: none;
color: var(--joplin-color-faded);
cursor: pointer;
font-size: 0.85em;
padding: 2px 6px;
}
.chat-panel > .header > .reset:hover {
color: var(--joplin-color);
}
.chat-panel > .degraded-status {
padding: 6px 10px;
font-size: 0.8em;
color: var(--joplin-color-warn);
background-color: var(--joplin-background-color3);
border-bottom: 1px solid var(--joplin-divider-color);
line-height: 1.3;
> a {
color: var(--joplin-url-color);
text-decoration: underline;
}
}
.chat-panel > .messages {
flex: 1;
overflow-y: auto;
padding: 8px;
display: flex;
flex-direction: column;
gap: 8px;
}
.chat-panel > .messages > .empty {
color: var(--joplin-color-faded);
font-style: italic;
text-align: center;
margin-top: 20px;
padding: 0 20px;
line-height: 1.5;
}
.chat-panel > .messages > .turn {
padding: 8px 10px;
border-radius: 6px;
overflow-wrap: anywhere;
line-height: 1.4;
}
// User turn uses the sidebar colour scheme (dark blue bg, white text).
.chat-panel > .messages > .turn.-user {
background-color: var(--joplin-background-color2);
color: var(--joplin-color2);
align-self: flex-end;
max-width: 90%;
> .content {
white-space: pre-wrap;
}
}
// Assistant turn uses the secondary panel scheme (light gray in light theme,
// near-black in dark theme), with regular text colour so contrast is correct
// in both themes. align-self stretch so a short reply still fills the column
// width instead of leaving empty space on the right.
.chat-panel > .messages > .turn.-assistant {
background-color: var(--joplin-background-color3);
color: var(--joplin-color);
align-self: stretch;
}
.chat-panel > .messages > .turn > .meta {
font-size: 0.8em;
color: var(--joplin-color-faded);
margin-top: 4px;
white-space: pre-wrap;
}
.chat-panel > .messages > .turn > .meta > .warning {
color: var(--joplin-color-warn);
}
.chat-panel > .messages > .separator {
text-align: center;
color: var(--joplin-color-faded);
font-size: 0.8em;
margin: 6px 0;
}
.chat-panel > .messages > .error {
color: var(--joplin-color-warn);
background-color: var(--joplin-background-color3);
border-left: 3px solid var(--joplin-color-warn);
padding: 6px 10px;
border-radius: 0 4px 4px 0;
}
.chat-panel > .composer {
border-top: 1px solid var(--joplin-divider-color);
padding: 8px;
display: flex;
flex-direction: column;
gap: 6px;
}
.chat-panel > .composer > .disclosure {
font-size: 0.8em;
color: var(--joplin-color-faded);
padding: 4px 0;
}
// Compose box: an outer wrapper that visually behaves as the input, with the
// real <textarea> filling it and the send button floating in the
// bottom-right corner.
.chat-panel > .composer > .input-wrapper {
position: relative;
display: flex;
border: 1px solid var(--joplin-divider-color);
border-radius: 4px;
background-color: var(--joplin-background-color);
&:focus-within {
border-color: var(--joplin-background-color5);
}
}
.chat-panel > .composer > .input-wrapper > .input {
flex: 1;
resize: none;
min-height: 70px;
max-height: 200px;
padding: 8px 44px 8px 10px;
border: none;
border-radius: 4px;
background-color: transparent;
color: var(--joplin-color);
font-family: inherit;
font-size: inherit;
outline: none;
}
.chat-panel > .composer > .input-wrapper > .send {
position: absolute;
right: 6px;
bottom: 6px;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--joplin-background-color5);
color: var(--joplin-color5);
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9em;
padding: 0;
}
.chat-panel > .composer > .input-wrapper > .send:hover:not(:disabled) {
background-color: var(--joplin-background-color-hover5);
}
.chat-panel > .composer > .input-wrapper > .send:active:not(:disabled) {
background-color: var(--joplin-background-color-active5);
}
.chat-panel > .composer > .input-wrapper > .send:disabled {
opacity: 0.4;
cursor: not-allowed;
}
// Spinner animation while the request is in flight.
.chat-panel > .composer > .input-wrapper > .send > .fa-spinner {
animation: chat-panel-spin 0.9s linear infinite;
}
@keyframes chat-panel-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.chat-panel > .disabled-message {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
text-align: center;
color: var(--joplin-color-faded);
line-height: 1.5;
}
@@ -7,6 +7,8 @@ import bridge from '../../services/bridge';
import Setting, { AppType, SettingMetadataSection, SettingValueType, SyncStartupOperation } from '@joplin/lib/models/Setting';
import { AppState } from '../../app.reducer';
import EncryptionConfigScreen from '../EncryptionConfigScreen/EncryptionConfigScreen';
import NoteLockSettings from './controls/NoteLockSettings';
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
import { reg } from '@joplin/lib/registry';
import { connect } from 'react-redux';
import { themeStyle } from '@joplin/lib/theme';
@@ -21,6 +23,7 @@ import { normalizeQuery } from '@joplin/lib/components/shared/config/config-sear
import { searchResultGroups, matchedSearchSections } from './configSearch';
import MacOSMissingPasswordHelpLink from './controls/MissingPasswordHelpLink';
import AiIndexStatus from './controls/AiIndexStatus';
import AiStatus from './controls/AiStatus';
const { KeymapConfigScreen } = require('../KeymapConfig/KeymapConfigScreen');
import SettingComponent, { UpdateSettingValueEvent } from './controls/SettingComponent';
import shim, { MessageBoxType } from '@joplin/lib/shim';
@@ -268,6 +271,11 @@ class ConfigScreenComponent extends React.Component<any, any> {
);
}
settingComps.push(<AiIndexStatus key='ai_index_status' />);
settingComps.push(<AiStatus key='ai_status' />);
}
if (section.name === 'noteLock' && isNoteLockEnabled()) {
settingComps.push(<NoteLockSettings key='note_lock_settings'/>);
}
if (section.name === 'sync') {
@@ -23,6 +23,7 @@ const indexerStateLabel = (s: IndexStatus['indexerState']) => {
case 'running': return _('Indexing…');
case 'ai-disabled': return _('AI is disabled');
case 'index-disabled': return _('Indexing is disabled');
case 'vector-search-unavailable': return _('Vector search is not available on this platform');
}
};
@@ -0,0 +1,17 @@
.ai-status {
margin-top: 16px;
padding: 10px 14px;
border-radius: 4px;
line-height: 1.4;
&.-degraded {
background: var(--joplin-background-color3);
color: var(--joplin-color-warn);
border: 1px solid var(--joplin-color-warn);
}
> a {
color: var(--joplin-url-color);
text-decoration: underline;
}
}
@@ -0,0 +1,19 @@
import * as React from 'react';
import { connect } from 'react-redux';
import AiDegradedNotice from '../../AiDegradedNotice';
import { AppState } from '../../../app.reducer';
interface Props {
degraded: boolean;
}
const AiStatus: React.FC<Props> = ({ degraded }) => {
if (!degraded) return null;
return <AiDegradedNotice className='ai-status -degraded' />;
};
const mapStateToProps = (state: AppState) => ({
degraded: !!state.aiStatus?.degraded,
});
export default connect(mapStateToProps)(AiStatus);
@@ -0,0 +1,54 @@
.note-lock-settings > .section {
padding-bottom: 16px;
margin-bottom: 16px;
border-bottom: 1px solid var(--joplin-divider-color);
}
.note-lock-settings > .section:last-child {
padding-bottom: 0;
margin-bottom: 0;
border-bottom: 0;
}
.note-lock-settings > .section > h2 {
margin-top: 0;
}
.note-lock-settings > .section > .form {
max-width: 420px;
}
.note-lock-settings > .section > .form > .buttons {
display: flex;
gap: 8px;
margin-top: 12px;
}
.note-lock-settings > .section > .form > .buttons + .buttons {
margin-top: 20px;
}
.note-lock-settings > .section > .form > .description {
margin-top: 8px;
margin-bottom: 0;
color: var(--joplin-color-faded);
font-style: italic;
line-height: var(--joplin-line-height);
}
.note-lock-settings > .section > .form > .reminder {
margin-top: 16px;
margin-bottom: 0;
}
.note-lock-settings > .section > .form > .warning {
padding: 12px;
border-left: 3px solid var(--joplin-color-warn);
background-color: var(--joplin-background-color);
color: var(--joplin-color);
line-height: 1.4;
}
.note-lock-settings > .section > .form > .error {
color: var(--joplin-color-error);
}
@@ -0,0 +1,251 @@
import * as React from 'react';
import { useCallback, useState } from 'react';
import { connect } from 'react-redux';
import { _ } from '@joplin/lib/locale';
import NoteLockKey from '@joplin/lib/services/noteLock/NoteLockKey';
import NoteLockSession from '@joplin/lib/services/noteLock/NoteLockSession';
import shim, { MessageBoxType } from '@joplin/lib/shim';
import { SyncInfo } from '@joplin/lib/services/synchronizer/syncInfoUtils';
import LabelledPasswordInput from '../../PasswordInput/LabelledPasswordInput';
import Button, { ButtonLevel } from '../../Button/Button';
import { AppState } from '../../../app.reducer';
import useNoteLockMode, { ActionMode } from './useNoteLockMode';
interface Props {
hasNoteLockKey: boolean;
needsNoteLockKeyUpgrade: boolean;
}
// WebCrypto reports a wrong password as an OperationError with an unhelpful generic message
const errorMessage = (error: unknown) => {
if (!(error instanceof Error)) return String(error);
if (error.name === 'OperationError') return _('Invalid password');
return error.message;
};
const NoteLockSettings: React.FC<Props> = props => {
const hasKey = props.hasNoteLockKey;
const {
mode,
onModeChange,
currentPassword,
setCurrentPassword,
password,
setPassword,
passwordRepeat,
setPasswordRepeat,
error,
setError,
} = useNoteLockMode(hasKey);
const [upgradePassword, setUpgradePassword] = useState('');
const [upgradeError, setUpgradeError] = useState('');
const [saving, setSaving] = useState(false);
const passwordMismatch = !!passwordRepeat && password !== passwordRepeat;
const canSave = !!password && !!passwordRepeat && !passwordMismatch && !saving && (mode !== ActionMode.Change || !!currentPassword);
const canUpgrade = !!upgradePassword && !saving;
const forgotPasswordDescription = _('Only do this if you\'ve forgotten your current password. A new password will be created, and any notes locked with the old one will become permanently unreadable.');
const forgotPasswordWarning = `${_('Warning:')} ${forgotPasswordDescription}`;
const resetConfirmationMessage = `${forgotPasswordWarning}\n\n${_('Continue?')}`;
const onCurrentPasswordChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
setCurrentPassword(event.target.value);
}, [setCurrentPassword]);
const onPasswordChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
setPassword(event.target.value);
}, [setPassword]);
const onPasswordRepeatChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
setPasswordRepeat(event.target.value);
}, [setPasswordRepeat]);
const onUpgradePasswordChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
setUpgradePassword(event.target.value);
}, []);
const submit = useCallback(async () => {
if (!canSave) return;
if (mode === ActionMode.Reset) {
const response = await shim.showMessageBox(resetConfirmationMessage, {
buttons: [_('Yes'), _('No')],
type: MessageBoxType.Confirm,
});
if (response !== 0) return;
}
setSaving(true);
setError('');
try {
const noteLockKey = NoteLockKey.instance();
if (mode === ActionMode.Create) {
await noteLockKey.create(password);
} else if (mode === ActionMode.Change) {
await noteLockKey.changePassword(currentPassword, password);
} else if (mode === ActionMode.Reset) {
await NoteLockSession.instance().reset(password);
}
onModeChange(ActionMode.Collapsed);
} catch (error) {
setError(errorMessage(error));
} finally {
setSaving(false);
}
}, [canSave, onModeChange, currentPassword, mode, password, setError, resetConfirmationMessage]);
const submitUpgrade = useCallback(async () => {
if (!canUpgrade) return;
setSaving(true);
setUpgradeError('');
try {
await NoteLockKey.instance().upgrade(upgradePassword);
setUpgradePassword('');
} catch (error) {
setUpgradeError(errorMessage(error));
} finally {
setSaving(false);
}
}, [canUpgrade, upgradePassword]);
const onChangePasswordClick = useCallback(() => {
onModeChange(ActionMode.Change);
}, [onModeChange]);
const onForgotPasswordClick = useCallback(() => {
onModeChange(ActionMode.Reset);
}, [onModeChange]);
const onCancel = useCallback(() => {
onModeChange(ActionMode.Collapsed);
}, [onModeChange]);
const resetPasswordTitle = `⚠️ ${_('Reset password')} ⚠️`;
const actionButtonTitle = mode === ActionMode.Reset ? resetPasswordTitle : _('Save');
const getSectionTitle = () => {
if (mode === ActionMode.Reset) return resetPasswordTitle;
if (mode === ActionMode.Change) return _('Change note lock password');
if (mode === ActionMode.Collapsed) return _('Manage password');
return _('Password setup');
};
const renderManageButtons = () => {
return (
<div className='form'>
<div className='buttons'>
<Button
title={_('Change note lock password')}
level={ButtonLevel.Secondary}
onClick={onChangePasswordClick}
/>
</div>
<div className='buttons'>
<Button
title={_('Forgot password?')}
level={ButtonLevel.Secondary}
onClick={onForgotPasswordClick}
/>
</div>
<p className='description'>{forgotPasswordDescription}</p>
</div>
);
};
const renderPasswordForm = () => {
return (
<div className='form'>
{mode === ActionMode.Reset && <p className='warning' role='alert'><strong>{_('Warning:')}</strong> {forgotPasswordDescription}</p>}
{mode === ActionMode.Change && (
<LabelledPasswordInput
labelText={_('Current password')}
value={currentPassword}
onChange={onCurrentPasswordChange}
reserveIconGutter={true}
/>
)}
<LabelledPasswordInput
labelText={mode === ActionMode.Create ? _('Password') : _('New password')}
value={password}
onChange={onPasswordChange}
reserveIconGutter={true}
/>
<LabelledPasswordInput
labelText={_('Repeat password')}
value={passwordRepeat}
onChange={onPasswordRepeatChange}
valid={passwordRepeat ? !passwordMismatch : undefined}
reserveIconGutter={true}
/>
{passwordMismatch && <p className='error'>{_('Passwords do not match')}</p>}
{error ? <p className='error' role='alert'>{error}</p> : null}
<div className='buttons'>
<Button
title={actionButtonTitle}
level={mode === ActionMode.Reset ? ButtonLevel.Secondary : ButtonLevel.Primary}
disabled={!canSave}
onClick={submit}
/>
{mode !== ActionMode.Create && (
<Button
title={_('Cancel')}
level={ButtonLevel.Secondary}
disabled={saving}
onClick={onCancel}
/>
)}
</div>
<p className='reminder'><strong>{_('Please make sure you remember your password. It cannot be recovered if lost, and any data encrypted with it will become inaccessible.')}</strong></p>
</div>
);
};
const renderUpgradeSection = () => {
if (!props.needsNoteLockKeyUpgrade) return null;
return (
<div className='section'>
<h2>{_('Key upgrade')}</h2>
<p>{_('The note lock key uses an out-dated encryption algorithm and it is recommended to upgrade it. The upgraded key will still be able to decrypt and encrypt your data as usual.')}</p>
<div className='form'>
<LabelledPasswordInput
labelText={_('Password')}
value={upgradePassword}
onChange={onUpgradePasswordChange}
reserveIconGutter={true}
/>
{upgradeError ? <p className='error' role='alert'>{upgradeError}</p> : null}
<div className='buttons'>
<Button
title={_('Upgrade key')}
level={ButtonLevel.Primary}
disabled={!canUpgrade}
onClick={submitUpgrade}
/>
</div>
</div>
</div>
);
};
return (
<div className='note-lock-settings'>
<div className='section'>
<h2>{getSectionTitle()}</h2>
{mode === ActionMode.Collapsed ? renderManageButtons() : renderPasswordForm()}
</div>
{renderUpgradeSection()}
</div>
);
};
const mapStateToProps = (state: AppState) => {
return {
hasNoteLockKey: !!new SyncInfo(state.settings['syncInfoCache']).noteLockKey,
needsNoteLockKeyUpgrade: NoteLockKey.instance().needsUpgrade(),
};
};
export default connect(mapStateToProps)(NoteLockSettings);
@@ -0,0 +1,50 @@
import { useCallback, useEffect, useState } from 'react';
export enum ActionMode {
Collapsed = 'collapsed',
Create = 'create',
Change = 'change',
Reset = 'reset',
}
const useNoteLockMode = (hasKey: boolean) => {
const [mode, setMode] = useState<ActionMode>(hasKey ? ActionMode.Collapsed : ActionMode.Create);
const [currentPassword, setCurrentPassword] = useState('');
const [password, setPassword] = useState('');
const [passwordRepeat, setPasswordRepeat] = useState('');
const [error, setError] = useState('');
const clearForm = useCallback(() => {
setCurrentPassword('');
setPassword('');
setPasswordRepeat('');
setError('');
}, []);
const onModeChange = useCallback((newMode: ActionMode) => {
setMode(newMode);
clearForm();
}, [clearForm]);
// The key can also appear or disappear through sync while this screen is open;
// clear any drafted input along with the mode switch.
useEffect(() => {
if (hasKey && mode === ActionMode.Create) onModeChange(ActionMode.Collapsed);
if (!hasKey && mode !== ActionMode.Create) onModeChange(ActionMode.Create);
}, [hasKey, mode, onModeChange]);
return {
mode,
onModeChange,
currentPassword,
setCurrentPassword,
password,
setPassword,
passwordRepeat,
setPasswordRepeat,
error,
setError,
};
};
export default useNoteLockMode;
@@ -1,6 +1,8 @@
@use "./styles/index.scss";
@use "./Sidebar/style.scss" as sidebar-styles;
@use "./controls/AiIndexStatus.scss" as ai-index-status;
@use "./controls/AiStatus.scss" as ai-status;
@use "./controls/NoteLockSettings.scss" as note-lock-settings;
.config-screen-content-wrapper {
padding: 24px;
@@ -54,6 +56,8 @@
overflow: auto;
padding: var(--joplin-config-screen-padding);
padding-top: 0;
padding-bottom: 0;
align-items: flex-start;
display: flex;
flex: 1;
+7 -1
View File
@@ -11,6 +11,10 @@ const Root = styled.h1<{ justifyContent?: string }>`
margin-bottom: 1em;
`;
const Title = styled.span<{ justifyContent?: string }>`
text-align: ${props => props.justifyContent === 'center' ? 'center' : 'start'};
`;
interface Props {
title: string;
@@ -19,6 +23,8 @@ interface Props {
export default function DialogTitle(props: Props) {
return (
<Root justifyContent={props.justifyContent}>{props.title}</Root>
<Root justifyContent={props.justifyContent}>
<Title justifyContent={props.justifyContent}>{props.title}</Title>
</Root>
);
}
@@ -350,8 +350,7 @@ export const EncryptionConfigScreen = (props: Props) => {
}
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- PasswordInput's ChangeEventHandler is typed as a custom {value} event but the runtime hands a React.ChangeEvent through
const onPasswordInputChange = (event: any) => {
const onPasswordInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setEnableEncryptionError('');
setEnableEncryptionPassword(event.target.value);
};
@@ -0,0 +1,63 @@
import * as React from 'react';
import { useEffect, useRef } from 'react';
import MarkdownIt = require('markdown-it');
import { isResourceUrl } from '@joplin/lib/urlUtils';
import { isHttpOrHttpsUrl } from '@joplin/utils/url';
import CommandService from '@joplin/lib/services/CommandService';
interface Props {
className: string;
markdown: string;
allowLinks: boolean;
}
const InlineMarkdownDisplay: React.FC<Props> = props => {
const outputElementRef = useRef<HTMLDivElement|null>(null);
useEffect(() => {
outputElementRef.current.replaceChildren(
renderMarkdownToElement(props.markdown, { allowLinks: props.allowLinks }),
);
}, [props.markdown, props.allowLinks]);
return <div className={`inline-markdown ${props.className}`} ref={outputElementRef} />;
};
export default InlineMarkdownDisplay;
interface RenderOptions {
allowLinks: boolean;
}
const renderMarkdownToElement = (markdown: string, { allowLinks }: RenderOptions) => {
// Since we're including the output in the main document, use markdown-it directly with minimal
// settings (e.g. HTML rendering disabled).
const markdownIt = MarkdownIt()
.set({
// Match Joplin's main renderer
breaks: false,
})
.disable([
'image',
allowLinks ? '' : 'link',
].filter(item => !!item));
const rendered = markdownIt.render(markdown);
const markdownContainer = document.createElement('div');
markdownContainer.innerHTML = rendered;
// Make links clickable
for (const link of markdownContainer.querySelectorAll<HTMLAnchorElement>('a[href]')) {
const href = link.getAttribute('href');
const url = isResourceUrl(href) || isHttpOrHttpsUrl(href) ? href : '#';
link.href = '#';
link.title = url;
link.onclick = (event) => {
event.preventDefault();
void CommandService.instance().execute('openItem', url);
};
}
return markdownContainer;
};
@@ -108,7 +108,7 @@ const JoplinCloudScreenComponent = (props: Props) => {
) : null}
</p>
{state.active === 'LINK_USED' ? <div className="loading-animation" /> : null}
<JoplinCloudSignUpCallToAction />
{state.active !== 'COMPLETED' ? <JoplinCloudSignUpCallToAction source='desktop-login-screen' withLeadIn={true} /> : null}
</div>
<ButtonBar onCancelClick={() => props.dispatch({ type: 'NAV_BACK' })} />
</div>
@@ -1,14 +1,34 @@
import { _ } from '@joplin/lib/locale';
import * as React from 'react';
import bridge from '../services/bridge';
import Button, { ButtonLevel } from './Button/Button';
const JoplinCloudSignUpCallToAction = () => {
interface Props {
source?: string;
primary?: boolean;
withLeadIn?: boolean;
}
const JoplinCloudSignUpCallToAction = (props: Props) => {
const source = props.source ?? 'desktop-app';
const onJoplinCloudSignUpClick = async () => {
await bridge().openExternal('https://joplinapp.org/plans/');
await bridge().openExternal(`https://joplinapp.org/plans/?source=${encodeURIComponent(source)}`);
};
if (props.primary) {
return <div className="joplin-cloud-sign-up -primary">
<Button
level={ButtonLevel.Primary}
title={_('Sign up to Joplin Cloud')}
onClick={onJoplinCloudSignUpClick}
/>
</div>;
}
return <div className="joplin-cloud-sign-up">
{props.withLeadIn ? `${_('Don\'t have an account yet?')} ` : null}
<a
href="#"
onClick={onJoplinCloudSignUpClick}
+173
View File
@@ -0,0 +1,173 @@
import * as React from 'react';
import { useContext, useRef } from 'react';
import NoteListWrapper from './NoteListWrapper/NoteListWrapper';
import { RenderItemEvent } from './ResizableLayout/ResizableLayout';
import Sidebar from './Sidebar/Sidebar';
import { WindowIdContext } from './NewWindowOrIFrame';
import { NoteListColumns } from '@joplin/lib/services/plugins/api/noteListType';
import NoteEditor from './NoteEditor/NoteEditor';
import ChatPanel from './ChatPanel/ChatPanel';
import { PluginHtmlContents, PluginStates, utils as pluginUtils } from '@joplin/lib/services/plugins/reducer';
import { _ } from '@joplin/lib/locale';
import UserWebview from '../services/plugins/UserWebview';
import removeItem from './ResizableLayout/utils/removeItem';
import { LayoutItem } from './ResizableLayout/utils/types';
import { AppState } from '../app.reducer';
import { connect } from 'react-redux';
import { stateUtils } from '@joplin/lib/reducer';
import validateColumns from './NoteListHeader/utils/validateColumns';
interface Props {
themeId: number;
listRendererId: string;
startupPluginsLoaded: boolean;
notesSortOrderField: string;
notesColumns: NoteListColumns;
selectedFolderId: string;
notesSortOrderReverse: boolean;
plugins: PluginStates;
pluginHtmlContents: PluginHtmlContents;
contentKey: string;
event: RenderItemEvent;
layout: LayoutItem;
onNoteTitleChange?: (title: string)=> void;
onUpdateLayout: (layout: LayoutItem)=> void;
}
const MainLayoutPane: React.FC<Props> = (props) => {
const { contentKey, event } = props;
const windowId = useContext(WindowIdContext);
const cancelLayoutRemoveRef = useRef(0);
// Key should never be undefined but somehow it can happen, also not
// clear how. For now in this case render nothing so that the app
// doesn't crash.
// https://discourse.joplinapp.org/t/rearranging-the-pannels-crushed-the-app-and-generated-fatal-error/14373?u=laurent
if (!contentKey) {
console.error('resizableLayout_renderItem: Trying to render an item using an empty key. Full layout is:', props.layout);
return null;
}
const eventEmitter = event.eventEmitter;
// const viewsToRemove:string[] = [];
const components: Record<string, ()=> React.ReactNode> = {
sideBar: () => {
return <Sidebar />;
},
noteList: () => {
return <NoteListWrapper
resizableLayoutEventEmitter={eventEmitter}
visible={event.visible}
size={event.size}
themeId={props.themeId}
listRendererId={props.listRendererId}
startupPluginsLoaded={props.startupPluginsLoaded}
notesSortOrderField={props.notesSortOrderField}
notesSortOrderReverse={props.notesSortOrderReverse}
columns={props.notesColumns}
selectedFolderId={props.selectedFolderId}
/>;
},
editor: () => {
return <div className='note-editor-wrapper' role='main' aria-label={_('Note')}>
<NoteEditor
windowId={windowId}
onTitleChange={props.onNoteTitleChange}
startupPluginsLoaded={props.startupPluginsLoaded}
/>
</div>;
},
chatPanel: () => {
return <ChatPanel windowId={windowId} />;
},
};
if (components[contentKey]) return components[contentKey]();
const viewsToRemove: string[] = [];
if (contentKey.indexOf('plugin-view') === 0) {
const viewInfo = pluginUtils.viewInfoByViewId(props.plugins, event.item.key);
if (!viewInfo) {
// Once all startup plugins have loaded, we know that all the
// views are ready so we can remove the orphans ones.
//
// Before they are loaded, there might be views that don't match
// any plugins, but that's only because it hasn't loaded yet.
if (props.startupPluginsLoaded) {
console.warn(`Could not find plugin associated with view: ${event.item.key}`);
viewsToRemove.push(event.item.key);
}
} else {
const { view, plugin } = viewInfo;
const html = props.pluginHtmlContents[plugin.id]?.[view.id] ?? '';
return <UserWebview
key={view.id}
viewId={view.id}
themeId={props.themeId}
html={html}
scripts={view.scripts}
pluginId={plugin.id}
borderBottom={true}
fitToContent={false}
/>;
}
} else {
// The layout may reference a component that no longer exists -
// for example a panel from a previous version of the app, or a
// feature that has been removed. Rather than crash, log the issue
// and queue the item for removal from the layout.
console.warn(`Invalid layout component: ${contentKey} - it will be removed from the layout`);
viewsToRemove.push(contentKey);
}
if (viewsToRemove.length) {
const cancelCounter = cancelLayoutRemoveRef.current + 1;
cancelLayoutRemoveRef.current = cancelCounter;
window.requestAnimationFrame(() => {
if (cancelCounter !== cancelLayoutRemoveRef.current) return;
let newLayout = props.layout;
for (const itemKey of viewsToRemove) {
newLayout = removeItem(newLayout, itemKey);
}
if (newLayout !== props.layout) {
console.warn('Removed invalid views:', viewsToRemove);
props.onUpdateLayout(newLayout);
}
});
}
return null;
};
interface OwnProps {
windowId: string;
}
export default connect((state: AppState, ownProps: OwnProps) => {
const windowState = stateUtils.windowStateById(state, ownProps.windowId);
return {
themeId: state.settings.theme,
plugins: state.pluginService.plugins,
pluginHtmlContents: state.pluginService.pluginHtmlContents,
startupPluginsLoaded: state.startupPluginsLoaded,
listRendererId: state.settings['notes.listRendererId'],
selectedFolderId: windowState.selectedFolderId,
notesSortOrderField: state.settings['notes.sortOrder.field'],
notesSortOrderReverse: state.settings['notes.sortOrder.reverse'],
notesColumns: validateColumns(state.settings['notes.columns']),
};
})(MainLayoutPane);
+121 -132
View File
@@ -7,13 +7,10 @@ import { move } from './ResizableLayout/utils/movements';
import { LayoutItem } from './ResizableLayout/utils/types';
import CommandService from '@joplin/lib/services/CommandService';
import { PluginHtmlContents, PluginStates, utils as pluginUtils } from '@joplin/lib/services/plugins/reducer';
import Sidebar from './Sidebar/Sidebar';
import UserWebview from '../services/plugins/UserWebview';
import UserWebviewDialog from '../services/plugins/UserWebviewDialog';
import { ContainerType } from '@joplin/lib/services/plugins/WebviewController';
import { defaultWindowId, StateLastDeletion, stateUtils } from '@joplin/lib/reducer';
import { _ } from '@joplin/lib/locale';
import NoteListWrapper from './NoteListWrapper/NoteListWrapper';
import { AppState } from '../app.reducer';
import { saveLayout, loadLayout } from './ResizableLayout/utils/persist';
import Setting from '@joplin/lib/models/Setting';
@@ -37,25 +34,23 @@ import { MasterKeyEntity } from '@joplin/lib/services/e2ee/types';
import invitationRespond from '@joplin/lib/services/share/invitationRespond';
import restart from '../services/restart';
import { connect } from 'react-redux';
import { NoteListColumns } from '@joplin/lib/services/plugins/api/noteListType';
import validateColumns from './NoteListHeader/utils/validateColumns';
import TrashNotification from './TrashNotification/TrashNotification';
import UpdateNotification from './UpdateNotification/UpdateNotification';
import NoteEditor from './NoteEditor/NoteEditor';
import PluginNotification from './PluginNotification/PluginNotification';
import { Toast } from '@joplin/lib/services/plugins/api/types';
import PluginService from '@joplin/lib/services/plugins/PluginService';
import QuitSyncDialog from './QuitSyncDialog';
import Logger from '@joplin/utils/Logger';
import checkForUpdates, { isReleaseVersion } from '../checkForUpdates';
const logger = Logger.create('MainScreen');
import { ipcRenderer } from 'electron';
import layoutKeyToLabel from '../utils/layout/layoutKeyToLabel';
import MainLayoutPane from './MainLayoutPane';
interface Props {
plugins: PluginStates;
pluginHtmlContents: PluginHtmlContents;
pluginsLoaded: boolean;
hasNotesBeingSaved: boolean;
dispatch: Dispatch;
mainLayout: LayoutItem;
@@ -69,21 +64,16 @@ interface Props {
showNeedUpgradingMasterKeyMessage: boolean;
showShouldReencryptMessage: boolean;
themeId: number;
startupPluginsLoaded: boolean;
shareInvitations: ShareInvitation[];
isSafeMode: boolean;
enableLegacyMarkdownEditor: boolean;
needApiAuth: boolean;
processingShareInvitationResponse: boolean;
isResettingLayout: boolean;
listRendererId: string;
lastDeletion: StateLastDeletion;
lastDeletionNotificationTime: number;
selectedFolderId: string;
mustUpgradeAppMessage: string;
notesSortOrderField: string;
notesSortOrderReverse: boolean;
notesColumns: NoteListColumns;
syncTargetAppMinVersion: string;
showInvalidJoplinCloudCredential: boolean;
toast: Toast;
shouldSwitchToAppleSiliconVersion: boolean;
@@ -100,6 +90,8 @@ interface State {
noteContentPropertiesDialogOptions: Record<string, unknown>;
shareNoteDialogOptions: Record<string, unknown>;
shareFolderDialogOptions: ShareFolderDialogOptions;
syncTargetAppMinVersionIsRelease: boolean | null;
didSyncTargetAppMinVersionReleaseLoadFail: boolean;
}
const StyledUserWebviewDialogContainer = styled.div`
@@ -118,22 +110,11 @@ const defaultLayout: LayoutItem = {
children: [
{ key: 'sideBar', width: 250 },
{ key: 'noteList', width: 250 },
{ key: 'editor' },
{ key: 'editor', flexible: true },
{ key: 'chatPanel', width: 340, visible: false },
],
};
const layoutKeyToLabel = (key: string, plugins: PluginStates) => {
if (key === 'sideBar') return _('Sidebar');
if (key === 'noteList') return _('Note list');
if (key === 'editor') return _('Editor');
const viewInfo = pluginUtils.viewInfoByViewId(plugins, key);
if (viewInfo) {
return PluginService.instance().safePluginNameById(viewInfo.plugin.id);
}
return key;
};
class MainScreenComponent extends React.Component<Props, State> {
private waitForNotesSavedIID_: ReturnType<typeof setInterval>;
@@ -153,6 +134,8 @@ class MainScreenComponent extends React.Component<Props, State> {
visible: false,
folderId: '',
},
syncTargetAppMinVersionIsRelease: null,
didSyncTargetAppMinVersionReleaseLoadFail: false,
};
this.updateMainLayout(this.buildLayout(props.plugins));
@@ -233,10 +216,22 @@ class MainScreenComponent extends React.Component<Props, State> {
private buildLayout(plugins: PluginStates): LayoutItem {
const rootLayoutSize = this.rootLayoutSize();
const userLayout = Setting.value('ui.layout');
let userLayout = Setting.value('ui.layout');
let output = null;
try {
// Migration: stamp the flexible flag on the editor and clear any
// stale width before validateLayout's first pass.
if (userLayout && Object.keys(userLayout).length) {
userLayout = produce(userLayout as LayoutItem, (draft: LayoutItem) => {
const editor = findItemByKey(draft, 'editor');
if (editor && !editor.flexible) {
editor.flexible = true;
delete editor.width;
}
});
}
output = loadLayout(Object.keys(userLayout).length ? userLayout : null, defaultLayout, rootLayoutSize);
// For unclear reasons, layout items sometimes end up without a key.
@@ -250,6 +245,14 @@ class MainScreenComponent extends React.Component<Props, State> {
if (!findItemByKey(output, 'sideBar') || !findItemByKey(output, 'noteList') || !findItemByKey(output, 'editor')) {
throw new Error('"sideBar", "noteList" and "editor" must be present in the layout');
}
// Migration: existing users have layouts saved before chatPanel
// existed. Add it (hidden) so the toggle works.
if (!findItemByKey(output, 'chatPanel')) {
output = produce(output, (draft: LayoutItem) => {
draft.children.push({ key: 'chatPanel', width: 340, visible: false });
});
}
} catch (error) {
console.warn('Could not load layout - restoring default layout:', error);
console.warn('Layout was:', userLayout);
@@ -350,6 +353,13 @@ class MainScreenComponent extends React.Component<Props, State> {
value: false,
});
}
if (
this.props.mustUpgradeAppMessage !== prevProps.mustUpgradeAppMessage ||
this.props.syncTargetAppMinVersion !== prevProps.syncTargetAppMinVersion
) {
void this.loadSyncTargetAppMinVersionIsRelease();
}
}
public layoutModeListenerKeyDown(event: KeyboardEvent) {
@@ -360,6 +370,7 @@ class MainScreenComponent extends React.Component<Props, State> {
public componentDidMount() {
window.addEventListener('keydown', this.layoutModeListenerKeyDown);
void this.loadSyncTargetAppMinVersionIsRelease();
}
public componentWillUnmount() {
@@ -367,6 +378,29 @@ class MainScreenComponent extends React.Component<Props, State> {
window.removeEventListener('keydown', this.layoutModeListenerKeyDown);
}
private async loadSyncTargetAppMinVersionIsRelease() {
const version = this.props.syncTargetAppMinVersion;
this.setState({
syncTargetAppMinVersionIsRelease: null,
didSyncTargetAppMinVersionReleaseLoadFail: false,
});
if (!this.props.mustUpgradeAppMessage || !version) return;
try {
const syncTargetAppMinVersionIsRelease = await isReleaseVersion(version);
if (!this.props.mustUpgradeAppMessage || this.props.syncTargetAppMinVersion !== version) return;
this.setState({
syncTargetAppMinVersionIsRelease,
didSyncTargetAppMinVersionReleaseLoadFail: syncTargetAppMinVersionIsRelease === null,
});
} catch (error) {
logger.error(error);
if (!this.props.mustUpgradeAppMessage || this.props.syncTargetAppMinVersion !== version) return;
this.setState({ didSyncTargetAppMinVersionReleaseLoadFail: true });
}
}
public rootLayoutSize() {
return {
width: window.innerWidth,
@@ -440,6 +474,17 @@ class MainScreenComponent extends React.Component<Props, State> {
</a>
);
if (!callForAction2 && message.includes(callForAction)) {
const actionIndex = message.indexOf(callForAction);
return (
<span>
{message.substring(0, actionIndex)}
{cfa}
{message.substring(actionIndex + callForAction.length)}
</span>
);
}
return (
<span>
{message}{callForAction ? ' ' : ''}
@@ -494,6 +539,10 @@ class MainScreenComponent extends React.Component<Props, State> {
shim.openUrl('https://joplinapp.org/download/');
};
const onCheckForUpdates = () => {
void checkForUpdates(false, bridge().mainWindow(), { includePreReleases: false });
};
const onRestartAndUpgrade = async () => {
Setting.setValue('sync.upgradeState', Setting.SYNC_UPGRADE_STATE_MUST_DO);
await Setting.saveAll();
@@ -575,7 +624,41 @@ class MainScreenComponent extends React.Component<Props, State> {
onViewEncryptionConfigScreen,
);
} else if (this.props.mustUpgradeAppMessage) {
msg = this.renderNotificationMessage(this.props.mustUpgradeAppMessage);
if (!this.props.syncTargetAppMinVersion) {
msg = this.renderNotificationMessage(this.props.mustUpgradeAppMessage);
} else if (this.state.didSyncTargetAppMinVersionReleaseLoadFail) {
msg = this.renderNotificationMessage(
_(
'In order to synchronise, Please upgrade your application to version %s. Joplin could not check update information.',
this.props.syncTargetAppMinVersion,
),
);
} else if (this.state.syncTargetAppMinVersionIsRelease === false && shim.isLinux()) {
const callForAction = _('Download it from GitHub Releases');
msg = this.renderNotificationMessage(
_(
'In order to synchronise, Please upgrade your application to version %s: %s or update it using your package manager',
this.props.syncTargetAppMinVersion,
callForAction,
),
callForAction,
() => shim.openUrl('https://github.com/laurent22/joplin/releases'),
);
} else if (this.state.syncTargetAppMinVersionIsRelease !== null) {
const isTargetPreRelease = this.state.syncTargetAppMinVersionIsRelease === false;
const callForAction = isTargetPreRelease ? _('Download it from GitHub Releases') : _('Check for updates');
msg = this.renderNotificationMessage(
_(
'In order to synchronise, Please upgrade your application to version %s: %s',
this.props.syncTargetAppMinVersion,
callForAction,
),
callForAction,
isTargetPreRelease ? () => shim.openUrl('https://github.com/laurent22/joplin/releases') : onCheckForUpdates,
);
} else {
msg = this.renderNotificationMessage(this.props.mustUpgradeAppMessage);
}
} else if (this.props.shouldSwitchToAppleSiliconVersion) {
msg = this.renderNotificationMessage(
_('You are running the Intel version of Joplin on an Apple Silicon processor. Download the Apple Silicon one for better performance.'),
@@ -633,102 +716,14 @@ class MainScreenComponent extends React.Component<Props, State> {
}
private resizableLayout_renderItem(key: string, event: RenderItemEvent): React.ReactNode {
// Key should never be undefined but somehow it can happen, also not
// clear how. For now in this case render nothing so that the app
// doesn't crash.
// https://discourse.joplinapp.org/t/rearranging-the-pannels-crushed-the-app-and-generated-fatal-error/14373?u=laurent
if (!key) {
console.error('resizableLayout_renderItem: Trying to render an item using an empty key. Full layout is:', this.props.mainLayout);
return null;
}
const eventEmitter = event.eventEmitter;
// const viewsToRemove:string[] = [];
const components: Record<string, ()=> React.ReactNode> = {
sideBar: () => {
return <Sidebar key={key} />;
},
noteList: () => {
return <NoteListWrapper
key={key}
resizableLayoutEventEmitter={eventEmitter}
visible={event.visible}
size={event.size}
themeId={this.props.themeId}
listRendererId={this.props.listRendererId}
startupPluginsLoaded={this.props.startupPluginsLoaded}
notesSortOrderField={this.props.notesSortOrderField}
notesSortOrderReverse={this.props.notesSortOrderReverse}
columns={this.props.notesColumns}
selectedFolderId={this.props.selectedFolderId}
/>;
},
editor: () => {
return <div className='note-editor-wrapper' role='main' aria-label={_('Note')}>
<NoteEditor
windowId={defaultWindowId}
key={key}
startupPluginsLoaded={this.props.startupPluginsLoaded}
/>
</div>;
},
};
if (components[key]) return components[key]();
const viewsToRemove: string[] = [];
if (key.indexOf('plugin-view') === 0) {
const viewInfo = pluginUtils.viewInfoByViewId(this.props.plugins, event.item.key);
if (!viewInfo) {
// Once all startup plugins have loaded, we know that all the
// views are ready so we can remove the orphans ones.
//
// Before they are loaded, there might be views that don't match
// any plugins, but that's only because it hasn't loaded yet.
if (this.props.startupPluginsLoaded) {
console.warn(`Could not find plugin associated with view: ${event.item.key}`);
viewsToRemove.push(event.item.key);
}
} else {
const { view, plugin } = viewInfo;
const html = this.props.pluginHtmlContents[plugin.id]?.[view.id] ?? '';
return <UserWebview
key={view.id}
viewId={view.id}
themeId={this.props.themeId}
html={html}
scripts={view.scripts}
pluginId={plugin.id}
borderBottom={true}
fitToContent={false}
/>;
}
} else {
throw new Error(`Invalid layout component: ${key}`);
}
if (viewsToRemove.length) {
window.requestAnimationFrame(() => {
let newLayout = this.props.mainLayout;
for (const itemKey of viewsToRemove) {
newLayout = removeItem(newLayout, itemKey);
}
if (newLayout !== this.props.mainLayout) {
console.warn('Removed invalid views:', viewsToRemove);
this.updateMainLayout(newLayout);
}
});
}
return null;
return <MainLayoutPane
key={key}
contentKey={key}
event={event}
windowId={defaultWindowId}
onUpdateLayout={this.updateMainLayout}
layout={this.props.mainLayout}
/>;
}
public renderPluginDialogs() {
@@ -814,7 +809,6 @@ class MainScreenComponent extends React.Component<Props, State> {
const mapStateToProps = (state: AppState) => {
const syncInfo = localSyncInfoFromState(state);
const showNeedUpgradingEnabledMasterKeyMessage = !!EncryptionService.instance().masterKeysThatNeedUpgrading(syncInfo.masterKeys.filter((k) => !!k.enabled)).length;
const windowState = stateUtils.windowStateById(state, defaultWindowId);
return {
themeId: state.settings.theme,
@@ -830,21 +824,16 @@ const mapStateToProps = (state: AppState) => {
hasNotesBeingSaved: stateUtils.hasNotesBeingSaved(state),
layoutMoveMode: state.layoutMoveMode,
mainLayout: state.mainLayout,
startupPluginsLoaded: state.startupPluginsLoaded,
shareInvitations: state.shareService.shareInvitations,
processingShareInvitationResponse: state.shareService.processingShareInvitationResponse,
isSafeMode: state.settings.isSafeMode,
enableLegacyMarkdownEditor: state.settings['editor.legacyMarkdown'],
needApiAuth: state.needApiAuth,
isResettingLayout: state.isResettingLayout,
listRendererId: state.settings['notes.listRendererId'],
lastDeletion: state.lastDeletion,
lastDeletionNotificationTime: state.lastDeletionNotificationTime,
selectedFolderId: windowState.selectedFolderId,
mustUpgradeAppMessage: state.mustUpgradeAppMessage,
notesSortOrderField: state.settings['notes.sortOrder.field'],
notesSortOrderReverse: state.settings['notes.sortOrder.reverse'],
notesColumns: validateColumns(state.settings['notes.columns']),
syncTargetAppMinVersion: syncInfo.appMinVersion,
showInvalidJoplinCloudCredential: state.settings['sync.target'] === 10 && state.mustAuthenticate,
toast: state.toast,
shouldSwitchToAppleSiliconVersion: shim.isAppleSilicon() && shim.isMac() && process.arch !== 'arm64',
@@ -104,18 +104,15 @@ export default function(props: Props) {
return !hasMasterPasswordEncryptedData;
}, [mode, showCurrentPassword, hasMasterPasswordEncryptedData]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- PasswordInput's ChangeEventHandler is typed as a custom {value} event but the runtime hands a React.ChangeEvent through
const onCurrentPasswordChange = useCallback((event: any) => {
const onCurrentPasswordChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
setCurrentPassword(event.target.value);
}, []);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Same as onCurrentPasswordChange
const onPasswordChange1 = useCallback((event: any) => {
const onPasswordChange1 = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
setPassword1(event.target.value);
}, []);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Same as onCurrentPasswordChange
const onPasswordChange2 = useCallback((event: any) => {
const onPasswordChange2 = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
setPassword2(event.target.value);
}, []);
+2 -1
View File
@@ -863,6 +863,8 @@ function useMenu(props: Props) {
menuItemDic.openNoteInNewWindow,
menuItemDic.toggleExternalEditing,
separator(),
menuItemDic.linkToNote,
menuItemDic.copyNoteExternalLink,
menuItemDic.setTags,
menuItemDic.showShareNoteDialog,
menuItemDic.convertNoteToMarkdown,
@@ -973,7 +975,6 @@ function useMenu(props: Props) {
rootMenus.go.submenu.push(menuItemDic.gotoAnything);
rootMenus.tools.submenu.push(menuItemDic.commandPalette);
rootMenus.tools.submenu.push(menuItemDic.linkToNote);
rootMenus.tools.submenu.push(menuItemDic.openMasterPasswordDialog);
for (const view of props.pluginMenuItems) {
@@ -12,6 +12,7 @@ import { SecondaryWindowApi } from '../utils/window/types';
export const WindowIdContext = createContext(defaultWindowId);
type OnCloseCallback = ()=> void;
type OnSetWindowCallback = (window: Window)=> void;
export enum WindowMode {
Iframe, NewWindow,
@@ -25,6 +26,7 @@ interface Props {
mode: WindowMode;
windowId: string;
onClose: OnCloseCallback;
onWindow: OnSetWindowCallback;
}
const useDocument = (
@@ -147,6 +149,15 @@ const NewWindowOrIFrame: React.FC<Props> = props => {
}
}, [doc, props.windowId]);
const onWindowRef = useRef(props.onWindow);
onWindowRef.current = props.onWindow;
useEffect(() => {
const win = doc?.defaultView;
if (win && onWindowRef.current) {
onWindowRef.current(win);
}
}, [doc]);
const parentNode = loaded ? doc?.body : null;
const wrappedChildren = <WindowIdContext.Provider value={props.windowId}>{props.children}</WindowIdContext.Provider>;
@@ -1,6 +1,5 @@
import * as React from 'react';
import { useCallback, useMemo, useRef, useState } from 'react';
import NoteEditor from './NoteEditor';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import StyleSheetContainer from '../StyleSheets/StyleSheetContainer';
import { connect } from 'react-redux';
import { AppState } from '../../app.reducer';
@@ -13,14 +12,20 @@ import { StyleSheetManager } from 'styled-components';
import createCache from '@emotion/cache';
import { CacheProvider } from '@emotion/react';
import { stateUtils } from '@joplin/lib/reducer';
import ResizableLayout, { RenderItemEvent } from '../ResizableLayout/ResizableLayout';
import { LayoutItem } from '../ResizableLayout/utils/types';
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
import layoutKeyToLabel from '../../utils/layout/layoutKeyToLabel';
import MainLayoutPane from '../MainLayoutPane';
interface Props {
dispatch: Dispatch;
themeId: number;
layout: LayoutItem;
plugins: PluginStates;
newWindow: boolean;
windowId: string;
startupPluginsLoaded: boolean;
}
const emptyCallback = () => {};
@@ -37,17 +42,75 @@ const useWindowTitle = (isNewWindow: boolean) => {
return { windowTitle: `Joplin - ${title}`, onNoteTitleChange: setTitle };
};
const SecondaryWindow: React.FC<Props> = props => {
const containerRef = useRef<HTMLDivElement>(null);
const defaultLayout = {
key: 'root',
isRoot: true,
width: 500,
height: 500,
children: [
{ key: 'editor' },
{ key: 'chatPanel', width: 340, visible: false },
],
};
const useLayout = ({ windowId, layout, plugins, dispatch }: Props) => {
const [window, setWindow] = useState<Window|null>(null);
layout ??= defaultLayout;
const onUpdateLayout = useCallback((newLayout: LayoutItem) => {
dispatch({
type: 'WINDOW_LAYOUT_SET',
windowId,
value: newLayout,
});
}, [dispatch, windowId]);
const onResize = useCallback((event: { layout: LayoutItem }) => {
onUpdateLayout(event.layout);
}, [onUpdateLayout]);
const onKeyToLabel = useCallback((key: string) => {
return layoutKeyToLabel(key, plugins);
}, [plugins]);
const currentLayoutRef = useRef(layout);
currentLayoutRef.current = layout;
const onRefreshLayoutSize = useCallback((window: Window) => {
const layout = currentLayoutRef.current;
if (layout.width !== window.innerWidth || layout.height !== window.innerHeight) {
const newLayout = {
...currentLayoutRef.current,
width: window.innerWidth,
height: window.innerHeight,
};
onUpdateLayout(newLayout);
}
}, [onUpdateLayout]);
useEffect(() => {
if (!window) return ()=>{};
const onWindowResize = () => onRefreshLayoutSize(window);
window.addEventListener('resize', onWindowResize);
onWindowResize();
return () => {
window.removeEventListener('resize', onWindowResize);
};
}, [window, onRefreshLayoutSize]);
return {
layout,
onWindow: setWindow,
onResize,
onUpdate: onUpdateLayout,
onKeyToLabel,
};
};
const SecondaryWindow: React.FC<Props> = props => {
const { windowTitle, onNoteTitleChange } = useWindowTitle(props.newWindow);
const editor = <div className='note-editor-wrapper' ref={containerRef}>
<NoteEditor
windowId={props.windowId}
onTitleChange={onNoteTitleChange}
startupPluginsLoaded={props.startupPluginsLoaded}
/>
</div>;
const newWindow = props.newWindow;
const onWindowClose = useCallback(() => {
@@ -56,17 +119,42 @@ const SecondaryWindow: React.FC<Props> = props => {
}
}, [props.dispatch, props.windowId, newWindow]);
const layout = useLayout(props);
const onRenderItem = useCallback((key: string, event: RenderItemEvent) => {
return <MainLayoutPane
key={key}
contentKey={key}
windowId={props.windowId}
onNoteTitleChange={onNoteTitleChange}
event={event}
onUpdateLayout={layout.onUpdate}
layout={layout.layout}
/>;
}, [props.windowId, onNoteTitleChange, layout.layout, layout.onUpdate]);
return <NewWindowOrIFrame
onWindow={layout.onWindow}
mode={newWindow ? WindowMode.NewWindow : WindowMode.Iframe}
windowId={props.windowId}
onClose={onWindowClose}
title={windowTitle}
>
<LibraryStyleRoot>
<WindowCommandsAndDialogs windowId={props.windowId} />
{editor}
</LibraryStyleRoot>
<StyleSheetContainer />
<div id='react-root' className='secondary-window-root'>
<LibraryStyleRoot>
<WindowCommandsAndDialogs windowId={props.windowId} />
<ResizableLayout
layout={layout.layout}
onResize={layout.onResize}
onMoveButtonClick={() => {}}
renderItem={onRenderItem}
layoutKeyToLabel={layout.onKeyToLabel}
moveMode={false}
moveModeMessage={''}
/>
</LibraryStyleRoot>
<StyleSheetContainer />
</div>
</NewWindowOrIFrame>;
};
@@ -107,8 +195,9 @@ export default connect((state: AppState, ownProps: ConnectProps) => {
return {
themeId: state.settings.theme,
isSafeMode: state.settings.isSafeMode,
layout: windowState.secondaryWindowLayout,
codeView: windowState?.editorCodeView ?? state.settings['editor.codeView'],
legacyMarkdown: state.settings['editor.legacyMarkdown'],
startupPluginsLoaded: state.startupPluginsLoaded,
plugins: state.pluginService.plugins,
};
})(SecondaryWindow);
@@ -58,6 +58,7 @@ const mapStateToProps = (state: AppState, connectProps: ConnectProps) => {
'textItalic',
'-',
'textLink',
'linkToNote',
'textCode',
'attachFile',
'-',
@@ -26,15 +26,17 @@ const useSyncEditorValue = ({ content, visiblePanes, onMessage, editorRef, noteI
// to the current note ID.
const updateProps = { noteId: noteId };
if (editorRef.current?.updateBody(content, updateProps)) {
editorRef.current?.clearHistory();
// Only reset the cursor location when switching notes. If, for example,
// the note is updated from a secondary window, the cursor location shouldn't
// reset.
const noteChanged = lastNoteIdRef.current !== noteId;
if (noteChanged) {
// Only reset the cursor location when switching notes. If, for example,
// the note is updated from a secondary window, the cursor location shouldn't
// reset.
const cursorLocation = initialCursorLocationRef.current;
editorRef.current?.select(cursorLocation, cursorLocation);
// Only reset history when switching notes to allow, e.g., undoing changes made from another
// window.
editorRef.current?.clearHistory();
}
lastNoteIdRef.current = noteId;
@@ -692,7 +692,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
const toolbar = [
'bold', 'italic', 'joplinHighlight', 'joplinStrikethrough', '|',
'joplinInsert', 'joplinSup', 'joplinSub', 'forecolor', '|',
'link', 'joplinInlineCode', 'joplinCodeBlock', 'joplinAttach', '|',
'link', 'joplinLinkToNote', 'joplinInlineCode', 'joplinCodeBlock', 'joplinAttach', '|',
'bullist', 'numlist', 'joplinChecklist', '|',
'h1', 'h2', 'h3', '|',
'hr', '|',
@@ -822,6 +822,14 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
},
});
editor.ui.registry.addButton('joplinLinkToNote', {
tooltip: _('Link to note'),
icon: 'export',
onAction: async function() {
void CommandService.instance().execute('linkToNote');
},
});
setupToolbarButtons(editor);
editor.ui.registry.addButton('joplinCodeBlock', {
@@ -1110,6 +1118,17 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
// when the note content is updated externally.
const offsetBookmarkId = 2;
const bookmark = editor.selection.getBookmark(offsetBookmarkId);
// This is a workaround to inject missing ink style for OneNote imported notes.
// See https://github.com/laurent22/joplin/issues/15578 for more details.
const oneNoteInkContentStyleId = 'joplin-onenote-content-style';
const hasOneNoteInkContentStyle = !!editor.getDoc().getElementById(oneNoteInkContentStyleId);
if (!hasOneNoteInkContentStyle && new DOMParser().parseFromString(result.html, 'text/html').querySelector('.ink-text, .ink-space, .container-outline')) {
const styleElement = editor.getDoc().createElement('style');
styleElement.id = oneNoteInkContentStyleId;
styleElement.textContent = '.ink-text, .ink-space { display: inline-block; position: relative; vertical-align: bottom; } .container-outline { font-family: Calibri, sans-serif; font-size: 6pt; font-weight: normal; }';
editor.getDoc().head.appendChild(styleElement);
}
const htmlAndCss = [
`<style>${result.cssStrings?.join('\n')}</style>`,
preprocessHtml(result.html),
@@ -1659,4 +1678,3 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
};
export default forwardRef(TinyMCE);
@@ -1,5 +1,6 @@
import * as React from 'react';
import { ReactNode } from 'react';
import { ReactNode, useEffect, useRef } from 'react';
import { focus } from '@joplin/lib/utils/focusHandler';
interface Props {
position?: 'bottom-center' | 'top-center' | 'top-right';
@@ -49,16 +50,59 @@ export const ActionButton = ({ onClick, active, disabled, title, children }: Act
export const ActionDivider = () => <div className="divider" />;
interface ActionSwatchProps {
color: string | null;
active?: boolean;
title?: string;
onClick: ()=> void;
}
export const ActionSwatch = ({ color, active, title, onClick }: ActionSwatchProps) => {
const classes = ['swatch'];
if (active) classes.push('-active');
if (color === null) classes.push('-none');
return (
<button
type="button"
onClick={onClick}
title={title}
aria-label={title}
aria-pressed={active}
className={classes.join(' ')}
style={color ? { background: color } : undefined}
/>
);
};
interface ActionInputProps {
value: string;
placeholder?: string;
width?: number;
// Incrementing this token requests focus + select-all on the input. Using a
// counter (rather than a boolean) lets the caller re-trigger focus even
// when the token was already truthy on a previous render.
focusToken?: number;
onChange: (value: string)=> void;
}
export const ActionInput = ({ value, placeholder, width = 140, onChange }: ActionInputProps) => {
export const ActionInput = ({ value, placeholder, width = 140, focusToken, onChange }: ActionInputProps) => {
const inputRef = useRef<HTMLInputElement | null>(null);
// Skip the initial effect fire: we only want to grab focus when the caller
// bumps the token, not on mount (single-clicking an edge would otherwise
// steal focus and let stray keystrokes overwrite the label).
const lastFocusTokenRef = useRef(focusToken);
useEffect(() => {
if (focusToken === undefined) return;
if (focusToken === lastFocusTokenRef.current) return;
lastFocusTokenRef.current = focusToken;
const input = inputRef.current;
if (!input) return;
focus('ActionInput::focusToken', input);
input.select();
}, [focusToken]);
return (
<input
ref={inputRef}
type="text"
value={value}
placeholder={placeholder}
@@ -3,12 +3,14 @@ import { MarkupLanguage } from '@joplin/renderer';
import { ResourceInfos } from '../../utils/types';
import { MarkupToHtmlOptions } from '../../../hooks/useMarkupToHtml';
import { RenderResult } from '@joplin/renderer/types';
import { ThemeAppearance } from '@joplin/lib/themes/type';
export interface WhiteboardContextValue {
markupToHtml: (markupLanguage: MarkupLanguage, md: string, options?: MarkupToHtmlOptions)=> Promise<RenderResult>;
resourceInfos: ResourceInfos;
resourceDirectory: string;
themeId: number;
themeAppearance: ThemeAppearance;
onOpenRef: (ref: string)=> void;
onUpdateNode: (canvasNodeId: string, patch: Record<string, unknown>)=> void;
onPromoteTextNode: (canvasNodeId: string)=> void;
@@ -6,6 +6,7 @@ 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 { _ } from '@joplin/lib/locale';
import { WhiteboardContext } from './WhiteboardContext';
import WhiteboardSurface from './WhiteboardSurface';
@@ -159,15 +160,18 @@ const WhiteboardEditor = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBody
}));
}, [props.noteId]);
const themeAppearance = useMemo(() => themeStyle(props.themeId).appearance, [props.themeId]);
const contextValue = useMemo(() => ({
markupToHtml: props.markupToHtml,
resourceInfos: props.resourceInfos,
resourceDirectory: props.resourceDirectory,
themeId: props.themeId,
themeAppearance,
onOpenRef,
onUpdateNode,
onPromoteTextNode,
}), [props.markupToHtml, props.resourceInfos, props.resourceDirectory, props.themeId, onOpenRef, onUpdateNode, onPromoteTextNode]);
}), [props.markupToHtml, props.resourceInfos, props.resourceDirectory, props.themeId, themeAppearance, onOpenRef, onUpdateNode, onPromoteTextNode]);
if (parseError) {
return (
@@ -13,15 +13,20 @@ import {
OnConnect,
OnEdgesChange,
OnNodesChange,
OnReconnect,
ReactFlow,
ReactFlowProvider,
applyEdgeChanges,
applyNodeChanges,
reconnectEdge,
useReactFlow,
} from '@xyflow/react';
import generateId from '@joplin/lib/services/whiteboard/generateId';
import findEmptySpot from '@joplin/lib/services/whiteboard/findEmptySpot';
import { _, _n } from '@joplin/lib/locale';
import { Canvas, CanvasEdge, CanvasNode } from '@joplin/lib/services/whiteboard/jsoncanvas';
import { Canvas, CanvasColor, CanvasEdge, CanvasNode } from '@joplin/lib/services/whiteboard/jsoncanvas';
import { presetColors, resolveCanvasColor } from '@joplin/lib/services/whiteboard/presetColors';
import { useWhiteboardContext } from './WhiteboardContext';
import shim from '@joplin/lib/shim';
import Logger from '@joplin/utils/Logger';
import { webUtils } from 'electron';
@@ -32,7 +37,7 @@ import TextNode from './nodes/TextNode';
import FileNode from './nodes/FileNode';
import LinkNode from './nodes/LinkNode';
import GroupNode from './nodes/GroupNode';
import { ActionButton, ActionDivider, ActionInput, ActionPanel } from './ActionPanel';
import { ActionButton, ActionDivider, ActionInput, ActionPanel, ActionSwatch } from './ActionPanel';
const makeArrowMarker = () => ({ type: MarkerType.ArrowClosed, width: 27, height: 27, markerUnits: 'userSpaceOnUse' });
@@ -59,9 +64,11 @@ const nodeTypes: NodeTypes = {
};
const InnerSurface = ({ canvas, onChange }: Props) => {
const ctx = useWhiteboardContext();
const initial = useMemo(() => canvasToFlow(canvas), [canvas]);
const [flowNodes, setFlowNodes] = useState<WhiteboardFlowNode[]>(initial.nodes);
const [flowEdges, setFlowEdges] = useState<WhiteboardFlowEdge[]>(initial.edges);
const [labelFocusToken, setLabelFocusToken] = useState(0);
const containerRef = useRef<HTMLDivElement | null>(null);
const rf = useReactFlow();
@@ -163,6 +170,26 @@ const InnerSurface = ({ canvas, onChange }: Props) => {
setFlowEdges(prev => [...prev, edge]);
}, []);
const onReconnect: OnReconnect = useCallback((oldEdge, newConnection) => {
// `oldEdge` is the rendered edge, which carries the derived `style.stroke`
// (blue when selected) baked in by `renderedEdges`. reconnectEdge copies
// those fields onto the new edge, so it would persist that stroke into
// state and leave the edge blue-but-not-bold after deselection. Reconnect
// against the clean edge from state instead, keyed by id.
setFlowEdges(prev => {
const clean = prev.find(e => e.id === oldEdge.id);
return reconnectEdge((clean ?? oldEdge) as unknown as WhiteboardFlowEdge, newConnection, prev) as WhiteboardFlowEdge[];
});
}, []);
// Double-clicking an edge selects it exclusively and focuses the label
// input, so a user can start typing without moving the mouse to the panel.
const onEdgeDoubleClick = useCallback((_e: React.MouseEvent, edge: Edge) => {
setFlowEdges(prev => prev.map(x => ({ ...x, selected: x.id === edge.id })));
setFlowNodes(prev => prev.some(n => n.selected) ? prev.map(n => ({ ...n, selected: false })) : prev);
setLabelFocusToken(t => t + 1);
}, []);
const defaultEdgeOptions = useMemo(() => ({
markerEnd: makeArrowMarker(),
}), []);
@@ -180,44 +207,83 @@ const InnerSurface = ({ canvas, onChange }: Props) => {
return { x: cx, y: cy };
}, [rf]);
// Viewport bounds in canvas coordinates.
const viewportBounds = useCallback((): { x: number; y: number; width: number; height: number } | null => {
const view = rf.getViewport();
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return null;
return {
x: -view.x / view.zoom,
y: -view.y / view.zoom,
width: rect.width / view.zoom,
height: rect.height / view.zoom,
};
}, [rf]);
const existingRects = useCallback(() => {
return flowNodes.map(n => ({
x: n.position.x,
y: n.position.y,
width: (typeof n.width === 'number' ? n.width : (typeof n.style?.width === 'number' ? n.style.width : 0)) ?? 0,
height: (typeof n.height === 'number' ? n.height : (typeof n.style?.height === 'number' ? n.style.height : 0)) ?? 0,
}));
}, [flowNodes]);
const onAddText = useCallback(() => {
const { x: cx, y: cy } = viewportCentre();
addCanvasNode({
id: generateId(),
type: 'text',
x: cx - 100,
y: cy - 50,
width: 200,
height: 100,
x: cx - 120,
y: cy - 70,
width: 240,
height: 140,
text: _('New text card'),
});
}, [viewportCentre, addCanvasNode]);
const onAddGroup = useCallback(() => {
const { x: cx, y: cy } = viewportCentre();
const { x, y, width, height } = findEmptySpot({
existing: existingRects(),
viewport: viewportBounds(),
centre: viewportCentre(),
preferred: { width: 320, height: 240 },
min: { width: 100, height: 100 },
});
addCanvasNode({
id: generateId(),
type: 'group',
x: cx - 200,
y: cy - 140,
width: 400,
height: 280,
x,
y,
width,
height,
label: _('New group'),
});
}, [viewportCentre, addCanvasNode]);
}, [existingRects, viewportBounds, viewportCentre, addCanvasNode]);
const selectedEdges = useMemo(() => flowEdges.filter(e => e.selected), [flowEdges]);
const selectedNodes = useMemo(() => flowNodes.filter(n => n.selected), [flowNodes]);
const SELECTED_EDGE_COLOR = '#4a90e2';
const DEFAULT_SELECTION_COLOR = '#4a90e2';
const renderedEdges = useMemo<WhiteboardFlowEdge[]>(() => {
return flowEdges.map(e => {
if (!e.selected) return e;
// If the edge has a colour, use it for both the stroke and (when
// selected) the selection affordance — otherwise fall back to the
// default blue for selected edges, and to nothing (React Flow's
// theme default) for unselected ones.
const own = resolveCanvasColor(e.data?.color, ctx.themeAppearance, 'stroke');
const stroke = own ?? (e.selected ? DEFAULT_SELECTION_COLOR : undefined);
if (!stroke) 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) };
(m && typeof m === 'object') ? { ...m, color: stroke } : m;
return {
...e,
style: { ...(e.style ?? {}), stroke },
markerEnd: tint(e.markerEnd),
markerStart: tint(e.markerStart),
};
});
}, [flowEdges]);
}, [flowEdges, ctx.themeAppearance]);
const updateSelectedEdges = useCallback((patch: (e: WhiteboardFlowEdge)=> WhiteboardFlowEdge) => {
setFlowEdges(prev => prev.map(e => e.selected ? patch(e) : e));
@@ -254,6 +320,53 @@ const InnerSurface = ({ canvas, onChange }: Props) => {
updateSelectedEdges(e => ({ ...e, label }));
}, [updateSelectedEdges]);
const setSelectedColor = useCallback((color: CanvasColor | undefined) => {
setFlowEdges(prev => prev.map(e => e.selected
? { ...e, data: { ...(e.data as WhiteboardFlowEdge['data'] & object), color } }
: e));
setFlowNodes(prev => prev.map(n => {
if (!n.selected) return n;
const cn = n.data?.canvasNode;
if (!cn) return n;
const nextCanvasNode = { ...cn, ...(color ? { color } : {}) } as CanvasNode;
if (!color) delete (nextCanvasNode as { color?: CanvasColor }).color;
return { ...n, data: { ...n.data, canvasNode: nextCanvasNode } };
}));
}, []);
// A single preset "wins" as the current colour only when every selected
// item shares it. Mixed selections show no active swatch — clicking one
// still applies it to everything, which matches the arrow-mode behaviour.
const currentColor = useMemo<CanvasColor | undefined>(() => {
const colors: (CanvasColor | undefined)[] = [
...selectedEdges.map(e => e.data?.color),
...selectedNodes.map(n => n.data?.canvasNode?.color),
];
if (!colors.length) return undefined;
const first = colors[0];
return colors.every(c => c === first) ? first : undefined;
}, [selectedEdges, selectedNodes]);
const colorSwatches = (
<>
<ActionSwatch
color={null}
active={currentColor === undefined}
title={_('No colour')}
onClick={() => setSelectedColor(undefined)}
/>
{presetColors.map(p => (
<ActionSwatch
key={p.id}
color={resolveCanvasColor(p.id, ctx.themeAppearance, 'stroke') ?? '#888'}
active={currentColor === p.id}
title={p.label}
onClick={() => setSelectedColor(p.id)}
/>
))}
</>
);
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')) {
@@ -333,9 +446,13 @@ const InnerSurface = ({ canvas, onChange }: Props) => {
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onReconnect={onReconnect}
onEdgeDoubleClick={onEdgeDoubleClick}
edgesReconnectable
onNodeDragStart={onNodeDragStart}
onNodeDrag={onNodeDrag}
onNodeDragStop={onNodeDragStop}
elevateEdgesOnSelect
deleteKeyCode={['Backspace', 'Delete']}
multiSelectionKeyCode={['Shift', 'Meta', 'Control']}
selectionKeyCode={['Shift']}
@@ -367,12 +484,15 @@ const InnerSurface = ({ canvas, onChange }: Props) => {
<ActionButton onClick={() => setArrowMode('both')} active={currentArrowMode === 'both'} title={_('Bidirectional')}>↔</ActionButton>
<ActionDivider />
<ActionButton onClick={flipDirection} title={_('Swap source and target')}>{_('Flip')}</ActionButton>
<ActionDivider />
{colorSwatches}
{selectedEdges.length === 1 ? (
<>
<ActionDivider />
<ActionInput
value={typeof selectedEdges[0].label === 'string' ? selectedEdges[0].label : ''}
placeholder={_('Label')}
focusToken={labelFocusToken}
onChange={setEdgeLabel}
/>
</>
@@ -384,7 +504,9 @@ const InnerSurface = ({ canvas, onChange }: Props) => {
<ActionPanel
position="bottom-center"
caption={_n('%d card', '%d cards', selectedNodes.length, selectedNodes.length)}
/>
>
{colorSwatches}
</ActionPanel>
) : null}
</ReactFlow>
</div>
@@ -1,13 +1,16 @@
import { CSSProperties } from 'react';
import { Edge as FlowEdge, MarkerType, Node as FlowNode } from '@xyflow/react';
import { Canvas, CanvasEdge, CanvasNode, CanvasNodeSide } from '@joplin/lib/services/whiteboard/jsoncanvas';
import { Canvas, CanvasColor, 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 }>;
// The `color` field mirrors CanvasEdge.color but is stored at the flow-edge
// data level (like `label` on the flow edge itself) so live edits reflect the
// current value instead of the value captured when the edge was first loaded.
export type WhiteboardFlowEdge = FlowEdge<{ canvasEdge: CanvasEdge; color?: CanvasColor }>;
const flowTypeForCanvasType = (type: CanvasNode['type']): string => {
switch (type) {
@@ -69,7 +72,7 @@ export const canvasToFlow = (canvas: Canvas): CanvasToFlowResult => {
sourceHandle: sideToHandle(e.fromSide),
targetHandle: sideToHandle(e.toSide),
label: e.label,
data: { canvasEdge: e },
data: { canvasEdge: e, color: e.color },
type: 'default',
markerStart: e.fromEnd === 'arrow' ? arrowMarker() : undefined,
markerEnd: e.toEnd === 'none' ? undefined : arrowMarker(),
@@ -117,6 +120,7 @@ export const flowToCanvas = (
const orig = fe.data?.canvasEdge;
const fromEnd: CanvasEdge['fromEnd'] = fe.markerStart ? 'arrow' : 'none';
const toEnd: CanvasEdge['toEnd'] = fe.markerEnd ? 'arrow' : 'none';
const color = fe.data?.color;
return {
id: fe.id,
fromNode: fe.source,
@@ -124,7 +128,7 @@ export const flowToCanvas = (
fromSide: handleToSide(fe.sourceHandle),
toSide: handleToSide(fe.targetHandle),
label: typeof fe.label === 'string' ? fe.label : (orig?.label),
...(orig?.color ? { color: orig.color } : {}),
...(color ? { color } : {}),
fromEnd,
toEnd,
};
@@ -13,11 +13,13 @@ 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 { resolveCanvasColor } from '@joplin/lib/services/whiteboard/presetColors';
import { isInternalRef, RefKind, resolveFileRef } from '@joplin/lib/services/whiteboard/resolveRef';
import { useWhiteboardContext } from '../WhiteboardContext';
import { WhiteboardNodeData } from '../canvasFlow';
import useCheckboxToggle from '../useCheckboxToggle';
import handlePositions from './handlePositions';
import useCardWheel from './useCardWheel';
const logger = Logger.create('WhiteboardFileNode');
@@ -91,6 +93,7 @@ const useResolvedRef = (file: string): { resolved: ResolvedItem | null; refetch:
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 onWheel = useCardWheel();
const onDoubleClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
@@ -214,7 +217,15 @@ const FileNode = ({ data, selected }: NodeProps<{ id: string; type: 'wbFile'; da
{handlePositions.map(({ id: hid, position }) => (
<Handle key={hid} type="source" position={position} id={hid} />
))}
<div className={`whiteboard-node ${selected ? '-selected' : ''}`} onDoubleClick={onDoubleClick}>
<div
className={`whiteboard-node ${selected ? '-selected' : ''}`}
onDoubleClick={onDoubleClick}
onWheelCapture={onWheel}
style={{
borderColor: resolveCanvasColor(node.color, ctx.themeAppearance, 'stroke') ?? (selected ? '#4a90e2' : undefined),
backgroundColor: resolveCanvasColor(node.color, ctx.themeAppearance, 'fill'),
}}
>
{renderContent()}
</div>
</>
@@ -4,6 +4,7 @@ import { NodeProps, NodeResizer } from '@xyflow/react';
import { focus } from '@joplin/lib/utils/focusHandler';
import { _ } from '@joplin/lib/locale';
import { GroupCanvasNode } from '@joplin/lib/services/whiteboard/jsoncanvas';
import { resolveCanvasColor } from '@joplin/lib/services/whiteboard/presetColors';
import { useWhiteboardContext } from '../WhiteboardContext';
import { WhiteboardNodeData } from '../canvasFlow';
@@ -55,15 +56,18 @@ const GroupNode = ({ data, selected, id }: NodeProps<{ id: string; type: 'wbGrou
beginEdit();
}, [beginEdit]);
const colorStroke = resolveCanvasColor(node.color, ctx.themeAppearance, 'stroke');
const colorFill = resolveCanvasColor(node.color, ctx.themeAppearance, 'fill');
const edgeStyle = colorStroke ? { borderColor: colorStroke } : undefined;
return (
<>
<NodeResizer minWidth={80} minHeight={60} isVisible={selected && !editing} />
<div className={`whiteboard-group ${selected ? '-selected' : ''}`}>
<div className="background" />
<div className="edge -top whiteboard-group-handle" />
<div className="edge -right whiteboard-group-handle" />
<div className="edge -bottom whiteboard-group-handle" />
<div className="edge -left whiteboard-group-handle" />
<div className="background" style={colorFill ? { background: colorFill } : undefined} />
<div className="edge -top whiteboard-group-handle" style={edgeStyle} />
<div className="edge -right whiteboard-group-handle" style={edgeStyle} />
<div className="edge -bottom whiteboard-group-handle" style={edgeStyle} />
<div className="edge -left whiteboard-group-handle" style={edgeStyle} />
<div className="anchor whiteboard-group-handle" title={_('Drag to move group')} aria-label={_('Drag to move group')}>
<i className="fas fa-arrows-alt" aria-hidden="true" />
</div>
@@ -3,13 +3,16 @@ import { useCallback } from 'react';
import { Handle, NodeProps, NodeResizer } from '@xyflow/react';
import { _ } from '@joplin/lib/locale';
import { LinkCanvasNode } from '@joplin/lib/services/whiteboard/jsoncanvas';
import { resolveCanvasColor } from '@joplin/lib/services/whiteboard/presetColors';
import { useWhiteboardContext } from '../WhiteboardContext';
import { WhiteboardNodeData } from '../canvasFlow';
import handlePositions from './handlePositions';
import useCardWheel from './useCardWheel';
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 onWheel = useCardWheel();
const onDoubleClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
@@ -22,7 +25,15 @@ const LinkNode = ({ data, selected }: NodeProps<{ id: string; type: 'wbLink'; da
{handlePositions.map(({ id: hid, position }) => (
<Handle key={hid} type="source" position={position} id={hid} />
))}
<div className={`whiteboard-node ${selected ? '-selected' : ''}`} onDoubleClick={onDoubleClick}>
<div
className={`whiteboard-node ${selected ? '-selected' : ''}`}
onDoubleClick={onDoubleClick}
onWheelCapture={onWheel}
style={{
borderColor: resolveCanvasColor(node.color, ctx.themeAppearance, 'stroke') ?? (selected ? '#4a90e2' : undefined),
backgroundColor: resolveCanvasColor(node.color, ctx.themeAppearance, 'fill'),
}}
>
<div className="header">{_('Link')}</div>
<div className="body -url">{node.url}</div>
</div>
@@ -5,10 +5,12 @@ 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 { resolveCanvasColor } from '@joplin/lib/services/whiteboard/presetColors';
import { useWhiteboardContext, WhiteboardContextValue } from '../WhiteboardContext';
import { WhiteboardNodeData } from '../canvasFlow';
import useCheckboxToggle from '../useCheckboxToggle';
import handlePositions from './handlePositions';
import useCardWheel from './useCardWheel';
const useRenderedMarkdown = (md: string, ctx: WhiteboardContextValue) => {
const [html, setHtml] = useState<string>('');
@@ -38,6 +40,7 @@ const useRenderedMarkdown = (md: string, ctx: WhiteboardContextValue) => {
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 onWheel = useCardWheel();
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(node.text);
@@ -112,7 +115,16 @@ const TextNode = ({ data, selected, id }: NodeProps<{ id: string; type: 'wbText'
{handlePositions.map(({ id: hid, position }) => (
<Handle key={hid} type="source" position={position} id={hid} />
))}
<div className={`whiteboard-node -text ${selected ? '-selected' : ''}`} onDoubleClick={onDoubleClick} onKeyDown={onCardKeyDown}>
<div
className={`whiteboard-node -text ${selected ? '-selected' : ''}`}
onDoubleClick={onDoubleClick}
onKeyDown={onCardKeyDown}
onWheelCapture={onWheel}
style={{
borderColor: resolveCanvasColor(node.color, ctx.themeAppearance, 'stroke') ?? (selected ? '#4a90e2' : undefined),
backgroundColor: resolveCanvasColor(node.color, ctx.themeAppearance, 'fill'),
}}
>
{editing ? (
<textarea
ref={textareaRef}
@@ -0,0 +1,34 @@
import { WheelEvent, useCallback } from 'react';
// Wheel handler for whiteboard cards. Stops propagation whenever the card
// (or something inside it) is scrollable at all — even if currently at a
// scroll boundary. Otherwise the event bubbles out and React Flow pans the
// canvas.
//
// The boundary detail matters: if we only stopped propagation when the card
// could still scroll further, scrolling past the bottom of a scrollable
// card would suddenly jump the canvas. That's disorienting. So the rule is:
// "if this card has scrollable content, wheel belongs to the card."
const isScrollable = (el: Element): boolean => {
const style = window.getComputedStyle(el);
if (/(auto|scroll|overlay)/.test(style.overflowY) && el.scrollHeight > el.clientHeight) return true;
if (/(auto|scroll|overlay)/.test(style.overflowX) && el.scrollWidth > el.clientWidth) return true;
return false;
};
const useCardWheel = () => {
return useCallback((event: WheelEvent<HTMLElement>) => {
const root = event.currentTarget;
let el: Element | null = event.target as Element;
while (el && el !== root.parentElement) {
if (isScrollable(el)) {
event.stopPropagation();
return;
}
el = el.parentElement;
}
}, []);
};
export default useCardWheel;
@@ -60,10 +60,12 @@ $hover-bg: rgba(127, 127, 127, 0.12);
--xy-controls-button-border-color-default: var(--joplin-divider-color);
}
// Only bump the stroke width on selection — the stroke colour is set
// inline (edge's own colour, or the default blue when unset) so we don't
// override it here.
.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; }
@@ -96,6 +98,9 @@ $hover-bg: rgba(127, 127, 127, 0.12);
font-size: 12px;
height: 36px;
color: var(--joplin-color);
user-select: none;
> .input { user-select: text; }
&.-bottom-center { bottom: 16px; left: 50%; transform: translateX(-50%); }
&.-top-center { top: 16px; left: 50%; transform: translateX(-50%); }
@@ -144,6 +149,36 @@ $hover-bg: rgba(127, 127, 127, 0.12);
background: var(--joplin-background-color);
color: var(--joplin-color);
}
> .swatch {
width: 18px;
height: 18px;
border-radius: 50%;
border: 1px solid var(--joplin-divider-color);
padding: 0;
margin: 0 2px;
cursor: pointer;
background: transparent;
&:hover { transform: scale(1.15); }
&.-active { outline: 2px solid $selection-color; outline-offset: 1px; }
// "No colour" swatch: diagonal slash on empty background.
&.-none {
position: relative;
background: var(--joplin-background-color);
}
&.-none::after {
content: '';
position: absolute;
top: 50%;
left: -1px;
right: -1px;
height: 1px;
background: var(--joplin-color-faded);
transform: rotate(-45deg);
}
}
}
.whiteboard-node {
@@ -159,8 +194,12 @@ $hover-bg: rgba(127, 127, 127, 0.12);
flex-direction: column;
color: var(--joplin-color);
// Only bump the border width and shadow on selection — the border colour
// is either the node's own colour (inline style) or falls back to the
// default selection blue set inline by the renderer.
&.-selected {
border: 2px solid $selection-color;
border-width: 2px;
border-style: solid;
box-shadow: $card-shadow-selected;
}
@@ -389,7 +428,13 @@ $hover-bg: rgba(127, 127, 127, 0.12);
h2 { font-size: 15px; margin: 4px 0; }
h3 { font-size: 14px; margin: 3px 0; }
h4, h5, h6 { font-size: 13px; margin: 3px 0; }
p { margin: 4px 0; }
p { margin: 8px 0; }
// Drop the top margin on whatever comes first so a leading heading /
// paragraph / list sits flush against the card's own 8px padding
// instead of stacking margins on top of it. The renderer wraps its
// output in a `#rendered-md` div, so we also target the first child
// inside that wrapper — `:first-child` alone would only match the div.
> :first-child, > * > :first-child { margin-top: 0; }
ul, ol { margin: 4px 0; padding-left: 20px; }
li { margin: 2px 0; }
li.md-checkbox, li.joplin-checkbox { list-style: none; margin-left: -20px; }
@@ -409,19 +454,44 @@ $hover-bg: rgba(127, 127, 127, 0.12);
font-size: 12px;
background: var(--joplin-code-background-color);
color: var(--joplin-code-color);
padding: 1px 4px;
border: 1px solid var(--joplin-code-border-color);
padding: 0 3px;
border-radius: 3px;
}
pre code { background: transparent; padding: 0; border: none; }
blockquote {
margin: 4px 0;
padding-left: 8px;
border-left: 3px solid var(--joplin-divider-color);
border-left: 3px solid var(--joplin-code-border-color);
color: var(--joplin-color-faded);
}
img { max-width: 100%; height: auto; }
table { font-size: 12px; border-collapse: collapse; }
th, td { padding: 2px 6px; border: 1px solid var(--joplin-divider-color); }
hr { margin: 6px 0; border: none; border-top: 1px solid var(--joplin-divider-color); }
a { color: var(--joplin-url-color, #{$selection-color}); }
table {
font-size: 12px;
border-collapse: collapse;
border: 1px solid var(--joplin-code-border-color);
}
th, td { padding: 2px 6px; border: 1px solid var(--joplin-code-border-color); }
th {
background: var(--joplin-background-color-hover3, var(--joplin-background-color3));
border-bottom-width: 2px;
}
// Only zebra rows for tables with an explicit thead — matches the viewer
// (see noteStyle.ts) so freeform key/value tables don't get striped.
table:has(thead) tr:nth-child(even) {
background: var(--joplin-background-color-hover3, var(--joplin-background-color3));
}
hr { margin: 6px 0; border: none; border-top: 2px solid var(--joplin-divider-color); }
a {
color: var(--joplin-url-color, #{$selection-color});
&:hover { text-decoration: underline; }
}
mark {
background: var(--joplin-search-marker-background-color);
color: var(--joplin-search-marker-color);
}
// Match the viewer's checked-checkbox affordance so completed items visually
// recede without disappearing.
.md-checkbox input[type=checkbox]:checked { opacity: 0.7; }
.md-checkbox .checkbox-label-checked { opacity: 0.5; }
}
@@ -1,6 +1,9 @@
import { RefObject } from 'react';
const getWindowCommandPriority = <T extends HTMLElement> (contentContainer: RefObject<T>) => {
const getWindowCommandPriority = <T extends HTMLElement> (contentContainer: RefObject<T>, inTargetWindow: boolean) => {
// If there's an explicit target window, always prefer the runtime
if (inTargetWindow) return 3;
if (!contentContainer.current) return 0;
const containerDocument = contentContainer.current.getRootNode() as Document;
if (!containerDocument || !containerDocument.hasFocus()) return 0;
@@ -1,4 +1,4 @@
import { MutableRefObject, RefObject, Dispatch, SetStateAction, useEffect } from 'react';
import { MutableRefObject, RefObject, Dispatch, SetStateAction, useEffect, useContext } from 'react';
import { Dispatch as ReduxDispatch } from 'redux';
import { WindowCommandDependencies, NoteBodyEditorRef, OnChangeEvent, ScrollOptionTypes } from './types';
import editorCommandDeclarations, { enabledCondition } from '../editorCommandDeclarations';
@@ -6,6 +6,8 @@ import CommandService, { CommandDeclaration, CommandRuntime, CommandContext, Reg
import { formatMsToLocal } from '@joplin/utils/time';
import { reg } from '@joplin/lib/registry';
import getWindowCommandPriority from './getWindowCommandPriority';
import { State } from '@joplin/lib/reducer';
import { WindowIdContext } from '../../NewWindowOrIFrame';
const commandsWithDependencies = [
require('../commands/showLocalSearch'),
@@ -79,9 +81,12 @@ function editorCommandRuntime(
export default function useWindowCommandHandler(dependencies: HookDependencies) {
const { setShowLocalSearch, noteSearchBarRef, editorRef, titleInputRef, onBodyChange, containerRef } = dependencies;
const windowId = useContext(WindowIdContext);
useEffect(() => {
const getRuntimePriority = () => getWindowCommandPriority(containerRef);
const getRuntimePriority = (_state: State, targetWindowId: string|null) => {
return getWindowCommandPriority(containerRef, windowId === targetWindowId);
};
const deregisterCallbacks: RegisteredRuntime[] = [];
for (const declaration of editorCommandDeclarations) {
@@ -115,5 +120,5 @@ export default function useWindowCommandHandler(dependencies: HookDependencies)
runtime.deregister();
}
};
}, [editorRef, setShowLocalSearch, noteSearchBarRef, titleInputRef, onBodyChange, containerRef]);
}, [editorRef, windowId, setShowLocalSearch, noteSearchBarRef, titleInputRef, onBodyChange, containerRef]);
}
@@ -30,8 +30,15 @@ import useFocusVisible from './utils/useFocusVisible';
import { stateUtils } from '@joplin/lib/reducer';
import { connect } from 'react-redux';
import useOnNoteDoubleClick from './utils/useOnNoteDoubleClick';
import { createSelector } from 'reselect';
import { isFolderPublished, ShareType } from '@joplin/lib/services/share/reducer';
import useAutoScroll from './utils/useAutoScroll';
const selectPublishedNoteIds = createSelector(
(state: AppState) => state.shareService.shares,
shares => shares.filter(s => s.type === ShareType.Note && !!s.note_id).map(s => s.note_id),
);
const commands = {
focusElementNoteList,
};
@@ -251,6 +258,7 @@ const NoteList = (props: Props) => {
focusVisible={focusVisible && activeNoteId === note.id}
isSelected={isSelected}
isWatched={props.watchedNoteFiles.includes(note.id)}
isPublished={props.publishedNoteIds.includes(note.id) || (!!note.is_shared && !note.share_id) || props.isFolderPublished}
listRenderer={listRenderer}
dispatch={props.dispatch}
columns={props.columns}
@@ -346,6 +354,8 @@ const mapStateToProps = (state: AppState, ownProps: ConnectProps) => {
searches: state.searches,
selectedSearchId: windowState.selectedSearchId,
watchedNoteFiles: state.watchedNoteFiles,
publishedNoteIds: selectPublishedNoteIds(state),
isFolderPublished: state.notesParentType === 'Folder' ? isFolderPublished(state, windowState.selectedFolderId) : false,
provisionalNoteIds: state.provisionalNoteIds,
isInsertingNotes: state.isInsertingNotes,
noteSortOrder: state.settings['notes.sortOrder.field'],
@@ -51,3 +51,21 @@
.note-list-item-wrapper.-provisional {
opacity: 0.5;
}
.note-title-tooltip {
position: fixed;
display: none;
z-index: 10000;
max-width: 480px;
padding: 4px 8px;
background-color: var(--joplin-background-color2);
color: var(--joplin-color2);
border: 1px solid var(--joplin-divider-color);
border-radius: 4px;
font-family: var(--joplin-font-family);
font-size: var(--joplin-font-size);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
pointer-events: none;
white-space: normal;
overflow-wrap: break-word;
}
@@ -11,6 +11,8 @@ export interface Props {
notes: NoteEntity[];
dispatch: Dispatch;
watchedNoteFiles: string[];
publishedNoteIds: string[];
isFolderPublished: boolean;
plugins: PluginStates;
selectedFolderId: string;
customCss: string;
@@ -28,6 +28,7 @@ interface NoteItemProps {
style: CSSProperties;
note: NoteEntity;
isWatched: boolean;
isPublished: boolean;
isSelected: boolean;
tabIndex: number;
@@ -69,10 +70,12 @@ const NoteListItem = (props: NoteItemProps, ref: LegacyRef<HTMLDivElement>) => {
const rootElement = useRootElement(elementId);
const renderedNote = useRenderedNote(props.note, props.isSelected, props.isWatched, props.listRenderer, props.highlightedWords, props.index, props.columns);
const renderedNote = useRenderedNote(props.note, props.isSelected, props.isWatched, props.isPublished, props.listRenderer, props.highlightedWords, props.index, props.columns);
const itemEventHandlers = useMemo((): ItemEventHandlers => ({ onInputChange, onClick: null }), [onInputChange]);
const displayTitle = useMemo(() => Note.displayTitle(props.note), [props.note]);
useItemElement(
rootElement,
noteId,
@@ -84,6 +87,7 @@ const NoteListItem = (props: NoteItemProps, ref: LegacyRef<HTMLDivElement>) => {
props.onDoubleClick,
props.flow,
itemEventHandlers,
displayTitle,
);
@@ -0,0 +1,109 @@
// Singleton custom tooltip for note list items. Shown when the rendered title
// is truncated by overflow:hidden / text-overflow:ellipsis. A single tooltip
// element is shared by all rows.
let tooltipElement: HTMLDivElement | null = null;
let showTimer: ReturnType<typeof setTimeout> | null = null;
let activeRow: HTMLElement | null = null;
const showDelayMs = 500;
const viewportMargin = 4;
const getTooltipElement = () => {
if (tooltipElement) return tooltipElement;
tooltipElement = document.createElement('div');
tooltipElement.className = 'note-title-tooltip';
tooltipElement.setAttribute('role', 'tooltip');
document.body.appendChild(tooltipElement);
return tooltipElement;
};
// Looks for the element that actually holds the title text. Covers both
// built-in renderers (compact and detailed); returns null for plugin
// renderers that don't use those class hooks.
const findTitleElement = (rowElement: HTMLElement): HTMLElement | null => {
const detailed = rowElement.querySelector<HTMLElement>('[data-name="note.title"] > .content');
if (detailed) return detailed;
const compact = rowElement.querySelector<HTMLElement>('.title');
if (compact) return compact;
return null;
};
const isTruncated = (element: HTMLElement) => {
// Check descendants too, since the title text may live in a child span
// (e.g. mark.js wraps highlighted matches).
if (element.scrollWidth > element.clientWidth) return true;
for (const child of Array.from(element.children) as HTMLElement[]) {
if (child.scrollWidth > child.clientWidth) return true;
}
return false;
};
const positionTooltip = (tooltip: HTMLElement, anchor: HTMLElement) => {
const anchorRect = anchor.getBoundingClientRect();
tooltip.style.visibility = 'hidden';
tooltip.style.display = 'block';
const tooltipRect = tooltip.getBoundingClientRect();
let top = anchorRect.bottom + viewportMargin;
if (top + tooltipRect.height > window.innerHeight - viewportMargin) {
top = Math.max(viewportMargin, anchorRect.top - tooltipRect.height - viewportMargin);
}
let left = anchorRect.left;
if (left + tooltipRect.width > window.innerWidth - viewportMargin) {
left = Math.max(viewportMargin, window.innerWidth - tooltipRect.width - viewportMargin);
}
tooltip.style.top = `${Math.round(top)}px`;
tooltip.style.left = `${Math.round(left)}px`;
tooltip.style.visibility = 'visible';
};
const hideTooltip = () => {
if (showTimer) {
clearTimeout(showTimer);
showTimer = null;
}
activeRow = null;
if (!tooltipElement) return;
tooltipElement.style.display = 'none';
tooltipElement.textContent = '';
};
const scheduleShow = (anchor: HTMLElement, text: string) => {
if (showTimer) clearTimeout(showTimer);
showTimer = setTimeout(() => {
const tooltip = getTooltipElement();
tooltip.textContent = text;
positionTooltip(tooltip, anchor);
showTimer = null;
}, showDelayMs);
};
const attachNoteTitleTooltip = (rowElement: HTMLElement, displayTitle: string) => {
const onPointerEnter = () => {
const titleElement = findTitleElement(rowElement);
if (!titleElement || !isTruncated(titleElement)) {
if (activeRow === rowElement) hideTooltip();
return;
}
activeRow = rowElement;
scheduleShow(titleElement, displayTitle);
};
const onPointerLeave = () => {
if (activeRow === rowElement) hideTooltip();
};
rowElement.addEventListener('mouseenter', onPointerEnter);
rowElement.addEventListener('mouseleave', onPointerLeave);
return () => {
rowElement.removeEventListener('mouseenter', onPointerEnter);
rowElement.removeEventListener('mouseleave', onPointerLeave);
if (activeRow === rowElement) hideTooltip();
};
};
export default attachNoteTitleTooltip;
@@ -43,6 +43,7 @@ const prepareViewProps = async (
noteTags: TagEntity[],
folder: FolderEntity | null,
itemIndex: number,
noteIsPublished = false,
) => {
const output: {
note?: Record<string, unknown> & { folder?: Record<string, unknown> };
@@ -60,6 +61,8 @@ const prepareViewProps = async (
output.note.titleHtml = noteTitleHtml;
} else if (dep === 'note.isWatched') {
output.note[propName] = noteIsWatched;
} else if (dep === 'note.is_published') {
output.note[propName] = noteIsPublished;
} else if (dep === 'note.tags') {
output.note[propName] = noteTags;
} else if (dep === 'note.folder.title') {
@@ -3,6 +3,7 @@ import { Size } from '@joplin/utils/types';
import { useEffect, useRef } from 'react';
import { ItemFlow } from '@joplin/lib/services/plugins/api/noteListType';
import { ItemEventHandlers } from './types';
import attachNoteTitleTooltip from './noteTitleTooltip';
const addItemEventListeners = (
element: HTMLElement,
@@ -51,7 +52,7 @@ const addItemEventListeners = (
};
const useItemElement = (
rootElement: HTMLDivElement | null, noteId: string, noteHtml: string, focusVisible: boolean, style: React.CSSProperties, itemSize: Size, onClick: React.MouseEventHandler<HTMLDivElement>, onDoubleClick: React.MouseEventHandler<HTMLDivElement>, flow: ItemFlow, itemEventHandlers: ItemEventHandlers,
rootElement: HTMLDivElement | null, noteId: string, noteHtml: string, focusVisible: boolean, style: React.CSSProperties, itemSize: Size, onClick: React.MouseEventHandler<HTMLDivElement>, onDoubleClick: React.MouseEventHandler<HTMLDivElement>, flow: ItemFlow, itemEventHandlers: ItemEventHandlers, displayTitle = '',
) => {
const itemElement = useRef<HTMLDivElement>(null);
@@ -69,16 +70,18 @@ const useItemElement = (
element.innerHTML = noteHtml;
const { cleanup } = addItemEventListeners(element, itemEventHandlers, onClick, onDoubleClick);
const detachTooltip = displayTitle ? attachNoteTitleTooltip(element, displayTitle) : null;
rootElement.appendChild(element);
itemElement.current = element;
return () => {
cleanup();
if (detachTooltip) detachTooltip();
itemElement.current = null;
element.remove();
};
}, [rootElement, itemSize, noteHtml, noteId, flow, style, onClick, onDoubleClick, itemEventHandlers]);
}, [rootElement, itemSize, noteHtml, noteId, flow, style, onClick, onDoubleClick, itemEventHandlers, displayTitle]);
useEffect(() => {
const element = itemElement.current;
@@ -22,7 +22,7 @@ const hashContent = (content: unknown) => {
return createHash('sha1').update(JSON.stringify(content)).digest('hex');
};
export default (note: NoteEntity, isSelected: boolean, isWatched: boolean, listRenderer: ListRenderer, highlightedWords: string[], itemIndex: number, columns: NoteListColumns) => {
export default (note: NoteEntity, isSelected: boolean, isWatched: boolean, isPublished: boolean, listRenderer: ListRenderer, highlightedWords: string[], itemIndex: number, columns: NoteListColumns) => {
const [renderedNote, setRenderedNote] = useState<RenderedNote>(null);
let dependencies = columns && columns.length ? columns.map(c => c.name) as ListRendererDependency[] : [];
@@ -50,6 +50,7 @@ export default (note: NoteEntity, isSelected: boolean, isWatched: boolean, listR
note.updated_time,
isSelected,
isWatched,
isPublished,
highlightedWords,
note.encryption_applied,
JSON.stringify(columns),
@@ -71,6 +72,7 @@ export default (note: NoteEntity, isSelected: boolean, isWatched: boolean, listR
noteTags,
folder,
itemIndex,
isPublished,
);
if (event.cancelled) return null;
@@ -96,7 +98,7 @@ export default (note: NoteEntity, isSelected: boolean, isWatched: boolean, listR
};
void renderNote();
}, [note, isSelected, isWatched, listRenderer, renderedNote, columns]);
}, [note, isSelected, isWatched, isPublished, listRenderer, renderedNote, columns]);
return renderedNote;
};
@@ -59,6 +59,8 @@ const mapStateToProps = (state: AppState, ownProps: ConnectProps) => {
'editAlarm',
'toggleVisiblePanes',
'showNoteProperties',
// Always shown — the panel itself surfaces any configuration issue.
'toggleAiChat',
];
// `toggleEditorPlugin` shows for plugin editors; we extend it to also
@@ -9,6 +9,7 @@ interface Props {
value: string;
onChange: ChangeEventHandler;
valid?: boolean;
reserveIconGutter?: boolean;
}
const LabelledPasswordInput: React.FC<Props> = props => {
@@ -38,7 +39,8 @@ const LabelledPasswordInput: React.FC<Props> = props => {
></i>;
};
return <div className='labelled-password-input form-input-group'>
const rootClassName = `labelled-password-input form-input-group${props.reserveIconGutter ? ' -reserve-icon-gutter' : ''}`;
return <div className={rootClassName}>
<label htmlFor={inputId}>{props.labelText}</label>
<div className='password'>
<PasswordInput
@@ -7,4 +7,19 @@
flex-direction: row;
align-items: center;
}
}
// Keep field widths equal whether the status icon is shown or not, by
// reserving a gutter for the icon and taking it out of the row's flex flow.
&.-reserve-icon-gutter > .password {
position: relative;
padding-right: 26px;
> .password-status-icon {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
margin-left: 0;
}
}
}
@@ -1,6 +1,3 @@
import { ChangeEventHandler as ReactChangeEventHandler } from 'react';
export interface ChangeEvent {
value: string;
}
export type ChangeEventHandler = (event: ChangeEvent)=> void;
export type ChangeEventHandler = ReactChangeEventHandler<HTMLInputElement>;
@@ -0,0 +1,107 @@
import * as React from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { _, _n } from '@joplin/lib/locale';
import Folder from '@joplin/lib/models/Folder';
import DialogButtonRow, { ClickEvent } from './DialogButtonRow';
import Dialog from '@joplin/lib/components/Dialog';
import DialogTitle from './DialogTitle';
import { FolderEntity } from '@joplin/lib/services/database/types';
import ShareService from '@joplin/lib/services/share/ShareService';
import { ShareType, StateShare } from '@joplin/lib/services/share/reducer';
import { connect } from 'react-redux';
import { AppState } from '../app.reducer';
import useEncryptionWarningMessage from '@joplin/lib/components/shared/ShareNoteDialog/useEncryptionWarningMessage';
import { clipboard } from 'electron';
import useOnPublishFolderLinkClick from '@joplin/lib/components/shared/PublishFolderDialog/useOnPublishFolderLinkClick';
import useShareStatusMessage from '@joplin/lib/components/shared/ShareNoteDialog/useShareStatusMessage';
import { SharingStatus } from '@joplin/lib/components/shared/ShareNoteDialog/types';
interface Props {
themeId: number;
folderId: string;
onClose: ()=> void;
shares: StateShare[];
folders: FolderEntity[];
}
export function PublishFolderDialog(props: Props) {
const [folder, setFolder] = useState<FolderEntity>(null);
const [noteCount, setNoteCount] = useState<number>(0);
const [publishFolderStatus, setPublishFolderStatus] = useState<SharingStatus>(SharingStatus.Unknown);
const encryptionWarning = useEncryptionWarningMessage();
const publishedShare = useMemo(() => {
return props.shares.find(share => share.type === ShareType.PublishedFolder && share.folder_id === props.folderId) || null;
}, [props.folderId, props.shares]);
const loadData = useCallback(async () => {
const loadedFolder = await Folder.load(props.folderId);
const childFolderIds = await Folder.childrenIds(props.folderId);
const allFolderIds = [props.folderId, ...childFolderIds];
let loadedNoteCount = 0;
for (const folderId of allFolderIds) {
loadedNoteCount += (await Folder.noteIds(folderId)).length;
}
setFolder(loadedFolder);
setNoteCount(loadedNoteCount);
}, [props.folderId]);
useEffect(() => {
void ShareService.instance().refreshShares();
void loadData();
}, [loadData]);
const onShareUrlReady = useCallback((url: string) => {
clipboard.writeText(url);
}, []);
const publishFolderLinkButton_click = useOnPublishFolderLinkClick({
folderId: props.folderId,
publishedShare,
setPublishFolderStatus,
onShareUrlReady,
});
const onDialogButtonClick = useCallback((_event: ClickEvent) => {
props.onClose();
}, [props]);
const statusMessage = useShareStatusMessage({ sharesState: publishFolderStatus, noteCount: 1 });
return (
<Dialog>
<div className="form share-note-dialog">
<DialogTitle title={_('Publish Notebook')}/>
<div className="folder-info">
<p>{_('Notebook: %s', folder ? folder.title : '...')}</p>
<p>{_('Status: %s', publishedShare ? _('Published') : _('Not published'))}</p>
<p>{_n('%d note in notebook', '%d notes in notebook', noteCount, noteCount)}</p>
</div>
<button
disabled={!folder || [SharingStatus.Creating, SharingStatus.Synchronizing].indexOf(publishFolderStatus) >= 0}
className="share"
onClick={publishFolderLinkButton_click}
>{publishedShare ? _('Copy Shareable Link') : _('Publish notebook')}</button>
<div className="message">{statusMessage}</div>
{encryptionWarning && <div className="message">{encryptionWarning}<hr/></div>}
<DialogButtonRow
themeId={props.themeId}
onClick={onDialogButtonClick}
okButtonShow={false}
cancelButtonLabel={_('Close')}
/>
</div>
</Dialog>
);
}
const mapStateToProps = (state: AppState) => {
return {
shares: state.shareService.shares,
folders: state.folders,
};
};
export default connect(mapStateToProps)(PublishFolderDialog);
@@ -1,7 +1,7 @@
import * as React from 'react';
import { Resizable, ResizeCallback, ResizeStartCallback, Size } from 're-resizable';
import { LayoutItem } from './utils/types';
import { itemMinHeight, itemMinWidth, itemSize, LayoutItemSizes } from './utils/useLayoutItemSizes';
import { LayoutItem, LayoutItemDirection } from './utils/types';
import { EdgeFlags, itemMinHeight, itemMinWidth, itemSize, LayoutItemSizes } from './utils/useLayoutItemSizes';
interface Props {
item: LayoutItem;
@@ -14,24 +14,31 @@ interface Props {
children: React.ReactNode;
isLastChild: boolean;
visible: boolean;
edges: EdgeFlags;
}
const LayoutItemContainer: React.FC<Props> = ({
item, visible, parent, sizes, resizedItemMaxSize, onResize, onResizeStart, onResizeStop, children, isLastChild,
item, visible, parent, sizes, resizedItemMaxSize, onResize, onResizeStart, onResizeStop, children, isLastChild, edges,
}) => {
const style: React.CSSProperties = {
display: visible ? 'flex' : 'none',
flexDirection: item.direction,
};
const size: Size = itemSize(item, parent, sizes, true);
const size: Size = itemSize(item, parent, sizes, true, edges);
// Drag rules follow the runtime "last visible in render" flag so hidden
// panels shown by moveMode still get proper dividers.
const parentDir = parent?.direction;
const canResizeRight = parentDir === LayoutItemDirection.Row && !isLastChild;
const canResizeBottom = parentDir === LayoutItemDirection.Column && !isLastChild;
const className = `resizableLayoutItem rli-${item.key}`;
if (item.resizableRight || item.resizableBottom) {
if (canResizeRight || canResizeBottom) {
const enable = {
top: false,
right: !!item.resizableRight && !isLastChild,
bottom: !!item.resizableBottom && !isLastChild,
right: canResizeRight,
bottom: canResizeBottom,
left: false,
topRight: false,
bottomRight: false,
@@ -2,10 +2,11 @@ import * as React from 'react';
import { useRef, useState, useEffect, useCallback } from 'react';
import useWindowResizeEvent from './utils/useWindowResizeEvent';
import setLayoutItemProps from './utils/setLayoutItemProps';
import useLayoutItemSizes, { LayoutItemSizes, itemSize, calculateMaxSizeAvailableForItem, itemMinWidth, itemMinHeight } from './utils/useLayoutItemSizes';
import useLayoutItemSizes, { EdgeFlags, LayoutItemSizes, itemSize, calculateMaxSizeAvailableForItem } from './utils/useLayoutItemSizes';
import validateLayout from './utils/validateLayout';
import { Size, LayoutItem } from './utils/types';
import { canMove, MoveDirection } from './utils/movements';
import { buildResizeSnapshot, computeEdges, isItemVisibleInRender, lastVisibleChildIndex, planResize, ResizeStartSnapshot } from './utils/resizeLogic';
import MoveButtons, { MoveButtonClickEvent } from './MoveButtons';
import { StyledWrapperRoot, StyledMoveOverlay, MoveModeRootMessage } from './utils/style';
import type { ResizeCallback, ResizeStartCallback } from 're-resizable';
@@ -17,10 +18,7 @@ interface OnResizeEvent {
layout: LayoutItem;
}
interface ResizedItem {
key: string;
initialWidth: number;
initialHeight: number;
interface ResizedItem extends ResizeStartSnapshot {
maxSize: Size;
}
@@ -43,11 +41,7 @@ interface Props {
moveModeMessage: string;
}
function itemVisible(item: LayoutItem, moveMode: boolean) {
if (moveMode) return true;
if (item.children && !item.children.length) return false;
return item.visible !== false;
}
const itemVisible = isItemVisibleInRender;
function ResizableLayout(props: Props) {
const eventEmitter = useRef(new EventEmitter());
@@ -88,34 +82,22 @@ function ResizableLayout(props: Props) {
}
function renderLayoutItem(
item: LayoutItem, parent: LayoutItem | null, sizes: LayoutItemSizes, isVisible: boolean, isLastChild: boolean, onlyMoveControls: boolean,
item: LayoutItem, parent: LayoutItem | null, sizes: LayoutItemSizes, isVisible: boolean, isLastChild: boolean, onlyMoveControls: boolean, parentEdges: EdgeFlags = { ownRight: false, ownBottom: false, parentRight: false, parentBottom: false },
): React.ReactNode {
const edges = computeEdges(parent, isLastChild, parentEdges);
const onResizeStart: ResizeStartCallback = () => {
setResizedItem({
key: item.key,
initialWidth: sizes[item.key].width,
initialHeight: sizes[item.key].height,
...buildResizeSnapshot(item, parent, props.moveMode, sizes),
maxSize: calculateMaxSizeAvailableForItem(item, parent, sizes),
});
};
const onResize: ResizeCallback = (_event, direction, _refToElement, delta) => {
const newWidth = Math.max(itemMinWidth, resizedItem.initialWidth + delta.width);
const newHeight = Math.max(itemMinHeight, resizedItem.initialHeight + delta.height);
const newSize: { width?: number; height?: number } = {};
if (item.width) newSize.width = item.width;
if (item.height) newSize.height = item.height;
if (direction === 'bottom') {
newSize.height = newHeight;
} else {
newSize.width = newWidth;
let newLayout = props.layout;
for (const update of planResize(resizedItem, direction, delta)) {
newLayout = setLayoutItemProps(newLayout, update.key, update.props);
}
const newLayout = setLayoutItemProps(props.layout, item.key, newSize);
props.onResize({ layout: newLayout });
eventEmitter.current.emit('resize');
};
@@ -128,10 +110,10 @@ function ResizableLayout(props: Props) {
const resizedItemMaxSize = resizedItem && item.key === resizedItem.key ? resizedItem.maxSize : null;
const visible = itemVisible(item, props.moveMode);
const itemContainerProps = {
key: item.key, item, parent, sizes, resizedItemMaxSize, onResizeStart, onResizeStop, onResize, isLastChild, visible,
key: item.key, item, parent, sizes, resizedItemMaxSize, onResizeStart, onResizeStop, onResize, isLastChild, visible, edges,
};
if (!item.children) {
const size = itemSize(item, parent, sizes, false);
const size = itemSize(item, parent, sizes, false, edges);
const comp = props.renderItem(item.key, {
item: item,
@@ -145,11 +127,12 @@ function ResizableLayout(props: Props) {
{wrapper}
</LayoutItemContainer>;
} else {
const lastVisibleIdx = lastVisibleChildIndex(item, props.moveMode);
const childrenComponents = [];
for (let i = 0; i < item.children.length; i++) {
const child = item.children[i];
childrenComponents.push(
renderLayoutItem(child, item, sizes, isVisible && itemVisible(child, props.moveMode), i === item.children.length - 1, onlyMoveControls),
renderLayoutItem(child, item, sizes, isVisible && itemVisible(child, props.moveMode), i === lastVisibleIdx, onlyMoveControls, edges),
);
}
@@ -11,6 +11,7 @@ export function saveLayout(layout: LayoutItem): Partial<LayoutItem> {
'children',
'key',
'context',
'flexible',
];
return produce(layout, (draft: LayoutItem) => {
@@ -0,0 +1,197 @@
import { LayoutItem, LayoutItemDirection } from './types';
import {
buildResizeSnapshot,
computeEdges,
isSubtreeFlexible,
lastVisibleChildIndex,
nextVisibleSibling,
planResize,
ResizeStartSnapshot,
} from './resizeLogic';
describe('resizeLogic', () => {
const sampleSizes = {
sideBar: { width: 250, height: 800 },
noteList: { width: 400, height: 800 },
editor: { width: 300, height: 800 },
chatPanel: { width: 340, height: 800 },
};
test.each([
['flag directly on item', { key: 'a', flexible: true } as LayoutItem, true],
['flag on nested descendant', { key: 'a', children: [{ key: 'b', children: [{ key: 'c', flexible: true }] }] } as LayoutItem, true],
['no flag anywhere in subtree', { key: 'a', children: [{ key: 'b' }] } as LayoutItem, false],
['leaf without flag', { key: 'a' } as LayoutItem, false],
])('isSubtreeFlexible: %s', (_label, item, expected) => {
expect(isSubtreeFlexible(item)).toBe(expected);
});
test('nextVisibleSibling skips hidden siblings in normal mode', () => {
const parent: LayoutItem = {
key: 'root',
children: [
{ key: 'a' },
{ key: 'b', visible: false },
{ key: 'c' },
],
};
expect(nextVisibleSibling(parent.children[0], parent, false)?.key).toBe('c');
});
test('nextVisibleSibling includes hidden siblings in moveMode', () => {
const parent: LayoutItem = {
key: 'root',
children: [
{ key: 'a' },
{ key: 'b', visible: false },
{ key: 'c' },
],
};
expect(nextVisibleSibling(parent.children[0], parent, true)?.key).toBe('b');
});
test('lastVisibleChildIndex is moveMode-aware', () => {
const item: LayoutItem = {
key: 'root',
children: [{ key: 'a' }, { key: 'b' }, { key: 'c', visible: false }],
};
expect(lastVisibleChildIndex(item, false)).toBe(1);
expect(lastVisibleChildIndex(item, true)).toBe(2);
});
test('computeEdges: middle row child gets an own right edge only', () => {
const parent: LayoutItem = { key: 'root', direction: LayoutItemDirection.Row };
expect(computeEdges(parent, false)).toEqual({ ownRight: true, ownBottom: false, parentRight: false, parentBottom: false });
});
test('computeEdges: last row child inherits parent right', () => {
const parent: LayoutItem = { key: 'root', direction: LayoutItemDirection.Row };
const parentEdges = { ownRight: false, ownBottom: false, parentRight: true, parentBottom: false };
expect(computeEdges(parent, true, parentEdges)).toEqual({ ownRight: false, ownBottom: false, parentRight: true, parentBottom: false });
});
test('computeEdges: every column child sits at the column right edge, so parentRight propagates through', () => {
const parent: LayoutItem = { key: 'col', direction: LayoutItemDirection.Column };
const parentEdges = { ownRight: true, ownBottom: false, parentRight: false, parentBottom: false };
expect(computeEdges(parent, false, parentEdges).parentRight).toBe(true);
});
test('computeEdges: same-axis nesting propagates outer own-right to a last row-in-row child', () => {
// Unusual but valid nesting: the inner container's right edge
// coincides with the outer's, so the outer's own right divider gap
// must flow to the inner's descendants.
const parent: LayoutItem = { key: 'inner', direction: LayoutItemDirection.Row };
const parentEdges = { ownRight: true, ownBottom: false, parentRight: false, parentBottom: false };
expect(computeEdges(parent, true, parentEdges).parentRight).toBe(true);
});
test('buildResizeSnapshot: only truly-flexible subtrees are flagged as absorbers', () => {
const parent: LayoutItem = { key: 'root', direction: LayoutItemDirection.Row, children: [
{ key: 'sideBar', width: 250 },
{ key: 'editor', flexible: true },
] };
const snap = buildResizeSnapshot(parent.children[0], parent, false, sampleSizes);
expect(snap.itemAbsorbsAlongAxis.width).toBe(false); // sideBar
expect(snap.nextAbsorbsAlongAxis.width).toBe(true); // editor
});
test('buildResizeSnapshot: width-less non-flexible next sibling is not an absorber', () => {
// Regression: dragging noteList↔chatPanel where chatPanel is unsized
// in state but not marked flexible. Old code treated any unsized
// panel as an absorber and skipped writes to it.
const parent: LayoutItem = { key: 'root', direction: LayoutItemDirection.Row, children: [
{ key: 'noteList', width: 400 },
{ key: 'chatPanel' },
] };
const snap = buildResizeSnapshot(parent.children[0], parent, false, sampleSizes);
expect(snap.nextAbsorbsAlongAxis.width).toBe(false);
});
test('buildResizeSnapshot: picks the next sibling that is hidden in state when in moveMode', () => {
const parent: LayoutItem = { key: 'root', direction: LayoutItemDirection.Row, children: [
{ key: 'noteList', width: 400 },
{ key: 'chatPanel', visible: false },
] };
const snap = buildResizeSnapshot(parent.children[0], parent, true, sampleSizes);
expect(snap.nextSiblingKey).toBe('chatPanel');
});
const baseSnap = (overrides: Partial<ResizeStartSnapshot> = {}): ResizeStartSnapshot => ({
key: 'a',
initialWidth: 300,
initialHeight: 200,
nextSiblingKey: 'b',
nextSiblingInitialWidth: 400,
nextSiblingInitialHeight: 200,
itemAbsorbsAlongAxis: { width: false, height: false },
nextAbsorbsAlongAxis: { width: false, height: false },
...overrides,
});
test('planResize: drag right by 50 grows the dragged panel and shrinks the next', () => {
expect(planResize(baseSnap(), 'right', { width: 50, height: 0 })).toEqual([
{ key: 'a', props: { width: 350 } },
{ key: 'b', props: { width: 350 } },
]);
});
test('planResize: skips write to the next sibling when it is the absorber', () => {
const snap = baseSnap({ nextAbsorbsAlongAxis: { width: true, height: false } });
expect(planResize(snap, 'right', { width: 50, height: 0 })).toEqual([
{ key: 'a', props: { width: 350 } },
]);
});
test('planResize: redirects to the next sibling (inverse) when dragged is the absorber', () => {
// Editor is flexible and unsized; dragging its right edge should
// resize the panel to its right instead so the editor stays flex.
const snap = baseSnap({ itemAbsorbsAlongAxis: { width: true, height: false } });
expect(planResize(snap, 'right', { width: 50, height: 0 })).toEqual([
{ key: 'b', props: { width: 350 } },
]);
});
test('planResize: writes to the dragged panel when both sides are absorbers (#15934)', () => {
// Sidebar and note list stacked in a column, both unsized. Without
// the bothAbsorb branch, neither was written and the divider never
// moved.
const snap = baseSnap({
itemAbsorbsAlongAxis: { width: false, height: true },
nextAbsorbsAlongAxis: { width: false, height: true },
});
expect(planResize(snap, 'bottom', { width: 0, height: 40 })).toEqual([
{ key: 'a', props: { height: 240 } },
]);
});
test('planResize: no next sibling means only the dragged is written', () => {
const snap = baseSnap({ nextSiblingKey: null });
expect(planResize(snap, 'right', { width: 50, height: 0 })).toEqual([
{ key: 'a', props: { width: 350 } },
]);
});
test('planResize: clamps a shared delta so both sides respect min and the combined size is preserved', () => {
const snap = baseSnap();
const originalSum = snap.initialWidth + snap.nextSiblingInitialWidth;
const updates = planResize(snap, 'right', { width: -500, height: 0 });
// itemMinWidth is 40; dragged clamps to 40, next grows by the same
// amount so the two sizes sum to the original combined width.
expect(updates[0].props.width).toBe(40);
expect(updates[1].props.width).toBe(originalSum - 40);
expect(updates[0].props.width + updates[1].props.width).toBe(originalSum);
});
test('planResize: preserves the combined size when the delta would push the next sibling below min', () => {
const snap = baseSnap();
const originalSum = snap.initialWidth + snap.nextSiblingInitialWidth;
const updates = planResize(snap, 'right', { width: 500, height: 0 });
// The next sibling clamps to itemMinWidth (40); the dragged panel
// grows by exactly the same amount so the sum is preserved.
expect(updates[1].props.width).toBe(40);
expect(updates[0].props.width).toBe(originalSum - 40);
expect(updates[0].props.width + updates[1].props.width).toBe(originalSum);
});
});
@@ -0,0 +1,154 @@
// Pure helpers extracted from ResizableLayout so they can be unit-tested
// without a React harness. Everything here is state-in / state-out — no
// re-resizable, no DOM, no hooks.
import { EdgeFlags, itemMinWidth, itemMinHeight } from './useLayoutItemSizes';
import { LayoutItem, LayoutItemDirection } from './types';
export function isItemVisibleInRender(item: LayoutItem, moveMode: boolean): boolean {
if (moveMode) return true;
if (item.children && !item.children.length) return false;
return item.visible !== false;
}
// The subtree carries an absorber if any node in it is flagged flexible.
export function isSubtreeFlexible(item: LayoutItem): boolean {
if (item.flexible) return true;
if (!item.children) return false;
for (const child of item.children) {
if (isSubtreeFlexible(child)) return true;
}
return false;
}
// The first visible-in-render sibling after `item` in `parent.children`, or
// null when `item` is the last visible child (or has no parent).
export function nextVisibleSibling(item: LayoutItem, parent: LayoutItem | null, moveMode: boolean): LayoutItem | null {
if (!parent || !parent.children) return null;
const idx = parent.children.findIndex(c => c.key === item.key);
for (let i = idx + 1; i < parent.children.length; i++) {
if (isItemVisibleInRender(parent.children[i], moveMode)) return parent.children[i];
}
return null;
}
// The index of the last visible-in-render child of `item`, or -1.
export function lastVisibleChildIndex(item: LayoutItem, moveMode: boolean): number {
if (!item.children) return -1;
for (let i = item.children.length - 1; i >= 0; i--) {
if (isItemVisibleInRender(item.children[i], moveMode)) return i;
}
return -1;
}
const zeroEdges: EdgeFlags = { ownRight: false, ownBottom: false, parentRight: false, parentBottom: false };
// Compute an item's edge flags from its parent's direction, its own position
// (is it the last visible child?), and the parent's own edges. Same-axis
// nesting is handled: a last-child of a row-in-row inherits both the outer's
// own right gap and the outer's inherited parentRight.
export function computeEdges(parent: LayoutItem | null, isLastChild: boolean, parentEdges: EdgeFlags = zeroEdges): EdgeFlags {
const parentDir = parent?.direction;
const ownRight = parentDir === LayoutItemDirection.Row && !isLastChild;
const ownBottom = parentDir === LayoutItemDirection.Column && !isLastChild;
const parentRight = parentDir === LayoutItemDirection.Column
? parentEdges.ownRight || parentEdges.parentRight
: (isLastChild && (parentEdges.ownRight || parentEdges.parentRight));
const parentBottom = parentDir === LayoutItemDirection.Row
? parentEdges.ownBottom || parentEdges.parentBottom
: (isLastChild && (parentEdges.ownBottom || parentEdges.parentBottom));
return { ownRight, ownBottom, parentRight, parentBottom };
}
export interface ResizeStartSnapshot {
key: string;
initialWidth: number;
initialHeight: number;
nextSiblingKey: string | null;
nextSiblingInitialWidth: number;
nextSiblingInitialHeight: number;
itemAbsorbsAlongAxis: { width: boolean; height: boolean };
nextAbsorbsAlongAxis: { width: boolean; height: boolean };
}
// Build the drag-start snapshot from the layout state. This captures which
// side of the divider counts as the absorber on each axis (only truly
// flexible subtrees do — an unsized panel that isn't flagged still gets
// deltas applied to it so drags on newly-shown panels behave normally).
export function buildResizeSnapshot(
item: LayoutItem,
parent: LayoutItem | null,
moveMode: boolean,
sizes: { [key: string]: { width: number; height: number } },
): ResizeStartSnapshot {
const next = nextVisibleSibling(item, parent, moveMode);
const itemFlex = isSubtreeFlexible(item);
const nextFlex = next ? isSubtreeFlexible(next) : false;
return {
key: item.key,
initialWidth: sizes[item.key].width,
initialHeight: sizes[item.key].height,
nextSiblingKey: next?.key ?? null,
nextSiblingInitialWidth: next ? sizes[next.key].width : 0,
nextSiblingInitialHeight: next ? sizes[next.key].height : 0,
itemAbsorbsAlongAxis: {
width: itemFlex && !('width' in item),
height: itemFlex && !('height' in item),
},
nextAbsorbsAlongAxis: {
width: nextFlex && next ? !('width' in next) : false,
height: nextFlex && next ? !('height' in next) : false,
},
};
}
export interface ResizeUpdate {
key: string;
props: { width?: number; height?: number };
}
// Given a drag snapshot, a direction, and re-resizable's cumulative delta,
// return the layout-state writes needed to move the divider. The dragged
// panel and its next visible sibling exchange space; either side can be
// skipped when it's an absorber so the layout system keeps it flexible.
// When both sides are absorbers (e.g. two unsized column-nested siblings),
// we write to the dragged panel so the divider still moves.
export function planResize(
snapshot: ResizeStartSnapshot,
direction: string,
delta: { width: number; height: number },
): ResizeUpdate[] {
const isHorizontal = direction !== 'bottom';
const minSize = isHorizontal ? itemMinWidth : itemMinHeight;
const rawDelta = isHorizontal ? delta.width : delta.height;
const itemIsAbsorber = isHorizontal ? snapshot.itemAbsorbsAlongAxis.width : snapshot.itemAbsorbsAlongAxis.height;
const nextIsAbsorber = isHorizontal ? snapshot.nextAbsorbsAlongAxis.width : snapshot.nextAbsorbsAlongAxis.height;
const bothAbsorb = itemIsAbsorber && nextIsAbsorber;
const writeItem = !itemIsAbsorber || bothAbsorb;
const writeNext = !nextIsAbsorber && !!snapshot.nextSiblingKey;
const itemInitial = isHorizontal ? snapshot.initialWidth : snapshot.initialHeight;
const nextInitial = isHorizontal ? snapshot.nextSiblingInitialWidth : snapshot.nextSiblingInitialHeight;
// When both panels are written, clamp a single shared delta against both
// minimums so their combined size is preserved. When only one side is
// written, the other is absorbing and we don't clamp against it.
let effectiveDelta = rawDelta;
if (writeItem) effectiveDelta = Math.max(effectiveDelta, minSize - itemInitial);
if (writeNext) effectiveDelta = Math.min(effectiveDelta, nextInitial - minSize);
const updates: ResizeUpdate[] = [];
if (writeItem) {
const newSize = itemInitial + effectiveDelta;
updates.push({ key: snapshot.key, props: isHorizontal ? { width: newSize } : { height: newSize } });
}
if (writeNext) {
const newSize = nextInitial - effectiveDelta;
updates.push({ key: snapshot.nextSiblingKey, props: isHorizontal ? { width: newSize } : { height: newSize } });
}
return updates;
}
@@ -21,6 +21,8 @@ export interface LayoutItem {
resizableBottom?: boolean;
visible?: boolean;
context?: Record<string, unknown>;
// Absorbs remaining space in its container, regardless of position.
flexible?: boolean;
}
export const tempContainerPrefix = 'tempContainer-';
@@ -134,8 +134,11 @@ describe('useLayoutItemSizes', () => {
expect(itemSize(layout.children[0], layout, sizes, true)).toEqual({ width: 100, height: 100 });
const parent = layout.children[0];
expect(itemSize(parent.children[0], parent, sizes, false)).toEqual({ width: 95, height: 45 });
expect(itemSize(parent.children[1], parent, sizes, false)).toEqual({ width: 95, height: 50 });
// row1: inside a column that has a right divider, and row1 itself has a
// bottom divider (there's a visible sibling below it).
expect(itemSize(parent.children[0], parent, sizes, false, { ownRight: false, ownBottom: true, parentRight: true, parentBottom: false })).toEqual({ width: 95, height: 45 });
// row2: same column, no bottom divider (it is the last row).
expect(itemSize(parent.children[1], parent, sizes, false, { ownRight: false, ownBottom: false, parentRight: true, parentBottom: false })).toEqual({ width: 95, height: 50 });
});
test('should decrease size of the largest item if the total size would be larger than the container', () => {
@@ -349,4 +352,166 @@ describe('useLayoutItemSizes', () => {
expect(maxSize1.width).toBe(160); // 160 = layout.width - col2.minWidth(=40)
});
test('flexible child absorbs remaining space regardless of position', () => {
const layout: LayoutItem = validateLayout({
key: 'root',
width: 1000,
height: 100,
direction: LayoutItemDirection.Row,
children: [
{ key: 'sideBar', width: 250 },
{ key: 'noteList', width: 250 },
{ key: 'editor', flexible: true },
{ key: 'chatPanel', width: 340 },
],
});
const editor = layout.children.find(c => c.key === 'editor');
expect(editor).not.toHaveProperty('width');
const sizes = renderHook(() => useLayoutItemSizes(layout)).result.current;
expect(sizes.editor.width).toBe(160);
expect(sizes.chatPanel.width).toBe(340);
const enlarged = validateLayout({ ...layout, width: 1200 });
const enlargedSizes = renderHook(() => useLayoutItemSizes(enlarged)).result.current;
expect(enlargedSizes.editor.width).toBe(360);
expect(enlargedSizes.chatPanel.width).toBe(340);
expect(enlargedSizes.sideBar.width).toBe(250);
expect(enlargedSizes.noteList.width).toBe(250);
});
test('flexible child still gets a draggable right edge so the divider against the next sibling stays usable', () => {
const layout: LayoutItem = validateLayout({
key: 'root',
width: 1000,
height: 100,
direction: LayoutItemDirection.Row,
children: [
{ key: 'sideBar', width: 250 },
{ key: 'editor', flexible: true },
{ key: 'chatPanel', width: 340 },
],
});
const editor = layout.children.find(c => c.key === 'editor');
expect(editor.resizableRight).toBe(true);
const chatPanel = layout.children.find(c => c.key === 'chatPanel');
expect(chatPanel.resizableRight).toBe(false);
});
test('width-less non-absorber siblings get a fallback width so the explicit absorber alone absorbs', () => {
// Mimics state after a move-mode rearrangement that wipes sibling widths.
const layout: LayoutItem = validateLayout({
key: 'root',
width: 1000,
height: 100,
direction: LayoutItemDirection.Row,
children: [
{ key: 'sideBar' },
{ key: 'editor', flexible: true, width: 408 },
{ key: 'noteList' },
{ key: 'chatPanel', width: 113 },
],
});
expect(layout.children.find(c => c.key === 'editor')).not.toHaveProperty('width');
expect(layout.children.find(c => c.key === 'sideBar').width).toBeGreaterThan(0);
expect(layout.children.find(c => c.key === 'noteList').width).toBeGreaterThan(0);
const enlarged = validateLayout({ ...layout, width: 1200 });
const before = renderHook(() => useLayoutItemSizes(layout)).result.current;
const after = renderHook(() => useLayoutItemSizes(enlarged)).result.current;
expect(after.editor.width - before.editor.width).toBe(200);
expect(after.sideBar.width).toBe(before.sideBar.width);
expect(after.noteList.width).toBe(before.noteList.width);
expect(after.chatPanel.width).toBe(before.chatPanel.width);
});
test('container holding a flexible descendant inherits the absorber role at the root level', () => {
const layout: LayoutItem = validateLayout({
key: 'root',
width: 1000,
height: 600,
direction: LayoutItemDirection.Row,
children: [
{ key: 'sideBar', width: 218 },
{
key: 'col',
width: 667,
children: [
{ key: 'noteList', height: 250 },
{ key: 'editor', flexible: true },
],
},
{ key: 'chatPanel' },
],
});
expect(layout.children.find(c => c.key === 'col')).not.toHaveProperty('width');
expect(layout.children.find(c => c.key === 'chatPanel').width).toBeGreaterThan(0);
const before = renderHook(() => useLayoutItemSizes(layout)).result.current;
const after = renderHook(() => useLayoutItemSizes(validateLayout({ ...layout, width: 1300 }))).result.current;
expect(after.col.width - before.col.width).toBe(300);
expect(after.sideBar.width).toBe(before.sideBar.width);
expect(after.chatPanel.width).toBe(before.chatPanel.width);
expect(after.editor.width).toBe(after.col.width);
});
test('itemSize propagates the ancestor divider gap through last-child same-axis nesting', () => {
// Same-axis nesting is unusual but valid; a row nested at the end of
// another row should inherit its outer sibling's right gap.
const sizes = { outer: { width: 200, height: 100 }, inner: { width: 200, height: 100 } };
const inner: LayoutItem = { key: 'inner' };
// The outer has its own trailing gap because it has a sibling to its right.
const edgesFromLastRowChild = { ownRight: false, ownBottom: false, parentRight: true, parentBottom: false };
const size = itemSize(inner, null, sizes, false, edgesFromLastRowChild);
// 5px reserved for the propagated right gap.
expect(size.width).toBe(195);
});
test('multiple width-less siblings stay within the container width even when combined fallbacks exceed it', () => {
const layout: LayoutItem = validateLayout({
key: 'root',
width: 400,
height: 100,
direction: LayoutItemDirection.Row,
children: [
// Three unsized non-absorber siblings would want 250 each (=750),
// far above the 400 container. The absorber (editor) must still
// get at least its minimum.
{ key: 'a' },
{ key: 'b' },
{ key: 'c' },
{ key: 'editor', flexible: true },
],
});
const { result } = renderHook(() => useLayoutItemSizes(layout));
const sizes = result.current;
const total = sizes.a.width + sizes.b.width + sizes.c.width + sizes.editor.width;
expect(total).toBeLessThanOrEqual(400);
expect(sizes.editor.width).toBeGreaterThanOrEqual(40); // itemMinWidth
});
test('flexible flag on a hidden child falls back to the last visible sibling', () => {
const layout: LayoutItem = validateLayout({
key: 'root',
width: 200,
height: 100,
direction: LayoutItemDirection.Row,
children: [
{ key: 'col1', width: 50 },
{ key: 'col2', flexible: true, visible: false },
{ key: 'col3', width: 60 },
],
});
const col3 = layout.children.find(c => c.key === 'col3');
expect(col3).not.toHaveProperty('width');
});
});
@@ -6,18 +6,43 @@ const dragBarThickness = 5;
export const itemMinWidth = 40;
export const itemMinHeight = 40;
// Bigger than itemMinWidth: used as a fallback for width-less non-absorber
// siblings so newly-shown panels (moveMode) get a usable width instead of
// squeezing to the minimum.
const nonAbsorberFallbackWidth = 250;
const nonAbsorberFallbackHeight = 250;
export interface LayoutItemSizes {
[key: string]: Size;
}
function containsFlexibleDescendant(item: LayoutItem): boolean {
if (item.flexible) return true;
if (!item.children) return false;
for (const child of item.children) {
if (containsFlexibleDescendant(child)) return true;
}
return false;
}
// Whether this item's trailing (right/bottom) edge is a divider against a
// visible-in-render sibling. The parent-side flags cover the case where the
// item's edge coincides with an ancestor's divider (e.g. a column-nested item
// inheriting its column container's right edge).
export interface EdgeFlags {
ownRight: boolean;
ownBottom: boolean;
parentRight: boolean;
parentBottom: boolean;
}
const noEdges: EdgeFlags = { ownRight: false, ownBottom: false, parentRight: false, parentBottom: false };
// Container always take the full space while the items within it need to
// accommodate for the resize handle.
export function itemSize(item: LayoutItem, parent: LayoutItem | null, sizes: LayoutItemSizes, isContainer: boolean): Size {
const parentResizableRight = !!parent && parent.resizableRight;
const parentResizableBottom = !!parent && parent.resizableBottom;
const rightGap = !isContainer && (item.resizableRight || parentResizableRight) ? dragBarThickness : 0;
const bottomGap = !isContainer && (item.resizableBottom || parentResizableBottom) ? dragBarThickness : 0;
export function itemSize(item: LayoutItem, _parent: LayoutItem | null, sizes: LayoutItemSizes, isContainer: boolean, edges: EdgeFlags = noEdges): Size {
const rightGap = !isContainer && (edges.ownRight || edges.parentRight) ? dragBarThickness : 0;
const bottomGap = !isContainer && (edges.ownBottom || edges.parentBottom) ? dragBarThickness : 0;
return {
width: sizes[item.key].width - rightGap,
@@ -93,20 +118,55 @@ function calculateChildrenSizes(item: LayoutItem, parent: LayoutItem | null, siz
remainingSize.height += dh;
}
if (noWidthChildren.length) {
const w = item.direction === 'row' ? Math.floor(remainingSize.width / noWidthChildren.length) : parentSize.width;
for (const child of noWidthChildren) {
const finalWidth = w;
sizes[child.item.key].width = finalWidth;
// When several children lack a size on the parent's axis (e.g. in
// moveMode, where normally-hidden panels are shown without one), route
// remaining space to the one that carries the flexible descendant and
// give the others a fallback size. This keeps the absorber's role
// intact and avoids equal-split behaviour that would eat editor space.
if (noWidthChildren.length && item.direction === 'row' && noWidthChildren.length > 1) {
const absorber = noWidthChildren.find(c => containsFlexibleDescendant(c.item));
if (absorber) {
const absorberMin = absorber.item.minWidth || itemMinWidth;
let budget = Math.max(0, remainingSize.width - absorberMin);
for (const child of noWidthChildren) {
if (child.item.key === absorber.item.key) continue;
const min = child.item.minWidth || itemMinWidth;
const desired = child.item.minWidth || nonAbsorberFallbackWidth;
const w = Math.max(min, Math.min(desired, budget));
sizes[child.item.key].width = w;
budget -= w;
}
sizes[absorber.item.key].width = Math.max(absorberMin, remainingSize.width - (noWidthChildren.reduce((sum, c) => c.item.key === absorber.item.key ? sum : sum + sizes[c.item.key].width, 0)));
} else {
const w = Math.floor(remainingSize.width / noWidthChildren.length);
for (const child of noWidthChildren) sizes[child.item.key].width = w;
}
} else if (noWidthChildren.length) {
const w = item.direction === 'row' ? Math.floor(remainingSize.width / noWidthChildren.length) : parentSize.width;
for (const child of noWidthChildren) sizes[child.item.key].width = w;
}
if (noHeightChildren.length) {
const h = item.direction === 'column' ? Math.floor(remainingSize.height / noHeightChildren.length) : parentSize.height;
for (const child of noHeightChildren) {
const finalHeight = h;
sizes[child.item.key].height = finalHeight;
if (noHeightChildren.length && item.direction === 'column' && noHeightChildren.length > 1) {
const absorber = noHeightChildren.find(c => containsFlexibleDescendant(c.item));
if (absorber) {
const absorberMin = absorber.item.minHeight || itemMinHeight;
let budget = Math.max(0, remainingSize.height - absorberMin);
for (const child of noHeightChildren) {
if (child.item.key === absorber.item.key) continue;
const min = child.item.minHeight || itemMinHeight;
const desired = child.item.minHeight || nonAbsorberFallbackHeight;
const h = Math.max(min, Math.min(desired, budget));
sizes[child.item.key].height = h;
budget -= h;
}
sizes[absorber.item.key].height = Math.max(absorberMin, remainingSize.height - (noHeightChildren.reduce((sum, c) => c.item.key === absorber.item.key ? sum : sum + sizes[c.item.key].height, 0)));
} else {
const h = Math.floor(remainingSize.height / noHeightChildren.length);
for (const child of noHeightChildren) sizes[child.item.key].height = h;
}
} else if (noHeightChildren.length) {
const h = item.direction === 'column' ? Math.floor(remainingSize.height / noHeightChildren.length) : parentSize.height;
for (const child of noHeightChildren) sizes[child.item.key].height = h;
}
for (const child of item.children) {
@@ -2,6 +2,11 @@ import { produce } from 'immer';
import iterateItems from './iterateItems';
import { LayoutItem, LayoutItemDirection } from './types';
// Fallback size assigned to a non-absorber sibling that has none, so the
// absorber stays the only width/height-less child of its container.
const itemMinWidthFallback = 250;
const itemMinHeightFallback = 250;
function isLastVisible(itemIndex: number, item: LayoutItem, parent: LayoutItem) {
if (item.visible === false) return false;
@@ -13,6 +18,28 @@ function isLastVisible(itemIndex: number, item: LayoutItem, parent: LayoutItem)
return false;
}
// A subtree counts as flexible if it carries the flag at any nested level, so
// the absorber role propagates up to the root container.
function containsFlexible(item: LayoutItem): boolean {
if (item.visible === false) return false;
if (item.flexible) return true;
if (!item.children) return false;
for (const child of item.children) {
if (containsFlexible(child)) return true;
}
return false;
}
function explicitAbsorberIndex(parent: LayoutItem) {
if (!parent.children) return -1;
for (let i = 0; i < parent.children.length; i++) {
const child = parent.children[i];
if (child.visible === false) continue;
if (containsFlexible(child)) return i;
}
return -1;
}
function updateItemSize(itemIndex: number, itemDraft: LayoutItem, parent: LayoutItem) {
if (!parent) return;
@@ -23,24 +50,48 @@ function updateItemSize(itemIndex: number, itemDraft: LayoutItem, parent: Layout
delete itemDraft.height;
}
// If all children of a container have a fixed width, the
// latest visible child should have a flexible width (i.e. no "width"
// property), so that it fills up the remaining space
const explicitIdx = explicitAbsorberIndex(parent);
if (explicitIdx !== -1) {
// Strip the absorber's size and assign fallback sizes to any sibling
// that lacks one, so the absorber alone fills remaining space.
if (explicitIdx !== itemIndex || itemDraft.visible === false) return;
if (parent.direction === LayoutItemDirection.Row) {
delete itemDraft.width;
for (let i = 0; i < parent.children.length; i++) {
if (i === itemIndex) continue;
const child = parent.children[i];
if (child.visible === false) continue;
if (!('width' in child)) {
(child as { width?: number }).width = child.minWidth || itemMinWidthFallback;
}
}
} else {
delete itemDraft.height;
for (let i = 0; i < parent.children.length; i++) {
if (i === itemIndex) continue;
const child = parent.children[i];
if (child.visible === false) continue;
if (!('height' in child)) {
(child as { height?: number }).height = child.minHeight || itemMinHeightFallback;
}
}
}
return;
}
// No explicit absorber: legacy fallback makes the last visible child
// flexible when every other sibling is already sized.
if (isLastVisible(itemIndex, itemDraft, parent)) {
let allChildrenAreSized = true;
for (const child of parent.children) {
if (child.visible === false) continue;
if (parent.direction === LayoutItemDirection.Row) {
if (!child.width) {
allChildrenAreSized = false;
break;
}
if (!child.width) { allChildrenAreSized = false; break; }
} else {
if (!child.height) {
allChildrenAreSized = false;
break;
}
if (!child.height) { allChildrenAreSized = false; break; }
}
}
@@ -54,8 +105,8 @@ function updateItemSize(itemIndex: number, itemDraft: LayoutItem, parent: Layout
}
}
// All items should be resizable, except for the root and the latest visible child
// of a container.
// All items get a draggable trailing edge except the last visible child,
// whose trailing edge is the container's edge.
function updateResizeRules(itemIndex: number, itemDraft: LayoutItem, parent: LayoutItem) {
if (!parent) return;
const isLastVisibleChild = isLastVisible(itemIndex, itemDraft, parent);
@@ -4,6 +4,7 @@ import { FolderEntity, TagsWithNoteCountEntity } from '@joplin/lib/services/data
import areAllFoldersCollapsed from '@joplin/lib/models/utils/areAllFoldersCollapsed';
import getCanBeCollapsedFolderIds from '@joplin/lib/models/utils/getCanBeCollapsedFolderIds';
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
import { StateShare } from '@joplin/lib/services/share/reducer';
import { connect } from 'react-redux';
import { Dispatch } from 'redux';
import { useMemo, useRef, useState } from 'react';
@@ -23,6 +24,7 @@ interface Props {
dispatch: Dispatch;
themeId: number;
plugins: PluginStates;
shares: StateShare[];
tags: TagsWithNoteCountEntity[];
folders: FolderEntity[];
@@ -124,6 +126,7 @@ const mapStateToProps = (state: AppState) => {
collapsedFolderIds: state.collapsedFolderIds,
selectedSmartFilterId: mainWindowState.selectedSmartFilterId,
plugins: state.pluginService.plugins,
shares: state.shareService.shares,
tagHeaderIsExpanded: state.settings.tagHeaderIsExpanded,
folderHeaderIsExpanded: state.settings.folderHeaderIsExpanded,
};
@@ -31,6 +31,7 @@ import ListItemWrapper, { ItemSelectionState } from '../listItemComponents/ListI
import { focus } from '@joplin/lib/utils/focusHandler';
import shim from '@joplin/lib/shim';
import useOnItemClick from './useOnItemClick';
import { ShareType, StateShare } from '@joplin/lib/services/share/reducer';
const Menu = bridge().Menu;
const MenuItem: typeof MenuItemType = bridge().MenuItem;
@@ -41,6 +42,7 @@ interface Props {
dispatch: Dispatch;
themeId: number;
plugins: PluginStates;
shares: StateShare[];
folders: FolderEntity[];
collapsedFolderIds: string[];
containerRef: React.RefObject<HTMLDivElement>;
@@ -255,6 +257,8 @@ const useOnRenderItem = (props: Props) => {
if (shareFolderItem.enabled) menu.append(shareFolderItem);
const leaveSharedFolderItem = folderCommandToMenuItem('leaveSharedFolder', itemId);
if (leaveSharedFolderItem.enabled) menu.append(leaveSharedFolderItem);
const publishFolderItem = folderCommandToMenuItem('showPublishFolderDialog', itemId);
if (publishFolderItem.enabled) menu.append(publishFolderItem);
menu.append(
new MenuItem({
@@ -382,6 +386,20 @@ const useOnRenderItem = (props: Props) => {
const showFolderIcons = useMemo(() => {
return Folder.shouldShowFolderIcons(props.folders);
}, [props.folders]);
const publishedFolderIds = useMemo(() => {
const output = new Set(props.shares.filter(share => share.type === ShareType.PublishedFolder).map(share => share.folder_id));
for (const folder of props.folders) {
if (folder.is_shared && !folder.share_id) output.add(folder.id);
}
let size = 0;
while (size !== output.size) {
size = output.size;
for (const folder of props.folders) {
if (output.has(folder.parent_id)) output.add(folder.id);
}
}
return output;
}, [props.folders, props.shares]);
const itemCount = props.listItems.length;
return useCallback((item: ListItem, index: number) => {
@@ -450,6 +468,7 @@ const useOnRenderItem = (props: Props) => {
folderItem_click={onItemClick}
onFolderToggleClick_={onFolderToggleClick_}
shareId={folder.share_id}
isPublished={publishedFolderIds.has(folder.id)}
parentId={folder.parent_id}
showFolderIcon={showFolderIcons}
index={index}
@@ -503,6 +522,7 @@ const useOnRenderItem = (props: Props) => {
onTagDrop_,
props.collapsedFolderIds,
props.folders,
publishedFolderIds,
showFolderIcons,
props.selectedIndex,
props.selectedIndexes,
@@ -46,6 +46,7 @@ interface FolderItemProps {
folderItem_click: (event: ItemClickEvent)=> void;
onFolderToggleClick_: ItemClickListener;
shareId: string;
isPublished: boolean;
selectionState: ItemSelectionState;
index: number;
@@ -53,10 +54,12 @@ interface FolderItemProps {
}
function FolderItem(props: FolderItemProps) {
const { hasChildren, showFolderIcon, isExpanded, parentId, depth, selectionState, folderId, folderTitle, folderIcon, noteCount, onFolderDragStart_, onFolderDragOver_, onFolderDrop_, itemContextMenu, folderItem_click, onFolderToggleClick_, shareId } = props;
const { hasChildren, showFolderIcon, isExpanded, parentId, depth, selectionState, folderId, folderTitle, folderIcon, noteCount, onFolderDragStart_, onFolderDragOver_, onFolderDrop_, itemContextMenu, folderItem_click, onFolderToggleClick_, shareId, isPublished } = props;
const shareTitle = _('Shared');
const shareIcon = shareId && !parentId ? <StyledShareIcon aria-label={shareTitle} title={shareTitle} className="fas fa-share-alt"/> : null;
const publishedTitle = _('Published');
const publishedIcon = isPublished ? <StyledShareIcon aria-label={publishedTitle} title={publishedTitle} className="fas fa-globe"/> : null;
const draggable = ![getTrashFolderId(), Folder.conflictFolderId()].includes(folderId);
const doRenderFolderIcon = () => {
@@ -98,6 +101,7 @@ function FolderItem(props: FolderItemProps) {
isConflictFolder={folderId === Folder.conflictFolderId()}
selected={selectionState.selected}
shareId={shareId}
isPublished={isPublished}
data-folder-id={folderId}
onDoubleClick={onFolderToggleClick_}
@@ -108,7 +112,7 @@ function FolderItem(props: FolderItemProps) {
}}
>
{doRenderFolderIcon()}<StyledSpanFix className="title">{folderTitle}</StyledSpanFix>
{shareIcon} <NoteCount count={noteCount}/>
{publishedIcon}{shareIcon} <NoteCount count={noteCount}/>
</StyledListItemAnchor>
<ExpandLink
// The ExpandLink is included after the title so that the screen reader reads the
@@ -8,8 +8,10 @@ interface StyleProps {
backgroundColor: string;
backgroundColorHover2: string;
color2: string;
color4: string;
colorError2: string;
colorFaded2: string;
colorPublished2: string;
colorWarn2: string;
topRowHeight: number;
mainPadding: number;
@@ -18,6 +20,7 @@ interface StyleProps {
};
isConflictFolder?: boolean;
isSpecialItem?: boolean;
isPublished?: boolean;
shareId?: string;
selected?: boolean;
}
@@ -73,6 +76,8 @@ export const StyledHeaderLabel = styled.span`
function listItemTextColor(props: StyleProps) {
if (props.isConflictFolder) return props.theme.colorError2;
if (props.isSpecialItem) return props.theme.colorFaded2;
if (props.selected && props.isPublished) return props.theme.color2;
if (props.isPublished) return props.theme.colorPublished2;
if (props.shareId) return props.theme.colorWarn2;
return props.theme.color2;
}
@@ -86,7 +91,7 @@ export const StyledListItemAnchor = styled.a`
// So that the conflicts folder and shared folders have sufficient contrast,
// use an opacity of 1 even when unselected.
const needsHigherContrast = props.isConflictFolder || props.isSpecialItem;
return (props.selected || props.shareId || needsHigherContrast) ? 1 : 0.8;
return (props.selected || props.shareId || props.isPublished || needsHigherContrast) ? 1 : 0.8;
}};
white-space: nowrap;
display: flex;
+100 -23
View File
@@ -1,6 +1,6 @@
import * as React from 'react';
import { Dispatch } from 'redux';
import { useRef, useCallback, useId } from 'react';
import { useRef, useCallback, useId, useState } from 'react';
import { _ } from '@joplin/lib/locale';
import DialogButtonRow from '../DialogButtonRow';
import Dialog from '@joplin/lib/components/Dialog';
@@ -18,9 +18,9 @@ interface Props {
dispatch: Dispatch;
}
const StyledRoot = styled.div`
min-width: 500px;
max-width: 1200px;
const StyledRoot = styled.div<{ expanded: boolean }>`
width: ${props => props.expanded ? '832px' : '432px'};
max-width: 100%;
`;
const SyncTargetDescription = styled.div<{ height: number }>`
@@ -33,12 +33,10 @@ const SyncTargetDescription = styled.div<{ height: number }>`
const ContentRoot = styled.div`
background-color: ${props => props.theme.backgroundColor3};
padding: 1em;
padding-right: 0;
`;
const SelfHostingMessage = styled.div`
color: ${props => props.theme.color};
padding-right: 1em;
font-style: italic;
margin-top: 1em;
opacity: 0.6;
@@ -50,6 +48,42 @@ const SyncTargetBoxes = styled.div`
justify-content: center;
`;
const OtherOptions = styled.div`
margin-top: 1.5em;
`;
const OtherOptionsToggle = styled.button`
display: flex;
align-items: center;
gap: 0.5em;
width: 100%;
background: none;
border: none;
cursor: pointer;
font-family: ${props => props.theme.fontFamily};
font-size: 16px;
font-weight: bold;
color: ${props => props.theme.color};
padding: 0.5em 0;
text-align: left;
&:hover {
opacity: 0.8;
}
`;
const OtherOptionsBoxes = styled.div`
display: flex;
flex-direction: row;
justify-content: center;
gap: 1em;
margin-top: 1em;
> * {
margin-right: 0;
}
`;
const SyncTargetTitle = styled.h2`
display: flex;
flex-direction: row;
@@ -64,17 +98,18 @@ const SyncTargetLogo = styled.img`
margin-right: 0.4em;
`;
const SyncTargetBox = styled.div`
const SyncTargetBox = styled.div<{ hero?: boolean }>`
display: flex;
flex: 1;
flex-direction: column;
box-sizing: border-box;
font-family: ${props => props.theme.fontFamily};
color: ${props => props.theme.color};
background-color: ${props => props.theme.backgroundColor};
border: 1px solid ${props => props.theme.dividerColor};
border: ${props => props.hero ? `2px solid ${props.theme.color4}` : `1px solid ${props.theme.dividerColor}`};
border-radius: 8px;
padding: 2em 2.2em 2em 2.2em;
margin-right: 1em;
flex: 1 1 0;
min-width: 0;
max-width: 400px;
opacity: 1;
`;
@@ -114,6 +149,13 @@ const SelectButton = styled(Button)`
font-size: 1em;
`;
const LoginLink = styled.a`
margin-top: 0.8em;
font-size: 14px;
align-self: center;
color: ${props => props.theme.urlColor};
`;
const SlowSyncWarning = styled.div`
margin-top: 1em;
opacity: 0.8;
@@ -199,6 +241,7 @@ export default function(props: Props) {
}, [props.dispatch, closeDialog]);
const baseId = useId();
const [otherOptionsExpanded, setOtherOptionsExpanded] = useState(false);
function renderSelectArea(info: SyncTargetInfo, describedById: string) {
return (
@@ -212,12 +255,23 @@ export default function(props: Props) {
);
}
function renderSignUpArea(info: SyncTargetInfo) {
if (info.name !== 'joplinCloud') return null;
return <JoplinCloudSignUpCallToAction/>;
function renderActionArea(info: SyncTargetInfo, describedById: string) {
if (info.name === 'joplinCloud') {
return (
<>
<JoplinCloudSignUpCallToAction source='desktop-sync-wizard' primary={true}/>
<LoginLink
href="#"
onClick={event => { event.preventDefault(); void onSelectButtonClick(info.name as SyncTargetInfoName); }}
aria-describedby={describedById}
>{_('Already have an account? Log in')}</LoginLink>
</>
);
}
return renderSelectArea(info, describedById);
}
function renderSyncTarget(info: SyncTargetInfo) {
function renderSyncTarget(info: SyncTargetInfo, hero: boolean) {
const key = `syncTarget_${info.name}`;
const height = info.name !== 'joplinCloud' ? descriptionHeight : null;
@@ -240,12 +294,11 @@ export default function(props: Props) {
const headerId = `${baseId}-${info.id}`;
return (
<SyncTargetBox id={key} key={key}>
<SyncTargetBox id={key} key={key} hero={hero}>
<SyncTargetTitle id={headerId}>{logo}{info.label}</SyncTargetTitle>
{descriptionComp}
{featuresComp}
{renderSelectArea(info, headerId)}
{renderSignUpArea(info)}
{renderActionArea(info, headerId)}
{renderSlowSyncWarning()}
</SyncTargetBox>
);
@@ -264,12 +317,17 @@ export default function(props: Props) {
}, [props.dispatch, closeDialog]);
function renderContent() {
const boxes: React.ReactNode[] = [];
const heroBoxes: React.ReactNode[] = [];
const otherBoxes: React.ReactNode[] = [];
for (const name of syncTargetNames) {
const info = SyncTargetRegistry.infoByName(name);
if (info.supportsSelfHosted) continue;
boxes.push(renderSyncTarget(info));
if (info.name === 'joplinCloud') {
heroBoxes.push(renderSyncTarget(info, true));
} else {
otherBoxes.push(renderSyncTarget(info, false));
}
}
const selfHostingLabelId = `${baseId}-selfHosting`;
@@ -293,20 +351,39 @@ export default function(props: Props) {
</a>.
</SelfHostingMessage>;
const otherOptionsRegionId = `${baseId}-otherOptions`;
const otherOptions = <OtherOptions>
<OtherOptionsToggle
type='button'
aria-expanded={otherOptionsExpanded}
aria-controls={otherOptionsRegionId}
onClick={() => setOtherOptionsExpanded(prev => !prev)}
>
<i className={otherOptionsExpanded ? 'fas fa-chevron-down' : 'fas fa-chevron-right'} aria-hidden={true}/>
{_('Other sync options')}
</OtherOptionsToggle>
<div id={otherOptionsRegionId} hidden={!otherOptionsExpanded}>
<OtherOptionsBoxes>
{otherBoxes}
</OtherOptionsBoxes>
{selfHostingMessage}
</div>
</OtherOptions>;
return (
<ContentRoot>
<SyncTargetBoxes>
{boxes}
{heroBoxes}
</SyncTargetBoxes>
{selfHostingMessage}
{otherOptions}
</ContentRoot>
);
}
function renderDialogWrapper() {
return (
<StyledRoot>
<DialogTitle title={_('Joplin can synchronise your notes using various providers. Select one from the list below.')} justifyContent="center"/>
<StyledRoot expanded={otherOptionsExpanded}>
<DialogTitle title={_('Sync your notes with Joplin Cloud to access them on all your devices. Other sync options are also available.')} justifyContent="center"/>
{renderContent()}
<DialogButtonRow
themeId={props.themeId}
@@ -4,6 +4,7 @@ import ShareFolderDialog from '../ShareFolderDialog/ShareFolderDialog';
import NotePropertiesDialog from '../NotePropertiesDialog';
import NoteContentPropertiesDialog from '../NoteContentPropertiesDialog';
import ShareNoteDialog from '../ShareNoteDialog';
import PublishFolderDialog from '../PublishFolderDialog';
import { PluginHtmlContents, PluginStates } from '@joplin/lib/services/plugins/reducer';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { DialogState } from './types';
@@ -49,6 +50,9 @@ const defaultDialogState: DialogState = {
shareFolderDialogOptions: {
visible: false,
},
publishFolderDialogOptions: {
visible: false,
},
promptOptions: null,
};
@@ -116,7 +120,7 @@ const WindowCommandsAndDialogs: React.FC<Props> = props => {
const pluginDialog = !dialogInfo || !Dialog ? null : <Dialog {...dialogInfo.props} />;
const {
noteContentPropertiesDialogOptions, notePropertiesDialogOptions, shareNoteDialogOptions, shareFolderDialogOptions, promptOptions,
noteContentPropertiesDialogOptions, notePropertiesDialogOptions, shareNoteDialogOptions, shareFolderDialogOptions, publishFolderDialogOptions, promptOptions,
} = dialogState;
@@ -165,6 +169,13 @@ const WindowCommandsAndDialogs: React.FC<Props> = props => {
onClose={onDialogHideCallbacks.shareFolderDialogOptions}
/>
)}
{publishFolderDialogOptions.visible && (
<PublishFolderDialog
themeId={props.themeId}
folderId={publishFolderDialogOptions.folderId}
onClose={onDialogHideCallbacks.publishFolderDialogOptions}
/>
)}
<PromptDialog
autocomplete={promptOptions && 'autocomplete' in promptOptions ? promptOptions.autocomplete : null}
@@ -0,0 +1,22 @@
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
import { _ } from '@joplin/lib/locale';
import { stateUtils } from '@joplin/lib/reducer';
import { getNoteCallbackUrl } from '@joplin/lib/callbackUrlUtils';
import { clipboard } from 'electron';
export const declaration: CommandDeclaration = {
name: 'copyNoteExternalLink',
label: () => _('Copy external link'),
iconName: 'fas fa-link',
};
export const runtime = (): CommandRuntime => {
return {
execute: async (context: CommandContext, noteId: string = null) => {
noteId = noteId || stateUtils.selectedNoteId(context.state);
if (!noteId) return;
clipboard.writeText(getNoteCallbackUrl(noteId));
},
enabledCondition: 'oneNoteSelected',
};
};
@@ -11,6 +11,7 @@ export enum UiType {
export interface GotoAnythingOptions {
mode?: Mode;
alwaysShowHelp?: boolean;
}
export const declaration: CommandDeclaration = {
@@ -50,6 +51,7 @@ export const runtime = (): CommandRuntime => {
const userData: GotoAnythingUserData = {
callback: { resolve, reject },
mode: options.mode,
alwaysShowHelp: options.alwaysShowHelp,
};
menuItem.userData = userData;
menuItem.click();
@@ -2,6 +2,7 @@
import * as addNoteToWhiteboard from './addNoteToWhiteboard';
import * as addProfile from './addProfile';
import * as commandPalette from './commandPalette';
import * as copyNoteExternalLink from './copyNoteExternalLink';
import * as deleteFolder from './deleteFolder';
import * as duplicateNote from './duplicateNote';
import * as editAlarm from './editAlarm';
@@ -37,9 +38,11 @@ import * as showModalMessage from './showModalMessage';
import * as showNoteContentProperties from './showNoteContentProperties';
import * as showNoteProperties from './showNoteProperties';
import * as showPrompt from './showPrompt';
import * as showPublishFolderDialog from './showPublishFolderDialog';
import * as showShareFolderDialog from './showShareFolderDialog';
import * as showShareNoteDialog from './showShareNoteDialog';
import * as showSpellCheckerMenu from './showSpellCheckerMenu';
import * as toggleAiChat from './toggleAiChat';
import * as toggleEditors from './toggleEditors';
import * as toggleLayoutMoveMode from './toggleLayoutMoveMode';
import * as toggleMenuBar from './toggleMenuBar';
@@ -56,6 +59,7 @@ const index: any[] = [
addNoteToWhiteboard,
addProfile,
commandPalette,
copyNoteExternalLink,
deleteFolder,
duplicateNote,
editAlarm,
@@ -91,9 +95,11 @@ const index: any[] = [
showNoteContentProperties,
showNoteProperties,
showPrompt,
showPublishFolderDialog,
showShareFolderDialog,
showShareNoteDialog,
showSpellCheckerMenu,
toggleAiChat,
toggleEditors,
toggleLayoutMoveMode,
toggleMenuBar,
@@ -11,6 +11,7 @@ const logger = Logger.create('linkToNote');
export const declaration: CommandDeclaration = {
name: 'linkToNote',
label: () => _('Link to note...'),
iconName: 'fas fa-file-export',
};
export const runtime = (): CommandRuntime => {
@@ -18,6 +19,7 @@ export const runtime = (): CommandRuntime => {
execute: async (_context: CommandContext) => {
const options: GotoAnythingOptions = {
mode: Mode.TitleOnly,
alwaysShowHelp: true,
};
const result = await CommandService.instance().execute('gotoAnything', UiType.ControlledApi, options);
if (!result) return result;
Loaded 100 of 416 files, more files were not shown because too many files have changed in this diff. Show more