1
0
mirror of https://github.com/laurent22/joplin.git synced 2026-03-06 09:38:28 +02:00

Compare commits

...

123 Commits

Author SHA1 Message Date
renovate[bot]
d6ac709e5f Update dependency ldapts to v8.0.13 (#14592)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-05 22:46:54 +00:00
Sriram Varun Kumar
b290046e66 Mobile: Fixes #14555: Fix tapping rendered image scrolling to cursor position (#14580) 2026-03-05 13:20:23 +00:00
Henry Heino
c2321a04ae Chore: Importing from OneNote: Add test to verify that errors are reported to JavaScript (#14550) 2026-03-05 09:13:18 +00:00
mrjo118
3df77a4395 Desktop, Mobile: Fix issue where the revision service does not start on the first launch of the app (#14554) 2026-03-05 09:06:04 +00:00
mrjo118
38fd790719 Mobile: Add ability to set per notebook sorting on mobile (#14562) 2026-03-05 09:04:26 +00:00
Vinayreddy765
40bfa9dd3d Desktop: Show feedback message when master passwords do not match (#14566) 2026-03-05 09:01:03 +00:00
Henry Heino
8d08e5df60 Desktop: Importing from OneNote: Fix importing cross-page links (#14567) 2026-03-05 09:00:16 +00:00
Ash092016
4121c47e18 CI: Add concurrency block to cancel outdated workflow runs (#14570) 2026-03-05 09:00:02 +00:00
Yugal Kaushik
d30e6ad0da Desktop: Fixes #13178: Invisible cursor in legacy editor when using dark theme in separate window (#14557)
Co-authored-by: Henry Heino <46334387+personalizedrefrigerator@users.noreply.github.com>
2026-03-05 08:32:09 +00:00
Yugal Kaushik
be712df89d Mobile: Fixes #14534: Call unmount() in Note.test.tsx tests to suppress act() warnings (#14535) 2026-03-05 08:31:44 +00:00
Sriram Varun Kumar
f7762c403e Mobile: Rich Text Editor: Fix extra blank line above nested lists (#14504) 2026-03-05 08:31:22 +00:00
renovate[bot]
b89d37de84 Update dependency @types/serviceworker to v0.0.168 (#14578)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-05 08:26:56 +00:00
Ashutosh Singh
a7b9af61c0 Desktop: Fixes #14500: Fixes zh_TW locale detection on first start (#14527) 2026-03-04 20:00:12 +00:00
Laurent Cozic
a3186cdfe1 Doc: Add CLAUDE.md rule regarding duplicate tests 2026-03-04 18:48:05 +00:00
Laurent Cozic
0a580493a2 Doc: Added YouTube link to main website page and removed Lemmy link 2026-03-04 16:07:39 +00:00
Laurent Cozic
7a7bf72aa8 Chore: Minor fix to Paste as Markdown feature 2026-03-04 16:06:27 +00:00
Laurent Cozic
a20a584273 Desktop: Add "Paste as Markdown" command for Markdown editor (#14556) 2026-03-04 14:31:54 +00:00
Sriram Varun Kumar
ae30e8cf00 CLI: Fix trailing spaces in ls -l output (#14559) 2026-03-04 10:16:22 +00:00
Joplin Bot
1a7bb9131a Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-03-04 02:14:13 +00:00
Harsh Gupta
81ed35b117 Desktop: Resolves #12210: Translate Find and Replace dialog in Rich Text editor (#14529) 2026-03-03 16:48:40 +00:00
Sriram Varun Kumar
2704495ac6 Desktop: Fixes #14196: Fix file:// links with backslashes for Windows UNC paths (#14541) 2026-03-03 16:38:08 +00:00
Parth Thirwani
a96f7c6ee7 Desktop: Fixes #13883: Secondary windows no longer follow primary selection after moving notes (#14498)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-03-03 15:32:04 +00:00
Henry Heino
af706ac1b3 Web: Add welcome notes specific to the web app (#14499) 2026-03-03 15:30:03 +00:00
Henry Heino
766ef933b9 Web: Link to the official web app when attempting to sync with Joplin Cloud (#14523) 2026-03-03 15:09:14 +00:00
Surendra Manjhi
35de2aca18 Desktop: Fixes #12313: Prevent All Notes sort order from overwriting shared notebook sort on relaunch (#14524) 2026-03-03 15:08:35 +00:00
Ahmed Idani
c1827e1b9e Desktop: Fixes #14522: App fails to restart on Linux AppImage (#14530) 2026-03-03 14:59:53 +00:00
Henry Heino
89e3544a0c Chore: Desktop: Fix automated tests fail when the system locale is not English (#14531) 2026-03-03 14:58:47 +00:00
mrjo118
7f40e9e661 Mobile: Prevent focus issues and keyboard opening when opening a note in view mode (#14533) 2026-03-03 14:58:10 +00:00
Akshaj Rawat
20405ea95f Desktop: Resolves #12326: Add keyboard shortcuts to toolbar buttons (#14408) 2026-03-03 13:39:57 +00:00
Akshaj Rawat
2574e18c2f Desktop: Fixes #14271: Error message is incorrect when plugin manifest is invalid (#14374) 2026-03-03 13:36:08 +00:00
Joplin Bot
36b25a9517 Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-03-03 02:18:36 +00:00
Laurent Cozic
b3e0575361 iOS 13.6.2 2026-03-02 22:16:27 +00:00
Joplin Bot
f9f40b3c9b Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-03-02 18:59:44 +00:00
Laurent Cozic
b59721f4b3 Android 3.6.13 2026-03-02 17:47:33 +00:00
Laurent Cozic
891ab3e317 Desktop release v3.6.3 2026-03-02 17:26:21 +00:00
Laurent Cozic
0e156796bc Desktop: Fix editor plugins receiving stale note body during navigation (#14513) 2026-03-01 18:54:42 +00:00
renovate[bot]
f2b558cb75 Update dependency react-native-localize to v3.6.0 (#14511)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-01 15:47:39 +00:00
renovate[bot]
322657ef72 Update dependency gettext-extractor to v4.0.3 (#14508)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-01 13:17:45 +00:00
renovate[bot]
c1e99afd2e Update dependency samlify to v2.10.2 (#14509)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-01 13:12:44 +00:00
Sriram Varun Kumar
b3822e2700 CLI: Fixes #13158: Fix null crash in e2ee decrypt command (#14461)
Co-authored-by: Henry Heino <46334387+personalizedrefrigerator@users.noreply.github.com>
2026-03-01 12:21:21 +00:00
Nagmani Upadhyay
a43f46fc01 Mobile: Fixes #11793: uses consistent padding in plugin info dialog (#14466) 2026-03-01 12:20:22 +00:00
Kanishka..
50a26b63c8 Desktop: Fixes #13679: Fix sidebar scroll jump when expanding/collapsing folders (#14467) 2026-03-01 12:20:02 +00:00
Harsh Gupta
02c1c75587 Desktop: Fixes #12401: copying from markdown preview including theme background colour (#14474) 2026-03-01 12:11:16 +00:00
Sriram Varun Kumar
345632324d Desktop: Fix UI freeze when closing plugin dialog with Escape key (#14477)
Co-authored-by: Henry Heino <46334387+personalizedrefrigerator@users.noreply.github.com>
2026-03-01 12:08:58 +00:00
mrjo118
8073e03daf Mobile: Prevent race condition when refreshing note contents on mobile (#14486) 2026-03-01 11:58:15 +00:00
Laurent Cozic
7feb953c70 Chore: Trying to use CodeRabbit to validate pull request descriptions 2026-03-01 11:57:51 +00:00
yentropysack
8e895fb2c0 Desktop: Fixes #12385: Copy and paste from markdown preview includes search highlight effect (#14493) 2026-03-01 11:24:41 +00:00
Harsh Gupta
ee97c41309 Desktop, Mobile: Resolves #12220: Add new option to disable the Joplin icon for internal note links (#14503) 2026-03-01 11:16:14 +00:00
Joplin Bot
c9a55563b5 Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-03-01 02:33:23 +00:00
GeorgiPopovIT
39f5dc8c95 All: Translation: Update bg_BG.po (#14505) 2026-02-28 19:48:02 -05:00
Joplin Bot
9256ab197a Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-02-28 18:44:15 +00:00
Laurent Cozic
ee2869da86 Doc: Fix sponsor link 2026-02-28 17:51:48 +00:00
Henry Heino
e11441cfbc Web: Move web app out of beta (#14497) 2026-02-28 12:01:50 +00:00
Yugal Kaushik
2b5be639ce All: Resolves #14336: Store note history settings in sync info (#14449)
Signed-off-by: yugalkaushik <yugalkaushik14@gmail.com>
2026-02-28 12:01:23 +00:00
GeorgiPopovIT
cf3d7f5b88 All: Translation: Update bg_BG.po (#14482) 2026-02-27 12:32:19 -05:00
Mihai Vasiliu
595452f30e All: Translation: Update ro_RO.po and ro_MD.po (#14470) 2026-02-27 12:30:11 -05:00
Henry Heino
33c4029547 Server: Performance: Improve performance of share maintenance task (#14484) 2026-02-27 14:31:48 +00:00
Laurent Cozic
eb238efc7b Transcribe v3.6.6 2026-02-26 21:08:39 +00:00
Laurent Cozic
513341f103 Transcribe: Upgraded image to node:24-bookworm to fix build issue 2026-02-26 21:08:18 +00:00
Henry Heino
65b7c4be26 Doc: Link to GSoC pull request guidelines in the pull request template (#14478) 2026-02-26 17:45:58 +00:00
Henry Heino
344a3c2605 Server: Fixes #14107: Update item ownership information when the original owner no longer has access (#14469) 2026-02-26 15:25:22 +00:00
Laurent Cozic
3fc724c076 Transcribe v3.6.5 2026-02-26 15:06:58 +00:00
Laurent Cozic
044fab96c2 Chore: Update dictionary 2026-02-26 15:06:36 +00:00
Laurent Cozic
df10bbdf2d Transcribe: Fixed location of llamacpp library 2026-02-26 15:05:38 +00:00
Joplin Bot
65d7d12533 Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-02-26 02:14:48 +00:00
Laurent Cozic
814a09035a Transcribe v3.6.3 2026-02-25 21:20:24 +00:00
Laurent Cozic
aadc05bd6c Merge branch 'release-3.5' into dev 2026-02-25 17:50:37 +00:00
Laurent Cozic
0c1511f39e Desktop release v3.5.13 2026-02-25 17:46:43 +00:00
Laurent Cozic
d75d0df88a Chore: Refactor and simplify Transcribe server (#14462) 2026-02-25 17:46:08 +00:00
Henry Heino
2249b3aa7f Desktop: Upgrade tar to v7.5.8 (#14464) 2026-02-25 17:38:10 +00:00
Henry Heino
5d9a6151ea Merge remote-tracking branch 'origin/release-3.5' into dev 2026-02-25 07:41:50 -08:00
Henry Heino
d3ea5bc4a2 Mobile,Desktop: Resolves #13215: Markdown editor: Enable in-editor rendering by default (#13878)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-02-25 08:56:54 +00:00
horvatkm
0ea374cc87 All: Fix status 400 error on Tomcat WebDAV servers (#14332)
Co-authored-by: horvatkm <horvatkm@users.noreply.github.com>
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
Co-authored-by: mrjo118 <jo.118@hotmail.com>
2026-02-25 08:56:24 +00:00
Sriram Varun Kumar
a53f196cae All: Fixes #14335: Support include_deleted parameter for GET /folders endpoint (#14421) 2026-02-25 08:46:59 +00:00
Ashutosh Singh
7b73b4ba87 Desktop: Resolves #9336: Add editor and sync target to about dialog (#14443) 2026-02-25 08:46:35 +00:00
Henry Heino
99e6d3961f All: Fix unexpected conflicts created during sync (#14453) 2026-02-25 08:45:42 +00:00
renovate[bot]
d0f82fb03b Update dependency nodejs to v24.10.0 (#14454)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-25 08:45:18 +00:00
mrjo118
b1b96e9529 Mobile: Fixes #14452: Make the view / edit note button hidden when an editor plugin is visible (#14458) 2026-02-25 08:42:48 +00:00
Henry Heino
18e178e6cf Chore: Update plugin types (#14457) 2026-02-25 08:41:55 +00:00
Harsh Gupta
075b16a4d2 Desktop: Fixes #12569: Prevent 4th backtick when closing fenced code block (#14423)
Co-authored-by: Henry Heino <46334387+personalizedrefrigerator@users.noreply.github.com>
2026-02-24 22:19:09 +00:00
mrjo118
6d50a947dd Mobile: Fixes #14387: Reset undo/redo button state when toggling an editor plugin (#14444) 2026-02-24 22:18:14 +00:00
Kanishka..
cb12e4efb0 All: Fixes #12545: Handle missing script assets in HTML export (#14442) 2026-02-24 22:17:46 +00:00
renovate[bot]
c63eac19c9 Update dependency axios to v1.13.0 (#14450)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-24 22:15:30 +00:00
Henry Heino
2544a55373 Chore: Fix incorrect test for versionInfo (#14451) 2026-02-24 22:13:40 +00:00
Henry Heino
932dbbed1a Server: Fix user can incorrectly retain access to shared items in some cases (#14438) 2026-02-24 22:08:15 +00:00
renovate[bot]
af040cbb79 Update dependency @pmmmwh/react-refresh-webpack-plugin to v0.6.2 (#14448)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-24 13:11:46 +00:00
Henry Heino
9833250bea Chore: Sync fuzzer: Include the step at which an action happened in the action log (#14407) 2026-02-24 09:54:05 +00:00
Henry Heino
3bcdc1b362 Server: Fix user can incorrectly retain access to shared items in some cases (#14445)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-02-24 09:50:02 +00:00
renovate[bot]
036e503d39 Update dependency @react-native-community/datetimepicker to v8.5.1 (#14441)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-23 21:15:58 +00:00
Joplin Bot
8667b28db3 Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-02-23 19:12:48 +00:00
renovate[bot]
3c317ccdc1 Update dependency @react-native-community/datetimepicker to v8.5.0 (#14439)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-23 19:02:07 +00:00
Laurent Cozic
6a0fc3e36e Doc: Added news item for warrant canary 2026-02-23 18:34:55 +00:00
Laurent Cozic
a95b5744ad Doc: Update sponsors 2026-02-23 16:32:31 +00:00
Sriram Varun Kumar
33eb2f02f8 Desktop: Fixes #14328: "Copy dev mode command to clipboard" does not work when path contains spaces (#14432) 2026-02-23 13:48:55 +00:00
Yugal Kaushik
93732f8df6 Desktop, Cli: Fixes #14139: Remove empty hidden divs from ENEX imports (#14411) 2026-02-22 18:15:17 +00:00
Kanishka..
f589197915 All: Resolves #13216: Move editor settings to dedicated editor section (#14403) 2026-02-22 09:00:16 +00:00
Harsh Gupta
55199244ba Doc: Added video tutorials to documentation pages (#14410) 2026-02-22 08:54:31 +00:00
Ashutosh Singh
6ea3180aee Desktop, Mobile: Resolves #13755: Add waving hand emoji to welcome notebook (#14398) 2026-02-21 11:15:48 +00:00
Laurent Cozic
471bb1bf2b Chore: Fix Code Rabbit auto-labelling 2026-02-20 21:47:30 +00:00
Laurent Cozic
e3948dab24 Desktop: Add context menu to non-image resources in Markdown editor (#14402) 2026-02-20 21:30:32 +00:00
Henry Heino
950cc54bf0 Docs: Add information about the sync fuzzer to the "Debugging the server project" page (#14404) 2026-02-20 21:29:41 +00:00
Anmol Garg
56d43fc3a5 Server: Fixes #14355: Admin emails sorting fails due to invalid user_id column (#14399) 2026-02-20 19:46:39 +00:00
Yugal Kaushik
45ad3ee078 Server: Fixes #14384: Remove warning logged on first startup (#14401)
Co-authored-by: Henry Heino <46334387+personalizedrefrigerator@users.noreply.github.com>
2026-02-20 19:35:37 +00:00
Laurent Cozic
8b2b0dfd8b Update CLAUDE.md 2026-02-20 19:15:39 +00:00
mrjo118
90de267c62 Desktop, Mobile: Fixes #13660: Defer starting revision service maintenance until the initial sync has completed (#14394) 2026-02-20 11:27:39 +00:00
Henry Heino
085fe0a1cf Server: Remove support for DELTA_INCLUDES_ITEMS (#14393) 2026-02-20 11:26:46 +00:00
Henry Heino
009f3ed692 Desktop,Mobile,Cli: Fixes #14383: Fix unexpected conflicts sometimes created after a full sync (#14388) 2026-02-20 11:26:36 +00:00
Henry Heino
2763a219e4 Chore: Sync fuzzer: Add utilities to allow reproducing a conflict-related share issue (#14382) 2026-02-20 11:26:24 +00:00
Henry Heino
01a51589fd Server: Fixes #14343: Fix certain note content is corrupted when uploaded to the server (#14379) 2026-02-20 11:26:16 +00:00
mrjo118
ba414a4e01 Mobile: Refresh note when updated via the API when in edit mode (#14378) 2026-02-20 11:26:05 +00:00
mrjo118
2da78b37b8 Mobile: Resolves #11521: Remember the viewing / editing mode (updated) (#14363) 2026-02-20 11:22:34 +00:00
Henry Heino
3ef21b0fff Server: Improve name generation for uploaded files (#14392) 2026-02-20 11:04:55 +00:00
Laurent Cozic
77353b015e Desktop: Resolves #12400: Add a text layer over OCR-ed PDF files to make them accessible (#14390) 2026-02-20 11:04:36 +00:00
Laurent Cozic
b6dc7730fc Chore: Update Code Rabbit auto-tagging feature 2026-02-20 10:42:21 +00:00
Laurent Cozic
e96b8d1079 Add comment guideline for duplicated code
Add guideline for commenting on duplicated code.
2026-02-19 22:58:21 +00:00
renovate[bot]
16abb027c2 Update dependency raw-body to v3.0.2 (#14389)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-19 22:24:11 +00:00
Laurent Cozic
8af0c451c6 Chore: Add CLAUDE.md to configure Claude 2026-02-19 21:57:22 +00:00
Joplin Bot
9badf985cb Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-02-19 19:02:02 +00:00
Laurent Cozic
59c6be2234 Doc: Update sponsors 2026-02-19 17:19:42 +00:00
Henry Heino
df7a04f552 Desktop: Importing from OneNote: Enable stricter path sanitization on Windows (#14321) 2026-02-10 10:09:34 +00:00
Henry Heino
8ad1dfa2bf Desktop, Cli: Upgrade tar to v7.5.7 (#14313) 2026-02-10 08:41:12 +00:00
Henry Heino
eeaed07a53 Mobile: Fix heading links (#14201) 2026-02-04 10:09:19 +00:00
Henry Heino
f497d898bc Windows: Fixes #14084: .onepkg file import: Fix import failure when notebook titles contain certain Unicode characters (#14090) 2026-01-26 16:56:39 +00:00
243 changed files with 6712 additions and 4594 deletions

View File

@@ -12,84 +12,99 @@ reviews:
auto_apply_labels: true
labeling_instructions:
- label: "accessibility"
instructions: "Apply when the PR contains changes related to accessibility, screen readers, keyboard navigation, or ARIA attributes."
instructions: "Apply when the PR contains changes related to accessibility, screen readers, keyboard navigation, or ARIA attributes"
- label: "android"
instructions: "Apply when the PR contains changes specific to the Android platform or Android app."
instructions: "Apply when the PR modifies files under packages/app-mobile/android/. Or when the PR modifies files under packages/app-mobile and the change is specific to Android only"
- label: "api"
instructions: "Apply when the PR modifies the Joplin API, REST endpoints, or API-related code."
instructions: "Apply when the PR modifies files under packages/lib/services/rest/"
- label: "bug"
instructions: "Apply when the PR fixes a bug or unexpected behaviour."
instructions: "Apply when the PR fixes a bug or unexpected behaviour"
- label: "ci"
instructions: "Apply when the PR modifies CI/CD configuration, GitHub Actions workflows, or build pipelines."
instructions: "Apply when the PR modifies files under .github/workflows/ or .circleci/"
- label: "cli"
instructions: "Apply when the PR contains changes specific to the Joplin CLI (command-line) application."
instructions: "Apply when the PR modifies files under packages/app-cli/, except if all the modified files are under packages/app-cli/tests/"
- label: "clipper"
instructions: "Apply when the PR contains changes to the Joplin Web Clipper browser extension."
instructions: "Apply when the PR modifies files under packages/app-clipper/"
- label: "database"
instructions: "Apply when the PR modifies database schema, migrations, or database-related logic."
instructions: "Apply when the PR is mainly about modifying database schema, migrations, or database-related logic"
- label: "desktop"
instructions: "Apply when the PR contains changes specific to the Joplin desktop (Electron) application."
instructions: "Apply when the PR modifies files under packages/app-desktop/"
- label: "documentation"
instructions: "Apply when the PR is mainly about adding or updating documentation, README files, or code comments."
instructions: "Apply when the PR modifies files under readme/"
- label: "draw"
instructions: "Apply when the PR contains changes related to the drawing or sketching feature."
instructions: "Apply when the PR modifies files under packages/default-plugins and relates to the JS-Draw drawing plugin"
- label: "editor"
instructions: "Apply when the PR contains changes to the note editor (CodeMirror, TinyMCE, or the editor infrastructure)."
instructions: "Apply when the PR modifies files under packages/editor/ or packages/app-mobile/components/NoteEditor/"
- label: "enhancement"
instructions: "Apply when the PR adds a new feature or improves existing functionality (not a bug fix)."
instructions: "Apply when the PR adds a new feature or improves existing functionality (not a bug fix)"
- label: "export"
instructions: "Apply when the PR contains changes to export functionality (PDF, HTML, JEX, etc.)."
instructions: "Apply when the PR is mainly about changes to the export functionality (PDF, HTML, JEX, etc.)"
- label: "import"
instructions: "Apply when the PR contains changes to import functionality (Evernote, Markdown, etc.)."
instructions: "Apply when the PR is mainly about changes to the import functionality (Evernote, Markdown, etc.)"
- label: "iOS"
instructions: "Apply when the PR contains changes specific to the iOS platform or iOS app."
instructions: "Apply when the PR modifies files under packages/app-mobile/ios/. Or when the PR modifies files under packages/app-mobile and the change is specific to iOS only"
- label: "linux"
instructions: "Apply when the PR contains changes specific to Linux."
instructions: "Apply when the PR is mainly about changes specific to Linux"
- label: "linux/wayland"
instructions: "Apply when the PR contains changes specific to Linux Wayland."
instructions: "Apply when the PR is mainly about changes specific to Linux Wayland"
- label: "macOS"
instructions: "Apply when the PR contains changes specific to macOS."
instructions: "Apply when the PR is mainly about changes specific to macOS"
- label: "markdown-editor"
instructions: "Apply when the PR contains changes to the Markdown editor or Markdown rendering."
instructions: "Apply when the PR modifies files under packages/editor/CodeMirror"
- label: "mobile"
instructions: "Apply when the PR contains changes to the mobile app (iOS or Android)."
- label: "multi-window"
instructions: "Apply when the PR contains changes related to multi-window support."
instructions: "Apply when the PR modifies files under packages/app-mobile/"
- label: "OCR"
instructions: "Apply when the PR contains changes related to OCR (optical character recognition) functionality."
instructions: "Apply when the PR contains changes related to OCR (optical character recognition) functionality"
- label: "performance"
instructions: "Apply when the PR improves performance, reduces memory usage, or optimises speed."
instructions: "Apply when the PR improves performance, reduces memory usage, or optimises speed"
- label: "plugins"
instructions: "Apply when the PR contains changes to the plugin system, plugin API, or specific plugins."
instructions: "Apply when the PR modifies files under packages/lib/services/plugins/ or packages/plugin-repo-cli/"
- label: "Regression"
instructions: "Apply when the linked issue, if any, has the Regression label."
instructions: "Apply when the linked issue, if any, has the Regression label"
- label: "renderer"
instructions: "Apply when the PR contains changes to the note renderer or how notes are displayed."
instructions: "Apply when the PR modifies files under packages/renderer/ or packages/turndown/"
- label: "search"
instructions: "Apply when the PR contains changes to search functionality."
instructions: "Apply when the PR is mainly about changes to the search functionality"
- label: "security"
instructions: "Apply when the PR is mainly about addressing a security vulnerability or improving security."
instructions: "Apply when the PR is mainly about addressing a security vulnerability or improving security"
- label: "server"
instructions: "Apply when the PR contains changes to files under the packages/server folder."
instructions: "Apply when the PR modifies files under packages/server/"
- label: "Sharing"
instructions: "Apply when the PR contains changes to note or notebook sharing features."
instructions: "Apply when the PR is mainly about changes to the note or notebook/folder sharing features"
- label: "sync"
instructions: "Apply when the PR contains changes to synchronisation logic or sync targets."
instructions: "Apply when the PR modifies files under packages/lib/services/synchronizer/, packages/lib/Sync*.ts or packages/lib/services/e2ee/"
- label: "tags"
instructions: "Apply when the PR contains changes to tag management or tagging functionality."
instructions: "Apply when the PR is mainly about changes to the tag management or tagging functionality"
- label: "transcribe"
instructions: "Apply when the PR contains changes to audio transcription functionality."
instructions: "Apply when the PR modifies files under packages/transcribe"
- label: "translation"
instructions: "Apply when the PR adds or updates translations or localisation strings."
instructions: "Apply when the PR modifies files under packages/tools/locales/ or **/locales/"
- label: "Voice typing"
instructions: "Apply when the PR contains changes to voice typing functionality."
instructions: "Apply when the PR is mainly about changes to the voice typing functionality"
- label: "web"
instructions: "Apply when the PR contains changes to the Joplin web application or web-related features."
instructions: "Apply when the PR modifies files under packages/app-web/. Or when the PR modifies files under packages/app-mobile and the change is specific to the web app only"
- label: "windows"
instructions: "Apply when the PR contains changes specific to Windows."
instructions: "Apply when the PR is mainly about changes specific to Windows"
pre_merge_checks:
description:
mode: "warning"
custom_checks:
- name: "PR Description Must Follow Guidelines"
mode: "error"
instructions: |
Fail if the pull request description does not include clear sections for:
- Problem or user-impact description
- A high-level Solution explanation
- Any Test Plan or verification steps
The description should align with our PR guidelines
at https://github.com/joplin/gsoc/blob/master/pull_request_guidelines.md
and should not just restate the diff or implementation details.
knowledge_base:
code_guidelines:
enabled: true
filePatterns:
- "readme/dev/coding_style.md"
- "readme/dev/index.md"
- "CLAUDE.md"

View File

@@ -17,3 +17,4 @@ packages/server/db-*.sqlite
packages/server/dist/
packages/server/logs/
packages/server/temp/
packages/transcribe/.env

View File

@@ -1,35 +1,33 @@
# Joplin Transcribe Configuration
#
# Copy this file to .env-transcribe and update the values.
# =============================================================================
# Required
# -----------------------------------------------------------------------------
# =============================================================================
SERVER_PORT=4567
# Set a secure API key for authentication
API_KEY=changeme
API_KEY=random-string
QUEUE_TTL=900000
QUEUE_RETRY_COUNT=2
QUEUE_MAINTENANCE_INTERVAL=30000
IMAGE_MAX_DIMENSION=400
# =============================================================================
# Optional (defaults are set in the Docker image)
# =============================================================================
HTR_CLI_DOCKER_IMAGE=joplin/htr-cli:latest
# Fullpath to images folder e.g.:
#HTR_CLI_IMAGES_FOLDER=/home/user/joplin/packages/transcribe/images
HTR_CLI_IMAGES_FOLDER=
# Server port (default: 4567)
# SERVER_PORT=4567
QUEUE_DRIVER=pg
# Maximum image dimension for processing (default: 400)
# IMAGE_MAX_DIMENSION=400
# Queue driver: sqlite (default) or pg
# QUEUE_DRIVER=sqlite
FILE_STORAGE_MAINTENANCE_INTERVAL=3600000
FILE_STORAGE_TTL=604800000 # one week
# =============================================================================
# PostgreSQL settings (only if QUEUE_DRIVER=pg)
# =============================================================================
# =============================================================================
# Queue driver
# -----------------------------------------------------------------------------
# =============================================================================
#
# QUEUE_DATABASE_NAME=./queue.sqlite3
QUEUE_DATABASE_NAME=transcribe
QUEUE_DATABASE_USER=transcribe
QUEUE_DATABASE_PASSWORD=transcribe
QUEUE_DATABASE_PORT=5432
QUEUE_DATABASE_HOST=localhost
# QUEUE_DATABASE_NAME=transcribe
# QUEUE_DATABASE_USER=transcribe
# QUEUE_DATABASE_PASSWORD=transcribe
# QUEUE_DATABASE_PORT=5432
# QUEUE_DATABASE_HOST=localhost

View File

@@ -169,6 +169,7 @@ packages/app-desktop/bridge.js
packages/app-desktop/checkForUpdates.js
packages/app-desktop/commands/copyDevCommand.js
packages/app-desktop/commands/copyToClipboard.js
packages/app-desktop/commands/createAccessibleDocument.js
packages/app-desktop/commands/editProfileConfig.js
packages/app-desktop/commands/emptyTrash.js
packages/app-desktop/commands/exportDeletionLog.test.js
@@ -302,6 +303,7 @@ packages/app-desktop/gui/NoteEditor/commands/focusElementNoteTitle.js
packages/app-desktop/gui/NoteEditor/commands/focusElementNoteViewer.js
packages/app-desktop/gui/NoteEditor/commands/focusElementToolbar.js
packages/app-desktop/gui/NoteEditor/commands/index.js
packages/app-desktop/gui/NoteEditor/commands/pasteAsMarkdown.js
packages/app-desktop/gui/NoteEditor/commands/pasteAsText.js
packages/app-desktop/gui/NoteEditor/commands/showLocalSearch.js
packages/app-desktop/gui/NoteEditor/commands/showRevisions.js
@@ -607,10 +609,6 @@ packages/app-desktop/services/plugins/hooks/useViewIsReady.js
packages/app-desktop/services/plugins/hooks/useWebviewToPluginMessages.js
packages/app-desktop/services/plugins/types.js
packages/app-desktop/services/restart.js
packages/app-desktop/services/sortOrder/PerFolderSortOrderService.test.js
packages/app-desktop/services/sortOrder/PerFolderSortOrderService.js
packages/app-desktop/services/sortOrder/notesSortOrderUtils.test.js
packages/app-desktop/services/sortOrder/notesSortOrderUtils.js
packages/app-desktop/services/spellChecker/SpellCheckerServiceDriverNative.js
packages/app-desktop/tools/bundleJs.js
packages/app-desktop/tools/copy7Zip.js
@@ -694,8 +692,6 @@ packages/app-mobile/components/ExtendedWebView/index.js
packages/app-mobile/components/ExtendedWebView/index.web.js
packages/app-mobile/components/ExtendedWebView/types.js
packages/app-mobile/components/ExtendedWebView/utils/useCss.js
packages/app-mobile/components/FeedbackBanner.test.js
packages/app-mobile/components/FeedbackBanner.js
packages/app-mobile/components/FolderPicker.js
packages/app-mobile/components/Icon.js
packages/app-mobile/components/IconButton.js
@@ -740,7 +736,6 @@ packages/app-mobile/components/ScreenHeader/Menu.js
packages/app-mobile/components/ScreenHeader/WarningBanner.test.js
packages/app-mobile/components/ScreenHeader/WarningBanner.js
packages/app-mobile/components/ScreenHeader/WarningBox.js
packages/app-mobile/components/ScreenHeader/WebBetaButton.js
packages/app-mobile/components/ScreenHeader/index.js
packages/app-mobile/components/SearchInput.js
packages/app-mobile/components/SelectDateTimeDialog.js
@@ -1265,6 +1260,7 @@ packages/lib/SyncTargetRegistry.js
packages/lib/Synchronizer.js
packages/lib/TaskQueue.js
packages/lib/WebDavApi.js
packages/lib/WelcomeUtils.test.js
packages/lib/WelcomeUtils.js
packages/lib/array.js
packages/lib/callbackUrlUtils.test.js
@@ -1293,6 +1289,7 @@ packages/lib/components/EncryptionConfigScreen/utils.test.js
packages/lib/components/EncryptionConfigScreen/utils.js
packages/lib/components/shared/NoteEditor/WarningBanner/onRichTextDismissLinkClick.js
packages/lib/components/shared/NoteEditor/WarningBanner/onRichTextReadMoreLinkClick.js
packages/lib/components/shared/NoteEditor/WarningBanner/useEditorTypeMigrationBanner.js
packages/lib/components/shared/NoteList/getEmptyFolderMessage.js
packages/lib/components/shared/NoteRevisionViewer/getHelpMessage.js
packages/lib/components/shared/NoteRevisionViewer/useDeleteHistoryClick.js
@@ -1431,6 +1428,7 @@ packages/lib/services/AlarmServiceDriverNode.js
packages/lib/services/BaseService.js
packages/lib/services/CommandService.test.js
packages/lib/services/CommandService.js
packages/lib/services/DecryptionWorker.test.js
packages/lib/services/DecryptionWorker.js
packages/lib/services/ExternalEditWatcher.js
packages/lib/services/ExternalEditWatcher/utils.js
@@ -1519,6 +1517,7 @@ packages/lib/services/interop/InteropService_Importer_Md.test.js
packages/lib/services/interop/InteropService_Importer_Md.js
packages/lib/services/interop/InteropService_Importer_Md_frontmatter.test.js
packages/lib/services/interop/InteropService_Importer_Md_frontmatter.js
packages/lib/services/interop/InteropService_Importer_OneNote.postprocessHtml.test.js
packages/lib/services/interop/InteropService_Importer_OneNote.test.js
packages/lib/services/interop/InteropService_Importer_OneNote.js
packages/lib/services/interop/InteropService_Importer_Raw.test.js
@@ -1552,6 +1551,7 @@ packages/lib/services/ocr/OcrService.js
packages/lib/services/ocr/drivers/OcrDriverTesseract.js
packages/lib/services/ocr/drivers/OcrDriverTranscribe.test.js
packages/lib/services/ocr/drivers/OcrDriverTranscribe.js
packages/lib/services/ocr/utils/createAccessiblePdf.js
packages/lib/services/ocr/utils/filterOcrText.test.js
packages/lib/services/ocr/utils/filterOcrText.js
packages/lib/services/ocr/utils/types.js
@@ -1671,6 +1671,10 @@ packages/lib/services/share/ShareService.test.js
packages/lib/services/share/ShareService.js
packages/lib/services/share/invitationRespond.js
packages/lib/services/share/reducer.js
packages/lib/services/sortOrder/PerFolderSortOrderService.test.js
packages/lib/services/sortOrder/PerFolderSortOrderService.js
packages/lib/services/sortOrder/notesSortOrderUtils.test.js
packages/lib/services/sortOrder/notesSortOrderUtils.js
packages/lib/services/spellChecker/SpellCheckerService.js
packages/lib/services/spellChecker/SpellCheckerServiceDriverBase.js
packages/lib/services/style/cssToTheme.test.js
@@ -1889,11 +1893,11 @@ packages/tools/fuzzer/utils/SeededRandom.js
packages/tools/fuzzer/utils/diffSortedStringArrays.test.js
packages/tools/fuzzer/utils/diffSortedStringArrays.js
packages/tools/fuzzer/utils/extractResourceIds.js
packages/tools/fuzzer/utils/getBinaryDiffDebugMessage.js
packages/tools/fuzzer/utils/getNumberProperty.js
packages/tools/fuzzer/utils/getProperty.js
packages/tools/fuzzer/utils/getStringProperty.js
packages/tools/fuzzer/utils/hangingIndent.js
packages/tools/fuzzer/utils/logDiffDebug.js
packages/tools/fuzzer/utils/openDebugSession.js
packages/tools/fuzzer/utils/randomId.test.js
packages/tools/fuzzer/utils/randomId.js

View File

@@ -1,6 +1,12 @@
<!--
Please prefix the title with the platform you are targetting:
Before contributing, please read the contribution guidelines: https://github.com/laurent22/joplin/blob/dev/readme/dev/index.md
If this is a Google Summer of Code pull request, please read the [GSoC pull request guidelines](https://github.com/joplin/gsoc/blob/master/pull_request_guidelines.md).
---
**Pull request title**: Please prefix the title with the platform you are targetting.
Here are some examples of good titles:
@@ -20,6 +26,4 @@ If it's not related to any platform (such as a translation, change to the docume
Then please append the issue that you've addressed or fixed. Use "Resolves #123" for new features or improvements and "Fixes #123" for bug fixes.
AND PLEASE READ THE GUIDE: https://github.com/laurent22/joplin/blob/dev/readme/dev/index.md
-->

View File

@@ -4,6 +4,10 @@
name: react-native-android-build-apk
on: [push, pull_request]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
AssembleRelease:
if: github.repository == 'laurent22/joplin'

View File

@@ -1,5 +1,10 @@
name: Build macOS M1
on: [push, pull_request]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
Main:
# We always process desktop release tags, because they also publish the release

View File

@@ -1,5 +1,10 @@
name: Joplin Continuous Integration
on: [push, pull_request]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
Main:
# We always process server or desktop release tags, because they also publish the release

View File

@@ -1,5 +1,10 @@
name: Joplin UI tests
on: [push, pull_request]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:

20
.gitignore vendored
View File

@@ -142,6 +142,7 @@ packages/app-desktop/bridge.js
packages/app-desktop/checkForUpdates.js
packages/app-desktop/commands/copyDevCommand.js
packages/app-desktop/commands/copyToClipboard.js
packages/app-desktop/commands/createAccessibleDocument.js
packages/app-desktop/commands/editProfileConfig.js
packages/app-desktop/commands/emptyTrash.js
packages/app-desktop/commands/exportDeletionLog.test.js
@@ -275,6 +276,7 @@ packages/app-desktop/gui/NoteEditor/commands/focusElementNoteTitle.js
packages/app-desktop/gui/NoteEditor/commands/focusElementNoteViewer.js
packages/app-desktop/gui/NoteEditor/commands/focusElementToolbar.js
packages/app-desktop/gui/NoteEditor/commands/index.js
packages/app-desktop/gui/NoteEditor/commands/pasteAsMarkdown.js
packages/app-desktop/gui/NoteEditor/commands/pasteAsText.js
packages/app-desktop/gui/NoteEditor/commands/showLocalSearch.js
packages/app-desktop/gui/NoteEditor/commands/showRevisions.js
@@ -580,10 +582,6 @@ packages/app-desktop/services/plugins/hooks/useViewIsReady.js
packages/app-desktop/services/plugins/hooks/useWebviewToPluginMessages.js
packages/app-desktop/services/plugins/types.js
packages/app-desktop/services/restart.js
packages/app-desktop/services/sortOrder/PerFolderSortOrderService.test.js
packages/app-desktop/services/sortOrder/PerFolderSortOrderService.js
packages/app-desktop/services/sortOrder/notesSortOrderUtils.test.js
packages/app-desktop/services/sortOrder/notesSortOrderUtils.js
packages/app-desktop/services/spellChecker/SpellCheckerServiceDriverNative.js
packages/app-desktop/tools/bundleJs.js
packages/app-desktop/tools/copy7Zip.js
@@ -667,8 +665,6 @@ packages/app-mobile/components/ExtendedWebView/index.js
packages/app-mobile/components/ExtendedWebView/index.web.js
packages/app-mobile/components/ExtendedWebView/types.js
packages/app-mobile/components/ExtendedWebView/utils/useCss.js
packages/app-mobile/components/FeedbackBanner.test.js
packages/app-mobile/components/FeedbackBanner.js
packages/app-mobile/components/FolderPicker.js
packages/app-mobile/components/Icon.js
packages/app-mobile/components/IconButton.js
@@ -713,7 +709,6 @@ packages/app-mobile/components/ScreenHeader/Menu.js
packages/app-mobile/components/ScreenHeader/WarningBanner.test.js
packages/app-mobile/components/ScreenHeader/WarningBanner.js
packages/app-mobile/components/ScreenHeader/WarningBox.js
packages/app-mobile/components/ScreenHeader/WebBetaButton.js
packages/app-mobile/components/ScreenHeader/index.js
packages/app-mobile/components/SearchInput.js
packages/app-mobile/components/SelectDateTimeDialog.js
@@ -1238,6 +1233,7 @@ packages/lib/SyncTargetRegistry.js
packages/lib/Synchronizer.js
packages/lib/TaskQueue.js
packages/lib/WebDavApi.js
packages/lib/WelcomeUtils.test.js
packages/lib/WelcomeUtils.js
packages/lib/array.js
packages/lib/callbackUrlUtils.test.js
@@ -1266,6 +1262,7 @@ packages/lib/components/EncryptionConfigScreen/utils.test.js
packages/lib/components/EncryptionConfigScreen/utils.js
packages/lib/components/shared/NoteEditor/WarningBanner/onRichTextDismissLinkClick.js
packages/lib/components/shared/NoteEditor/WarningBanner/onRichTextReadMoreLinkClick.js
packages/lib/components/shared/NoteEditor/WarningBanner/useEditorTypeMigrationBanner.js
packages/lib/components/shared/NoteList/getEmptyFolderMessage.js
packages/lib/components/shared/NoteRevisionViewer/getHelpMessage.js
packages/lib/components/shared/NoteRevisionViewer/useDeleteHistoryClick.js
@@ -1404,6 +1401,7 @@ packages/lib/services/AlarmServiceDriverNode.js
packages/lib/services/BaseService.js
packages/lib/services/CommandService.test.js
packages/lib/services/CommandService.js
packages/lib/services/DecryptionWorker.test.js
packages/lib/services/DecryptionWorker.js
packages/lib/services/ExternalEditWatcher.js
packages/lib/services/ExternalEditWatcher/utils.js
@@ -1492,6 +1490,7 @@ packages/lib/services/interop/InteropService_Importer_Md.test.js
packages/lib/services/interop/InteropService_Importer_Md.js
packages/lib/services/interop/InteropService_Importer_Md_frontmatter.test.js
packages/lib/services/interop/InteropService_Importer_Md_frontmatter.js
packages/lib/services/interop/InteropService_Importer_OneNote.postprocessHtml.test.js
packages/lib/services/interop/InteropService_Importer_OneNote.test.js
packages/lib/services/interop/InteropService_Importer_OneNote.js
packages/lib/services/interop/InteropService_Importer_Raw.test.js
@@ -1525,6 +1524,7 @@ packages/lib/services/ocr/OcrService.js
packages/lib/services/ocr/drivers/OcrDriverTesseract.js
packages/lib/services/ocr/drivers/OcrDriverTranscribe.test.js
packages/lib/services/ocr/drivers/OcrDriverTranscribe.js
packages/lib/services/ocr/utils/createAccessiblePdf.js
packages/lib/services/ocr/utils/filterOcrText.test.js
packages/lib/services/ocr/utils/filterOcrText.js
packages/lib/services/ocr/utils/types.js
@@ -1644,6 +1644,10 @@ packages/lib/services/share/ShareService.test.js
packages/lib/services/share/ShareService.js
packages/lib/services/share/invitationRespond.js
packages/lib/services/share/reducer.js
packages/lib/services/sortOrder/PerFolderSortOrderService.test.js
packages/lib/services/sortOrder/PerFolderSortOrderService.js
packages/lib/services/sortOrder/notesSortOrderUtils.test.js
packages/lib/services/sortOrder/notesSortOrderUtils.js
packages/lib/services/spellChecker/SpellCheckerService.js
packages/lib/services/spellChecker/SpellCheckerServiceDriverBase.js
packages/lib/services/style/cssToTheme.test.js
@@ -1862,11 +1866,11 @@ packages/tools/fuzzer/utils/SeededRandom.js
packages/tools/fuzzer/utils/diffSortedStringArrays.test.js
packages/tools/fuzzer/utils/diffSortedStringArrays.js
packages/tools/fuzzer/utils/extractResourceIds.js
packages/tools/fuzzer/utils/getBinaryDiffDebugMessage.js
packages/tools/fuzzer/utils/getNumberProperty.js
packages/tools/fuzzer/utils/getProperty.js
packages/tools/fuzzer/utils/getStringProperty.js
packages/tools/fuzzer/utils/hangingIndent.js
packages/tools/fuzzer/utils/logDiffDebug.js
packages/tools/fuzzer/utils/openDebugSession.js
packages/tools/fuzzer/utils/randomId.test.js
packages/tools/fuzzer/utils/randomId.js

View File

@@ -0,0 +1,24 @@
# Resolves an issue in which notes and attachments larger than 16 KB
# could become corrupted during the upload process.
# See https://github.com/laurent22/joplin/issues/14343
diff --git a/src/parsers/JSON.js b/src/parsers/JSON.js
index 9a096c25778c7c68be1ddd9dd78faa85bd1d8ec3..6d6bfd2d3789313a7adc8966ab8e58c3d3167356 100644
--- a/src/parsers/JSON.js
+++ b/src/parsers/JSON.js
@@ -12,13 +12,14 @@ class JSONParser extends Transform {
}
_transform(chunk, encoding, callback) {
- this.chunks.push(String(chunk)); // todo consider using a string decoder
+ this.chunks.push(chunk); // type: Uint8Array
callback();
}
_flush(callback) {
try {
- const fields = JSON.parse(this.chunks.join(''));
+ const data = Buffer.concat(this.chunks);
+ const fields = JSON.parse(data.toString('utf-8'));
Object.keys(fields).forEach((key) => {
const value = fields[key];
this.push({ key, value });

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

View File

@@ -1,4 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Joplin]]></title><description><![CDATA[Joplin, the open source note-taking application]]></description><link>https://joplinapp.org</link><generator>RSS for Node</generator><lastBuildDate>Tue, 10 Feb 2026 00:00:00 GMT</lastBuildDate><atom:link href="https://joplinapp.org/rss.xml" rel="self" type="application/rss+xml"/><pubDate>Tue, 10 Feb 2026 00:00:00 GMT</pubDate><item><title><![CDATA[Joplin will come preloaded on the HMD Terra M]]></title><description><![CDATA[<div style="overflow: auto;">
<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Joplin]]></title><description><![CDATA[Joplin, the open source note-taking application]]></description><link>https://joplinapp.org</link><generator>RSS for Node</generator><lastBuildDate>Mon, 23 Feb 2026 00:00:00 GMT</lastBuildDate><atom:link href="https://joplinapp.org/rss.xml" rel="self" type="application/rss+xml"/><pubDate>Mon, 23 Feb 2026 00:00:00 GMT</pubDate><item><title><![CDATA[Introducing our Warrant Canary]]></title><description><![CDATA[<p>We have introduced a publicly signed warrant canary for Joplin.</p>
<p>A warrant canary is a regularly updated statement confirming that, as of the stated date, the project has not received secret legal orders, gag orders, or demands requiring the introduction of backdoors into the software or its infrastructure.</p>
<p>The canary is:</p>
<ul>
<li>
<p>Cryptographically signed using a dedicated OpenPGP key</p>
</li>
<li>
<p>Updated every 60 days</p>
</li>
<li>
<p>Published in plain text for independent verification</p>
</li>
</ul>
<p>If the canary is not updated within its stated validity window, it should be considered expired.</p>
<p>You can view and verify the current canary here:</p>
<p><a href="https://raw.githubusercontent.com/laurent22/joplin/refs/heads/dev/readme/canary.txt">https://raw.githubusercontent.com/laurent22/joplin/refs/heads/dev/readme/canary.txt</a></p>
<p>With additional information on how it is generated and managed there:</p>
<p><a href="https://github.com/laurent22/joplin/blob/dev/readme/canary.md">https://github.com/laurent22/joplin/blob/dev/readme/canary.md</a></p>
<p>This measure is intended to improve transparency and provide an additional signal to the community. It does not prevent legal orders, but it helps ensure that any material change in our legal status cannot occur silently.</p>
]]></description><link>https://joplinapp.org/news/20260223-warrant-canary</link><guid isPermaLink="false">20260223-warrant-canary</guid><pubDate>Mon, 23 Feb 2026 00:00:00 GMT</pubDate><twitter-text></twitter-text></item><item><title><![CDATA[Joplin will come preloaded on the HMD Terra M]]></title><description><![CDATA[<div style="overflow: auto;">
<img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20260210-hmd-joplin-logo.png" width="200px" style="float: left; margin-right: 16px; margin-bottom: 16px;"/>
<p>We’re happy to announce a collaboration with <a href="https://www.hmdsecure.com/">HMD Secure</a>, who will preload Joplin on their upcoming device, the HMD Terra M.</p>
<p>This partnership brings Joplin to a new class of rugged, professional devices built for instant reliable communication, and reflects a shared focus on reliability, security, and long-term use.</p>
@@ -508,15 +528,4 @@ sys 0m38.013s</p>
]]></description><link>https://joplinapp.org/news/20230508-release-2-10</link><guid isPermaLink="false">20230508-release-2-10</guid><pubDate>Wed, 10 May 2023 12:00:00 GMT</pubDate><twitter-text>What&apos;s new in Joplin 2.10</twitter-text></item><item><title><![CDATA[Joplin will participate in JdLL 2023!]]></title><description><![CDATA[<p>On 1 and 2 April 2023, we will have a stand for Joplin at the <a href="https://www.jdll.org/">Journées du Logiciel Libre</a> in Lyon, France. The JdLL has been taking place in Lyon for 24 years and is a popular open source conference in France. We had a stand in 2020 and 2021 but that was cancelled due to Covid, so this year is a first for Joplin!</p>
<p>Admission is free, so don't hesitate to come and meet us, exchange ideas and learn more about Joplin!</p>
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20230202-jdll.jpg" alt="Joplin at JdLL 2023"></p>
]]></description><link>https://joplinapp.org/news/20230302-jdll-2023</link><guid isPermaLink="false">20230302-jdll-2023</guid><pubDate>Thu, 02 Mar 2023 00:00:00 GMT</pubDate><twitter-text></twitter-text></item><item><title><![CDATA[Introducing the "GitHub Actions Raw Log Viewer" extension for Chrome]]></title><description><![CDATA[<p>If you've ever used GitHub Actions, you will find that they provide by default a nice coloured output for the log. It looks good and it's even interactive! (You can click to collapse/expand blocks of text) But unfortunately it doesn't scale to large workflows, like we have for Joplin - the log can freeze and it will take forever to search for something. Indeed searching is done in &quot;real time&quot;... which mostly means it will freeze for a minute or two for each letter you type in the search box. Not great.</p>
<p>Thankfully GitHub provides an alternative access: the raw logs. This is much better because they will open as plain text, without any styling or JS magic, which means you can use the browser native search and it will be fast.</p>
<p>But now the problem is that raw logs look like this:</p>
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20230116-ga-raw-log.png" alt="Raw log without extension"></p>
<p>While it's not impossible to read, all colours that would display nicely in a terminal are gone and replaced by <a href="https://en.wikipedia.org/wiki/ANSI_escape_code">ANSI codes</a>. You can find what you need in there but it's not particularly easy.</p>
<p>This is where the new <strong>GitHub Action Raw Log Viewer</strong> extension for Chrome can help. It will parse your raw log and convert the ANSI codes to proper colours. This results in a much more readable rendering:</p>
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20230116-ga-raw-log-colored.png" alt="Raw log with extension"></p>
<p>The extension is fast even for very large logs and it's of course easy to search for text since it simply works with your browser built-in search.</p>
<p>The extension is open source, with the code available here: <a href="https://github.com/laurent22/github-actions-logs-extension">https://github.com/laurent22/github-actions-logs-extension</a></p>
<p>And to install it, follow this link:</p>
<p><a href="https://chrome.google.com/webstore/detail/github-action-raw-log-vie/lgejlnoopmcdglhfjblaeldbcfnmjddf"><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20230116-extension-get-it-now.png" alt="Download GitHub Action Raw Log Viewer extension"></a></p>
]]></description><link>https://joplinapp.org/news/20230116-github-actions-log-viewer</link><guid isPermaLink="false">20230116-github-actions-log-viewer</guid><pubDate>Mon, 16 Jan 2023 00:00:00 GMT</pubDate><twitter-text>Introducing the &quot;GitHub Action Raw Log Viewer&quot; extension for Chrome</twitter-text></item></channel></rss>
]]></description><link>https://joplinapp.org/news/20230302-jdll-2023</link><guid isPermaLink="false">20230302-jdll-2023</guid><pubDate>Thu, 02 Mar 2023 00:00:00 GMT</pubDate><twitter-text></twitter-text></item></channel></rss>

View File

@@ -3,9 +3,9 @@
<a class="social-link-bluesky" href="https://bsky.app/profile/joplinapp.bsky.social" title="Joplin Bluesky feed"><i class="fa-brands fa-bluesky"></i></a>
<a class="social-link-mastodon" href="https://mastodon.social/@joplinapp" title="Joplin Mastodon feed"><i class="fab fa-mastodon"></i></a>
<a class="social-link-patreon" href="https://www.patreon.com/joplin" title="Joplin Patreon"><i class="fab fa-patreon"></i></a>
<a class="social-link-youtube" href="https://youtube.com/@joplinapp" title="Joplin YouTube channel"><i class="fab fa-youtube"></i></a>
<a class="social-link-discord" href="https://discord.gg/VSj7AFHvpq" title="Joplin Discord chat"><i class="fab fa-discord"></i></a>
<a class="social-link-linkedin" href="https://www.linkedin.com/company/joplin" title="Joplin LinkedIn Feed"><i class="fab fa-linkedin"></i></a>
<a class="social-link-lemmy" href="https://sopuli.xyz/c/joplinapp" title="Joplin Lemmy Community"><i class="fas fa-otter"></i></a>
<a class="social-link-github" href="https://github.com/laurent22/joplin/" title="Joplin GitHub repository"><i class="fab fa-github"></i></a>
</div>
</div>

17
CLAUDE.md Normal file
View File

@@ -0,0 +1,17 @@
# Joplin Guidelines
## Quick Reference
- Tabs for indentation
- Single quotes for strings
- Proper TypeScript types (avoid `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.
- Focus on testing essential behaviour and edge cases — avoid adding tests for every minor detail.
- Avoid duplicating code in tests; when testing the same logic with different inputs, use `test.each` or shared helpers instead of repeating similar test blocks.
## Full Documentation
- Coding style: [readme/dev/coding_style.md](readme/dev/coding_style.md)
- Contributing: [CONTRIBUTING.md](CONTRIBUTING.md)

View File

@@ -1,4 +1,4 @@
FROM node:24-bullseye
FROM node:24-bookworm
RUN apt-get update \
&& apt-get install -y \
@@ -18,7 +18,7 @@ RUN wget -q https://github.com/ggml-org/llama.cpp/releases/download/b5449/llama-
&& chmod +x /opt/llama/build/bin/llama-mtmd-cli
# Create non-root user for security
RUN groupadd -r transcribe && useradd -r -g transcribe transcribe
RUN groupadd -r transcribe && useradd -r -g transcribe -m transcribe
WORKDIR /app
@@ -43,17 +43,21 @@ RUN BUILD_SEQUENCIAL=1 yarn install --inline-builds \
&& yarn cache clean \
&& rm -rf .yarn/berry
# Create images directory and set permissions
RUN mkdir -p /app/packages/transcribe/images \
&& chown -R transcribe:transcribe /app/packages/transcribe/images
# Create data directory and set permissions
RUN mkdir -p /data/images \
&& chown -R transcribe:transcribe /data
WORKDIR /app/packages/transcribe
# Switch to non-root user
USER transcribe
# Set environment variable for embedded llama.cpp binary
# Set environment variables
ENV HTR_CLI_BINARY_PATH=/opt/llama/build/bin/llama-mtmd-cli
ENV LD_LIBRARY_PATH=/opt/llama/build/bin
ENV DATA_DIR=/data
ENV QUEUE_DRIVER=sqlite
# Start the Node.js application
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["yarn", "start"]

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://www.slotozilla.com/nz/no-deposit-bonus"><img title="casino without making any upfront cost" width="256" src="https://joplinapp.org/images/sponsors/Slotozilla.png" alt="casino without making any upfront cost"/></a> <a href="https://writepaper.com/"><img title="best service to write my paper for me" width="256" src="https://joplinapp.org/images/sponsors/WritePaper.png" alt="best service to write my paper for me"/></a> <a href="https://paperwriter.com/"><img title="high-quality paper writing service PaperWriter" width="256" src="https://joplinapp.org/images/sponsors/PaperWriter.png" alt="high-quality paper writing service PaperWriter"/></a> <a href="https://www.bestetf.net/"><img title="BestETF" width="256" src="https://joplinapp.org/images/sponsors/BestEtf.png" alt="BestETF"/></a> <a href="https://freespinny.io/free-spins-no-deposit/"><img title="Freespinny.io Free Spins Bonus site" width="256" src="https://joplinapp.org/images/sponsors/Freespinny.png" alt="Freespinny.io Free Spins Bonus site"/></a> <a href="https://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://domyessay.com"><img title="Essay writers DoMyEssay are dedicated to providing top-notch, custom-written papers that meet your academic requirements" width="256" src="https://joplinapp.org/images/sponsors/DoMyEssay.png" alt="DoMyEssay"/></a> <a href="https://essaypro.com/"><img title="best essay writing service" width="256" src="https://joplinapp.org/images/sponsors/EssayPro.png" alt="best essay writing service"/></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://uk.notgamstop.com/bonuses/free-spins-no-deposit-no-gamstop/"><img title="free spins no deposit at NotGamstop" width="256" src="https://joplinapp.org/images/sponsors/NotGamStop.jpg" alt="free spins no deposit at NotGamstop"/></a> <a href="https://www.writemyessay.com/"><img title="writing service for students WriteMyEssay" width="256" src="https://joplinapp.org/images/sponsors/WriteMyEssay.png" alt="writing service for students WriteMyEssay"/></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://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>
<!-- SPONSORS-ORG -->
* * *
@@ -39,9 +39,9 @@ Please see the [donation page](https://github.com/laurent22/joplin/blob/dev/read
<!-- SPONSORS-GITHUB -->
| | | | |
| :---: | :---: | :---: | :---: |
| <img width="50" src="https://avatars2.githubusercontent.com/u/97193607?s=96&v=4"/></br>[Akhil-CM](https://github.com/Akhil-CM) | <img width="50" src="https://avatars2.githubusercontent.com/u/552452?s=96&v=4"/></br>[andypiper](https://github.com/andypiper) | <img width="50" src="https://avatars2.githubusercontent.com/u/215668?s=96&v=4"/></br>[avanderberg](https://github.com/avanderberg) | <img width="50" src="https://avatars2.githubusercontent.com/u/67130?s=96&v=4"/></br>[chr15m](https://github.com/chr15m) |
| <img width="50" src="https://avatars2.githubusercontent.com/u/1177810?s=96&v=4"/></br>[felixstorm](https://github.com/felixstorm) | <img width="50" src="https://avatars2.githubusercontent.com/u/11947658?s=96&v=4"/></br>[KentBrockman](https://github.com/KentBrockman) | <img width="50" src="https://avatars2.githubusercontent.com/u/42319182?s=96&v=4"/></br>[marcdw1289](https://github.com/marcdw1289) | <img width="50" src="https://avatars2.githubusercontent.com/u/668977?s=96&v=4"/></br>[ugoertz](https://github.com/ugoertz) |
| | | | |
| <img width="50" src="https://avatars2.githubusercontent.com/u/552452?s=96&v=4"/></br>[andypiper](https://github.com/andypiper) | <img width="50" src="https://avatars2.githubusercontent.com/u/215668?s=96&v=4"/></br>[avanderberg](https://github.com/avanderberg) | <img width="50" src="https://avatars2.githubusercontent.com/u/67130?s=96&v=4"/></br>[chr15m](https://github.com/chr15m) | <img width="50" src="https://avatars2.githubusercontent.com/u/1177810?s=96&v=4"/></br>[felixstorm](https://github.com/felixstorm) |
| <img width="50" src="https://avatars2.githubusercontent.com/u/8030470?s=96&v=4"/></br>[Galliver7](https://github.com/Galliver7) | <img width="50" src="https://avatars2.githubusercontent.com/u/4721118?s=96&v=4"/></br>[GPrimola](https://github.com/GPrimola) | <img width="50" src="https://avatars2.githubusercontent.com/u/64712218?s=96&v=4"/></br>[Hegghammer](https://github.com/Hegghammer) | <img width="50" src="https://avatars2.githubusercontent.com/u/42319182?s=96&v=4"/></br>[marcdw1289](https://github.com/marcdw1289) |
| <img width="50" src="https://avatars2.githubusercontent.com/u/668977?s=96&v=4"/></br>[ugoertz](https://github.com/ugoertz) | | | |
<!-- SPONSORS-GITHUB -->
# Community

View File

@@ -9,7 +9,7 @@
"vips.dev": {
"platforms": ["aarch64-darwin"],
},
"nodejs": "24.9.0",
"nodejs": "24.10.0",
"pkg-config": "latest",
"python": "3.13.3",
"bat": "latest",

View File

@@ -0,0 +1,44 @@
# Standalone docker-compose for Joplin Transcribe
#
# Uses SQLite for the queue (no external database needed).
# Data is stored in a named volume for proper permissions.
#
# Usage:
#
# 1. Download models:
# mkdir -p ./data/models
# wget -O ./data/models/Model-7.6B-Q4_K_M.gguf https://huggingface.co/openbmb/MiniCPM-o-2_6-gguf/resolve/main/Model-7.6B-Q4_K_M.gguf
# wget -O ./data/models/mmproj-model-f16.gguf https://huggingface.co/openbmb/MiniCPM-o-2_6-gguf/resolve/main/mmproj-model-f16.gguf
#
# 2. Configure:
# cp .env-transcribe-sample .env
# # Edit .env and set API_KEY
#
# 3. Run:
# docker compose -f docker-compose.transcribe.yml up
volumes:
transcribe-data:
services:
transcribe:
image: joplin/transcribe:amd64-latest
ports:
- "4567:4567"
volumes:
- transcribe-data:/data
- ./data/models:/data/models:ro
restart: unless-stopped
# Security: limit resources to prevent runaway processes
deploy:
resources:
limits:
memory: 16G
cpus: '4'
# Security: read-only root filesystem
read_only: true
tmpfs:
- /tmp
- /home/transcribe/.cache
env_file:
- .env

View File

@@ -124,6 +124,7 @@
"depd@npm:~1.1.2": "patch:depd@npm%3A2.0.0#~/.yarn/patches/depd-npm-2.0.0-b6c51a4b43.patch",
"depd@npm:2.0.0": "patch:depd@npm%3A2.0.0#~/.yarn/patches/depd-npm-2.0.0-b6c51a4b43.patch",
"depd@npm:^1.1.2": "patch:depd@npm%3A2.0.0#~/.yarn/patches/depd-npm-2.0.0-b6c51a4b43.patch",
"depd@npm:^1.1.0": "patch:depd@npm%3A2.0.0#~/.yarn/patches/depd-npm-2.0.0-b6c51a4b43.patch"
"depd@npm:^1.1.0": "patch:depd@npm%3A2.0.0#~/.yarn/patches/depd-npm-2.0.0-b6c51a4b43.patch",
"formidable@npm:^2.0.1": "patch:formidable@npm%3A2.1.2#~/.yarn/patches/formidable-npm-2.1.2-40ba18d67f.patch"
}
}

View File

@@ -31,9 +31,14 @@ cliUtils.printArray = function(logFunction, rows) {
const line = [];
for (let col = 0; col < colWidths.length; col++) {
const item = rows[row][col];
const width = colWidths[col];
const dir = colAligns[col] === ALIGN_LEFT ? stringPadding.RIGHT : stringPadding.LEFT;
line.push(stringPadding(item, width, ' ', dir));
const isLastCol = col === colWidths.length - 1;
if (isLastCol) {
line.push(item ? item.toString() : '');
} else {
const width = colWidths[col];
const dir = colAligns[col] === ALIGN_LEFT ? stringPadding.RIGHT : stringPadding.LEFT;
line.push(stringPadding(item, width, ' ', dir));
}
}
logFunction(line.join(' '));
}

View File

@@ -96,4 +96,34 @@ describe('HtmlToMd', () => {
expect(htmlToMd.parse('> 1 _2_ 3.pdf', { disableEscapeContent: false })).toBe('\\> 1 \\_2_ 3.pdf');
});
it('should support tightLists option', async () => {
const htmlToMd = new HtmlToMd();
const html = '<ul><li><p><strong>Item 1</strong></p></li><li><p><strong>Item 2</strong></p></li><li><p><strong>Item 3</strong></p></li></ul>';
// Without tightLists, paragraphs inside list items produce extra blank lines
const looseResult = htmlToMd.parse(html, { tightLists: false });
expect(looseResult).toContain('\n \n');
// With tightLists, list items are compact without blank lines
const tightResult = htmlToMd.parse(html, { tightLists: true });
expect(tightResult).toBe('- **Item 1**\n- **Item 2**\n- **Item 3**');
});
it('should support collapseMultipleBlankLines option', async () => {
const htmlToMd = new HtmlToMd();
const html = '<p>First</p><br><br><br><p>Second</p>';
// Without collapseMultipleBlankLines, multiple blank lines are preserved
const looseResult = htmlToMd.parse(html, { collapseMultipleBlankLines: false });
expect(looseResult).toContain('\n\n \n');
// With collapseMultipleBlankLines, multiple blank lines are collapsed into one
const collapsedResult = htmlToMd.parse(html, { collapseMultipleBlankLines: true });
expect(collapsedResult).not.toContain('\n\n\n');
expect(collapsedResult).not.toContain('\n\n \n');
// Verify that a single blank line is preserved (not fully removed)
expect(collapsedResult).toContain('\n\n');
});
});

View File

@@ -10,6 +10,7 @@ import Folder from '@joplin/lib/models/Folder';
import { expectNotThrow, setupDatabaseAndSynchronizer, switchClient, expectThrow, createTempDir, supportDir, mockMobilePlatform } from '@joplin/lib/testing/test-utils';
import { newPluginScript } from '../../testUtils';
import { join } from 'path';
import { PluginManifest } from '@joplin/lib/services/plugins/utils/types';
const testPluginDir = `${supportDir}/plugins`;
@@ -472,4 +473,18 @@ describe('services_PluginService', () => {
await fs.remove(testDir);
}
});
it('should report a missing app_min_version field specifically', () => {
const service = newPluginService();
const manifest = {
manifest_version: 1,
id: 'test.plugin',
name: 'Test Plugin',
version: '1.0.0',
// Missing app_min_version
};
const error = service.describeIncompatibility(manifest as unknown as PluginManifest);
expect(error).toContain('Invalid plugin manifest: Missing required field: app_min_version');
});
});

Binary file not shown.

View File

@@ -43,7 +43,7 @@ const electronContextMenu = require('./services/electron-context-menu');
// Commands that are not tied to any particular component.
// The runtime for these commands can be loaded when the app starts.
import PerFolderSortOrderService from './services/sortOrder/PerFolderSortOrderService';
import PerFolderSortOrderService from '@joplin/lib/services/sortOrder/PerFolderSortOrderService';
import ShareService from '@joplin/lib/services/share/ShareService';
import checkForUpdates from './checkForUpdates';
import { AppState } from './app.reducer';
@@ -638,18 +638,23 @@ class Application extends BaseApplication {
if (Setting.value('env') === 'dev') {
void AlarmService.updateAllNotifications();
RevisionService.instance().runInBackground();
} else {
// eslint-disable-next-line promise/prefer-await-to-then -- Old code before rule was applied
void reg.scheduleSync(1000).then(() => {
// Wait for the first sync before updating the notifications, since synchronisation
// might change the notifications.
void AlarmService.updateAllNotifications();
setTimeout(() => {
// Schedule sync with a delay of 0 and wrap with the desired timeout, as shim.setTimeout may not fire on first run or after an upgrade
// eslint-disable-next-line promise/prefer-await-to-then -- Old code before rule was applied
void reg.scheduleSync(0).then(() => {
// Wait for the first sync before updating the notifications, since synchronisation
// might change the notifications.
void AlarmService.updateAllNotifications();
void DecryptionWorker.instance().scheduleStart();
});
void DecryptionWorker.instance().scheduleStart();
RevisionService.instance().runInBackground();
});
}, 1000);
}
RevisionService.instance().runInBackground();
this.startRotatingLogMaintenance(Setting.value('profileDir'));
});

View File

@@ -583,6 +583,11 @@ export class Bridge {
execPath: process.env.PORTABLE_EXECUTABLE_FILE,
};
app.relaunch(options);
} else if (process.env.APPIMAGE && !this.altInstanceId_) {
app.relaunch({
execPath: process.env.APPIMAGE,
args: ['--appimage-extract-and-run'],
});
} else if (this.altInstanceId_) {
// Couldn't get it to work using relaunch() - it would just "close" the app, but it
// would still be open in the tray except unusable. Or maybe it reopens it quickly but

View File

@@ -13,7 +13,8 @@ export const runtime = (): CommandRuntime => {
return {
execute: async () => {
const appPath = app.getPath('exe');
const cmd = `${appPath} --env dev`;
// Quote the path so it works when it contains spaces (e.g. "C:\Program Files\Joplin\Joplin.exe" on Windows)
const cmd = `"${appPath}" --env dev`;
clipboard.writeText(cmd);
await shim.showMessageBox(`The dev mode command has been copied to clipboard:\n\n${cmd}`, { type: MessageBoxType.Info });
},

View File

@@ -0,0 +1,79 @@
import { CommandRuntime, CommandDeclaration } from '@joplin/lib/services/CommandService';
import Resource from '@joplin/lib/models/Resource';
import Setting from '@joplin/lib/models/Setting';
import shim from '@joplin/lib/shim';
import { _ } from '@joplin/lib/locale';
import { ResourceOcrStatus } from '@joplin/lib/services/database/types';
import bridge from '../services/bridge';
import Logger from '@joplin/utils/Logger';
const logger = Logger.create('createAccessibleDocument');
export const declaration: CommandDeclaration = {
name: 'createAccessibleDocument',
};
export const runtime = (): CommandRuntime => {
return {
execute: async (_context: unknown, resourceId: string) => {
const resource = await Resource.load(resourceId);
if (!resource) {
bridge().showErrorMessageBox(_('Resource not found'));
return;
}
const resourcePath = Resource.fullPath(resource);
if (resource.mime !== 'application/pdf') {
bridge().showInfoMessageBox(_('This feature is only available for PDF files.'));
return;
}
if (resource.ocr_status !== ResourceOcrStatus.Done) {
bridge().showInfoMessageBox(_('OCR is not complete. Please wait for OCR to finish before creating an accessible document.'));
return;
}
const ocrDetails = resource.ocr_details;
// If ocr_details is missing (legacy PDF processed before this feature),
// automatically re-run OCR to get the coordinate data
if (!ocrDetails) {
const result = await bridge().showMessageBox(_('OCR needs to run to generate an accessible document. This may take a moment. Would you like to continue?'), {
buttons: [_('Run OCR'), _('Cancel')],
});
if (result === 1) return; // User cancelled
// Trigger OCR re-run with TodoAccessible status to request full OCR details
await Resource.save({
id: resource.id,
ocr_status: ResourceOcrStatus.TodoAccessible,
ocr_details: '',
ocr_error: '',
ocr_text: '',
});
bridge().showInfoMessageBox(_('OCR has been queued. Please wait for it to complete and then try again.'));
return;
}
// Show save dialog
const defaultFilename = `${(resource.filename || resource.title || resource.id).replace(/\.pdf$/i, '')}_accessible.pdf`;
const outputPath = await bridge().showSaveDialog({
defaultPath: defaultFilename,
filters: [{ name: 'PDF', extensions: ['pdf'] }],
});
if (!outputPath) return;
try {
await shim.createAccessiblePdf(resourcePath, ocrDetails, outputPath, Setting.value('tempDir'));
await bridge().openItem(outputPath);
} catch (error) {
logger.error(error);
bridge().showErrorMessageBox(_('Failed to create accessible document: %s', error.message));
}
},
};
};

View File

@@ -1,6 +1,7 @@
// AUTO-GENERATED using `gulp buildScriptIndexes`
import * as copyDevCommand from './copyDevCommand';
import * as copyToClipboard from './copyToClipboard';
import * as createAccessibleDocument from './createAccessibleDocument';
import * as editProfileConfig from './editProfileConfig';
import * as emptyTrash from './emptyTrash';
import * as exportDeletionLog from './exportDeletionLog';
@@ -27,6 +28,7 @@ import * as toggleTabMovesFocus from './toggleTabMovesFocus';
const index: any[] = [
copyDevCommand,
copyToClipboard,
createAccessibleDocument,
editProfileConfig,
emptyTrash,
exportDeletionLog,

View File

@@ -138,6 +138,7 @@ export default function(props: Props) {
}, [currentPassword]);
function renderPasswordForm() {
const passwordsMatch = password1 === password2;
const renderCurrentPassword = () => {
if (!showCurrentPassword) return null;
@@ -176,12 +177,22 @@ export default function(props: Props) {
value={password1}
onChange={onPasswordChange1}
/>
{needToRepeatPassword && (
<LabelledPasswordInput
labelText={_('Re-enter password')}
value={password2}
onChange={onPasswordChange2}
/>
<>
<LabelledPasswordInput
labelText={_('Re-enter password')}
value={password2}
onChange={onPasswordChange2}
valid={password2 ? passwordsMatch : undefined}
/>
{password2 && !passwordsMatch && (
<p className="error-message">
{_('Passwords do not match')}
</p>
)}
</>
)}
</div>
<p className="bold">Please make sure you remember your password. For security reasons, it is not possible to recover it if it is lost.</p>

View File

@@ -709,6 +709,7 @@ function useMenu(props: Props) {
menuItemDic.textCut,
menuItemDic.textPaste,
menuItemDic.pasteAsText,
menuItemDic.pasteAsMarkdown,
menuItemDic.textSelectAll,
separator(),
menuItemDic.globalUndo,

View File

@@ -4,62 +4,38 @@ describe('useContextMenu', () => {
const resourceId = 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4';
const resourceId2 = 'b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5';
it('should return resource ID when cursor is inside markdown image', () => {
it('should return type=image when cursor is inside markdown image', () => {
const line = `![alt text](:/${resourceId})`;
expect(getResourceIdFromMarkup(line, 0)).toBe(resourceId);
expect(getResourceIdFromMarkup(line, 15)).toBe(resourceId);
expect(getResourceIdFromMarkup(line, line.length - 1)).toBe(resourceId);
expect(getResourceIdFromMarkup(line, 15)).toEqual({ resourceId, type: 'image' });
});
it('should return null when cursor is outside markdown image', () => {
it('should return type=file when cursor is inside markdown link', () => {
const line = `[document.pdf](:/${resourceId})`;
expect(getResourceIdFromMarkup(line, 15)).toEqual({ resourceId, type: 'file' });
});
it('should return null when cursor is outside markup', () => {
const line = `Some text ![alt](:/${resourceId}) more text`;
expect(getResourceIdFromMarkup(line, 5)).toBeNull();
expect(getResourceIdFromMarkup(line, line.length - 5)).toBeNull();
});
it('should handle markdown image without alt text', () => {
const line = `![](:/${resourceId})`;
expect(getResourceIdFromMarkup(line, 5)).toBe(resourceId);
});
it('should return resource ID when cursor is inside HTML img tag', () => {
const line = `<img src=":/${resourceId}" />`;
expect(getResourceIdFromMarkup(line, 10)).toBe(resourceId);
});
it('should handle HTML img tag with additional attributes', () => {
const line = `<img alt="test" src=":/${resourceId}" width="100" />`;
expect(getResourceIdFromMarkup(line, 25)).toBe(resourceId);
});
it('should return null when cursor is outside HTML img tag', () => {
const line = `text <img src=":/${resourceId}" /> more`;
expect(getResourceIdFromMarkup(line, 2)).toBeNull();
expect(getResourceIdFromMarkup(line, line.length - 2)).toBeNull();
});
it('should return correct resource ID when multiple images on same line', () => {
const line = `![first](:/${resourceId}) ![second](:/${resourceId2})`;
expect(getResourceIdFromMarkup(line, 10)).toBe(resourceId);
expect(getResourceIdFromMarkup(line, 50)).toBe(resourceId2);
it('should correctly distinguish between image and file on same line', () => {
const line = `![image](:/${resourceId}) [file](:/${resourceId2})`;
expect(getResourceIdFromMarkup(line, 10)).toEqual({ resourceId, type: 'image' });
expect(getResourceIdFromMarkup(line, 48)).toEqual({ resourceId: resourceId2, type: 'file' });
});
it('should return null for empty line', () => {
expect(getResourceIdFromMarkup('', 0)).toBeNull();
});
it('should return null for line without images', () => {
it('should return null for line without resources', () => {
expect(getResourceIdFromMarkup('Just some regular text', 10)).toBeNull();
});
it('should return null for non-resource links', () => {
const line = '![alt](https://example.com/image.png)';
expect(getResourceIdFromMarkup(line, 10)).toBeNull();
});
it('should handle cursor at exact boundaries of image markup', () => {
const line = `![a](:/${resourceId})`;
expect(getResourceIdFromMarkup(line, 0)).toBe(resourceId);
expect(getResourceIdFromMarkup(line, line.length)).toBe(resourceId);
it('should return null for non-resource URLs', () => {
expect(getResourceIdFromMarkup('![alt](https://example.com/image.png)', 10)).toBeNull();
expect(getResourceIdFromMarkup('[link](https://example.com)', 10)).toBeNull();
});
});

View File

@@ -17,9 +17,16 @@ import isItemId from '@joplin/lib/models/utils/isItemId';
import { extractResourceUrls } from '@joplin/lib/urlUtils';
import { WindowIdContext } from '../../../../NewWindowOrIFrame';
// Extract resource ID from image markup at a given cursor position within a line.
// Returns the resource ID if the cursor is within an image markup, null otherwise.
export const getResourceIdFromMarkup = (lineContent: string, cursorPosInLine: number): string | null => {
export type ResourceMarkupType = 'image' | 'file';
export interface ResourceMarkupInfo {
resourceId: string;
type: ResourceMarkupType;
}
// Extract resource ID from resource markup (images or file attachments) at a given cursor position within a line.
// Returns the resource ID and its type if the cursor is within a resource markup, null otherwise.
export const getResourceIdFromMarkup = (lineContent: string, cursorPosInLine: number): ResourceMarkupInfo | null => {
const resourceUrls = extractResourceUrls(lineContent);
if (!resourceUrls.length) return null;
@@ -27,16 +34,38 @@ export const getResourceIdFromMarkup = (lineContent: string, cursorPosInLine: nu
const resourcePattern = new RegExp(`[:](/?${resourceInfo.itemId})`, 'g');
let match;
while ((match = resourcePattern.exec(lineContent)) !== null) {
// Look backwards for ![ or <img
let markupStart = lineContent.lastIndexOf('![', match.index);
// Look backwards for ![, [, <img, or <a
const imageMarkupStart = lineContent.lastIndexOf('![', match.index);
const linkMarkupStart = lineContent.lastIndexOf('[', match.index);
const imgTagStart = lineContent.lastIndexOf('<img', match.index);
if (imgTagStart > markupStart) markupStart = imgTagStart;
const aTagStart = lineContent.lastIndexOf('<a', match.index);
// Find the closest markup start and determine type
let markupStart = -1;
let markupType: ResourceMarkupType = 'file';
if (imageMarkupStart !== -1 && imageMarkupStart > markupStart) {
markupStart = imageMarkupStart;
markupType = 'image';
}
if (linkMarkupStart !== -1 && linkMarkupStart > markupStart && lineContent[linkMarkupStart - 1] !== '!') {
markupStart = linkMarkupStart;
markupType = 'file';
}
if (imgTagStart !== -1 && imgTagStart > markupStart) {
markupStart = imgTagStart;
markupType = 'image';
}
if (aTagStart !== -1 && aTagStart > markupStart) {
markupStart = aTagStart;
markupType = 'file';
}
if (markupStart === -1) continue;
// Find the end of the markup
let markupEnd: number;
if (lineContent[markupStart] === '!') {
if (lineContent[markupStart] === '!' || lineContent[markupStart] === '[') {
markupEnd = lineContent.indexOf(')', match.index);
if (markupEnd !== -1) markupEnd += 1;
} else {
@@ -45,7 +74,7 @@ export const getResourceIdFromMarkup = (lineContent: string, cursorPosInLine: nu
}
if (markupEnd !== -1 && cursorPosInLine >= markupStart && cursorPosInLine <= markupEnd) {
return resourceInfo.itemId;
return { resourceId: resourceInfo.itemId, type: markupType };
}
}
}
@@ -132,8 +161,8 @@ const useContextMenu = (props: ContextMenuProps) => {
return clickedElement?.closest(`.${imageClassName}`) as HTMLElement | null;
};
// Get resource ID from image markup at click position (not cursor position)
const getResourceIdAtClickPos = (params: ContextMenuParams): string | null => {
// Get resource info from markup at click position (not cursor position)
const getResourceInfoAtClickPos = (params: ContextMenuParams): ResourceMarkupInfo | null => {
if (!editorRef.current) return null;
const editor = editorRef.current.editor;
@@ -152,10 +181,10 @@ const useContextMenu = (props: ContextMenuProps) => {
const targetWindow = bridge().windowById(windowId);
const showImageContextMenu = async (resourceId: string) => {
const showResourceContextMenu = async (resourceId: string, type: ResourceMarkupType) => {
const menu = new Menu();
const contextMenuOptions: ContextMenuOptions = {
itemType: ContextMenuItemType.Image,
itemType: type === 'image' ? ContextMenuItemType.Image : ContextMenuItemType.Resource,
resourceId,
filename: null,
mime: null,
@@ -170,8 +199,8 @@ const useContextMenu = (props: ContextMenuProps) => {
mdToHtml: null,
};
const imageMenuItems = await buildMenuItems(menuItems(props.dispatch), contextMenuOptions);
for (const item of imageMenuItems) {
const resourceMenuItems = await buildMenuItems(menuItems(props.dispatch), contextMenuOptions);
for (const item of resourceMenuItems) {
menu.append(item);
}
@@ -206,17 +235,17 @@ const useContextMenu = (props: ContextMenuProps) => {
if (resourceId) {
event.preventDefault();
moveCursorToImageLine(imageContainer);
await showImageContextMenu(resourceId);
await showResourceContextMenu(resourceId, 'image');
return;
}
}
}
// Check if right-clicking on image markup text
const markupResourceId = getResourceIdAtClickPos(params);
if (markupResourceId && pointerInsideEditor(params)) {
// Check if right-clicking on resource markup text (images or file attachments)
const markupResourceInfo = getResourceInfoAtClickPos(params);
if (markupResourceInfo && pointerInsideEditor(params)) {
event.preventDefault();
await showImageContextMenu(markupResourceId);
await showResourceContextMenu(markupResourceInfo.resourceId, markupResourceInfo.type);
return;
}
@@ -260,6 +289,16 @@ const useContextMenu = (props: ContextMenuProps) => {
}),
);
menu.append(
new MenuItem({
label: _('Paste as Markdown'),
enabled: true,
click: async () => {
await CommandService.instance().execute('pasteAsMarkdown');
},
}),
);
const spellCheckerMenuItems = SpellCheckerService.instance().contextMenuItems(params.misspelledWord, params.dictionarySuggestions);
for (const item of spellCheckerMenuItems) {

View File

@@ -338,7 +338,7 @@ function CodeMirror(props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
}, [editorPasteText, onEditorPaste]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const loadScript = async (script: any) => {
const loadScript = async (script: any, document: Document) => {
return new Promise((resolve) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
let element: any = document.createElement('script');
@@ -367,6 +367,7 @@ function CodeMirror(props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
};
useEffect(() => {
if (!editorRoot) return () => { };
let cancelled = false;
async function loadScripts() {
@@ -393,13 +394,14 @@ function CodeMirror(props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
});
}
const ownerDoc = editorRoot.ownerDocument;
for (const s of scriptsToLoad) {
if (document.getElementById(s.id)) {
if (ownerDoc.getElementById(s.id)) {
s.loaded = true;
continue;
}
await loadScript(s);
await loadScript(s, ownerDoc);
if (cancelled) return;
s.loaded = true;
@@ -411,7 +413,7 @@ function CodeMirror(props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
return () => {
cancelled = true;
};
}, [styles.editor.codeMirrorTheme]);
}, [styles.editor.codeMirrorTheme, editorRoot]);
useEffect(() => {
if (!editorRoot) return () => {};
@@ -646,6 +648,7 @@ function CodeMirror(props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
useCustomPdfViewer: props.useCustomPdfViewer,
noteId: props.noteId,
vendorDir: bridge().vendorDir(),
showNoteLinkIcon: props.showNoteLinkIcon,
}));
if (cancelled) return;
@@ -666,7 +669,7 @@ function CodeMirror(props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
shim.clearTimeout(timeoutId);
};
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [props.content, props.contentKey, renderedBodyContentKey, props.contentMarkupLanguage, props.visiblePanes, props.resourceInfos, props.markupToHtml]);
}, [props.content, props.contentKey, renderedBodyContentKey, props.contentMarkupLanguage, props.visiblePanes, props.resourceInfos, props.markupToHtml, props.showNoteLinkIcon]);
useEffect(() => {
if (!webviewReady) return;

View File

@@ -222,6 +222,7 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
noteId: props.noteId,
vendorDir: bridge().vendorDir(),
globalSettings: getGlobalSettings(Setting),
showNoteLinkIcon: props.showNoteLinkIcon,
}));
if (cancelled) return;
@@ -244,7 +245,7 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
}, [
props.content, props.contentKey, renderedBodyContentKey, props.contentMarkupLanguage,
props.visiblePanes, props.resourceInfos, props.markupToHtml, props.contentMaxWidth,
props.noteId, props.useCustomPdfViewer,
props.noteId, props.useCustomPdfViewer, props.showNoteLinkIcon,
]);
useEffect(() => {

View File

@@ -705,6 +705,15 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const containerWindow = editorContainerDom.defaultView as any;
const isDefaultEnglishLocale = ['en_US', 'en_GB'].includes(language);
if (!isDefaultEnglishLocale) {
await loadScript({
id: `tinyMceLang_${language}`,
src: `${bridge().vendorDir()}/lib/tinymce/langs/${language}.js`,
}, editorContainerDom);
}
const editors = await containerWindow.tinymce.init({
selector: `#${editorContainer.id}`,
@@ -735,7 +744,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
// Handle the first table row as table header.
// https://www.tiny.cloud/docs/plugins/table/#table_header_type
table_header_type: 'sectionCells',
language_url: ['en_US', 'en_GB'].includes(language) ? undefined : `${bridge().vendorDir()}/lib/tinymce/langs/${language}`,
language: isDefaultEnglishLocale ? undefined : language,
toolbar: toolbar.join(' '),
localization_function: _,
// See https://www.tiny.cloud/docs/tinymce/latest/tinymce-and-csp/#content_security_policy

View File

@@ -14,7 +14,7 @@ import useFormNote, { OnLoadEvent, OnSetFormNote } from './utils/useFormNote';
import useEffectiveNoteId from './utils/useEffectiveNoteId';
import useFolder from './utils/useFolder';
import styles_ from './styles';
import { NoteEditorProps, FormNote, OnChangeEvent, AllAssetsOptions, NoteBodyEditorRef, NoteBodyEditorPropsAndRef } from './utils/types';
import { NoteEditorProps, FormNote, OnChangeEvent, AllAssetsOptions, NoteBodyEditorRef, NoteBodyEditorPropsAndRef, NoteBodyEditorType } from './utils/types';
import CommandService from '@joplin/lib/services/CommandService';
import Button, { ButtonLevel } from '../Button/Button';
import eventManager, { EventName } from '@joplin/lib/eventManager';
@@ -474,6 +474,7 @@ function NoteEditorContent(props: NoteEditorProps) {
noteId: props.noteId,
watchedNoteFiles: props.watchedNoteFiles,
enableHtmlToMarkdownBanner: props.enableHtmlToMarkdownBanner,
showNoteLinkIcon: props.showNoteLinkIcon,
};
let editor = null;
@@ -715,11 +716,11 @@ const mapStateToProps = (state: AppState, ownProps: ConnectProps) => {
const windowState = stateUtils.windowStateById(state, ownProps.windowId);
const noteId = stateUtils.selectedNoteId(windowState);
let bodyEditor = windowState.editorCodeView ? 'CodeMirror6' : 'TinyMCE';
let bodyEditor = windowState.editorCodeView ? NoteBodyEditorType.CodeMirror6 : NoteBodyEditorType.TinyMce;
if (state.settings.isSafeMode) {
bodyEditor = 'PlainText';
bodyEditor = NoteBodyEditorType.PlainText;
} else if (windowState.editorCodeView && state.settings['editor.legacyMarkdown']) {
bodyEditor = 'CodeMirror5';
bodyEditor = NoteBodyEditorType.CodeMirror5;
}
const mainWindowState = stateUtils.windowStateById(state, defaultWindowId);
@@ -766,6 +767,8 @@ const mapStateToProps = (state: AppState, ownProps: ConnectProps) => {
shareCacheSetting: state.settings['sync.shareCache'],
searchResults: state.searchResults,
enableHtmlToMarkdownBanner: state.settings['editor.enableHtmlToMarkdownBanner'],
enableInEditorRendering: state.settings['editor.inlineRendering'],
showNoteLinkIcon: state.settings['notes.showNoteLinkIcon'],
};
};

View File

@@ -6,6 +6,7 @@ interface Props {
acceptMessage: string;
onAccept: ()=> void;
onDismiss?: ()=> void;
dismissMessage?: string;
visible: boolean;
}
@@ -17,7 +18,7 @@ const BannerContent: React.FC<Props> = props => {
return <div className='warning-banner'>
{props.children}
&nbsp;&nbsp;<a onClick={props.onAccept} className='warning-banner-link' href="#">[ {props.acceptMessage} ]</a>
&nbsp;&nbsp;{ props.onDismiss ? <a onClick={props.onDismiss} className='warning-banner-link' href="#">[ {_('Dismiss')} ]</a> : null }
&nbsp;&nbsp;{ props.onDismiss ? <a onClick={props.onDismiss} className='warning-banner-link' href="#">[ {props.dismissMessage ?? _('Dismiss')} ]</a> : null }
</div>;
};

View File

@@ -6,13 +6,17 @@ import BannerContent from './BannerContent';
import { _ } from '@joplin/lib/locale';
import onRichTextReadMoreLinkClick from '@joplin/lib/components/shared/NoteEditor/WarningBanner/onRichTextReadMoreLinkClick';
import onRichTextDismissLinkClick from '@joplin/lib/components/shared/NoteEditor/WarningBanner/onRichTextDismissLinkClick';
import useEditorTypeMigrationBanner from '@joplin/lib/components/shared/NoteEditor/WarningBanner/useEditorTypeMigrationBanner';
import { useMemo } from 'react';
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
import PluginService from '@joplin/lib/services/plugins/PluginService';
import { NoteBodyEditorType } from '../utils/types';
interface Props {
bodyEditor: string;
bodyEditor: NoteBodyEditorType;
editorMigrationVersion: number;
richTextBannerDismissed: boolean;
inEditorRenderingEnabled: boolean;
pluginCompatibilityBannerDismissedFor: string[];
plugins: PluginStates;
}
@@ -35,6 +39,22 @@ const incompatiblePluginIds = [
];
const WarningBanner: React.FC<Props> = props => {
const editorMigrationMessage = useEditorTypeMigrationBanner({
markdownEditorEnabled: props.bodyEditor === 'CodeMirror6',
editorMigrationVersion: props.editorMigrationVersion,
inEditorRenderingEnabled: props.inEditorRenderingEnabled,
});
const editorMigrationBanner = (
<BannerContent
visible={editorMigrationMessage.enabled}
acceptMessage={editorMigrationMessage.keepEnabled.label}
onAccept={editorMigrationMessage.keepEnabled.onPress}
onDismiss={editorMigrationMessage.disable.onPress}
dismissMessage={editorMigrationMessage.disable.label}
>{editorMigrationMessage.label}</BannerContent>
);
const wysiwygBanner = (
<BannerContent
acceptMessage={_('Read more about it')}
@@ -83,6 +103,7 @@ const WarningBanner: React.FC<Props> = props => {
return <>
{wysiwygBanner}
{markdownPluginBanner}
{editorMigrationBanner}
</>;
};
@@ -91,5 +112,7 @@ export default connect((state: AppState) => {
richTextBannerDismissed: state.settings.richTextBannerDismissed,
pluginCompatibilityBannerDismissedFor: state.settings['editor.pluginCompatibilityBannerDismissedFor'],
plugins: state.pluginService.plugins,
editorMigrationVersion: state.settings['editor.migration'],
inEditorRenderingEnabled: state.settings['editor.inlineRendering'],
};
})(WarningBanner);

View File

@@ -3,6 +3,7 @@ import * as focusElementNoteBody from './focusElementNoteBody';
import * as focusElementNoteTitle from './focusElementNoteTitle';
import * as focusElementNoteViewer from './focusElementNoteViewer';
import * as focusElementToolbar from './focusElementToolbar';
import * as pasteAsMarkdown from './pasteAsMarkdown';
import * as pasteAsText from './pasteAsText';
import * as showLocalSearch from './showLocalSearch';
import * as showRevisions from './showRevisions';
@@ -12,6 +13,7 @@ const index: any[] = [
focusElementNoteTitle,
focusElementNoteViewer,
focusElementToolbar,
pasteAsMarkdown,
pasteAsText,
showLocalSearch,
showRevisions,

View File

@@ -0,0 +1,42 @@
import { CommandRuntime, CommandDeclaration } from '@joplin/lib/services/CommandService';
import { _ } from '@joplin/lib/locale';
import HtmlToMd from '@joplin/lib/HtmlToMd';
import { processImagesInPastedHtml } from '../utils/resourceHandling';
const { clipboard } = require('electron');
export const declaration: CommandDeclaration = {
name: 'pasteAsMarkdown',
label: () => _('Paste as Markdown'),
};
let htmlToMd_: HtmlToMd | null = null;
const htmlToMd = () => {
if (!htmlToMd_) {
htmlToMd_ = new HtmlToMd();
}
return htmlToMd_;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Props passed from NoteEditor component
export const runtime = (comp: any): CommandRuntime => {
return {
execute: async () => {
let html = clipboard.readHTML();
if (html) {
// Download images and convert them to Joplin resources
html = await processImagesInPastedHtml(html, { useInternalUrls: true });
const markdown = htmlToMd().parse(html, { tightLists: true, collapseMultipleBlankLines: true });
comp.editorRef.current.execCommand({ name: 'insertText', value: markdown });
} else {
// Fall back to plain text if no HTML is available
const text = clipboard.readText();
if (text) {
comp.editorRef.current.execCommand({ name: 'insertText', value: text });
}
}
},
enabledCondition: 'oneNoteSelected && markdownEditorVisible',
};
};

View File

@@ -207,6 +207,16 @@ export function menuItems(dispatch: Function): ContextMenuItems {
return itemType === ContextMenuItemType.Resource || (itemType === ContextMenuItemType.Image && options.resourceId);
},
},
createAccessibleDocument: {
label: _('Create accessible document'),
onAction: async (options: ContextMenuOptions) => {
const { resource } = await resourceInfo(options);
await CommandService.instance().execute('createAccessibleDocument', resource.id);
},
isActive: (itemType: ContextMenuItemType, options: ContextMenuOptions) => {
return itemType === ContextMenuItemType.Resource || (itemType === ContextMenuItemType.Image && options.resourceId);
},
},
separator3: makeSeparator(),
copyPathToClipboard: {
label: _('Copy path to clipboard'),

View File

@@ -2,6 +2,7 @@ import shim from '@joplin/lib/shim';
import Setting from '@joplin/lib/models/Setting';
import Note from '@joplin/lib/models/Note';
import Resource from '@joplin/lib/models/Resource';
import { ResourceEntity } from '@joplin/lib/services/database/types';
import ResourceFetcher from '@joplin/lib/services/ResourceFetcher';
import htmlUtils from '@joplin/lib/htmlUtils';
import rendererHtmlUtils, { extractHtmlBody, removeWrappingParagraphAndTrailingEmptyElements } from '@joplin/renderer/htmlUtils';
@@ -120,10 +121,21 @@ export async function getResourcesFromPasteEvent(event: any) {
}
const processImagesInPastedHtml = async (html: string) => {
export interface ProcessImagesOptions {
// When true, returns Joplin internal URLs (:/resourceId) instead of file:// URLs
useInternalUrls?: boolean;
}
export const processImagesInPastedHtml = async (html: string, options: ProcessImagesOptions = {}) => {
const allImageUrls: string[] = [];
const mappedResources: Record<string, string> = {};
const resourceUrl = (resource: ResourceEntity) => {
return options.useInternalUrls
? Resource.internalUrl(resource)
: `file://${encodeURI(Resource.fullPath(resource))}`;
};
htmlUtils.replaceImageUrls(html, (src: string) => {
allImageUrls.push(src);
});
@@ -138,7 +150,7 @@ const processImagesInPastedHtml = async (html: string) => {
await shim.fetchBlob(imageSrc, { path: filePath });
const createdResource = await shim.createResourceFromPath(filePath);
await shim.fsDriver().remove(filePath);
mappedResources[imageSrc] = `file://${encodeURI(Resource.fullPath(createdResource))}`;
mappedResources[imageSrc] = resourceUrl(createdResource);
} catch (error) {
logger.warn(`Error creating a resource for ${imageSrc}.`, error);
mappedResources[imageSrc] = imageSrc;
@@ -155,11 +167,19 @@ const processImagesInPastedHtml = async (html: string) => {
const imageFilePath = path.normalize(fileUriToPath(imageSrc));
const resourceDirPath = path.normalize(Setting.value('resourceDir'));
if (imageFilePath.startsWith(resourceDirPath)) {
mappedResources[imageSrc] = imageSrc;
// Use path.relative for robust containment check - startsWith can falsely match sibling paths
const rel = path.relative(resourceDirPath, imageFilePath);
const isInsideResourceDir = rel && !rel.startsWith('..') && !path.isAbsolute(rel);
if (isInsideResourceDir) {
if (options.useInternalUrls) {
const resourceId = Resource.pathToId(imageFilePath);
mappedResources[imageSrc] = `:/${resourceId}`;
} else {
mappedResources[imageSrc] = imageSrc;
}
} else {
const createdResource = await shim.createResourceFromPath(imageFilePath);
mappedResources[imageSrc] = `file://${encodeURI(Resource.fullPath(createdResource))}`;
mappedResources[imageSrc] = resourceUrl(createdResource);
}
} else if (imageSrc.startsWith('data:')) {
mappedResources[imageSrc] = imageSrc;

View File

@@ -26,6 +26,13 @@ export interface ToolbarButtonInfos {
[key: string]: ToolbarButtonInfo;
}
export enum NoteBodyEditorType {
CodeMirror6 = 'CodeMirror6',
CodeMirror5 = 'CodeMirror5',
TinyMce = 'TinyMCE',
PlainText = 'PlainText',
}
export interface NoteEditorProps {
noteId: string;
themeId: number;
@@ -65,9 +72,10 @@ export interface NoteEditorProps {
searchResults: ProcessResultsRow[];
pluginHtmlContents: PluginHtmlContents;
onTitleChange?: (title: string)=> void;
bodyEditor: string;
bodyEditor: NoteBodyEditorType;
startupPluginsLoaded: boolean;
enableHtmlToMarkdownBanner: boolean;
showNoteLinkIcon: boolean;
}
export interface NoteBodyEditorRef {
@@ -149,6 +157,7 @@ export interface NoteBodyEditorProps {
useCustomPdfViewer: boolean;
watchedNoteFiles: string[];
enableHtmlToMarkdownBanner: boolean;
showNoteLinkIcon: boolean;
}
export interface NoteBodyEditorPropsAndRef extends NoteBodyEditorProps {

View File

@@ -95,12 +95,18 @@ const useConnectToEditorPlugin = ({
}, [activeEditorView, editorPluginHandler]);
const formNoteBody = formNote.body;
const formNoteId = formNote.id;
useEffect(() => {
// Don't emit updates when formNote hasn't loaded the current note yet.
// This can happen during note navigation when effectiveNoteId updates
// immediately but formNote still contains the previous note's data.
if (formNoteId !== effectiveNoteId) return;
editorPluginHandler.emitUpdate({
noteId: effectiveNoteId,
newBody: formNoteBody,
}, shownEditorViewIds);
}, [effectiveNoteId, formNoteBody, editorPluginHandler, shownEditorViewIds]);
}, [effectiveNoteId, formNoteId, formNoteBody, editorPluginHandler, shownEditorViewIds]);
};
export default useConnectToEditorPlugin;

View File

@@ -13,6 +13,7 @@ const commandsWithDependencies = [
require('../commands/focusElementNoteViewer'),
require('../commands/focusElementToolbar'),
require('../commands/pasteAsText'),
require('../commands/pasteAsMarkdown'),
];
type OnBodyChange = (event: OnChangeEvent)=> void;

View File

@@ -6,7 +6,7 @@ import Button, { ButtonLevel, ButtonSize, buttonSizePx } from '../Button/Button'
import CommandService from '@joplin/lib/services/CommandService';
import { runtime as focusSearchRuntime } from './commands/focusSearch';
import Note from '@joplin/lib/models/Note';
import { notesSortOrderNextField } from '../../services/sortOrder/notesSortOrderUtils';
import { notesSortOrderNextField } from '@joplin/lib/services/sortOrder/notesSortOrderUtils';
import { _ } from '@joplin/lib/locale';
import { connect } from 'react-redux';
import styled from 'styled-components';

View File

@@ -35,6 +35,7 @@ interface Props {
customCss: string;
scrollbarSize: ScrollbarSize;
fontFamily: string;
showNoteLinkIcon: boolean;
}
const useNoteContent = (
@@ -45,6 +46,7 @@ const useNoteContent = (
customCss: string,
scrollbarSize: ScrollbarSize,
fontFamily: string,
showNoteLinkIcon: boolean,
) => {
const [note, setNote] = useState<NoteEntity>(null);
@@ -75,17 +77,18 @@ const useNoteContent = (
resources: await shared.attachedResources(noteBody),
whiteBackgroundNoteRendering: markupLanguage === MarkupLanguage.Html,
globalSettings: getGlobalSettings(Setting),
showNoteLinkIcon,
});
viewerRef.current.setHtml(result.html, {
pluginAssets: result.pluginAssets,
});
}, [note, viewerRef]);
}, [note, viewerRef, markupToHtml, showNoteLinkIcon]);
return note;
};
const NoteRevisionViewerComponent: React.FC<Props> = ({ themeId, noteId, onBack, customCss, scrollbarSize, fontFamily }) => {
const NoteRevisionViewerComponent: React.FC<Props> = ({ themeId, noteId, onBack, customCss, scrollbarSize, fontFamily, showNoteLinkIcon }) => {
const helpButton_onClick = useCallback(() => {}, []);
const viewerRef = useRef<NoteViewerControl|null>(null);
const revisionListRef = useRef<HTMLSelectElement|null>(null);
@@ -96,7 +99,7 @@ const NoteRevisionViewerComponent: React.FC<Props> = ({ themeId, noteId, onBack,
const [deleting, setDeleting] = useState(false);
const note = useNoteContent(
viewerRef, currentRevId, revisions, themeId, customCss, scrollbarSize, fontFamily,
viewerRef, currentRevId, revisions, themeId, customCss, scrollbarSize, fontFamily, showNoteLinkIcon,
);
const viewer_domReady = useCallback(async () => {
@@ -229,6 +232,7 @@ const mapStateToProps = (state: AppState) => {
themeId: state.settings.theme,
scrollbarSize: state.settings['style.scrollbarSize'],
fontFamily: state.settings['style.viewer.fontFamily'],
showNoteLinkIcon: state.settings['notes.showNoteLinkIcon'],
};
};

View File

@@ -28,12 +28,19 @@ const useScrollToSelectionHandler = (
return lastSelectedItemKey.current;
}
}, [listItems, selectedIndex]);
lastSelectedItemKey.current = selectedItemKey;
const selectedIndexRef = useRef(selectedIndex);
selectedIndexRef.current = selectedIndex;
useEffect(() => {
// Skip scrolling if the selected item hasn't actually changed. When a folder is
// expanded or collapsed the selected item's index may shift, but its key stays
// the same — in that case we don't want to scroll the view.
if (selectedItemKey === lastSelectedItemKey.current) {
return;
}
lastSelectedItemKey.current = selectedItemKey;
if (!itemListRef.current || !selectedItemKey) return;
const hasFocus = !!itemListRef.current.container.contains(document.activeElement);

View File

@@ -18,7 +18,7 @@ import { FolderEntity } from '@joplin/lib/services/database/types';
import InteropService from '@joplin/lib/services/interop/InteropService';
import InteropServiceHelper from '../../../InteropServiceHelper';
import Setting from '@joplin/lib/models/Setting';
import PerFolderSortOrderService from '../../../services/sortOrder/PerFolderSortOrderService';
import PerFolderSortOrderService from '@joplin/lib/services/sortOrder/PerFolderSortOrderService';
import { getFolderCallbackUrl, getTagCallbackUrl } from '@joplin/lib/callbackUrlUtils';
import { PluginStates, utils as pluginUtils } from '@joplin/lib/services/plugins/reducer';
import { MenuItemLocation } from '@joplin/lib/services/plugins/api/types';
@@ -54,12 +54,42 @@ type ItemContextMenuListener = MouseEventHandler<HTMLElement>;
const menuUtils = new MenuUtils(CommandService.instance());
// Checks whether an element is at least partially visible within a scrollable
// container by comparing their bounding rectangles.
const isElementVisibleInContainer = (element: HTMLElement, container: HTMLElement) => {
const elementRect = element.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
return elementRect.bottom > containerRect.top && elementRect.top < containerRect.bottom;
};
const focusListItem = (item: HTMLElement|null) => {
if (item) {
// Avoid scrolling to the selected item when refocusing the note list. Such a refocus
// can happen if the note list rerenders and the selection is scrolled out of view and
// can cause scroll to change unexpectedly.
focus('useOnRenderItem', item, { preventScroll: true });
const activeElement = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const itemList = item.closest('.item-list');
const activeTreeItem = activeElement?.closest('[role="treeitem"]');
const focusWasLost = activeElement === document.body;
// If the currently focused element is a tree item inside the same list,
// the user is navigating with the keyboard — always allow focus to move
// to the newly selected item so arrow-key scrolling is not interrupted.
const isKeyboardNavigating = !!activeTreeItem && itemList?.contains(activeTreeItem);
// Avoid disturbing scroll while user is manually scrolling through the list.
// However, if focus was lost (activeElement -> <body>), or the user is
// navigating with the keyboard, allow re-focusing even when the selected
// item is currently out of view.
if (itemList instanceof HTMLElement && !isElementVisibleInContainer(item, itemList) && !focusWasLost && !isKeyboardNavigating) {
return;
}
// Move focus only if needed: either focus was lost, or selection changed
// to a different tree item.
if (focusWasLost || activeTreeItem !== item) {
// preventScroll: true avoids a secondary scroll caused by the focus() call
// itself when the item is near the edge of the visible area.
focus('useOnRenderItem', item, { preventScroll: true });
}
}
};
@@ -363,7 +393,12 @@ const useOnRenderItem = (props: Props) => {
multipleItemsSelected: props.selectedIndexes.length > 1,
};
const focusInList = document.hasFocus() && props.containerRef.current?.contains(document.activeElement);
const sidebarContainsFocus = props.containerRef.current?.contains(document.activeElement);
// Focus moves to <body> when the previously-focused element is removed
// from the DOM (e.g. scrolled out of the virtualized list). We still
// want to restore focus to the newly-selected item in that case.
const focusLostFromDom = document.activeElement === document.body;
const focusInList = document.hasFocus() && (sidebarContainsFocus || focusLostFromDom);
const anchorRef = (focusInList && primarySelected) ? focusListItem : noFocusListItem;
if (item.kind === ListItemType.Tag) {

View File

@@ -6,7 +6,7 @@ import bridge from '../../../services/bridge';
import Setting from '@joplin/lib/models/Setting';
import MenuUtils from '@joplin/lib/services/commands/MenuUtils';
import CommandService from '@joplin/lib/services/CommandService';
import PerFolderSortOrderService from '../../../services/sortOrder/PerFolderSortOrderService';
import PerFolderSortOrderService from '@joplin/lib/services/sortOrder/PerFolderSortOrderService';
import { connect } from 'react-redux';
import EmptyExpandLink from './EmptyExpandLink';
import ListItemWrapper, { ItemSelectionState, ListItemRef } from './ListItemWrapper';

View File

@@ -30,19 +30,27 @@ export const runtime = (): CommandRuntime => {
} else {
void bridge().openExternal(link);
}
} else if (urlProtocol(link)) {
if (link.indexOf('file://') === 0) {
// When using the file:// protocol, openPath doesn't work (does
// nothing) with URL-encoded paths.
//
// shell.openPath seems to work with file:// urls on Windows,
// but doesn't on macOS, so we need to convert it to a path
// before passing it to openPath.
const decodedPath = fileUriToPath(urlDecode(link), shim.platformName());
void bridge().openItem(decodedPath);
} else {
void bridge().openExternal(link);
} else if (link.indexOf('file://') === 0) {
// When using the file:// protocol, openPath doesn't work (does
// nothing) with URL-encoded paths.
//
// shell.openPath seems to work with file:// urls on Windows,
// but doesn't on macOS, so we need to convert it to a path
// before passing it to openPath.
let decoded = urlDecode(link);
// On Windows, UNC paths like file://\\server\share have backslashes
// right after file:// which makes the URL invalid. Convert them
// to forward slashes so fileUriToPath can handle them correctly.
// https://github.com/laurent22/joplin/issues/14196
if (decoded.startsWith('file://\\')) {
decoded = `file://${decoded.substring(7).replace(/\\/g, '/')}`;
}
const decodedPath = fileUriToPath(decoded, shim.platformName());
void bridge().openItem(decodedPath);
} else if (urlProtocol(link)) {
void bridge().openExternal(link);
} else {
bridge().showErrorMessageBox(_('Unsupported link or message: %s', link));
}

View File

@@ -1,5 +1,5 @@
import { CommandContext, CommandDeclaration, CommandRuntime } from '@joplin/lib/services/CommandService';
import { setNotesSortOrder } from '../../../services/sortOrder/notesSortOrderUtils';
import { setNotesSortOrder } from '@joplin/lib/services/sortOrder/notesSortOrderUtils';
import { _ } from '@joplin/lib/locale';
export const declaration: CommandDeclaration = {

View File

@@ -1,7 +1,7 @@
import { CommandContext, CommandDeclaration, CommandRuntime } from '@joplin/lib/services/CommandService';
import Setting from '@joplin/lib/models/Setting';
import { _ } from '@joplin/lib/locale';
import { setNotesSortOrder } from '../../../services/sortOrder/notesSortOrderUtils';
import { setNotesSortOrder } from '@joplin/lib/services/sortOrder/notesSortOrderUtils';
export const declaration: CommandDeclaration = {
name: 'toggleNotesSortOrderReverse',

View File

@@ -1,6 +1,6 @@
import { CommandContext, CommandDeclaration, CommandRuntime } from '@joplin/lib/services/CommandService';
import { _ } from '@joplin/lib/locale';
import PerFolderSortOrderService from '../../../services/sortOrder/PerFolderSortOrderService';
import PerFolderSortOrderService from '@joplin/lib/services/sortOrder/PerFolderSortOrderService';
export const declaration: CommandDeclaration = {
name: 'togglePerFolderSortOrder',

View File

@@ -81,6 +81,7 @@ export default function() {
'switchProfile2',
'switchProfile3',
'pasteAsText',
'pasteAsMarkdown',
'showNoteProperties',
'convertNoteToMarkdown',
'toggleEditors',

View File

@@ -35,6 +35,10 @@
ul ul, ul ol, ol ul, ol ol {
margin-bottom: 0px;
}
::highlight(search-results) {
background-color: #f7d26e;
color: black;
}
</style>
</head>
@@ -503,7 +507,7 @@
if (!options) options = {};
// TODO: Add support for scriptType on mobile and CLI
CSS.highlights.clear();
if (!mark_) {
mark_ = new Mark(document.getElementById('joplin-container-content'), {
exclude: ['img'],
@@ -519,7 +523,8 @@
let selectedElement = null;
let elementIndex = 0;
const allRanges = [];
const markKeywordOptions = {};
if ('separateWordSearch' in options) markKeywordOptions.separateWordSearch = options.separateWordSearch;
@@ -527,9 +532,17 @@
try {
for (const keyword of keywords) {
markJsUtils.markKeyword(mark_, keyword, {
pregQuote: pregQuote,
replaceRegexDiacritics: replaceRegexDiacritics,
}, markKeywordOptions);
pregQuote: pregQuote,
replaceRegexDiacritics: replaceRegexDiacritics,
}, {
...markKeywordOptions,
element: 'mark-ghost',
each: (node) => {
const range = new Range();
range.selectNodeContents(node);
allRanges.push(range);
}
});
}
} catch (error) {
if (error.name !== 'SyntaxError') {
@@ -540,6 +553,10 @@
// https://github.com/laurent22/joplin/issues/7634
console.error('Error while trying to highlight words from search: ', error);
}
if (allRanges.length > 0) {
const searchResultsHighlight = new Highlight(...allRanges);
CSS.highlights.set("search-results", searchResultsHighlight);
}
}
let markLoader_ = { state: 'idle', whenDone: null };
@@ -809,6 +826,37 @@
);
}));
// By default, Chromium inlines body styles (e.g. theme background color) into the clipboard HTML.
// Intercept the copy event and write only the selected content to bypass this behaviour.
document.addEventListener('copy', webviewLib.logEnabledEventHandler(e => {
const selection = window.getSelection();
if (!selection || selection.isCollapsed) return;
if (selection.rangeCount === 0 || !e.clipboardData) return;
const range = selection.getRangeAt(0);
const wrapper = document.createElement('div');
wrapper.appendChild(range.cloneContents());
wrapper.querySelectorAll('style').forEach(s => s.remove());
const inlineTags = new Set(['STRONG', 'EM', 'CODE', 'S', 'DEL', 'INS', 'MARK', 'SUP', 'SUB', 'U', 'SPAN', 'A']);
let node = range.commonAncestorContainer;
if (node.nodeType === Node.TEXT_NODE) node = node.parentElement;
while (node && node !== document.body && node.id !== 'rendered-md' && node.id !== 'joplin-container-content') {
if (inlineTags.has(node.tagName)) {
const el = node.cloneNode(false);
while (wrapper.firstChild) el.appendChild(wrapper.firstChild);
wrapper.appendChild(el);
}
node = node.parentElement;
}
e.clipboardData.setData('text/html', wrapper.innerHTML);
e.clipboardData.setData('text/plain', selection.toString());
e.preventDefault();
}));
let lastClientWidth_ = NaN, lastClientHeight_ = NaN, lastScrollTop_ = NaN;
window.addEventListener('resize', webviewLib.logEnabledEventHandler(() => {

View File

@@ -161,7 +161,7 @@ test.describe('markdownEditor', () => {
const viewer = noteEditor.getNoteViewerFrameLocator();
await expect(viewer.locator('h1')).toHaveText('Testing');
const matches = viewer.locator('mark');
const matches = viewer.locator('mark-ghost');
await expect(matches).toHaveCount(0);
await mainWindow.keyboard.press(process.platform === 'darwin' ? 'Meta+f' : 'Control+f');
@@ -276,8 +276,12 @@ test.describe('markdownEditor', () => {
expect(imageSize[1]).toBeGreaterThan(0);
});
test('ctrl-clicking on note links should open the linked note (when the viewer is hidden)', async ({ mainWindow }) => {
const mainScreen = await new MainScreen(mainWindow).setup();
test('ctrl-clicking on note links should open the linked note (when the viewer is hidden)', async ({ mainWindow, electronApp }) => {
const mainScreen = await new MainScreen(mainWindow);
// Workaround: Required for extracting content accurately from the Markdown editor
await mainScreen.noteEditor.disableInlineRendering(electronApp);
await mainScreen.setup();
await mainScreen.createNewNote('Original');
const noteEditor = mainScreen.noteEditor;
await noteEditor.hideViewer();
@@ -400,5 +404,35 @@ test.describe('markdownEditor', () => {
await activateMainMenuItem(electronApp, 'Redo');
await noteEditor.expectToHaveText('A');
});
});
test('copying from the preview pane should not include theme background color and should preserve bold formatting', async ({ mainWindow, electronApp }) => {
// Set dark theme so background-color would be present in clipboard without the fix
await setSettingValue(electronApp, mainWindow, 'theme', 2);
const mainScreen = await new MainScreen(mainWindow).setup();
await mainScreen.waitFor();
await mainScreen.createNewNote('Test copy formatting');
const noteEditor = mainScreen.noteEditor;
await noteEditor.focusCodeMirrorEditor();
await mainWindow.keyboard.type('**hello**');
const viewerFrame = noteEditor.getNoteViewerFrameLocator();
await expect(viewerFrame.locator('strong')).toHaveText('hello');
// Double-click selects the text node inside <strong>, not <strong> itself.
// Without the ancestor re-wrapping fix, <strong> would be dropped.
await viewerFrame.locator('strong').dblclick();
const modifier = process.platform === 'darwin' ? 'Meta' : 'Control';
await mainWindow.keyboard.press(`${modifier}+c`);
const clipboardHtml = await mainWindow.evaluate(() => {
const { clipboard } = require('electron');
return clipboard.readHTML();
});
expect(clipboardHtml).toContain('hello');
expect(clipboardHtml).not.toMatch(/background-color\s*:/i);
expect(clipboardHtml).toContain('<strong>');
});
});

View File

@@ -2,6 +2,7 @@ import { ElectronApplication, Locator, Page } from '@playwright/test';
import { expect } from '../util/test';
import activateMainMenuItem from '../util/activateMainMenuItem';
import EditorCodeDialog from './EditorCodeDialog';
import setSettingValue from '../util/setSettingValue';
export default class NoteEditorPage {
public readonly codeMirrorEditor: Locator;
@@ -24,8 +25,8 @@ export default class NoteEditorPage {
private readonly containerLocator: Locator;
public constructor(page: Page) {
this.containerLocator = page.locator('.rli-editor');
public constructor(private page_: Page) {
this.containerLocator = page_.locator('.rli-editor');
this.codeMirrorEditor = this.containerLocator.locator('.cm-editor');
this.richTextEditor = this.containerLocator.locator('iframe[title="Rich Text Area"]');
this.editorPluginFrame = this.containerLocator.locator('iframe[id^="plugin-view-"]');
@@ -41,7 +42,7 @@ export default class NoteEditorPage {
this.disableTabNavigationButton = this.containerLocator.getByRole('button', { name: 'Tab moves focus' });
this.toggleEditorPluginButton = this.containerLocator.getByRole('button', { name: 'Toggle editor plugin' });
this.richTextCodeEditor = new EditorCodeDialog(page);
this.richTextCodeEditor = new EditorCodeDialog(page_);
}
public toolbarButtonLocator(title: string) {
@@ -65,6 +66,10 @@ export default class NoteEditorPage {
}
}
public async disableInlineRendering(electronApp: ElectronApplication) {
await setSettingValue(electronApp, this.page_, 'editor.inlineRendering', false);
}
public async expectToHaveText(expected: string|RegExp) {
// expect(...).toHaveText can fail in the Rich Text Editor (perhaps due to frame locators).
// Using expect.poll refreshes the locator on each attempt, which seems to prevent flakiness.

View File

@@ -2,6 +2,7 @@
import { test, expect } from './util/test';
import MainScreen from './models/MainScreen';
import { msleep, Second } from '@joplin/utils/time';
import setSettingValue from './util/setSettingValue';
test.describe('pluginApi', () => {
test('the editor.setText command should update the current note (use RTE: false)', async ({ startAppWithPlugins }) => {
@@ -80,6 +81,30 @@ test.describe('pluginApi', () => {
await expectVisible(false);
});
// Regression tests for #13718
for (const method of ['Cancel button', 'Escape key'] as const) {
test(`should dismiss a plugin dialog via ${method} with isolated iframes`, async ({ startAppWithPlugins }) => {
const { app, mainWindow } = await startAppWithPlugins(['resources/test-plugins/dialogs.js']);
const mainScreen = await new MainScreen(mainWindow).setup();
await mainScreen.createNewNote('Test note');
// WebView isolation is currently behind a feature flag:
await setSettingValue(app, mainWindow, 'featureFlag.plugins.isolatePluginWebViews', true);
await mainScreen.goToAnything.runCommand(app, 'showTestDialogWithDismiss');
const dialogContent = mainScreen.dialog.locator('iframe').contentFrame();
await dialogContent.locator('p').waitFor();
if (method === 'Cancel button') {
await mainScreen.dialog.getByRole('button', { name: 'Cancel' }).click();
} else {
await mainWindow.keyboard.press('Escape');
}
await expect(mainScreen.dialog).toBeHidden();
});
}
test('should be possible to create multiple toasts with the same text from a plugin', async ({ startAppWithPlugins }) => {
const { app, mainWindow } = await startAppWithPlugins(['resources/test-plugins/showToast.js']);
const mainScreen = await new MainScreen(mainWindow).setup();

View File

@@ -48,6 +48,21 @@ joplin.plugins.register({
},
});
const dismissDialogHandle = await dialogs.create('test-dialog-with-dismiss');
await dialogs.setHtml(dismissDialogHandle, '<p>Press Escape to dismiss</p>');
await dialogs.setButtons(dismissDialogHandle, [
{ id: 'ok', title: 'Okay' },
{ id: 'cancel', title: 'Cancel' },
]);
await joplin.commands.register({
name: 'showTestDialogWithDismiss',
label: 'showTestDialogWithDismiss',
execute: async () => {
const result = await joplin.views.dialogs.open(dismissDialogHandle);
await joplin.commands.execute('editor.setText', result.id);
},
});
await joplin.commands.register({
name: 'getTestDialogVisibility',
label: 'Returns the dialog visibility state',

View File

@@ -7,7 +7,7 @@ const createStartupArgs = (profileDirectory: string) => {
// We need to run with --env dev to disable the single instance check.
return [
mainPath, '--env', 'dev', '--log-level', 'debug', '--no-welcome', '--running-tests', '--profile', resolve(profileDirectory),
mainPath, '--env', 'dev', '--lang=en-GB', '--log-level', 'debug', '--no-welcome', '--running-tests', '--profile', resolve(profileDirectory),
];
};

View File

@@ -1,6 +1,6 @@
{
"name": "@joplin/app-desktop",
"version": "3.6.2",
"version": "3.6.3",
"description": "Joplin for Desktop",
"main": "main.bundle.js",
"private": true,

View File

@@ -1,5 +1,5 @@
import * as React from 'react';
import { useRef, useCallback } from 'react';
import { useRef, useCallback, useMemo } from 'react';
import { ButtonSpec, DialogResult } from '@joplin/lib/services/plugins/api/types';
import PluginService from '@joplin/lib/services/plugins/PluginService';
import WebviewController from '@joplin/lib/services/plugins/WebviewController';
@@ -76,8 +76,16 @@ export default function UserWebviewDialog(props: Props) {
if (webviewRef.current) focus('UserWebviewDialog', webviewRef.current);
}, []);
// When the iframe is isolated (security setting enabled), keyboard events
// like Escape don't reach the iframe content. We let the native <dialog>
// handle Escape by passing onCancel, but only when there's a dismiss button.
// https://github.com/laurent22/joplin/issues/13718
const onCancel = useMemo(() => {
return findDismissButton(buttons) ? onDismiss : undefined;
}, [buttons, onDismiss]);
return (
<Dialog className={`user-webview-dialog ${props.fitToContent ? '-fit' : ''}`}>
<Dialog className={`user-webview-dialog ${props.fitToContent ? '-fit' : ''}`} onCancel={onCancel}>
<div className='user-dialog-wrapper'>
<UserWebview
ref={webviewRef}

View File

@@ -83,8 +83,8 @@ android {
applicationId "net.cozic.joplin"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 2097800
versionName "3.6.12"
versionCode 2097801
versionName "3.6.13"
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""

View File

@@ -57,6 +57,7 @@ const useStyles = (themeId: number, containerStyle: ViewStyle, size: DialogVaria
},
closeButton: {
margin: 0,
marginRight: -8,
},
// Ensure that the close button is aligned with the center of the header:
// Make its container smaller and center it.
@@ -91,8 +92,8 @@ const useStyles = (themeId: number, containerStyle: ViewStyle, size: DialogVaria
dialogSurface: {
borderRadius: 24,
backgroundColor: theme.backgroundColor,
paddingHorizontal: 16,
paddingVertical: 24,
paddingHorizontal: theme.margin,
paddingVertical: theme.margin,
...dialogSizing,
},
});

View File

@@ -1,134 +0,0 @@
import * as React from 'react';
import { Store } from 'redux';
import { AppState } from '../utils/types';
import TestProviderStack from './testing/TestProviderStack';
import { switchClient, setupDatabase, mockMobilePlatform, mockFetch } from '@joplin/lib/testing/test-utils';
import waitFor from '@joplin/lib/testing/waitFor';
import createMockReduxStore from '../utils/testing/createMockReduxStore';
import setupGlobalStore from '../utils/testing/setupGlobalStore';
import { act, fireEvent, render, screen } from '@testing-library/react-native';
import FeedbackBanner from './FeedbackBanner';
import { MobilePlatform } from '@joplin/lib/shim';
interface WrapperProps { }
let store: Store<AppState>;
const WrappedFeedbackBanner: React.FC<WrapperProps> = () => {
return <TestProviderStack store={store}>
<FeedbackBanner/>
</TestProviderStack>;
};
const getFeedbackButton = (positive: boolean) => {
return screen.getByRole('button', { name: positive ? 'Useful' : 'Not useful' });
};
const getSurveyLink = () => {
return screen.getByRole('button', { name: 'Take survey' });
};
const mockFeedbackServer = (surveyName = 'web-app-test') => {
let helpfulCount = 0;
let unhelpfulCount = 0;
const { reset } = mockFetch((request) => {
const surveyBaseUrls = [
'https://objects.joplinusercontent.com/',
'http://localhost:3430/',
];
const isSurveyRequest = surveyBaseUrls.some(url => request.url.startsWith(url));
if (!isSurveyRequest) {
return null;
}
const url = new URL(request.url);
if (url.pathname === `/r/survey--${surveyName}--helpful`) {
helpfulCount ++;
} else if (url.pathname === `/r/survey--${surveyName}--unhelpful`) {
unhelpfulCount ++;
} else {
return new Response('Not found', { status: 404 });
}
// The feedback server always redirects to another URL after a
// successful request. Mock this by always redirecting to the
// same URL.
return new Response('', {
// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/302
status: 302,
statusText: 'Found',
headers: [
['location', 'https://joplinapp.org'],
],
});
});
return {
reset,
get helpfulCount() {
return helpfulCount;
},
get unhelpfulCount() {
return unhelpfulCount;
},
};
};
describe('FeedbackBanner', () => {
const resetMobilePlatform = ()=>{};
beforeEach(async () => {
await setupDatabase(0);
await switchClient(0);
store = createMockReduxStore();
setupGlobalStore(store);
jest.useFakeTimers({ advanceTimers: true });
mockMobilePlatform(MobilePlatform.Web);
});
afterEach(() => {
screen.unmount();
resetMobilePlatform();
});
test.each([
{ platform: MobilePlatform.Android, shouldShow: false },
{ platform: MobilePlatform.Web, shouldShow: true },
{ platform: MobilePlatform.Ios, shouldShow: false },
])('should correctly show/hide the feedback banner on %s', ({ platform, shouldShow }) => {
mockMobilePlatform(platform);
render(<WrappedFeedbackBanner />);
const header = screen.queryByRole('header', { name: 'Feedback' });
if (shouldShow) {
expect(header).toBeVisible();
} else {
expect(header).toBeNull();
}
});
test('clicking the "Useful" button should submit the response and show the "take survey" link', async () => {
const feedbackServerMock = mockFeedbackServer();
render(<WrappedFeedbackBanner />);
try {
const usefulButton = getFeedbackButton(true);
fireEvent.press(usefulButton);
await act(() => waitFor(async () => {
expect(getSurveyLink()).toBeVisible();
}));
expect(feedbackServerMock).toMatchObject({
helpfulCount: 1,
unhelpfulCount: 0,
});
} finally {
feedbackServerMock.reset();
}
});
});

View File

@@ -1,216 +0,0 @@
import { _ } from '@joplin/lib/locale';
import * as React from 'react';
import { View, StyleSheet, useWindowDimensions, TextStyle, Linking } from 'react-native';
import { Portal, Text } from 'react-native-paper';
import IconButton from './IconButton';
import { useCallback, useMemo } from 'react';
import shim from '@joplin/lib/shim';
import { Dispatch } from 'redux';
import { themeStyle } from './global-style';
import { AppState } from '../utils/types';
import { connect } from 'react-redux';
import Setting from '@joplin/lib/models/Setting';
import { LinkButton } from './buttons';
import Logger from '@joplin/utils/Logger';
import { SurveyProgress } from '@joplin/lib/models/settings/builtInMetadata';
const logger = Logger.create('FeedbackBanner');
interface Props {
dispatch: Dispatch;
progress: SurveyProgress;
surveyKey: string;
themeId: number;
}
const useStyles = (themeId: number, sentFeedback: boolean) => {
const { width: windowWidth } = useWindowDimensions();
return useMemo(() => {
const theme = themeStyle(themeId);
const iconBaseStyle: TextStyle = {
fontSize: 24,
color: theme.color3,
};
return StyleSheet.create({
container: {
backgroundColor: theme.backgroundColor3,
borderTopRightRadius: 16,
display: 'flex',
flexGrow: 1,
flexWrap: 'wrap',
flexDirection: 'row',
position: 'absolute',
bottom: 0,
left: 0,
maxWidth: windowWidth - 50,
gap: 18,
padding: 12,
},
contentRight: {
display: sentFeedback ? 'none' : 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: 16,
},
header: {
fontWeight: 'bold',
},
iconUseful: {
...iconBaseStyle,
color: theme.colorCorrect,
},
iconNotUseful: {
...iconBaseStyle,
color: theme.colorWarn,
},
dismissButtonIcon: {
fontSize: 16,
color: theme.color2,
marginLeft: 'auto',
marginRight: 'auto',
},
dismissButton: {
backgroundColor: theme.backgroundColor2,
borderColor: theme.backgroundColor,
borderWidth: 2,
width: 29,
height: 29,
borderRadius: 14,
position: 'absolute',
top: -16,
right: -16,
justifyContent: 'center',
},
dismissButtonContent: {
flexShrink: 1,
},
});
}, [themeId, windowWidth, sentFeedback]);
};
const useSurveyUrl = (surveyKey: string) => {
return useMemo(() => {
let baseUrl = 'https://objects.joplinusercontent.com/';
// For testing with a locally-hosted server:
const useLocalServer = false;
if (Setting.value('env') === 'dev' && useLocalServer) {
baseUrl = 'http://localhost:3430/';
}
return `${baseUrl}r/survey--${encodeURIComponent(surveyKey)}`;
}, [surveyKey]);
};
const setProgress = (progress: SurveyProgress) => {
Setting.setValue('survey.webClientEval2025.progress', progress);
};
const onDismiss = () => {
setProgress(SurveyProgress.Dismissed);
};
const FeedbackBanner: React.FC<Props> = props => {
const surveyUrl = useSurveyUrl(props.surveyKey);
const sentFeedback = props.progress === SurveyProgress.Started;
const sendSurveyResponse = useCallback(async (surveyResponse: string) => {
const fetchUrl = `${surveyUrl}--${encodeURIComponent(surveyResponse)}`;
logger.debug('sending response to', fetchUrl);
const showError = (message: string) => {
logger.error('Error', message);
void shim.showErrorDialog(
_('An error occurred while sending the response. This can happen if the app is offline or cannot connect to the server.\nError: %s', message),
);
};
try {
const response = await shim.fetch(fetchUrl);
// The server currently redirects (status 302) in response
// to many survey-related requests. This may be returned by
// the web app service worker as a 200 OK response, however. Support both:
if (response.ok || response.status === 302) {
setProgress(SurveyProgress.Started);
} else {
const body = await response.text();
showError(`Server error: ${response.status} ${body}`);
}
} catch (error) {
showError(error);
}
}, [surveyUrl]);
const onSurveyLinkClick = useCallback(() => {
void Linking.openURL(surveyUrl);
onDismiss();
}, [surveyUrl]);
const onNotUsefulClick = useCallback(() => {
void sendSurveyResponse('unhelpful');
}, [sendSurveyResponse]);
const onUsefulClick = useCallback(() => {
void sendSurveyResponse('helpful');
}, [sendSurveyResponse]);
const styles = useStyles(props.themeId, sentFeedback);
const renderStatusMessage = () => {
if (sentFeedback) {
return <View>
<Text>{_('Thank you for the feedback!\nDo you have time to complete a short survey?')}</Text>
<LinkButton onPress={onSurveyLinkClick}>{_('Take survey')}</LinkButton>
</View>;
} else {
return <Text>{_('Do you find the Joplin web app useful?')}</Text>;
}
};
if (shim.mobilePlatform() !== 'web' || props.progress === SurveyProgress.Dismissed) return null;
return <Portal>
<View style={styles.container} role='complementary'>
<View>
<Text
accessibilityRole='header'
variant='titleMedium'
style={styles.header}
>{_('Feedback')}</Text>
<Text>{renderStatusMessage()}</Text>
</View>
<View style={styles.contentRight}>
<IconButton
iconName='fas times'
themeId={props.themeId}
onPress={onNotUsefulClick}
description={_('Not useful')}
iconStyle={styles.iconNotUseful}
/>
<IconButton
iconName='fas check'
themeId={props.themeId}
onPress={onUsefulClick}
description={_('Useful')}
iconStyle={styles.iconUseful}
/>
</View>
<IconButton
iconName='fas times'
themeId={props.themeId}
onPress={onDismiss}
description={_('Dismiss')}
iconStyle={styles.dismissButtonIcon}
contentWrapperStyle={styles.dismissButtonContent}
containerStyle={styles.dismissButton}
/>
</View>
</Portal>;
};
export default connect((state: AppState) => ({
themeId: state.settings.theme,
surveyKey: 'web-app-test',
progress: state.settings['survey.webClientEval2025.progress'],
}))(FeedbackBanner);

View File

@@ -34,6 +34,7 @@ interface Props {
onScroll: OnScrollCallback;
onLoadEnd?: ()=> void;
pluginStates: PluginStates;
showNoteLinkIcon: boolean;
}
const onJoplinLinkClick = async (message: string) => {
@@ -84,6 +85,7 @@ function NoteBodyViewer(props: Props) {
initialScrollPercent: props.initialScrollPercent,
paddingBottom: props.paddingBottom,
showNoteLinkIcon: props.showNoteLinkIcon,
});
const onLoadEnd = useCallback(() => {
@@ -115,4 +117,5 @@ export default connect((state: AppState) => ({
themeId: state.settings.theme,
fontSize: state.settings['style.viewer.fontSize'],
pluginStates: state.pluginService.plugins,
showNoteLinkIcon: state.settings['notes.showNoteLinkIcon'],
}))(NoteBodyViewer);

View File

@@ -27,6 +27,7 @@ interface Props {
initialScrollPercent: number|undefined;
paddingBottom: number;
showNoteLinkIcon: boolean;
}
const onlyCheckboxHasChangedHack = (previousBody: string, newBody: string) => {
@@ -100,7 +101,7 @@ const useRerenderHandler = (props: Props) => {
const effectDependencies = [
props.noteBody, props.noteMarkupLanguage, props.renderer, props.highlightedKeywords,
props.noteHash, props.noteResources, props.themeId, props.paddingBottom, resourceDownloadRerenderCounter,
props.fontSize,
props.fontSize, props.showNoteLinkIcon,
];
const previousDeps = usePrevious(effectDependencies, []);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
@@ -138,6 +139,7 @@ const useRerenderHandler = (props: Props) => {
// instead.
initialScrollPercent: (previousHash && hashChanged) ? undefined : props.initialScrollPercent,
noteHash: props.noteHash,
showNoteLinkIcon: props.showNoteLinkIcon,
};
try {

View File

@@ -16,7 +16,7 @@ import setupGlobalStore from '../../utils/testing/setupGlobalStore';
import { Store } from 'redux';
import { AppState } from '../../utils/types';
import { MarkupLanguage } from '@joplin/renderer';
import { EditorType } from './types';
import { EditorControl, EditorType } from './types';
let store: Store<AppState>;
let registeredRuntime: RegisteredRuntime;
@@ -65,6 +65,27 @@ describe('NoteEditor', () => {
registeredRuntime.deregister();
});
it('should provide an editor ref', () => {
let editorRef: EditorControl;
const onSetEditorRef = (ref: EditorControl) => {
editorRef = ref;
};
const wrappedNoteEditor = render(
<TestProviderStack store={store}>
<NoteEditor
ref={onSetEditorRef}
{...defaultEditorProps}
mode={EditorType.RichText}
/>
</TestProviderStack>,
);
expect(editorRef).toBeTruthy();
wrappedNoteEditor.unmount();
});
it('should hide the markdown toolbar when the window is small', async () => {
const wrappedNoteEditor = render(
<TestProviderStack store={store}>

View File

@@ -34,6 +34,8 @@ import { MarkupLanguage } from '@joplin/renderer';
import WarningBanner from './WarningBanner';
import useIsScreenReaderEnabled from '../../utils/hooks/useIsScreenReaderEnabled';
import Logger from '@joplin/utils/Logger';
import { AppState } from '../../utils/types';
import { connect } from 'react-redux';
import { Second } from '@joplin/utils/time';
import useDebounced from '../../utils/hooks/useDebounced';
@@ -62,6 +64,8 @@ interface Props {
readOnly: boolean;
plugins: PluginStates;
noteResources: ResourceInfos;
editorImageRendering: boolean;
editorInlineRendering: boolean;
onScroll: OnScroll;
onChange: OnChange;
@@ -69,6 +73,7 @@ interface Props {
onSelectionChange: OnSelectionChange;
onUndoRedoDepthChange: OnUndoRedoDepthChange;
onAttach: OnAttach;
refreshKey?: number;
}
function fontFamilyFromSettings() {
@@ -256,12 +261,48 @@ const useEditorControl = (
}, [webviewRef, editorRef, setLinkDialogVisible, setSearchState]);
};
const useHighlightActiveLine = () => {
const useEditorSettings = (props: Props) => {
const screenReaderEnabled = useIsScreenReaderEnabled();
// Guess whether highlighting the active line can be enabled without triggering
// https://github.com/codemirror/dev/issues/1559.
const canHighlight = Platform.OS !== 'ios' || !screenReaderEnabled;
return canHighlight && Setting.value('editor.highlightActiveLine');
const highlightActiveLine = canHighlight && Setting.value('editor.highlightActiveLine');
// Also disable inline rendering. As of January 2026, inline rendering
// seems to cause screen readers to behave strangely (e.g. sometimes not announce full
// line content, reading "image" when not in an image, etc.)
// However, `screenReaderEnabled` is always `true` on web (likely due to the lack of an API
// to reliably detect whether the user is using a screen reader), so also allow inline rendering
// to be enabled on web:
const inlineRenderingEnabled = props.editorInlineRendering && (!screenReaderEnabled || Platform.OS === 'web');
const editorSettings: EditorSettings = useMemo(() => ({
themeData: editorTheme(props.themeId),
markdownMarkEnabled: Setting.value('markdown.plugin.mark'),
katexEnabled: Setting.value('markdown.plugin.katex'),
spellcheckEnabled: Setting.value('editor.mobile.spellcheckEnabled'),
inlineRenderingEnabled,
imageRenderingEnabled: props.editorImageRendering,
language: props.markupLanguage === MarkupLanguage.Html ? EditorLanguageType.Html : EditorLanguageType.Markdown,
useExternalSearch: true,
readOnly: props.readOnly,
highlightActiveLine,
keymap: EditorKeymap.Default,
preferMacShortcuts: shim.mobilePlatform() === 'ios',
automatchBraces: false,
ignoreModifiers: false,
autocompleteMarkup: Setting.value('editor.autocompleteMarkup'),
// For now, mobile CodeMirror uses its built-in focus toggle shortcut.
tabMovesFocus: false,
indentWithTabs: true,
editorLabel: _('Markdown editor'),
}), [props.themeId, props.readOnly, props.markupLanguage, highlightActiveLine, inlineRenderingEnabled, props.editorImageRendering]);
return editorSettings;
};
const useHasSpaceForToolbar = () => {
@@ -280,32 +321,7 @@ const useHasSpaceForToolbar = () => {
function NoteEditor(props: Props) {
const webviewRef = useRef<WebViewControl>(null);
const highlightActiveLine = useHighlightActiveLine();
const editorSettings: EditorSettings = useMemo(() => ({
themeData: editorTheme(props.themeId),
markdownMarkEnabled: Setting.value('markdown.plugin.mark'),
katexEnabled: Setting.value('markdown.plugin.katex'),
spellcheckEnabled: Setting.value('editor.mobile.spellcheckEnabled'),
inlineRenderingEnabled: Setting.value('editor.inlineRendering'),
imageRenderingEnabled: Setting.value('editor.imageRendering'),
language: props.markupLanguage === MarkupLanguage.Html ? EditorLanguageType.Html : EditorLanguageType.Markdown,
useExternalSearch: true,
readOnly: props.readOnly,
highlightActiveLine,
keymap: EditorKeymap.Default,
preferMacShortcuts: shim.mobilePlatform() === 'ios',
automatchBraces: false,
ignoreModifiers: false,
autocompleteMarkup: Setting.value('editor.autocompleteMarkup'),
// For now, mobile CodeMirror uses its built-in focus toggle shortcut.
tabMovesFocus: false,
indentWithTabs: true,
editorLabel: _('Markdown editor'),
}), [props.themeId, props.readOnly, props.markupLanguage, highlightActiveLine]);
const editorSettings = useEditorSettings(props);
const [selectionState, setSelectionState] = useState<SelectionFormatting>(defaultSelectionFormatting);
const [linkDialogVisible, setLinkDialogVisible] = useState(false);
@@ -456,6 +472,7 @@ function NoteEditor(props: Props) {
minHeight: '30%',
}}>
<EditorComponent
key={props.refreshKey}
editorRef={editorRef}
webviewRef={webviewRef}
themeId={props.themeId}
@@ -473,7 +490,11 @@ function NoteEditor(props: Props) {
/>
</View>
<WarningBanner editorType={props.mode}/>
<WarningBanner
editorType={props.mode}
markupLanguage={props.markupLanguage}
inEditorRendering={editorSettings.inlineRenderingEnabled}
/>
<SearchPanel
editorSettings={editorSettings}
@@ -486,4 +507,10 @@ function NoteEditor(props: Props) {
);
}
export default NoteEditor;
export default connect((state: AppState) => {
return {
themeId: state.settings.theme,
editorInlineRendering: state.settings['editor.inlineRendering'],
editorImageRendering: state.settings['editor.imageRendering'],
};
}, null, null, { forwardRef: true })(NoteEditor);

View File

@@ -460,6 +460,24 @@ describe('RichTextEditor', () => {
});
});
it.each(['-', '1.'])('should not add extra blank lines around nested lists (marker: %j)', async (marker) => {
const nested = marker === '1.' ? '1.' : '-';
let body = `${marker} a\n${marker} b\n ${nested} c\n ${nested} d\n${marker} e`;
render(<WrappedEditor
noteBody={body}
onBodyChange={newBody => { body = newBody; }}
/>);
const window = await getEditorWindow();
mockTyping(window, ' testing');
await waitFor(async () => {
// Nested lists should not have extra blank lines above or below
expect(body).not.toMatch(/\n\n\s*[-\d]/);
});
});
it('should preserve table of contents blocks on edit', async () => {
let body = '# Heading\n\n# Heading 2\n\n[toc]\n\nTest.';

View File

@@ -7,35 +7,76 @@ import { AppState } from '../../utils/types';
import { EditorType } from './types';
import { Banner } from 'react-native-paper';
import { useMemo } from 'react';
import useEditorTypeMigrationBanner from '@joplin/lib/components/shared/NoteEditor/WarningBanner/useEditorTypeMigrationBanner';
import { MarkupLanguage } from '@joplin/renderer/types';
interface Props {
editorType: EditorType;
richTextBannerDismissed: boolean;
editorMigrationVersion: number;
inEditorRendering: boolean;
markupLanguage: MarkupLanguage;
}
const WarningBanner: React.FC<Props> = props => {
const actions = useMemo(() => [
{
label: _('Read more'),
onPress: onRichTextReadMoreLinkClick,
},
{
label: _('Dismiss'),
accessibilityHint: _('Hides warning'),
onPress: onRichTextDismissLinkClick,
},
], []);
const useBanner = ({
editorType,
richTextBannerDismissed,
editorMigrationVersion,
inEditorRendering,
}: Props) => {
const editorMigrationBanner = useEditorTypeMigrationBanner({
markdownEditorEnabled: editorType === EditorType.Markdown,
editorMigrationVersion: editorMigrationVersion,
inEditorRenderingEnabled: inEditorRendering,
});
if (props.editorType !== EditorType.RichText || props.richTextBannerDismissed) return null;
return useMemo(() => {
if (editorType === EditorType.RichText && !richTextBannerDismissed) {
return {
label: _('This Rich Text editor has a number of limitations and it is recommended to be aware of them before using it.'),
warning: true,
actions: [
{
label: _('Read more'),
onPress: onRichTextReadMoreLinkClick,
},
{
label: _('Dismiss'),
accessibilityHint: _('Hides warning'),
onPress: onRichTextDismissLinkClick,
},
],
};
}
if (editorMigrationBanner.enabled) {
return {
label: editorMigrationBanner.label,
actions: [
editorMigrationBanner.keepEnabled,
editorMigrationBanner.disable,
],
};
}
return null;
}, [editorType, richTextBannerDismissed, editorMigrationBanner]);
};
const WarningBanner: React.FC<Props> = props => {
const banner = useBanner(props);
if (!banner) return null;
return (
<Banner
icon='alert-outline'
actions={actions}
icon={banner.warning ? 'alert-outline' : 'information-outline'}
actions={banner.actions}
// Avoid hiding with react-native-paper's "visible" prop to avoid potential accessibility issues
// related to how react-native-paper hides the banner.
visible={true}
>
{_('This Rich Text editor has a number of limitations and it is recommended to be aware of them before using it.')}
{banner.label}
</Banner>
);
};
@@ -43,5 +84,6 @@ const WarningBanner: React.FC<Props> = props => {
export default connect((state: AppState) => {
return {
richTextBannerDismissed: state.settings.richTextBannerDismissed,
editorMigrationVersion: state.settings['editor.migration'],
};
})(WarningBanner);

View File

@@ -1,89 +0,0 @@
import * as React from 'react';
import { Linking, StyleSheet, TextStyle, View, ViewStyle } from 'react-native';
import { Text } from 'react-native-paper';
import IconButton from '../IconButton';
import { _ } from '@joplin/lib/locale';
import { useCallback, useState } from 'react';
import DismissibleDialog, { DialogVariant } from '../DismissibleDialog';
import { LinkButton, PrimaryButton } from '../buttons';
import makeDiscourseDebugUrl from '@joplin/lib/makeDiscourseDebugUrl';
import getPackageInfo from '../../utils/getPackageInfo';
import PluginService from '@joplin/lib/services/plugins/PluginService';
import Setting from '@joplin/lib/models/Setting';
interface Props {
wrapperStyle: ViewStyle;
iconStyle: TextStyle;
themeId: number;
}
const onLeaveFeedback = () => {
void Linking.openURL('https://forms.gle/B5YGDNzsUYBnoPx19');
};
const onReportBug = () => {
void Linking.openURL(
makeDiscourseDebugUrl('', '', [], getPackageInfo(), PluginService.instance(), Setting.value('plugins.states')),
);
};
const styles = StyleSheet.create({
feedbackContainer: {
flexGrow: 1,
flexDirection: 'row',
gap: 16,
justifyContent: 'flex-end',
flexWrap: 'wrap',
},
paragraph: {
paddingBottom: 7,
},
});
const WebBetaButton: React.FC<Props> = props => {
const [dialogVisible, setDialogVisible] = useState(false);
const onShowDialog = useCallback(() => {
setDialogVisible(true);
}, []);
const onHideDialog = useCallback(() => {
setDialogVisible(false);
}, []);
const renderParagraph = (content: string) => {
return <Text variant='bodyLarge' style={styles.paragraph}>{content}</Text>;
};
return (
<>
<IconButton
onPress={onShowDialog}
description={_('Beta')}
themeId={props.themeId}
contentWrapperStyle={props.wrapperStyle}
iconName="material beta"
iconStyle={props.iconStyle}
/>
<DismissibleDialog
heading={_('Beta')}
size={DialogVariant.SmallResize}
themeId={props.themeId}
visible={dialogVisible}
onDismiss={onHideDialog}
>
{renderParagraph('Welcome to the beta version of the Joplin Web App!')}
{renderParagraph('Thank you for participating in the beta version of the Joplin Web App.')}
{renderParagraph('The Joplin Web App is available for a limited time in open beta and may later join the Joplin Cloud plans.')}
{renderParagraph('Feel free to use it and let us know if have any questions or notice any issues!')}
<View style={styles.feedbackContainer}>
<LinkButton onPress={onReportBug}>{'Report bug'}</LinkButton>
<PrimaryButton onPress={onLeaveFeedback}>{'Give feedback'}</PrimaryButton>
</View>
</DismissibleDialog>
</>
);
};
export default WebBetaButton;

View File

@@ -1,7 +1,7 @@
import * as React from 'react';
import { PureComponent, ReactElement } from 'react';
import { connect } from 'react-redux';
import { View, Text, StyleSheet, TouchableOpacity, ViewStyle, Platform } from 'react-native';
import { View, Text, StyleSheet, TouchableOpacity, ViewStyle } from 'react-native';
import BackButtonService from '../../services/BackButtonService';
import NavService from '@joplin/lib/services/NavService';
import { _, _n } from '@joplin/lib/locale';
@@ -20,7 +20,6 @@ import { PluginStates } from '@joplin/lib/services/plugins/reducer';
import { ContainerType } from '@joplin/lib/services/plugins/WebviewController';
import { Dispatch } from 'redux';
import WarningBanner from './WarningBanner';
import WebBetaButton from './WebBetaButton';
import Menu, { MenuOptionType } from './Menu';
import shim from '@joplin/lib/shim';
@@ -71,6 +70,9 @@ interface ScreenHeaderProps {
showContextMenuButton?: boolean;
showPluginEditorButton?: boolean;
showBackButton?: boolean;
showViewToggleButton?: boolean;
onViewTogglePress?: OnPressCallback;
viewToggleIconName?: string;
saveButtonDisabled?: boolean;
showSaveButton?: boolean;
@@ -168,7 +170,7 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
},
contextMenuTrigger: {
fontSize: 30,
paddingLeft: 10,
paddingLeft: 5,
paddingRight: theme.marginRight,
color: theme.color2,
fontWeight: 'bold',
@@ -374,6 +376,16 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
});
};
const renderViewToggleButton = () => {
if (!this.props.showViewToggleButton || !this.props.onViewTogglePress || !this.props.viewToggleIconName) return null;
return renderTopButton({
iconName: this.props.viewToggleIconName,
description: _('Toggle view/edit'),
onPress: this.props.onViewTogglePress,
visible: true,
});
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
function selectAllButton(styles: any, onPress: OnPressCallback) {
return (
@@ -443,18 +455,6 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
);
};
const betaIconButton = () => {
if (Platform.OS !== 'web') return null;
return (
<WebBetaButton
themeId={themeId}
wrapperStyle={this.styles().iconButton}
iconStyle={this.styles().topIcon}
/>
);
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const renderTogglePluginEditorButton = (styles: any, onPress: OnPressCallback, disabled: boolean) => {
if (!this.props.showPluginEditorButton) return null;
@@ -653,7 +653,6 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
const sideMenuComp = !showSideMenuButton ? null : sideMenuButton(this.styles(), () => this.sideMenuButton_press());
const backButtonComp = !showBackButton ? null : backButton(this.styles(), () => this.backButton_press(), backButtonDisabled);
const pluginPanelsComp = pluginPanelToggleButton(this.styles(), () => this.pluginPanelToggleButton_press());
const betaIconComp = betaIconButton();
const selectAllButtonComp = !showSelectAllButton ? null : selectAllButton(this.styles(), () => this.selectAllButton_press());
const searchButtonComp = !showSearchButton ? null : searchButton(this.styles(), () => this.searchButton_press());
const customDeleteButtonComp = this.props.onDeleteButtonPress ? customDeleteButton(this.styles(), this.props.onDeleteButtonPress) : null;
@@ -667,12 +666,12 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
// space while in use, we allow certain buttons to be hidden.
const hideableRightComponents = <>
{pluginPanelsComp}
{betaIconComp}
{togglePluginEditorButton}
{selectAllButtonComp}
{searchButtonComp}
{deleteButtonComp}
{customDeleteButtonComp}
{renderViewToggleButton()}
</>;
const titleComp = createTitleComponent(hideableRightComponents);

View File

@@ -8,9 +8,10 @@ import { Icon, Text } from 'react-native-paper';
import { _ } from '@joplin/lib/locale';
import JoplinCloudIcon from './JoplinCloudIcon';
import NavService from '@joplin/lib/services/NavService';
import { StyleSheet, View } from 'react-native';
import { Platform, StyleSheet, View } from 'react-native';
import CardButton from '../buttons/CardButton';
import Setting from '@joplin/lib/models/Setting';
import shim from '@joplin/lib/shim';
interface Props {
dispatch: Dispatch;
@@ -47,6 +48,16 @@ const styles = StyleSheet.create({
},
});
const isAppJoplinCloud = () => {
return Platform.OS === 'web' && location.origin === 'https://app.joplincloud.com';
};
const useShouldShowOtherButton = () => {
// Don't show "other" when hosted on Joplin Cloud (other sync
// targets can still be selected from settings).
return !isAppJoplinCloud();
};
interface SyncProviderProps {
title: string;
icon: ()=> React.ReactNode;
@@ -94,7 +105,15 @@ const SyncWizard: React.FC<Props> = ({ themeId, visible, dispatch }) => {
const onSelectJoplinCloud = useCallback(async () => {
onDismiss();
await NavService.go('JoplinCloudLogin');
if (Platform.OS === 'web' && !isAppJoplinCloud()) {
if (await shim.showConfirmationDialog(
_('Self-hosted instances of the Joplin web app cannot sync with Joplin Cloud. Open the official web app?'),
)) {
await shim.openUrl('https://app.joplincloud.com/');
}
} else {
await NavService.go('JoplinCloudLogin');
}
}, [onDismiss]);
const onSelectOtherTarget = useCallback(async () => {
@@ -102,6 +121,8 @@ const SyncWizard: React.FC<Props> = ({ themeId, visible, dispatch }) => {
await NavService.go('Config', { sectionName: 'sync' });
}, [onDismiss]);
const showOther = useShouldShowOtherButton();
return <DismissibleDialog
themeId={themeId}
visible={visible}
@@ -126,14 +147,14 @@ const SyncWizard: React.FC<Props> = ({ themeId, visible, dispatch }) => {
onPress={onSelectJoplinCloud}
disabled={false}
/>
<SyncProvider
{showOther && <SyncProvider
title={_('Other')}
description={_('Select one of the other supported sync targets.')}
icon={() => <Icon size={iconSize} source='dots-horizontal-circle'/>}
featuresList={[]}
onPress={onSelectOtherTarget}
disabled={false}
/>
/>}
</View>
</DismissibleDialog>;
};

View File

@@ -8,7 +8,6 @@ import { themeStyle } from './global-style';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import useKeyboardState from '../utils/hooks/useKeyboardState';
import usePrevious from '@joplin/lib/hooks/usePrevious';
import FeedbackBanner from './FeedbackBanner';
import { Theme } from '@joplin/lib/themes/type';
import { useMemo } from 'react';
import KeyboardAvoidingView from './KeyboardAvoidingView';
@@ -78,7 +77,6 @@ const AppNavComponent: React.FC<Props> = (props) => {
<NotesScreen visible={notesScreenVisible} />
{searchScreenLoaded && <SearchScreen visible={searchScreenVisible} />}
{!notesScreenVisible && !searchScreenVisible && <Screen navigation={{ state: route }} themeId={props.themeId} dispatch={props.dispatch} />}
{notesScreenVisible ? <FeedbackBanner/> : null}
<View style={{ height: autocompletionBarPadding }} />
</KeyboardAvoidingView>
);

View File

@@ -39,8 +39,6 @@ const styles = (() => {
display: 'flex',
flexDirection: 'column',
gap: 20,
marginLeft: 10,
marginRight: 10,
};
return StyleSheet.create({
descriptionText: {
@@ -64,7 +62,7 @@ const styles = (() => {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 10,
paddingVertical: 10,
marginTop: 12,
marginBottom: 14,
},
@@ -123,7 +121,7 @@ const PluginInfoModalContent: React.FC<Props> = props => {
});
const aboutPlugin = (
<Card mode='outlined' style={{ margin: 8 }} testID='plugin-card'>
<Card mode='outlined' style={{ marginVertical: 8 }} testID='plugin-card'>
<Card.Content>
<PluginTitle manifest={manifest}/>
<Text variant='bodyMedium'>{_('by %s', manifest.author)}</Text>

View File

@@ -126,20 +126,17 @@ const openNoteActionsMenu = async () => {
};
const expectToBeEditing = async (editing: boolean) => {
await waitFor(() => {
const editButton = screen.queryByLabelText('Edit');
if (editing) {
expect(editButton).toBeNull();
} else {
expect(editButton).not.toBeNull();
}
});
if (editing) {
await getMarkdownEditorControl();
} else {
await getNoteViewerDom();
}
};
const openEditor = async () => {
const editButton = await screen.findByLabelText('Edit');
const editToggle = await screen.findByLabelText('Toggle view/edit');
fireEvent.press(editButton);
fireEvent.press(editToggle);
await expectToBeEditing(true);
};
@@ -149,6 +146,18 @@ const runEditorCommand = async (commandName: string) => {
});
};
const setupNoteWithPanes = async (panes: string[], noteTitle = 'Test note') => {
store.dispatch({
type: 'NOTE_VISIBLE_PANES_SET',
panes: panes,
});
await openNewNote({ title: noteTitle, body: 'Test body' });
const renderResult = render(<WrappedNoteScreen />);
const titleInput = await screen.findByDisplayValue(noteTitle);
expect(titleInput).toBeVisible();
return renderResult;
};
describe('screens/Note', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(1);
@@ -364,4 +373,133 @@ describe('screens/Note', () => {
unmount();
});
it.each([
[['viewer']],
[['editor']],
])('should initialize in the correct mode when noteVisiblePanes is %j', async (panes) => {
const { unmount } = await setupNoteWithPanes(panes);
await expectToBeEditing(panes.includes('editor'));
unmount();
});
it('should show toggle button', async () => {
const { unmount } = await setupNoteWithPanes(['viewer']);
const toggleButton = await screen.findByLabelText('Toggle view/edit');
expect(toggleButton).toBeVisible();
unmount();
});
it.each([
[['viewer']],
[['editor']],
])('should switch modes when toggle button is pressed', async (panes) => {
const initialEditing = panes.includes('editor');
const expectedEditing = !initialEditing;
const { unmount } = await setupNoteWithPanes(panes);
await expectToBeEditing(initialEditing);
const toggleButton = await screen.findByLabelText('Toggle view/edit');
fireEvent.press(toggleButton);
await expectToBeEditing(expectedEditing);
unmount();
});
it('should always start in edit mode for provisional notes regardless of noteVisiblePanes', async () => {
store.dispatch({
type: 'NOTE_VISIBLE_PANES_SET',
panes: ['viewer'],
});
const noteId = await openNewNote({ title: 'Provisional note', body: 'Test body' });
// Mark note as provisional by dispatching NOTE_UPDATE_ONE with provisional flag
const note = await Note.load(noteId);
store.dispatch({
type: 'NOTE_UPDATE_ONE',
note: note,
provisional: true,
});
const { unmount } = render(<WrappedNoteScreen />);
const titleInput = await screen.findByDisplayValue('Provisional note');
expect(titleInput).toBeVisible();
await expectToBeEditing(true);
unmount();
});
it.each([
[['viewer']],
[['editor']],
])('should preserve noteVisiblePanes state when leaving and returning to the same note', async (panes) => {
const firstRender = await setupNoteWithPanes(panes);
await expectToBeEditing(panes.includes('editor'));
// Navigate away
await act(async () => {
store.dispatch({
type: 'NAV_GO',
routeName: 'Notes',
});
});
firstRender.unmount();
// Navigate back to the same note
const currentState = store.getState();
const noteId = currentState.selectedNoteIds[0];
await act(async () => {
await openExistingNote(noteId);
});
const { unmount } = render(<WrappedNoteScreen />);
const titleInput = await screen.findByDisplayValue('Test note');
expect(titleInput).toBeVisible();
// Should still be in the same mode
await expectToBeEditing(panes.includes('editor'));
expect(store.getState().noteVisiblePanes).toEqual(panes);
unmount();
});
it.each([
[['viewer']],
[['editor']],
])('should preserve noteVisiblePanes state when navigating from note 1 to note 2', async (panes) => {
// Open note 1
await act(async () => {
store.dispatch({
type: 'NOTE_VISIBLE_PANES_SET',
panes: panes,
});
});
await act(async () => {
return await openNewNote({ title: 'Note 1', body: 'Test body 1' });
});
const render1 = render(<WrappedNoteScreen />);
const titleInput1 = await screen.findByDisplayValue('Note 1');
expect(titleInput1).toBeVisible();
await expectToBeEditing(panes.includes('editor'));
render1.unmount();
// Open note 2
const note2Id = await act(async () => {
return await openNewNote({ title: 'Note 2', body: 'Test body 2' });
});
await act(async () => {
await openExistingNote(note2Id);
});
const { unmount } = render(<WrappedNoteScreen />);
const titleInput2 = await screen.findByDisplayValue('Note 2');
expect(titleInput2).toBeVisible();
// Note 2 should be in the same mode
await expectToBeEditing(panes.includes('editor'));
expect(store.getState().noteVisiblePanes).toEqual(panes);
unmount();
});
it('should set the initial editor cursor location to the specified hash', async () => {
await openNewNote({ title: 'To be edited', body: 'a test\n\n# Test\n\n# Test 2\n\n# Test 3' });
store.dispatch({ type: 'NAV_GO', noteHash: 'test-2' });
const { unmount } = render(<WrappedNoteScreen />);
await openEditor();
const editor = await getMarkdownEditorControl();
expect(editor.getCursor().line).toBe(4);
unmount();
});
});

View File

@@ -19,7 +19,6 @@ const md5 = require('md5');
import BackButtonService from '../../../services/BackButtonService';
import NavService, { OnNavigateCallback as OnNavigateCallback } from '@joplin/lib/services/NavService';
import { ModelType } from '@joplin/lib/BaseModel';
import FloatingActionButton from '../../buttons/FloatingActionButton';
import { fileExtension, safeFileExtension } from '@joplin/lib/path-utils';
import * as mimeUtils from '@joplin/lib/mime-utils';
import ScreenHeader, { MenuOptionType } from '../../ScreenHeader';
@@ -122,6 +121,7 @@ interface Props extends BaseProps {
pluginHtmlContents: PluginHtmlContents;
editorNoteReloadTimeRequest: number;
canPublish: boolean;
noteVisiblePanes: string[];
}
interface ComponentProps extends Props {
@@ -199,6 +199,7 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
private editorPluginHandler_ = new EditorPluginHandler(PluginService.instance(), saveEvent => {
return shared.noteComponent_change(this, 'body', saveEvent.body);
});
private refreshKey: number | undefined;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
public static navigationOptions(): any {
@@ -208,9 +209,11 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
public constructor(props: ComponentProps) {
super(props);
const initialMode = props.noteVisiblePanes?.includes('editor') ? 'edit' : 'view';
this.state = {
note: Note.new(),
mode: 'view',
mode: initialMode,
readOnly: false,
folder: null,
lastSavedNote: null,
@@ -242,12 +245,16 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
titleContainerWidth: 0,
};
const initialCursorLocation = NotePositionService.instance().getCursorPosition(props.noteId, defaultWindowId).markdown;
if (initialCursorLocation) {
this.selection = { start: initialCursorLocation, end: initialCursorLocation };
}
const initialScroll = NotePositionService.instance().getScrollPercent(props.noteId, defaultWindowId);
this.lastBodyScroll = initialScroll;
const initialCursorLocation = NotePositionService.instance().getCursorPosition(props.noteId, defaultWindowId).markdown;
// Ignore the initial scroll and cursor location when there's a note hash. The editor/viewer should jump to
// the hash, rather than the last position.
if (!props.noteHash) {
if (initialCursorLocation) {
this.selection = { start: initialCursorLocation, end: initialCursorLocation };
}
this.lastBodyScroll = initialScroll;
}
this.titleTextFieldRef = React.createRef();
@@ -293,14 +300,7 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
if (this.state.mode === 'edit') {
Keyboard.dismiss();
this.setState({
mode: 'view',
});
await this.undoRedoService_.reset();
return true;
}
if (this.state.fromShare) {
@@ -393,6 +393,7 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
setMode: (mode: 'view'|'edit') => {
this.setState({ mode });
},
dispatch: this.props.dispatch,
},
commands,
true,
@@ -711,10 +712,27 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
});
}
if (this.props.visibleEditorPluginIds !== prevProps.visibleEditorPluginIds || this.props.editorNoteReloadTimeRequest !== prevProps.editorNoteReloadTimeRequest) {
const editorPluginIdsChanged = this.props.visibleEditorPluginIds !== prevProps.visibleEditorPluginIds;
if (editorPluginIdsChanged || this.props.editorNoteReloadTimeRequest !== prevProps.editorNoteReloadTimeRequest) {
const { editorPlugin } = getShownPluginEditorView(this.props.plugins, this.props.windowId);
if (!editorPlugin && this.props.editorNoteReloadTimeRequest > this.state.noteLastLoadTime) {
void shared.reloadNote(this);
const explicitReloadRequired = !editorPlugin && this.props.editorNoteReloadTimeRequest > this.state.noteLastLoadTime;
if (explicitReloadRequired) {
void this.reloadNoteAndUpdateRefreshKey();
}
if (explicitReloadRequired || (editorPlugin && editorPluginIdsChanged)) {
// Clear the undo / redo state, as undo / redo steps wont be in sync with the current content after the note editor has been refreshed
if (!this.useEditorBeta()) {
void this.undoRedoService_.reset();
}
this.setState({
undoRedoButtonState: {
canUndo: false,
canRedo: false,
},
});
}
}
@@ -755,6 +773,11 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
});
}
private async reloadNoteAndUpdateRefreshKey() {
await shared.reloadNote(this);
this.refreshKey = this.props.editorNoteReloadTimeRequest;
}
private title_changeText(text: string) {
let newText = text;
newText = text.replace(/(\r\n|\n|\r)/gm, ' ');
@@ -1445,6 +1468,14 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
await this.saveOneProperty('todo_completed', checked ? time.unixMs() : 0);
}
private toggleVisiblePanes = () => {
const isSwitchingToEdit = this.state.mode === 'view';
void CommandService.instance().execute('toggleVisiblePanes');
if (isSwitchingToEdit) {
this.doFocusUpdate_ = true;
}
};
public scheduleFocusUpdate() {
if (this.focusUpdateIID_) shim.clearInterval(this.focusUpdateIID_);
@@ -1705,7 +1736,6 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
bodyComponent = <NoteEditor
ref={this.editorRef}
toolbarEnabled={this.props.toolbarEnabled && !increaseSpaceForEditor}
themeId={this.props.themeId}
noteId={this.props.noteId}
noteHash={this.props.noteHash}
initialText={note.body}
@@ -1736,32 +1766,12 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
onScroll={this.props.editorType === EditorType.RichText ? this.onBodyViewerScroll : this.onMarkdownEditorScroll}
mode={this.props.editorType}
refreshKey={this.refreshKey}
/>;
}
}
}
const voiceTypingDialogShown = this.state.showSpeechToTextDialog || this.state.showAudioRecorder;
const renderActionButton = () => {
if (voiceTypingDialogShown) return null;
if (editorView) return null;
if (!this.state.note || !!this.state.note.deleted_time) return null;
const editButton = {
label: _('Edit'),
icon: 'create',
onPress: () => {
this.setState({ mode: 'edit' });
this.doFocusUpdate_ = true;
},
};
if (this.state.mode === 'edit') return null;
return <FloatingActionButton mainButton={editButton} />;
};
// Save button is not really needed anymore with the improved save logic
const showSaveButton = false; // this.state.mode === 'edit' || this.isModified() || this.saveButtonHasBeenShown_;
const saveButtonDisabled = true;// !this.isModified();
@@ -1800,6 +1810,7 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
multiline={this.state.multiline}
text={note.title}
updateState={textWrapCalculator_updateState}
readOnly={false}
/>
{isTodo && <Checkbox style={this.styles().checkbox} checked={!!Number(note.todo_completed)} onChange={this.todoCheckbox_change} />}
<TextInput
@@ -1860,6 +1871,9 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
undoButtonDisabled={!this.state.undoRedoButtonState.canUndo && this.state.undoRedoButtonState.canRedo}
onUndoButtonPress={this.screenHeader_undoButtonPress}
onRedoButtonPress={this.screenHeader_redoButtonPress}
showViewToggleButton={!!this.state.note && !this.state.note.deleted_time && !editorView}
viewToggleIconName={this.state.mode === 'edit' ? 'ionicon book' : 'ionicon pencil'}
onViewTogglePress={this.toggleVisiblePanes}
title={getDisplayParentTitle(this.state.note, this.state.folder)}
/>;
@@ -1869,7 +1883,6 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
{!increaseSpaceForEditor && titleComp}
{bodyComponent}
{renderVoiceTypingDialogs()}
{renderActionButton()}
<SelectDateTimeDialog themeId={this.props.themeId} shown={this.state.alarmDialogShown} date={dueDate} onAccept={this.onAlarmDialogAccept} onReject={this.onAlarmDialogReject} />
@@ -1937,6 +1950,7 @@ const NoteScreen = connect((state: AppState) => {
plugins: state.pluginService.plugins,
pluginHtmlContents: state.pluginService.pluginHtmlContents,
editorNoteReloadTimeRequest: state.editorNoteReloadTimeRequest,
noteVisiblePanes: state.noteVisiblePanes,
editorType: state.settings['editor.codeView'] ? EditorType.Markdown : EditorType.RichText,

View File

@@ -1,5 +1,6 @@
import { CommandContext, CommandDeclaration, CommandRuntime } from '@joplin/lib/services/CommandService';
import { CommandRuntimeProps } from '../types';
import Setting from '@joplin/lib/models/Setting';
export const declaration: CommandDeclaration = {
// For compatibility with the desktop app, this command is called "toggleVisiblePanes".
@@ -10,8 +11,13 @@ export const declaration: CommandDeclaration = {
export const runtime = (props: CommandRuntimeProps): CommandRuntime => {
return {
execute: async (_context: CommandContext) => {
// For now, the only two "panes" on mobile are view and edit.
const newMode = props.getMode() === 'edit' ? 'view' : 'edit';
const panes = Setting.value('noteVisiblePanes') || ['viewer'];
props.dispatch({
type: 'NOTE_VISIBLE_PANES_SET',
panes: panes.includes('editor') ? ['viewer'] : ['editor'],
});
const currentMode = props.getMode();
const newMode = currentMode === 'edit' ? 'view' : 'edit';
props.setMode(newMode);
},
};

View File

@@ -1,5 +1,6 @@
import { ResourceEntity } from '@joplin/lib/services/database/types';
import { DialogControl } from '../../DialogManager';
import { Dispatch } from 'redux';
export interface PickerResponse {
uri?: string;
@@ -20,4 +21,5 @@ export interface CommandRuntimeProps {
setTagDialogVisible(visible: boolean): void;
setAudioRecorderVisible(visible: boolean): void;
dialogs: DialogControl;
dispatch: Dispatch;
}

View File

@@ -253,6 +253,7 @@ const NoteRevisionViewer: React.FC<Props> = props => {
multiline={multiline}
text={note?.title ?? ''}
updateState={textWrapCalculator_updateState}
readOnly={true}
/>
{
multiline ?

View File

@@ -13,13 +13,15 @@ import { _ } from '@joplin/lib/locale';
import { BaseScreenComponent } from '../../base-screen';
import { AppState } from '../../../utils/types';
import { FolderEntity, NoteEntity, TagEntity } from '@joplin/lib/services/database/types';
import { itemIsInTrash } from '@joplin/lib/services/trash';
import { getTrashFolderId, itemIsInTrash } from '@joplin/lib/services/trash';
import AccessibleView from '../../accessibility/AccessibleView';
import { Dispatch } from 'redux';
import { DialogContext, DialogControl } from '../../DialogManager';
import { useContext } from 'react';
import { MenuChoice } from '../../DialogManager/types';
import NewNoteButton from './NewNoteButton';
import PerFolderSortOrderService from '@joplin/lib/services/sortOrder/PerFolderSortOrderService';
const { ALL_NOTES_FILTER_ID } = require('@joplin/lib/reserved-ids');
interface Props {
dispatch: Dispatch;
@@ -102,12 +104,57 @@ class NotesScreenComponent extends BaseScreenComponent<ComponentProps, State> {
id: { name: 'showCompletedTodos', value: !Setting.value('showCompletedTodos') },
});
const showPerFolderToggle = this.shouldShowPerFolderSortToggle();
const currentFolderId = this.getCurrentFolderIdForSort();
if (showPerFolderToggle) {
const isSet = PerFolderSortOrderService.isSet(currentFolderId);
buttons.push({
text: `[ ${_('Use own sort order')} ]`,
checked: isSet,
id: { name: 'perFolderSortOrder', value: !isSet },
});
}
const r = await this.props.dialogManager.showMenu(Setting.settingMetadata('notes.sortOrder.field').label(), buttons);
if (!r) return;
Setting.setValue(r.name, r.value);
if (r.name === 'perFolderSortOrder') {
PerFolderSortOrderService.set(currentFolderId, r.value as boolean);
} else if (r.name === 'notes.sortOrder.field' || r.name === 'notes.sortOrder.reverse') {
Setting.setValue(r.name, r.value);
// Update the appropriate sort order storage based on whether per-folder sort is enabled
PerFolderSortOrderService.onSortOrderChange(currentFolderId);
} else {
Setting.setValue(r.name, r.value);
}
};
// Show "use own sort order" toggle for folders and the All Notes smart filter,
// but not for tags, conflicts folder, or trash folder.
private shouldShowPerFolderSortToggle(): boolean {
const { notesParentType, selectedFolderId, selectedSmartFilterId } = this.props;
if (notesParentType === 'Folder') {
return selectedFolderId !== Folder.conflictFolderId() && selectedFolderId !== getTrashFolderId();
}
if (notesParentType === 'SmartFilter') {
return selectedSmartFilterId === ALL_NOTES_FILTER_ID;
}
return false;
}
private getCurrentFolderIdForSort(): string {
if (this.props.notesParentType === 'Folder') {
return this.props.selectedFolderId;
} else if (this.props.notesParentType === 'SmartFilter') {
return this.props.selectedSmartFilterId;
}
return '';
}
public styles() {
if (!this.styles_) this.styles_ = {};
const themeId = this.props.themeId;

View File

@@ -8,6 +8,7 @@ interface Props {
multiline: boolean;
text: string;
updateState: (showMultilineToggle: boolean, multiline: boolean)=> void;
readOnly: boolean;
}
// This component can be used to estimate when text wrapping is required for a TextInput or Text element, to conditionally display a button to enable / disable
@@ -17,6 +18,11 @@ interface Props {
// Even if already using a Text element, a separate hidden Text element must be used for text wrapping estimation, because if onTextLayout is used on a visible
// component, it prevents text highlighting from working.
const TextWrapCalculator: React.FC<Props> = props => {
// Text values which are initially 0 or 1 characters in length do not trigger onTextLayout a second time after textCompContainerWidth has been set,
// which may result in a component remount after entering the first character on the TextInput this is linked to, which will cause change or loss of focus.
// Set the text to a dummy value of at least 2 characters when the titleContainerWidth is not yet measured, to ensure onTextLayout will fire a second time
const text = props.textCompContainerWidth !== 0 ? props.text : 'abc';
return Platform.OS === 'web' ? null : <Text
pointerEvents='none'
style={[
@@ -33,8 +39,9 @@ const TextWrapCalculator: React.FC<Props> = props => {
const showToggle = numberOfLines > 1;
let enableMultiline;
if (props.showMultilineToggle === null) {
// Upon opening the screen, multiline should be enabled when not wrapped, or disabled when wrapped (so that the element starts collapsed)
enableMultiline = !showToggle;
// Upon opening the screen, multiline should always be disabled, so long titles start collapsed and it does not open the keyboard automatically.
// If the field is readonly, the keyboard opening is a non issue, and multiline should be enabled when not expandable to make text selectable
enableMultiline = props.readOnly ? !showToggle : false;
} else {
// In every other case, retain the value of multiline so that it does not change while the user is typing, but only showMultilineToggle changes
enableMultiline = props.multiline;
@@ -45,7 +52,7 @@ const TextWrapCalculator: React.FC<Props> = props => {
}
}}
>
{props.text}
{text}
</Text>;
};

View File

@@ -21,6 +21,7 @@ const defaultRendererSettings: RenderSettings = {
pluginSettings: {},
requestPluginSetting: () => { },
showNoteLinkIcon: true,
};
const makeRenderer = (options: Partial<RendererSetupOptions>) => {

View File

@@ -27,6 +27,7 @@ export interface RenderSettings {
splitted?: boolean; // Move CSS into a separate output
mapsToLine?: boolean; // Sourcemaps
showNoteLinkIcon?: boolean;
createEditPopupSyntax: string;
destroyEditPopupSyntax: string;
@@ -136,6 +137,7 @@ export default class Renderer {
splitted: settings.splitted,
mapsToLine: settings.mapsToLine,
whiteBackgroundNoteRendering: markup.language === MarkupLanguage.Html,
showNoteLinkIcon: settings.showNoteLinkIcon,
globalSettings: settings.globalSettings,
};

View File

@@ -71,6 +71,7 @@ export interface RenderOptions {
// Forwarded renderer settings
splitted?: boolean;
mapsToLine?: boolean;
showNoteLinkIcon?: boolean;
}
type CancelEvent = { cancelled: boolean };

View File

@@ -240,6 +240,7 @@ const useWebViewSetup = (props: Props): Result => {
}
},
removeUnusedPluginAssets: options.removeUnusedPluginAssets,
showNoteLinkIcon: options.showNoteLinkIcon,
globalSettings: {
'markdown.plugin.abc.options': Setting.value('markdown.plugin.abc.options'),
},

View File

@@ -520,7 +520,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
CURRENT_PROJECT_VERSION = 150;
CURRENT_PROJECT_VERSION = 151;
DEVELOPMENT_TEAM = A9BXAFS6CT;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Joplin/Info.plist;
@@ -529,7 +529,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 13.6.1;
MARKETING_VERSION = 13.6.2;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@@ -555,7 +555,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
CURRENT_PROJECT_VERSION = 150;
CURRENT_PROJECT_VERSION = 151;
DEVELOPMENT_TEAM = A9BXAFS6CT;
INFOPLIST_FILE = Joplin/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
@@ -563,7 +563,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 13.6.1;
MARKETING_VERSION = 13.6.2;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@@ -758,7 +758,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 150;
CURRENT_PROJECT_VERSION = 151;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = A9BXAFS6CT;
GCC_C_LANGUAGE_STANDARD = gnu11;
@@ -769,7 +769,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 13.6.1;
MARKETING_VERSION = 13.6.2;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
OTHER_LDFLAGS = (
@@ -801,7 +801,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 150;
CURRENT_PROJECT_VERSION = 151;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = A9BXAFS6CT;
GCC_C_LANGUAGE_STANDARD = gnu11;
@@ -812,7 +812,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 13.6.1;
MARKETING_VERSION = 13.6.2;
MTL_FAST_MATH = YES;
OTHER_LDFLAGS = (
"$(inherited)",

View File

@@ -2015,7 +2015,7 @@ PODS:
- Yoga
- RNCPushNotificationIOS (1.11.0):
- React-Core
- RNDateTimePicker (8.4.7):
- RNDateTimePicker (8.5.1):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@@ -2041,7 +2041,7 @@ PODS:
- React-Core
- RNFileViewer (2.1.5):
- React-Core
- RNLocalize (3.5.4):
- RNLocalize (3.6.0):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@@ -2627,10 +2627,10 @@ SPEC CHECKSUMS:
rn-fetch-blob: 25612b6d6f6e980c6f17ed98ba2f58f5696a51ca
RNCClipboard: 88d7eeb555d1183915f0885bdbc5c97eb6f7f3ba
RNCPushNotificationIOS: 6c4ca3388c7434e4a662b92e4dfeeee858e6f440
RNDateTimePicker: f11373a05d806e849ab984e2806c531278b47cdd
RNDateTimePicker: 19ffa303c4524ec0a2dfdee2658198451c16b7f1
RNDeviceInfo: bcce8752b5043a623fe3c26789679b473f705d3c
RNFileViewer: 4b5d83358214347e4ab2d4ca8d5c1c90d869e251
RNLocalize: da3c00bf1044a67e72cf8b450289e263fd5baab3
RNLocalize: 83a242b38886bf7e84073410c101e9ea39a1c1a5
RNQuickAction: c2c8f379e614428be0babe4d53a575739667744d
RNSecureRandom: b64d263529492a6897e236a22a2c4249aa1b53dc
RNShare: 0e600372fb35783fe30d413efd28d11de2bf6cf0

View File

@@ -31,7 +31,7 @@
"@joplin/whisper-voice-typing": "~3.6",
"@js-draw/material-icons": "1.33.0",
"@react-native-clipboard/clipboard": "1.16.3",
"@react-native-community/datetimepicker": "8.4.7",
"@react-native-community/datetimepicker": "8.5.1",
"@react-native-community/geolocation": "3.4.0",
"@react-native-community/netinfo": "11.4.1",
"@react-native-community/push-notification-ios": "1.11.0",
@@ -66,7 +66,7 @@
"react-native-file-viewer": "2.1.5",
"react-native-get-random-values": "1.11.0",
"react-native-image-picker": "8.2.1",
"react-native-localize": "3.5.4",
"react-native-localize": "3.6.0",
"react-native-modal-datetime-picker": "18.0.0",
"react-native-nitro-modules": "0.33.2",
"react-native-paper": "5.14.5",
@@ -116,7 +116,7 @@
"@types/node": "18.19.130",
"@types/react": "19.1.10",
"@types/react-redux": "7.1.33",
"@types/serviceworker": "0.0.167",
"@types/serviceworker": "0.0.168",
"@types/tar-stream": "3.1.4",
"babel-jest": "29.7.0",
"babel-loader": "9.1.3",

View File

@@ -225,6 +225,10 @@ const generalMiddleware = (store: any) => (next: any) => async (action: any) =>
void ResourceFetcher.instance().autoAddResources();
}
if (['NOTE_VISIBLE_PANES_SET'].indexOf(action.type) >= 0) {
Setting.setValue('noteVisiblePanes', newState.noteVisiblePanes);
}
if (doRefreshFolders) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
await scheduleRefreshFolders((action: any) => storeDispatch(action), newState.selectedFolderId);

View File

@@ -18,6 +18,7 @@ const appDefaultState: AppState = {
showPanelsDialog: false,
noteEditorVisible: false,
syncWizardVisible: false,
noteVisiblePanes: ['viewer'],
...defaultState,
// On mobile, it's possible to select notes that aren't in the selected folder/tag/etc.

View File

@@ -260,6 +260,13 @@ const appReducer = (state = appDefaultState, action: any) => {
case 'SYNC_WIZARD_VISIBLE_CHANGE':
newState = { ...state, syncWizardVisible: action.visible };
break;
case 'NOTE_VISIBLE_PANES_SET':
newState = {
...state,
noteVisiblePanes: Array.isArray(action.panes) && action.panes.length ? action.panes : ['viewer'],
};
break;
}
} catch (error) {
error.message = `In reducer: ${error.message} Action: ${JSON.stringify(action)}`;

View File

@@ -92,6 +92,7 @@ import shim from '@joplin/lib/shim';
import { Platform } from 'react-native';
import VoiceTyping from '../services/voiceTyping/VoiceTyping';
import whisper from '../services/voiceTyping/whisper';
import PerFolderSortOrderService from '@joplin/lib/services/sortOrder/PerFolderSortOrderService';
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
@@ -367,6 +368,19 @@ const buildStartupTasks = (
ids: Setting.value('collapsedFolderIds'),
});
});
addTask('buildStartupTasks/initialize note visible panes', async () => {
const panes = Setting.value('noteVisiblePanes') || ['viewer'];
dispatch({
type: 'NOTE_VISIBLE_PANES_SET',
panes: panes,
});
dispatch({
type: 'NOTE_EDITOR_VISIBLE_CHANGE',
visible: panes.includes('editor'),
});
});
addTask('buildStartupTasks/load tags', async () => {
const tags = await Tag.allWithNotes();
@@ -376,6 +390,9 @@ const buildStartupTasks = (
});
});
addTask('buildStartupTasks/clear shared files cache', clearSharedFilesCache);
addTask('buildStartupTasks/initialize PerFolderSortOrderService', async () => {
PerFolderSortOrderService.initialize();
});
addTask('buildStartupTasks/go: initial route', async () => {
const folder = await getInitialActiveFolder();
@@ -417,24 +434,27 @@ const buildStartupTasks = (
ResourceFetcher.instance().on('downloadComplete', resourceFetcher_downloadComplete);
void ResourceFetcher.instance().start();
// Collect revisions more frequently on mobile because it doesn't auto-save
// and it cannot collect anything when the app is not active.
RevisionService.instance().runInBackground(1000 * 30);
reg.setupRecurrentSync();
// When the app starts we want the full sync to
// start almost immediately to get the latest data.
// doWifiConnectionCheck set to true so initial sync
// doesn't happen on mobile data
// eslint-disable-next-line promise/prefer-await-to-then -- Old code before rule was applied
void reg.scheduleSync(100, null, true).then(() => {
// Wait for the first sync before updating the notifications, since synchronisation
// might change the notifications.
void AlarmService.updateAllNotifications();
setTimeout(() => {
// Schedule sync with a delay of 0 and wrap with the desired timeout, as shim.setTimeout may not fire on first run or after an upgrade
// eslint-disable-next-line promise/prefer-await-to-then -- Old code before rule was applied
void reg.scheduleSync(0, null, true).then(() => {
// Wait for the first sync before updating the notifications, since synchronisation
// might change the notifications.
void AlarmService.updateAllNotifications();
void DecryptionWorker.instance().scheduleStart();
});
void DecryptionWorker.instance().scheduleStart();
// Collect revisions more frequently on mobile because it doesn't auto-save
// and it cannot collect anything when the app is not active.
RevisionService.instance().runInBackground(1000 * 30);
});
}, 100);
});
addTask('buildStartupTasks/set up welcome utils', async () => {
await WelcomeUtils.install(Setting.value('locale'), dispatch);

View File

@@ -12,4 +12,5 @@ export interface AppState extends State {
disableSideMenuGestures: boolean;
noteEditorVisible: boolean;
syncWizardVisible: boolean;
noteVisiblePanes: string[];
}

View File

@@ -2,7 +2,7 @@ import { EditorView, keymap } from '@codemirror/view';
import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete';
import { EditorKeymap, EditorLanguageType, EditorSettings } from '../types';
import createTheme from './theme';
import { EditorState } from '@codemirror/state';
import { EditorState, Prec, StateField } from '@codemirror/state';
import { deleteMarkupBackward, markdown, markdownLanguage } from '@codemirror/lang-markdown';
import { GFM as GitHubFlavoredMarkdownExtension } from '@lezer/markdown';
import markdownMathExtension from './extensions/markdownMathExtension';
@@ -13,13 +13,22 @@ import { html } from '@codemirror/lang-html';
import { defaultKeymap, emacsStyleKeymap } from '@codemirror/commands';
import { vim } from '@replit/codemirror-vim';
import { indentUnit } from '@codemirror/language';
import { Prec } from '@codemirror/state';
import insertNewlineContinueMarkup from './editorCommands/insertNewlineContinueMarkup';
import renderingExtension from './extensions/rendering/renderingExtension';
import { RenderedContentContext } from './extensions/rendering/types';
import highlightActiveLineExtension from './extensions/highlightActiveLineExtension';
import renderBlockImages from './extensions/rendering/renderBlockImages';
const closingFencedBlock = StateField.define<boolean>({
create: () => false,
update: (_, tr) => {
const pos = tr.state.selection.main.from;
const textBefore = tr.state.doc.sliceString(Math.max(0, pos - 2), pos);
const backticksBefore = textBefore.length - textBefore.replace(/`+$/, '').length;
return backticksBefore >= 2;
},
});
const configFromSettings = (settings: EditorSettings, context: RenderedContentContext) => {
const languageExtension = (() => {
const openingBrackets = '`([{\'"‘“(《「『【〔〖〘〚'.split('');
@@ -50,7 +59,14 @@ const configFromSettings = (settings: EditorSettings, context: RenderedContentCo
htmlTagLanguage: html({ matchClosingTags: false, autoCloseTags: false }),
}),
}),
markdownLanguage.data.of({ closeBrackets: { brackets: openingBrackets } }),
markdownLanguage.data.compute([closingFencedBlock], state => {
// Don't auto-complete `s when closing a code block.
// See https://github.com/laurent22/joplin/issues/12569.
if (state.field(closingFencedBlock)) {
return { closeBrackets: { brackets: openingBrackets.filter(b => b !== '`') } };
}
return { closeBrackets: { brackets: openingBrackets } };
}),
keymap.of(settings.autocompleteMarkup ? [
{ key: 'Enter', run: insertNewlineContinueMarkup },
{ key: 'Backspace', run: deleteMarkupBackward },
@@ -66,6 +82,7 @@ const configFromSettings = (settings: EditorSettings, context: RenderedContentCo
const extensions = [
languageExtension,
closingFencedBlock,
createTheme(settings.themeData),
EditorView.contentAttributes.of({
autocapitalize: 'sentence',

View File

@@ -101,7 +101,7 @@ class ImageWidget extends WidgetType {
return true;
}
public toDOM(_view: EditorView) {
public toDOM(view: EditorView) {
const container = document.createElement('div');
container.classList.add(imageClassName);
container.dataset.sourceFrom = String(this.sourceFrom_);
@@ -112,6 +112,16 @@ class ImageWidget extends WidgetType {
container.appendChild(image);
this.updateDOM(container);
container.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
e.preventDefault();
const pos = Math.min(view.posAtDOM(container), view.state.doc.length);
view.dispatch({
selection: { anchor: view.state.doc.lineAt(pos).from },
scrollIntoView: false,
});
});
return container;
}
@@ -122,6 +132,10 @@ class ImageWidget extends WidgetType {
public get estimatedHeight() {
return imageHeightCache.get(this.cacheKey) ?? -1;
}
public ignoreEvent() {
return true;
}
}
const getImageSrc = (node: SyntaxNodeRef, state: EditorState) => {

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