1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-12-29 23:48:19 +02:00

Compare commits

...

159 Commits

Author SHA1 Message Date
Laurent Cozic
c44aad544e update 2025-11-06 17:54:15 +01:00
Laurent Cozic
996a0894ae Chore: Fixed Postgres tool path for new Homebrew version 2025-11-06 17:50:59 +01:00
Laurent Cozic
66fa3fc808 Server: Remove query optimisation that now seems to be slower with newer versions of Postgres 2025-11-06 17:12:45 +01:00
renovate[bot]
dab55daf95 Update dependency prosemirror-model to v1.25.3 (#13623)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-05 02:16:07 +00:00
summoner
7f1c31e03f All: Translation: Update hu_HU.po (#13620) 2025-11-04 15:42:36 -05:00
cedecode
0a8255f091 All: Translation: Update de_DE.po (#13618) 2025-11-04 15:41:04 -05:00
Helmut K. C. Tessarek
9f3e6650a9 Update translations 2025-11-03 17:23:28 -05:00
mrjo118
4a17da3df5 All: Fixes #13531: When creating a conflict, ensure the latest note contents are used to create the conflict (#13552) 2025-11-03 20:21:05 +01:00
Henry Heino
2c4f0d4d8c Desktop: Fixes #13574: Fix crash when opening the legacy Markdown editor (#13576) 2025-11-03 20:12:39 +01:00
Henry Heino
9c1c2fb0d4 Chore: Desktop: Enable source maps for error reporting by default (#13577) 2025-11-03 20:12:24 +01:00
Henry Heino
2332e4bf62 Desktop: Fixes #13579: Rich Text Editor: Make cursor jump during editing less likely (#13581) 2025-11-03 20:11:45 +01:00
Henry Heino
a488ac1b27 Desktop: Fixes #13177: Location: Remove geoplugin.net from location providers (#13583) 2025-11-03 20:11:37 +01:00
Henry Heino
6daa41ca66 All: Fixes #13291: Improve performance of item deserialization (#13585) 2025-11-03 20:11:21 +01:00
Henry Heino
cc9517f1a2 Desktop: Resolves #13586: Preserve scroll when switching between Markdown and Rich Text Editors (#13587) 2025-11-03 20:11:12 +01:00
Henry Heino
200a471e55 Chore: OneNote importer: Remove unused dependency (#13590) 2025-11-03 12:21:03 +01:00
renovate[bot]
c21d37bd91 Update dependency @types/serviceworker to v0.0.149 (#13604)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-03 12:20:50 +01:00
renovate[bot]
e36cd0e60b Update dependency mermaid to v11.8.1 (#13607)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-02 21:16:04 +00:00
VortexP
871f55bf11 All: Translation: Update fi_FI.po (#13605) 2025-11-02 16:13:27 -05:00
renovate[bot]
22c9fed663 Update dependency mermaid to v11.8.0 (#13589)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-02 20:14:11 +01:00
renovate[bot]
ea362d7a82 Update dependency @electron/remote to v2.1.3 (#13594)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-02 20:13:54 +01:00
renovate[bot]
9ae9347f89 Update eslint (#13597)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-01 20:28:29 +00:00
Joplin Bot
ae8bb902f9 Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-11-01 01:50:23 +00:00
renovate[bot]
90eeec23de Update eslint (#13595)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-01 01:49:05 +00:00
Henry Heino
474fd094c4 Chore: Update licenses.md (#13582) 2025-10-31 10:28:04 +01:00
renovate[bot]
937d8fa4f7 Update dependency react-native-share to v12.1.2 (#13570)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-30 12:10:11 +01:00
renovate[bot]
45c9844616 Update dependency @types/serviceworker to v0.0.148 (#13568)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-30 10:49:02 +01:00
Joplin Bot
12b8ef5a54 Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-10-29 18:41:30 +00:00
mrjo118
18f72c224e Mobile: Fixes #13151: Reset the state of undo and redo buttons when switching editor (#13505) 2025-10-29 18:22:56 +01:00
mrjo118
7ca3aaa83f Web: Fixes #13241: Find and replace toolbar in the note editor is not sized correctly (#13559) 2025-10-29 18:21:30 +01:00
mrjo118
04b1443e5a Mobile: Make title field work with very long text (#13566) 2025-10-29 18:20:56 +01:00
mrjo118
c461741778 All: Fixes #13319: Treat unclosed quotes as fully quoted search terms, to prevent malformed match expression error (#13564) 2025-10-29 18:19:38 +01:00
renovate[bot]
2865b0a803 Update dependency follow-redirects to v1.15.11 (#13565)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-29 17:58:37 +01:00
Laurent Cozic
21e49be22f Doc: Fixed order of tags in spellcheck document 2025-10-29 17:56:40 +01:00
Laurent Cozic
fef761cbab Doc: Added documentation to setup Joplin Server with Keycloak to test SAML auth 2025-10-29 17:55:07 +01:00
renovate[bot]
c15a353dc2 Update dependency react-native-safe-area-context to v5.5.2 (#13496)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2025-10-29 14:00:36 +01:00
Laurent Cozic
ffb32766c1 Desktop release v3.5.6 2025-10-29 13:45:27 +01:00
Henry Heino
038908550e Chore: Desktop: Share folder dialog: Remove duplicate "refreshShares" call (#13535) 2025-10-29 13:43:56 +01:00
Henry Heino
42f59134ae Desktop: Fixes #13549: OneNote importer: Task lists: Fix checkbox sizes and accessibility (#13558) 2025-10-29 13:43:48 +01:00
Laurent Cozic
fc0014c0b5 All: Open the connection screen when a SAML session has expired 2025-10-29 13:42:11 +01:00
Laurent Cozic
42d8df3036 Desktop, Cli, Mobile: Ensure that sync process ends up properly when Joplin Server shares cannot be accessed 2025-10-29 13:42:11 +01:00
Frank Fesevur
1fad9ca1cc All: Translation: Update nl_NL.po (#13556) 2025-10-28 17:37:04 -04:00
Laurent Cozic
ae289be77a Server: Add support for DELETE_EXPIRED_SESSIONS_SCHEDULE to prevent auto-logout when using SAML login 2025-10-28 17:37:38 +01:00
Laurent Cozic
7f6bfe9c6e Doc: Clarifies that SAML does not support the API_BASE_URL 2025-10-28 17:21:57 +01:00
Laurent Cozic
ead4001b7a Revert "Server: Fix SAML routes to prevent cookie issues on redirect (#13557)"
This reverts commit a4556bf598.
2025-10-28 17:05:27 +01:00
Laurent Cozic
7b95ef72a0 Server: Fixes #13368: Cannot login with SAML when already logged in on the browser 2025-10-28 16:59:42 +01:00
Laurent Cozic
a4556bf598 Server: Fix SAML routes to prevent cookie issues on redirect (#13557) 2025-10-28 16:58:11 +01:00
mrjo118
8d6268dc92 Chore: Fix intermittent revision test failure (#13458) 2025-10-28 11:35:07 +01:00
Henry Heino
7ffcbdf60a Server: Fixes #13490: Make server less likely to generate non-unique SSO codes (#13501) 2025-10-28 11:34:22 +01:00
mrjo118
76989ddc45 Mobile: Fixes #13120: Fix truncated buttons on tag association screen (#13502) 2025-10-28 11:33:52 +01:00
mrjo118
1db1254617 Mobile: Fixes #12957: Avoid dismissing the keyboard when tapping markdown toolbar buttons with the title in focus (#13504) 2025-10-28 11:33:36 +01:00
mrjo118
9810bffddc Mobile: Fixes #11468: Ensure note list is re-ordered after updating a note opened via a search (#13506) 2025-10-28 11:28:59 +01:00
Henry Heino
b25e18107b Desktop,Mobile,Cli: Fixes #13522: Fix "cannot add an item as a child of a read-only item" error when updating share IDs (#13523) 2025-10-28 11:28:37 +01:00
Henry Heino
edc5fe5d1b Desktop: Allow adding and removing users from a share while a sync is in progress (#13529) 2025-10-28 11:26:46 +01:00
Henry Heino
7ffb44b3a4 Desktop: Fixes #13537: Fix adding a new user to a share creates an unused E2EE key (#13538) 2025-10-28 11:23:02 +01:00
Henry Heino
32f4c33140 Desktop: Disallow unsharing a folder while sharing is in progress (#13551) 2025-10-28 11:22:13 +01:00
renovate[bot]
1a7b09c91c Update dependency koa to v2.16.2 (#13554)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-28 09:40:08 +00:00
renovate[bot]
e5bf8e0e58 Update dependency @types/node-fetch to v2.6.13 (#13553)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-28 09:38:10 +00:00
renovate[bot]
94725c533c Update dependency @react-native-community/datetimepicker to v8.4.3 (#13547)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-26 14:46:31 +00:00
Jozef Gaal
359c92b64f All: Translation: Update sk_SK.po (#13542) 2025-10-25 16:28:26 -04:00
renovate[bot]
8f8b8ad943 Update dependency dotenv to v16.6.1 (#13543)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-25 14:36:57 +00:00
renovate[bot]
dd2f329fd5 Update dependency @types/serviceworker to v0.0.147 (#13541)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-25 14:34:38 +00:00
mrjo118
813f594cb4 Mobile: Increase height of tag association screen to cater for a larger tag list area (#13521) 2025-10-25 14:13:15 +02:00
mrjo118
0e0ce49867 Mobile: Fixes #13108: Markdown toolbar overlaps with the gesture bar (#13533) 2025-10-25 14:12:37 +02:00
Henry Heino
e485d318b7 Desktop: Accessibility: Improve dialog keyboard handling (#13536) 2025-10-25 14:09:10 +02:00
renovate[bot]
4e82d81df1 Update dependency dotenv to v16.6.0 (#13539)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-25 14:04:29 +02:00
Frank Fesevur
d5dbda201b All: Translation: Update nl_NL.po (#13519) 2025-10-23 15:29:45 -04:00
Joplin Bot
831258506b Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-10-23 12:52:21 +00:00
renovate[bot]
67f3329ecb Update dependency rate-limiter-flexible to v7.1.1 (#13517)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-23 14:32:35 +02:00
renovate[bot]
ed7e6751f0 Update dependency react-native-share to v12.1.1 (#13516)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-23 14:32:03 +02:00
mrjo118
35e69486d3 Mobile: Fixes #13457: Prevent toggling of multiline mode from clearing the title field on iOS (#13515) 2025-10-23 11:40:50 +02:00
Henry Heino
918c8830e0 Mobile: Fixes #13193: Fix Markdown toolbar (#13514) 2025-10-23 11:40:28 +02:00
renovate[bot]
c3b4a4b955 Update dependency rate-limiter-flexible to v7 (#13513)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-23 11:38:21 +02:00
Laurent Cozic
44a14fabbd Doc: Updated sponsors 2025-10-23 10:19:53 +02:00
Frank Fesevur
49399cd1fa All: Translation: Update nl_NL.po (#13510) 2025-10-22 14:39:24 -04:00
Bartolomeo
fc4cd2e942 Server: Resolves #13147: Add LOG_LEVEL env var to control logging verbosity (#13503) 2025-10-22 12:26:02 +02:00
Arman Saga
cd6e457dc5 All: Translation: Update ru_RU.po (#13507) 2025-10-22 00:31:23 -04:00
Eric Duarte
2e9bf3a4e5 All: Translation: Update ca.po and es_ES.po (#13499) 2025-10-21 18:27:12 -04:00
Eric Duarte
547ceea4b0 All: Translation: Update es_ES.po (#13498) 2025-10-21 18:11:53 -04:00
renovate[bot]
776ff5e7ea Update dependency @fortawesome/react-fontawesome to v0.2.3 (#13500)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-21 23:22:49 +02:00
Joplin Bot
2b3bac0d43 Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-10-21 18:39:58 +00:00
Henry Heino
e48efe2e8d Desktop: OneNote importer: Resolve possible import failure related to unsupported formatting (#13495) 2025-10-21 17:19:56 +02:00
Laurent Cozic
5f6382fbc0 Merge branch 'release-3.4' into dev 2025-10-21 16:36:53 +02:00
Laurent Cozic
3d5d82081a iOS 13.4.4 2025-10-21 16:17:15 +02:00
Laurent Cozic
cff96b1306 iOS: Removed donation link since Apple is blocking the release because of this 2025-10-21 16:08:02 +02:00
Henry Heino
98c5a9c096 Desktop: Fixes #13481: Accessibility: Prevent sidebar header text from moving: Don't change the header icon on hover (#13482) 2025-10-21 00:46:52 +02:00
Henry Heino
e92430b3ed Desktop: Accessibility: Fix global keyboard shortcuts are ignored when the sidebar has focus (#13485) 2025-10-21 00:46:36 +02:00
renovate[bot]
848d1bfe64 Update dependency react-native-safe-area-context to v5.5.0 (#13487)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-21 00:45:52 +02:00
Henry Heino
a386283530 Docs: Update OneNote import workflow (#13494) 2025-10-21 00:45:33 +02:00
Greg Oledzki
6101031269 Chore: Replace if with it in one of the tests (#13489) 2025-10-20 21:18:07 +02:00
Henry Heino
2fc3431f46 Web: Accessibility: Fix focus indicator is invisible for sync wizard options (#13492) 2025-10-20 21:12:06 +02:00
renovate[bot]
361fa2c768 Update dependency @types/serviceworker to v0.0.146 (#13484)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-20 16:07:56 +00:00
Helmut K. C. Tessarek
f4a0a2466b Update translations 2025-10-19 14:42:12 -04:00
summoner
dbf225d6ad All: Translation: Update hu_HU.po (#13486) 2025-10-19 14:36:40 -04:00
Helmut K. C. Tessarek
4773a3831c fix: remove \r escape sequence from hu_HU.po 2025-10-18 15:46:49 -04:00
Mihai Vasiliu
6a19690581 All: Translation: Update ro_RO.po and ro_MD.po (#13479) 2025-10-18 15:24:06 -04:00
Arda Kılıçdağı
b7a771d58d All: Translation: Update tr_TR.po (#13478) 2025-10-18 15:23:53 -04:00
Jozef Gaal
e3daefb81a All: Translation: Update sk_SK.po (#13477) 2025-10-18 15:23:41 -04:00
Joplin Bot
b4253dace8 Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-10-18 12:46:07 +00:00
renovate[bot]
fcf3be1be1 Update dependency esbuild to v0.25.8 (#13473)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-18 12:41:05 +01:00
renovate[bot]
99aebbad81 Update dependency mermaid to v11.7.0 (#13476)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-18 12:40:51 +01:00
Laurent Cozic
81b695a2a9 Chore: Exclude translation updates from changelog 2025-10-18 11:30:24 +01:00
Laurent Cozic
2dbba27357 Plugin Repo CLI v3.5.3 2025-10-18 10:12:33 +01:00
Laurent Cozic
8713cd2fd8 CLI v3.5.1 2025-10-18 10:11:50 +01:00
Laurent Cozic
d0fc4ea21b Lock file 2025-10-18 10:11:18 +01:00
Laurent Cozic
8bd62800ef Releasing sub-packages 2025-10-18 10:10:42 +01:00
Laurent Cozic
00f9e932e6 Desktop release v3.5.5 2025-10-18 09:53:34 +01:00
Helmut K. C. Tessarek
b8b55e4a55 Chore: Fix generated file line ending (#13459) 2025-10-18 09:50:37 +01:00
renovate[bot]
ef5be2ded3 Update dependency @types/node to v18.19.130 (#13463)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-18 09:49:19 +01:00
Henry Heino
00702dde00 Desktop: OneNote importer: Improve file header validation (#13467) 2025-10-18 09:48:51 +01:00
Henry Heino
2a6af9bed9 Desktop: Accessibility: Allow jumping to notebooks by typing the initial letter or Home/End (#13469) 2025-10-18 09:48:44 +01:00
Henry Heino
c26fe0960b Web: Show sync wizard on first start (#13470) 2025-10-18 09:48:13 +01:00
Henry Heino
ab9d36fc08 Chore: Windows: Tests: Fix Rust OneNote importer tests fail (#13471) 2025-10-18 09:48:03 +01:00
Henry Heino
28eb53bd9f Desktop: OneNote importer: Support directly importing .one files and, on Windows, .onepkg files (#13474)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2025-10-18 09:47:35 +01:00
Laurent Cozic
3097c3e589 Desktop, Cli: Correctly import Evernote resources that do not have the encoding specified 2025-10-18 09:44:35 +01:00
renovate[bot]
08371ef718 Update dependency @types/serviceworker to v0.0.144 (#13475)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-18 08:19:27 +00:00
Laurent Cozic
561716efea Desktop, Cli: Fixed importing certain Evernote images that have invalid dimension attributes (#13472) 2025-10-18 09:17:22 +01:00
renovate[bot]
0d457d1bde Update dependency esbuild to v0.25.7 (#13461)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-17 09:18:57 +01:00
renovate[bot]
8c11f17c93 Update dependency @types/node to v18.19.120 (#13460)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-17 09:18:50 +01:00
renovate[bot]
f7a90ee1d2 Update dependency @types/serviceworker to v0.0.143 (#13449)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2025-10-17 09:18:08 +01:00
Eric Duarte
8822409f7c All: Translation: Update ca.po (#13462) 2025-10-17 03:54:42 -04:00
Eric Duarte
cd3e7f485a All: Translation: Update es_ES.po (#13454) 2025-10-16 17:25:46 -04:00
summoner
8d42b01d4f All: Translation: Update hu_HU.po (#13451) 2025-10-16 17:25:34 -04:00
Henry Heino
2c37197641 Desktop: Resolves #520: Save and restore the cursor position when switching between notes (#13447) 2025-10-16 14:56:38 +01:00
Henry Heino
c2c37b3741 Desktop: Fixes #13411: Fix header links only work if the note viewer is visible (#13442) 2025-10-16 12:10:01 +01:00
Henry Heino
3e770300dc Chore: Desktop: Add tool for resolving stack traces based on source maps (#13427) 2025-10-16 12:09:02 +01:00
Henry Heino
683291d5df Chore: Transcribe: Make logic for starting transcription workers safer (#13425) 2025-10-16 12:08:52 +01:00
Henry Heino
d239035417 Mobile: Resolves #13067: Rich Text Editor: Improve table support (#13413)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2025-10-16 12:08:44 +01:00
Henry Heino
5ef37d9de0 Desktop: Support importing .one files from OneNote 2016 (#13391) 2025-10-16 12:08:35 +01:00
mrjo118
1111bde017 All: Fixes #6517: Prevent Joplin from missing changes when syncing with file system or WebDAV (#13054) 2025-10-16 12:06:48 +01:00
Laurent Cozic
468cf00d77 Chore: Clean-up exclusion list in buildTranslation 2025-10-16 11:20:05 +01:00
Laurent Cozic
3c5b41b992 Chore: Fixed CI 2025-10-16 11:19:44 +01:00
Helmut K. C. Tessarek
5f66c51dba All: Update translations 2025-10-15 23:34:40 -04:00
k-santos
bfeaa67ec4 All: Translation: Update pt_BR.po (#13448) 2025-10-15 23:30:16 -04:00
renovate[bot]
348fd0333f Update dependency react-native-share to v12.1.0 (#13446)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-15 21:19:13 +01:00
Henry Heino
51c4d6d6ef Desktop: Upgrade to Electron v37.7.0 (#13445) 2025-10-15 20:39:26 +01:00
Laurent Cozic
09d77a65e8 Plugin Repo CLI v3.5.1 2025-10-15 20:03:29 +01:00
Laurent Cozic
d1aec4a9f7 Plugin Generator release v3.5.1 2025-10-15 20:02:14 +01:00
Henry Heino
cab1525589 Chore: Plugin repository script: Fix certain plugins are not being published (#13443) 2025-10-15 20:00:29 +01:00
Henry Heino
a52f3fea9e Mobile: Resolves: #12823: Disable auto-search for 1-2 character searches (#13444)
Co-authored-by: pedr <pedr@users.noreply.github.com>
2025-10-15 20:00:02 +01:00
renovate[bot]
dfbd5eb8ed Update dependency expo to v53.0.20 (#13441)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-15 14:18:13 +01:00
Laurent Cozic
3131f36033 Chore: Trying to fix random CI failure 2025-10-15 12:55:40 +01:00
renovate[bot]
dc5b2cfa21 Update dependency form-data to v4.0.4 (#13439)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-15 09:42:15 +00:00
renovate[bot]
cad0f35fcc Update dependency expo-camera to v16.1.11 (#13438)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-15 02:43:03 +00:00
renovate[bot]
38ea92ff57 Update dependency axios to v1.10.0 (#13431)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-14 14:35:53 +01:00
renovate[bot]
830deada22 Update dependency @types/serviceworker to v0.0.142 (#13434)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-13 18:47:47 +01:00
renovate[bot]
38cd4033ea Update dependency @types/node to v18.19.119 (#13435)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-13 13:36:57 +00:00
Shania
02900752d9 Doc: Missing hashtag in rich_text_editor.md (#13418) 2025-10-11 12:56:26 +01:00
renovate[bot]
091e9813b5 Update dependency @react-native/babel-preset to v0.80.1 (#13426)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-11 01:23:33 +01:00
renovate[bot]
e61e5ac32a Update dependency @react-native/babel-preset to v0.80.0 (#13423)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-10 21:28:24 +01:00
Joplin Bot
414970c9a1 Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-10-10 18:39:00 +00:00
Laurent Cozic
d4ed49ff23 Doc: Clarify how to disable spellchecking on Markdown files 2025-10-10 17:48:37 +01:00
Laurent Cozic
8751d5d152 Doc: Add documentation for LDAP and SAML support in Joplin Server 2025-10-10 17:47:28 +01:00
Laurent Cozic
2e846fe15d Desktop release v3.5.4 2025-10-10 15:48:13 +01:00
Laurent Cozic
e54b7696d9 Chore: Prevent sign tool from being added to the Windows app 2025-10-10 15:48:01 +01:00
Laurent Cozic
97fa85a3f7 Desktop release v3.4.13 2025-10-02 09:35:36 +01:00
Laurent Cozic
defe36bba1 Server: Enable publish and share notebook for SAML login 2025-10-02 09:34:51 +01:00
Henry Heino
711d214741 Android: Fixes #13193: Fix Markdown toolbar buttons sometimes don't work (#13233) 2025-09-18 12:05:57 +01:00
pedr
0795c67354 All: Fixes #12249: Change default content-type for Webdav connector to application/octet-stream (#13053) 2025-09-13 14:13:27 +01:00
Laurent Cozic
e9a9f68568 Desktop release v3.4.12 2025-09-09 15:36:24 +01:00
509 changed files with 110073 additions and 89613 deletions

View File

@@ -90,7 +90,7 @@ plugin_types/
readme/
packages/react-native-vosk/lib/
packages/lib/countable/Countable.js
packages/onenote-converter/pkg/onenote_converter.js
packages/onenote-converter/renderer/pkg/*
# AUTO-GENERATED - EXCLUDED TYPESCRIPT BUILD
packages/app-cli/app/LinkSelector.js
@@ -270,6 +270,7 @@ packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/useEditorCommands.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/localisation.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/useKeymap.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/useRefocusOnVisiblePaneChange.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/useSyncEditorValue.js
packages/app-desktop/gui/NoteEditor/NoteBody/PlainEditor/PlainEditor.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/styles/index.js
@@ -280,6 +281,7 @@ packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/shouldPasteResources.
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/shouldPasteResources.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/types.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useContextMenu.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useCursorPositioning.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useEditDialog.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useEditDialogEventListeners.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useKeyboardRefocusHandler.js
@@ -321,6 +323,7 @@ packages/app-desktop/gui/NoteEditor/utils/useEffectiveNoteId.js
packages/app-desktop/gui/NoteEditor/utils/useFolder.js
packages/app-desktop/gui/NoteEditor/utils/useFormNote.test.js
packages/app-desktop/gui/NoteEditor/utils/useFormNote.js
packages/app-desktop/gui/NoteEditor/utils/useInitialCursorLocation.js
packages/app-desktop/gui/NoteEditor/utils/useMessageHandler.js
packages/app-desktop/gui/NoteEditor/utils/useNoteSearchBar.js
packages/app-desktop/gui/NoteEditor/utils/usePluginEditorView.test.js
@@ -604,6 +607,7 @@ packages/app-desktop/tools/generateLatestArm64Yml.js
packages/app-desktop/tools/githubReleasesUtils.js
packages/app-desktop/tools/modifyReleaseAssets.js
packages/app-desktop/tools/notarizeMacApp.js
packages/app-desktop/tools/resolveSourceMap.js
packages/app-desktop/utils/7zip/getPathToExecutable7Zip.js
packages/app-desktop/utils/7zip/pathToBundled7Zip.js
packages/app-desktop/utils/checkForUpdatesUtils.test.js
@@ -697,6 +701,7 @@ packages/app-mobile/components/NoteEditor/ImageEditor/ImageEditor.js
packages/app-mobile/components/NoteEditor/ImageEditor/autosave.js
packages/app-mobile/components/NoteEditor/ImageEditor/isEditableResource.js
packages/app-mobile/components/NoteEditor/ImageEditor/promptRestoreAutosave.js
packages/app-mobile/components/NoteEditor/MarkdownEditor.test.js
packages/app-mobile/components/NoteEditor/MarkdownEditor.js
packages/app-mobile/components/NoteEditor/NoteEditor.test.js
packages/app-mobile/components/NoteEditor/NoteEditor.js
@@ -844,6 +849,7 @@ packages/app-mobile/components/screens/NoteTagsDialog.js
packages/app-mobile/components/screens/Notes/NewNoteButton.test.js
packages/app-mobile/components/screens/Notes/NewNoteButton.js
packages/app-mobile/components/screens/Notes/Notes.js
packages/app-mobile/components/screens/SearchScreen/SearchResults.test.js
packages/app-mobile/components/screens/SearchScreen/SearchResults.js
packages/app-mobile/components/screens/SearchScreen/index.js
packages/app-mobile/components/screens/ShareManager/AcceptedShareItem.js
@@ -1097,8 +1103,9 @@ packages/editor/CodeMirror/utils/markdown/stripBlockquote.js
packages/editor/CodeMirror/utils/markdown/toggleCheckboxAt.js
packages/editor/CodeMirror/utils/setupVim.js
packages/editor/CodeMirror/vendor/announceSearchMatch.js
packages/editor/ProseMirror/commands.test.js
packages/editor/ProseMirror/commands.js
packages/editor/ProseMirror/commands/commands.test.js
packages/editor/ProseMirror/commands/commands.js
packages/editor/ProseMirror/commands/focusEditor.js
packages/editor/ProseMirror/createEditor.js
packages/editor/ProseMirror/index.js
packages/editor/ProseMirror/plugins/detailsPlugin.test.js
@@ -1117,6 +1124,7 @@ packages/editor/ProseMirror/plugins/linkTooltipPlugin.js
packages/editor/ProseMirror/plugins/listPlugin.js
packages/editor/ProseMirror/plugins/originalMarkupPlugin.js
packages/editor/ProseMirror/plugins/searchPlugin.js
packages/editor/ProseMirror/plugins/tablePlugin.js
packages/editor/ProseMirror/plugins/utils/createExternalEditorPlugin.js
packages/editor/ProseMirror/plugins/utils/createFloatingButtonPlugin.js
packages/editor/ProseMirror/schema.js
@@ -1146,6 +1154,12 @@ packages/editor/ProseMirror/utils/sanitizeHtml.js
packages/editor/ProseMirror/utils/selectFirstInstanceOfNode.js
packages/editor/ProseMirror/utils/trimEmptyParagraphs.js
packages/editor/ProseMirror/vendor/changedDescendants.js
packages/editor/ProseMirror/vendor/icons/addColumnRight.js
packages/editor/ProseMirror/vendor/icons/addRowBelow.js
packages/editor/ProseMirror/vendor/icons/icon.js
packages/editor/ProseMirror/vendor/icons/removeColumn.js
packages/editor/ProseMirror/vendor/icons/removeRow.js
packages/editor/ProseMirror/vendor/icons/types.js
packages/editor/ProseMirror/vendor/splitBlockAs.js
packages/editor/SelectionFormatting.js
packages/editor/events.js
@@ -1414,6 +1428,7 @@ packages/lib/services/database/migrations/45.js
packages/lib/services/database/migrations/46.js
packages/lib/services/database/migrations/47.js
packages/lib/services/database/migrations/48.js
packages/lib/services/database/migrations/49.js
packages/lib/services/database/migrations/index.js
packages/lib/services/database/sqlStringToLines.js
packages/lib/services/database/types.js
@@ -1629,6 +1644,7 @@ packages/lib/services/synchronizer/Synchronizer.sharing.test.js
packages/lib/services/synchronizer/Synchronizer.tags.test.js
packages/lib/services/synchronizer/Synchronizer.tools.test.js
packages/lib/services/synchronizer/gui/useSyncTargetUpgrade.js
packages/lib/services/synchronizer/handleConflictAction.test.js
packages/lib/services/synchronizer/migrations/1.js
packages/lib/services/synchronizer/migrations/2.js
packages/lib/services/synchronizer/migrations/3.js
@@ -1742,6 +1758,7 @@ packages/plugin-repo-cli/lib/gitCompareUrl.test.js
packages/plugin-repo-cli/lib/gitCompareUrl.js
packages/plugin-repo-cli/lib/overrideUtils.test.js
packages/plugin-repo-cli/lib/overrideUtils.js
packages/plugin-repo-cli/lib/searchPlugins.js
packages/plugin-repo-cli/lib/types.js
packages/plugin-repo-cli/lib/updateReadme.test.js
packages/plugin-repo-cli/lib/updateReadme.js

21
.gitignore vendored
View File

@@ -243,6 +243,7 @@ packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/useEditorCommands.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/localisation.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/useKeymap.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/useRefocusOnVisiblePaneChange.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/useSyncEditorValue.js
packages/app-desktop/gui/NoteEditor/NoteBody/PlainEditor/PlainEditor.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/styles/index.js
@@ -253,6 +254,7 @@ packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/shouldPasteResources.
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/shouldPasteResources.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/types.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useContextMenu.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useCursorPositioning.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useEditDialog.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useEditDialogEventListeners.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useKeyboardRefocusHandler.js
@@ -294,6 +296,7 @@ packages/app-desktop/gui/NoteEditor/utils/useEffectiveNoteId.js
packages/app-desktop/gui/NoteEditor/utils/useFolder.js
packages/app-desktop/gui/NoteEditor/utils/useFormNote.test.js
packages/app-desktop/gui/NoteEditor/utils/useFormNote.js
packages/app-desktop/gui/NoteEditor/utils/useInitialCursorLocation.js
packages/app-desktop/gui/NoteEditor/utils/useMessageHandler.js
packages/app-desktop/gui/NoteEditor/utils/useNoteSearchBar.js
packages/app-desktop/gui/NoteEditor/utils/usePluginEditorView.test.js
@@ -577,6 +580,7 @@ packages/app-desktop/tools/generateLatestArm64Yml.js
packages/app-desktop/tools/githubReleasesUtils.js
packages/app-desktop/tools/modifyReleaseAssets.js
packages/app-desktop/tools/notarizeMacApp.js
packages/app-desktop/tools/resolveSourceMap.js
packages/app-desktop/utils/7zip/getPathToExecutable7Zip.js
packages/app-desktop/utils/7zip/pathToBundled7Zip.js
packages/app-desktop/utils/checkForUpdatesUtils.test.js
@@ -670,6 +674,7 @@ packages/app-mobile/components/NoteEditor/ImageEditor/ImageEditor.js
packages/app-mobile/components/NoteEditor/ImageEditor/autosave.js
packages/app-mobile/components/NoteEditor/ImageEditor/isEditableResource.js
packages/app-mobile/components/NoteEditor/ImageEditor/promptRestoreAutosave.js
packages/app-mobile/components/NoteEditor/MarkdownEditor.test.js
packages/app-mobile/components/NoteEditor/MarkdownEditor.js
packages/app-mobile/components/NoteEditor/NoteEditor.test.js
packages/app-mobile/components/NoteEditor/NoteEditor.js
@@ -817,6 +822,7 @@ packages/app-mobile/components/screens/NoteTagsDialog.js
packages/app-mobile/components/screens/Notes/NewNoteButton.test.js
packages/app-mobile/components/screens/Notes/NewNoteButton.js
packages/app-mobile/components/screens/Notes/Notes.js
packages/app-mobile/components/screens/SearchScreen/SearchResults.test.js
packages/app-mobile/components/screens/SearchScreen/SearchResults.js
packages/app-mobile/components/screens/SearchScreen/index.js
packages/app-mobile/components/screens/ShareManager/AcceptedShareItem.js
@@ -1070,8 +1076,9 @@ packages/editor/CodeMirror/utils/markdown/stripBlockquote.js
packages/editor/CodeMirror/utils/markdown/toggleCheckboxAt.js
packages/editor/CodeMirror/utils/setupVim.js
packages/editor/CodeMirror/vendor/announceSearchMatch.js
packages/editor/ProseMirror/commands.test.js
packages/editor/ProseMirror/commands.js
packages/editor/ProseMirror/commands/commands.test.js
packages/editor/ProseMirror/commands/commands.js
packages/editor/ProseMirror/commands/focusEditor.js
packages/editor/ProseMirror/createEditor.js
packages/editor/ProseMirror/index.js
packages/editor/ProseMirror/plugins/detailsPlugin.test.js
@@ -1090,6 +1097,7 @@ packages/editor/ProseMirror/plugins/linkTooltipPlugin.js
packages/editor/ProseMirror/plugins/listPlugin.js
packages/editor/ProseMirror/plugins/originalMarkupPlugin.js
packages/editor/ProseMirror/plugins/searchPlugin.js
packages/editor/ProseMirror/plugins/tablePlugin.js
packages/editor/ProseMirror/plugins/utils/createExternalEditorPlugin.js
packages/editor/ProseMirror/plugins/utils/createFloatingButtonPlugin.js
packages/editor/ProseMirror/schema.js
@@ -1119,6 +1127,12 @@ packages/editor/ProseMirror/utils/sanitizeHtml.js
packages/editor/ProseMirror/utils/selectFirstInstanceOfNode.js
packages/editor/ProseMirror/utils/trimEmptyParagraphs.js
packages/editor/ProseMirror/vendor/changedDescendants.js
packages/editor/ProseMirror/vendor/icons/addColumnRight.js
packages/editor/ProseMirror/vendor/icons/addRowBelow.js
packages/editor/ProseMirror/vendor/icons/icon.js
packages/editor/ProseMirror/vendor/icons/removeColumn.js
packages/editor/ProseMirror/vendor/icons/removeRow.js
packages/editor/ProseMirror/vendor/icons/types.js
packages/editor/ProseMirror/vendor/splitBlockAs.js
packages/editor/SelectionFormatting.js
packages/editor/events.js
@@ -1387,6 +1401,7 @@ packages/lib/services/database/migrations/45.js
packages/lib/services/database/migrations/46.js
packages/lib/services/database/migrations/47.js
packages/lib/services/database/migrations/48.js
packages/lib/services/database/migrations/49.js
packages/lib/services/database/migrations/index.js
packages/lib/services/database/sqlStringToLines.js
packages/lib/services/database/types.js
@@ -1602,6 +1617,7 @@ packages/lib/services/synchronizer/Synchronizer.sharing.test.js
packages/lib/services/synchronizer/Synchronizer.tags.test.js
packages/lib/services/synchronizer/Synchronizer.tools.test.js
packages/lib/services/synchronizer/gui/useSyncTargetUpgrade.js
packages/lib/services/synchronizer/handleConflictAction.test.js
packages/lib/services/synchronizer/migrations/1.js
packages/lib/services/synchronizer/migrations/2.js
packages/lib/services/synchronizer/migrations/3.js
@@ -1715,6 +1731,7 @@ packages/plugin-repo-cli/lib/gitCompareUrl.test.js
packages/plugin-repo-cli/lib/gitCompareUrl.js
packages/plugin-repo-cli/lib/overrideUtils.test.js
packages/plugin-repo-cli/lib/overrideUtils.js
packages/plugin-repo-cli/lib/searchPlugins.js
packages/plugin-repo-cli/lib/types.js
packages/plugin-repo-cli/lib/updateReadme.test.js
packages/plugin-repo-cli/lib/updateReadme.js

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://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>
<!-- SPONSORS-ORG -->
* * *

View File

@@ -76,7 +76,7 @@
"cspell": "5.21.2",
"eslint": "8.57.1",
"eslint-interactive": "10.8.0",
"eslint-plugin-import": "2.31.0",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-jest": "27.9.0",
"eslint-plugin-promise": "6.6.0",
"eslint-plugin-react": "7.37.5",

View File

@@ -35,7 +35,7 @@
],
"owner": "Laurent Cozic"
},
"version": "3.5.0",
"version": "3.5.1",
"bin": "./main.js",
"engines": {
"node": ">=10.0.0"
@@ -73,7 +73,7 @@
"@joplin/tools": "~3.5",
"@types/fs-extra": "11.0.4",
"@types/jest": "29.5.14",
"@types/node": "18.19.118",
"@types/node": "18.19.130",
"@types/proper-lockfile": "^4.1.2",
"gulp": "4.0.2",
"jest": "29.7.0",

View File

@@ -0,0 +1,8 @@
<en-note>
<div>
<en-media style="--en-viewerProps:{};" type="image/jpeg" hash="e2d4887c5a32ab1686276c7c5ae733ef" width="1.125in" />
</div>
<div>
<br />
</div>
</en-note>

View File

@@ -0,0 +1,8 @@
<en-note>
<div>
<img src=":/e2d4887c5a32ab1686276c7c5ae733ef" style="--en-viewerProps:{};" type="image/jpeg" hash="e2d4887c5a32ab1686276c7c5ae733ef" width="108" alt="attachment-image" />
</div>
<div>
<br/>
</div>
</en-note>

View File

@@ -1,6 +1,8 @@
<en-note>
<div><a href=":/21ca2b948f222a38802940ec7e2e5de3" hash="21ca2b948f222a38802940ec7e2e5de3" type="application/pdf" style="cursor:pointer;" alt="attachment-1">attachment-1</a></div>
<div>
<br>
<a href=':/21ca2b948f222a38802940ec7e2e5de3' hash="21ca2b948f222a38802940ec7e2e5de3" type="application/pdf" style="cursor:pointer;" alt="attachment-1"> attachment-1</a>
</div>
<div>
<br/>
</div>
</en-note>

View File

@@ -1,16 +1,11 @@
<en-note>
<div>
<p>For example, consider an exported Evernote list with todo checkboxes like this:</p>
<ul>
<li>
<div><input checked="checked" type="checkbox" onclick="return false;">Foo</div>
</li>
<li>
<div><input type="checkbox" onclick="return false;"><b>Bar</b></div>
</li>
<li>
<div><input type="checkbox" onclick="return false;"><i>Baz</i></div>
</li>
<li><div><input checked="checked" type="checkbox" onclick="return false;" />Foo</div></li>
<li><div><input type="checkbox" onclick="return false;" /><b>Bar</b></div></li>
<li><div><input type="checkbox" onclick="return false;" /><i>Baz</i></div></li>
</ul>
</div>
</en-note>

View File

@@ -1,19 +1,11 @@
<en-note>
<div>
<p>In Evernote a checklist is not the same as a list with checkboxes.</p>
<ul style="--en-todo:true;">
<li style="--en-checked:false;">
<input type="checkbox" onclick="return false;">
<div>One</div>
</li>
<li style="--en-checked:true;">
<input checked="checked" type="checkbox" onclick="return false;">
<div>Two</div>
</li>
<li style="--en-checked:false;">
<input type="checkbox" onclick="return false;">
<div>Three</div>
</li>
<ul STYLE="--en-todo:true;">
<li STYLE="--en-checked:false;"> <input type="checkbox" onclick="return false;" /><div>One</div></li>
<li STYLE="--en-checked:true;"> <input checked="checked" type="checkbox" onclick="return false;" /><div>Two</div>
</li><li STYLE="--en-checked:false;"> <input type="checkbox" onclick="return false;" /><div>Three</div></li>
</ul>
</div>
</en-note>

View File

@@ -1,12 +1 @@
<en-note>
<div>
<audio controls="" preload="none" style="width:480px;">
<source src=":/9168ee833d03c5ea7c730ac6673978c1" type="audio/mp4">
<p>Your browser does not support HTML5 audio.</p>
</audio>
<p><a href=":/9168ee833d03c5ea7c730ac6673978c1">audio test</a></p>
</div>
<div>
<br>
</div>
</en-note>
<en-note><div><audio controls preload="none" style="width:480px;"><source src=":/9168ee833d03c5ea7c730ac6673978c1" type="audio/mp4" /><p>Your browser does not support HTML5 audio.</p></audio><p><a href=":/9168ee833d03c5ea7c730ac6673978c1">audio test</a></p></div><div><br/></div></en-note>

View File

@@ -1,12 +1 @@
<en-note>
<div><input type="checkbox" onclick="return false;">This is a test</div>
<div><input type="checkbox" onclick="return false;">A test for <span style="font-weight: bold;">bold</span></div>
<div>
<input type="checkbox" onclick="return false;">A test for <i>italic</i>
<br>
</div>
<div>
<br>
</div>
<div><i><img src=":/89ce7da62c6b2832929a6964237e98e9" hash="89ce7da62c6b2832929a6964237e98e9" type="image/jpeg" alt=""></i></div>
</en-note>
<en-note><div><input type="checkbox" onclick="return false;" />This is a test</div><div><input type="checkbox" onclick="return false;" />A test for <span STYLE="font-weight: bold;">bold</span></div><div><input type="checkbox" onclick="return false;" />A test for <i>italic</i><br/></div><div><br/></div><div><i><img src=":/89ce7da62c6b2832929a6964237e98e9" hash="89ce7da62c6b2832929a6964237e98e9" type="image/jpeg" alt="" /></i></div></en-note>

View File

@@ -1,3 +1,3 @@
<en-note>
<h1 style="box-sizing:inherit;font-family:&quot;Guardian TextSans Web&quot;, &quot;Helvetica Neue&quot;, Helvetica, Arial, sans-serif;margin-top:0.2em;margin-bottom:0.35em;font-size:2.125em;font-weight:600;line-height:1.3;">Association Between mRNA Vaccination and COVID-19 Hospitalization and Disease Severity</h1>
<h1 STYLE="box-sizing:inherit;font-family:&quot;Guardian TextSans Web&quot;, &quot;Helvetica Neue&quot;, Helvetica, Arial, sans-serif;margin-top:0.2em;margin-bottom:0.35em;font-size:2.125em;font-weight:600;line-height:1.3;">Association Between mRNA Vaccination and COVID-19 Hospitalization and Disease Severity</h1>
</en-note>

View File

@@ -1,3 +1,5 @@
<en-note>
<div><img style="margin:0px;padding:0px;outline:0px;width:74px;height:36px;position:absolute;bottom:-5px;left:0px;transform:translate(0px, 100%);stroke-dasharray:90;transition:stroke-dashoffset 0.5s cubic-bezier(0.97, 0.16, 0.62, 0.76) 0s;stroke-dashoffset:0;" src="data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' data-evernote-id='97' class='js-evernote-checked'%3e%3cuse xlink:href='https://wordminds.com/wp-content/themes/wordminds/assets/img/hint_left.svg%23hint_left' data-evernote-id='98' class='js-evernote-checked'%3e%3c/use%3e%3c/svg%3e"></div>
<div>
<img STYLE="margin:0px;padding:0px;outline:0px;width:74px;height:36px;position:absolute;bottom:-5px;left:0px;transform:translate(0px, 100%);stroke-dasharray:90;transition:stroke-dashoffset 0.5s cubic-bezier(0.97, 0.16, 0.62, 0.76) 0s;stroke-dashoffset:0;" SRC="data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' data-evernote-id='97' class='js-evernote-checked'%3e%3cuse xlink:href='https://wordminds.com/wp-content/themes/wordminds/assets/img/hint_left.svg%23hint_left' data-evernote-id='98' class='js-evernote-checked'%3e%3c/use%3e%3c/svg%3e"/>
</div>
</en-note>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE en-export SYSTEM "http://xml.evernote.com/pub/evernote-export4.dtd">
<en-export export-date="20230724T173816Z" application="Evernote" version="10.58.8">
<note>
<title>test.json</title>
<content><![CDATA[<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE en-note SYSTEM "http://xml.evernote.com/pub/enml2.dtd">
<en-note><en-media hash="ac91cc691d21261b222681dd38c1e4ad" type="application/json"/></en-note>
]]>
</content>
<created>20191002T075850Z</created>
<updated>20191002T075850Z</updated>
<note-attributes><latitude>48.79547119140625</latitude><longitude>9.809423921920198</longitude><altitude>398.0</altitude><author>Laurent</author><source>desktop.mac</source></note-attributes>
<resource><data>eyAidGVzdCI6IDEyMyB9</data><mime>application/json</mime><width>0</width><height>0</height><resource-attributes><file-name>test.json</file-name><attachment>false</attachment></resource-attributes></resource></note></en-export>

View File

@@ -52,7 +52,7 @@ describe('app.reducer', () => {
...createAppDefaultState({}),
backgroundWindows: {
testWindow: {
...createAppDefaultWindowState(),
...createAppDefaultWindowState(null),
windowId: 'testWindow',
visibleDialogs: {

View File

@@ -26,10 +26,21 @@ export interface AppStateDialog {
props: Record<string, any>;
}
export interface EditorScrollPercents {
export interface NoteIdToScrollPercent {
[noteId: string]: number;
}
type RichTextEditorSelectionBookmark = unknown;
export interface EditorCursorLocations {
readonly richText?: RichTextEditorSelectionBookmark;
readonly markdown?: number;
}
export interface NoteIdToEditorCursorLocations {
[noteId: string]: EditorCursorLocations;
}
export interface VisibleDialogs {
[dialogKey: string]: boolean;
}
@@ -42,6 +53,9 @@ export interface AppWindowState extends WindowState {
devToolsVisible: boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
watchedResources: any;
lastEditorScrollPercents: NoteIdToScrollPercent;
lastEditorCursorLocations: NoteIdToEditorCursorLocations;
}
interface BackgroundWindowStates {
@@ -55,7 +69,6 @@ export interface AppState extends State, AppWindowState {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
navHistory: any[];
watchedNoteFiles: string[];
lastEditorScrollPercents: EditorScrollPercents;
focusedField: string;
layoutMoveMode: boolean;
startupPluginsLoaded: boolean;
@@ -66,7 +79,7 @@ export interface AppState extends State, AppWindowState {
isResettingLayout: boolean;
}
export const createAppDefaultWindowState = (): AppWindowState => {
export const createAppDefaultWindowState = (globalState: AppState|null): AppWindowState => {
return {
...defaultWindowState,
visibleDialogs: {},
@@ -75,6 +88,12 @@ export const createAppDefaultWindowState = (): AppWindowState => {
editorCodeView: true,
devToolsVisible: false,
watchedResources: {},
// Maintain the scroll and cursor location for secondary windows separate from the
// main window. This prevents scrolling in a secondary window from changing/resetting
// the default scroll position in the main window:
lastEditorCursorLocations: globalState?.lastEditorCursorLocations ?? {},
lastEditorScrollPercents: globalState?.lastEditorScrollPercents ?? {},
};
};
@@ -82,7 +101,7 @@ export const createAppDefaultWindowState = (): AppWindowState => {
export function createAppDefaultState(resourceEditWatcherDefaultState: any): AppState {
return {
...defaultState,
...createAppDefaultWindowState(),
...createAppDefaultWindowState(null),
route: {
type: 'NAV_GO',
routeName: 'Main',
@@ -90,7 +109,6 @@ export function createAppDefaultState(resourceEditWatcherDefaultState: any): App
},
navHistory: [],
watchedNoteFiles: [],
lastEditorScrollPercents: {},
visibleDialogs: {}, // empty object if no dialog is visible. Otherwise contains the list of visible dialogs.
focusedField: null,
layoutMoveMode: false,
@@ -299,6 +317,18 @@ export default function(state: AppState, action: any) {
}
break;
case 'EDITOR_CURSOR_POSITION_SET':
{
newState = { ...state };
const newCursorLocations = { ...newState.lastEditorCursorLocations };
newCursorLocations[action.noteId] = {
...(newCursorLocations[action.noteId] ?? {}),
...action.location,
};
newState.lastEditorCursorLocations = newCursorLocations;
}
break;
case 'NOTE_DEVTOOLS_TOGGLE':
newState = { ...state };
newState.devToolsVisible = !newState.devToolsVisible;

View File

@@ -2,7 +2,7 @@ import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/
import { _ } from '@joplin/lib/locale';
import { stateUtils } from '@joplin/lib/reducer';
import Note from '@joplin/lib/models/Note';
import { createAppDefaultWindowState } from '../app.reducer';
import { AppState, createAppDefaultWindowState } from '../app.reducer';
import Setting from '@joplin/lib/models/Setting';
export const declaration: CommandDeclaration = {
@@ -25,7 +25,7 @@ export const runtime = (): CommandRuntime => {
folderId: note.parent_id,
windowId: `window-${noteId}-${idCounter++}`,
defaultAppWindowState: {
...createAppDefaultWindowState(),
...createAppDefaultWindowState(context.state as AppState),
noteVisiblePanes: Setting.value('noteVisiblePanes'),
editorCodeView: Setting.value('editor.codeView'),
},

View File

@@ -7,6 +7,7 @@ import useKeyboardHandler from './DialogButtonRow/useKeyboardHandler';
export interface ButtonSpec {
name: string;
label: string;
disabled?: boolean;
}
export interface ClickEvent {
@@ -51,21 +52,29 @@ export default function DialogButtonRow(props: Props) {
if (props.onClick) props.onClick(event);
}, [props.onClick]);
const onKeyDown = useKeyboardHandler({ onOkButtonClick, onCancelButtonClick });
const okButtonShow = props.okButtonShow ?? true;
const cancelButtonShow = props.cancelButtonShow ?? true;
const canClickOk = okButtonShow && !props.okButtonDisabled;
const canClickCancel = cancelButtonShow && !props.cancelButtonDisabled;
const onKeyDown = useKeyboardHandler({
onOkButtonClick: canClickOk ? onOkButtonClick : null,
onCancelButtonClick: canClickCancel ? onCancelButtonClick : null,
});
const buttonComps = [];
if (props.customButtons) {
for (const b of props.customButtons) {
buttonComps.push(
<button key={b.name} style={buttonStyle} onClick={() => onCustomButtonClick({ buttonName: b.name })} onKeyDown={onKeyDown}>
<button key={b.name} style={buttonStyle} onClick={() => onCustomButtonClick({ buttonName: b.name })} disabled={b.disabled} onKeyDown={onKeyDown}>
{b.label}
</button>,
);
}
}
if (props.okButtonShow !== false) {
if (okButtonShow) {
buttonComps.push(
<button disabled={props.okButtonDisabled} key="ok" style={buttonStyle} onClick={onOkButtonClick} ref={props.okButtonRef} onKeyDown={onKeyDown}>
{props.okButtonLabel ? props.okButtonLabel : _('OK')}
@@ -73,7 +82,7 @@ export default function DialogButtonRow(props: Props) {
);
}
if (props.cancelButtonShow !== false) {
if (cancelButtonShow) {
buttonComps.push(
<button disabled={props.cancelButtonDisabled} key="cancel" style={{ ...buttonStyle }} onClick={onCancelButtonClick}>
{props.cancelButtonLabel ? props.cancelButtonLabel : _('Cancel')}

View File

@@ -2,11 +2,10 @@ import * as React from 'react';
import { useEffect, useState, useRef, useCallback } from 'react';
import { isInsideContainer } from '@joplin/lib/dom';
type OnButtonClick = ()=> void;
interface Props {
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
onOkButtonClick: Function;
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
onCancelButtonClick: Function;
onOkButtonClick: null|OnButtonClick;
onCancelButtonClick: null|OnButtonClick;
}
const globalKeydownHandlers: string[] = [];
@@ -48,15 +47,17 @@ export default (props: Props) => {
if (!isTopDialog() || isInSubModal(event.target)) return;
if (event.keyCode === 13) {
if (event.keyCode === 13 && props.onOkButtonClick) {
if ('nodeName' in event.target && event.target.nodeName === 'INPUT') {
const target = event.target as HTMLInputElement;
if (target.type !== 'button' && target.type !== 'checkbox') {
event.preventDefault();
props.onOkButtonClick();
}
}
} else if (event.keyCode === 27) {
} else if (event.keyCode === 27 && props.onCancelButtonClick) {
event.preventDefault();
props.onCancelButtonClick();
}
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied

View File

@@ -172,7 +172,12 @@ export default function NoteContentPropertiesDialog(props: NoteContentProperties
<div style={{ ...labelCompStyle, marginTop: 10 }}>
{readTimeLabel}
</div>
<DialogButtonRow themeId={props.themeId} onClick={buttonRow_click} okButtonShow={false} cancelButtonLabel={_('Close')}/>
<DialogButtonRow
themeId={props.themeId}
onClick={buttonRow_click}
okButtonShow={false}
cancelButtonLabel={_('Close')}
/>
</Dialog>
);
}

View File

@@ -8,7 +8,36 @@ const logger = Logger.create('useEditorSearch');
// Registers a helper CodeMirror extension to be used with
// useEditorSearchHandler.
export default function useEditorSearchExtension(CodeMirror: CodeMirror5Emulation) {
interface SetMarkersOptions {
selectedIndex: number;
searchTimestamp: number;
showEditorMarkers?: boolean;
withSelection?: boolean;
}
type Keyword = { value: string };
export type OnSetMarkers = (cm: CodeMirror5Emulation, keywords: Keyword[], options: SetMarkersOptions)=> number;
// Modified from codemirror/addons/search/search.js
const searchOverlay = (query: RegExp) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
return { token: function(stream: any) {
query.lastIndex = stream.pos;
const match = query.exec(stream.string);
if (match && match.index === stream.pos) {
stream.pos += match[0].length || 1;
return 'search-marker';
} else if (match) {
stream.pos = match.index;
} else {
stream.skipToEnd();
}
return null;
} };
};
export default function useEditorSearchExtension() {
const [markers, setMarkers] = useState([]);
const [overlay, setOverlay] = useState(null);
@@ -48,23 +77,6 @@ export default function useEditorSearchExtension(CodeMirror: CodeMirror5Emulatio
setOverlayTimeout(null);
}, [scrollbarMarks, overlay, overlayTimeout]);
// Modified from codemirror/addons/search/search.js
const searchOverlay = useCallback((query: RegExp) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
return { token: function(stream: any) {
query.lastIndex = stream.pos;
const match = query.exec(stream.string);
if (match && match.index === stream.pos) {
stream.pos += match[0].length || 1;
return 'search-marker';
} else if (match) {
stream.pos = match.index;
} else {
stream.skipToEnd();
}
return null;
} };
}, []);
// Highlights the currently active found work
// It's possible to get tricky with this functions and just use findNext/findPrev
@@ -115,16 +127,17 @@ export default function useEditorSearchExtension(CodeMirror: CodeMirror5Emulatio
};
}, []);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
CodeMirror?.defineExtension('setMarkers', function(keywords: any, options: any) {
const onSetMarkers: OnSetMarkers = (cm, keywords, options) => {
// Pass arguments in via options to allow the extension to work if multiple editors are open simultaneously
// See https://github.com/laurent22/joplin/issues/13399.
if (!options) {
options = { selectedIndex: 0, searchTimestamp: 0 };
}
if (options.showEditorMarkers === false) {
clearMarkers();
clearOverlay(this);
return;
clearOverlay(cm);
return 0;
}
clearMarkers();
@@ -145,7 +158,7 @@ export default function useEditorSearchExtension(CodeMirror: CodeMirror5Emulatio
const scrollTo = i === 0 && (previousKeywordValue !== keyword.value || previousIndex !== options.selectedIndex || options.searchTimestamp !== previousSearchTimestamp);
try {
const match = highlightSearch(this, searchTerm, options.selectedIndex, scrollTo, !!options.withSelection);
const match = highlightSearch(cm, searchTerm, options.selectedIndex, scrollTo, !!options.withSelection);
if (match) marks.push(match);
} catch (error) {
if (error.name !== 'SyntaxError') {
@@ -165,7 +178,7 @@ export default function useEditorSearchExtension(CodeMirror: CodeMirror5Emulatio
// SEARCHOVERLAY
// We only want to highlight all matches when there is only 1 search term
if (keywords.length !== 1 || keywords[0].value === '') {
clearOverlay(this);
clearOverlay(cm);
const prev = keywords.length > 1 ? keywords[0].value : '';
setPreviousKeywordValue(prev);
return 0;
@@ -175,22 +188,22 @@ export default function useEditorSearchExtension(CodeMirror: CodeMirror5Emulatio
// Determine the number of matches in the source, this is passed on
// to the NoteEditor component
const regexMatches = this.getValue().match(searchTerm);
const regexMatches = cm.getValue().match(searchTerm);
const nMatches = regexMatches ? regexMatches.length : 0;
// Don't bother clearing and re-calculating the overlay if the search term
// hasn't changed
if (keywords[0].value === previousKeywordValue) return nMatches;
clearOverlay(this);
clearOverlay(cm);
setPreviousKeywordValue(keywords[0].value);
// These operations are pretty slow, so we won't add use them until the user
// has finished typing, 500ms is probably enough time
const timeout = shim.setTimeout(() => {
const scrollMarks = this.showMatchesOnScrollbar?.(searchTerm, true, 'cm-search-marker-scrollbar');
const scrollMarks = cm.showMatchesOnScrollbar?.(searchTerm, true, 'cm-search-marker-scrollbar');
const overlay = searchOverlay(searchTerm);
this.addOverlay(overlay);
cm.addOverlay(overlay);
setOverlay(overlay);
setScrollbarMarks(scrollMarks);
}, 500);
@@ -199,5 +212,9 @@ export default function useEditorSearchExtension(CodeMirror: CodeMirror5Emulatio
overlayTimeoutRef.current = timeout;
return nMatches;
});
};
const onSetMarkersRef = useRef(onSetMarkers);
onSetMarkersRef.current = onSetMarkers;
return { onSetMarkersRef };
}

View File

@@ -2,6 +2,8 @@ import { RefObject, useEffect, useMemo, useRef } from 'react';
import usePrevious from '../../../../hooks/usePrevious';
import { RenderedBody } from './types';
import { SearchMarkers } from '../../../utils/useSearchMarkers';
import CodeMirror5Emulation from '@joplin/editor/CodeMirror/CodeMirror5Emulation/CodeMirror5Emulation';
import useEditorSearchExtension from './useEditorSearchExtension';
const debounce = require('debounce');
interface Props {
@@ -10,8 +12,7 @@ interface Props {
searchMarkers: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
webviewRef: RefObject<any>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
editorRef: RefObject<any>;
editorRef: RefObject<CodeMirror5Emulation>;
noteContent: string;
renderedBody: RenderedBody;
@@ -23,6 +24,8 @@ const useEditorSearchHandler = (props: Props) => {
webviewRef, editorRef, renderedBody, noteContent, searchMarkers, showEditorMarkers,
} = props;
const { onSetMarkersRef } = useEditorSearchExtension();
const previousContent = usePrevious(noteContent);
const previousRenderedBody = usePrevious(renderedBody);
const previousSearchMarkers = usePrevious(searchMarkers);
@@ -31,15 +34,15 @@ const useEditorSearchHandler = (props: Props) => {
// Fixes https://github.com/laurent22/joplin/issues/7565
const debouncedMarkers = useMemo(() => debounce((searchMarkers: SearchMarkers) => {
if (!editorRef.current) return;
if (!onSetMarkersRef.current) return;
if (showEditorMarkersRef.current) {
const matches = editorRef.current.setMarkers(searchMarkers.keywords, searchMarkers.options);
const matches = onSetMarkersRef.current(editorRef.current, searchMarkers.keywords, searchMarkers.options);
props.setLocalSearchResultCount(matches);
} else {
editorRef.current.setMarkers(searchMarkers.keywords, { ...searchMarkers.options, showEditorMarkers: false });
onSetMarkersRef.current(editorRef.current, searchMarkers.keywords, { ...searchMarkers.options, showEditorMarkers: false });
}
}, 50), [editorRef, props.setLocalSearchResultCount]);
}, 50), [editorRef, onSetMarkersRef, props.setLocalSearchResultCount]);
useEffect(() => {
if (!searchMarkers) return () => {};
@@ -59,7 +62,7 @@ const useEditorSearchHandler = (props: Props) => {
}
return () => {};
}, [
editorRef,
onSetMarkersRef,
webviewRef,
searchMarkers,
previousSearchMarkers,
@@ -71,6 +74,10 @@ const useEditorSearchHandler = (props: Props) => {
debouncedMarkers,
]);
return {
// Returned to allow quickly setting the initial search markers just after the editor loads.
onSetInitialMarkersRef: onSetMarkersRef,
};
};
export default useEditorSearchHandler;

View File

@@ -695,7 +695,7 @@ function CodeMirror(props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [renderedBody, webviewReady]);
useEditorSearchHandler({
const { onSetInitialMarkersRef } = useEditorSearchHandler({
setLocalSearchResultCount: props.setLocalSearchResultCount,
searchMarkers: props.searchMarkers,
webviewRef,
@@ -737,6 +737,7 @@ function CodeMirror(props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
<Editor
value={props.content}
searchMarkers={props.searchMarkers}
onSetMarkersRef={onSetInitialMarkersRef}
ref={editorRef}
mode={props.contentMarkupLanguage === MarkupToHtml.MARKUP_LANGUAGE_HTML ? 'xml' : 'joplin-markdown'}
codeMirrorTheme={styles.editor.codeMirrorTheme}

View File

@@ -1,5 +1,5 @@
import * as React from 'react';
import { useEffect, useImperativeHandle, useState, useRef, useCallback, forwardRef } from 'react';
import { useEffect, useImperativeHandle, useState, useRef, useCallback, forwardRef, RefObject } from 'react';
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
import CodeMirror from 'codemirror';
@@ -16,7 +16,7 @@ import useListIdent from './utils/useListIdent';
import useScrollUtils from './utils/useScrollUtils';
import useCursorUtils from './utils/useCursorUtils';
import useLineSorting from './utils/useLineSorting';
import useEditorSearch from '../utils/useEditorSearchExtension';
import { OnSetMarkers } from '../utils/useEditorSearchExtension';
import useJoplinMode from './utils/useJoplinMode';
import useKeymap from './utils/useKeymap';
import useExternalPlugins from './utils/useExternalPlugins';
@@ -77,6 +77,7 @@ export interface EditorProps {
value: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
searchMarkers: any;
onSetMarkersRef: RefObject<OnSetMarkers>;
mode: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
style: any;
@@ -119,7 +120,6 @@ function Editor(props: EditorProps, ref: any) {
useScrollUtils(CodeMirror);
useCursorUtils(CodeMirror);
useLineSorting(CodeMirror);
useEditorSearch(CodeMirror);
useJoplinMode(CodeMirror);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const pluginOptions: any = useExternalPlugins(CodeMirror, props.plugins);
@@ -228,7 +228,7 @@ function Editor(props: EditorProps, ref: any) {
// It's possible for searchMarkers to be available before the editor
// In these cases we set the markers asap so the user can see them as
// soon as the editor is ready
if (props.searchMarkers) { cm.setMarkers(props.searchMarkers.keywords, props.searchMarkers.options); }
if (props.searchMarkers) { props.onSetMarkersRef.current(cm, props.searchMarkers.keywords, props.searchMarkers.options); }
return () => {
// Clean up codemirror

View File

@@ -31,6 +31,7 @@ import CommandService from '@joplin/lib/services/CommandService';
import useRefocusOnVisiblePaneChange from './utils/useRefocusOnVisiblePaneChange';
import { WindowIdContext } from '../../../../NewWindowOrIFrame';
import eventManager, { EventName, ResourceChangeEvent } from '@joplin/lib/eventManager';
import useSyncEditorValue from './utils/useSyncEditorValue';
const logger = Logger.create('CodeMirror6');
const logDebug = (message: string) => logger.debug(message);
@@ -167,9 +168,8 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
},
scrollTo: (options: ScrollOptions) => {
if (options.type === ScrollOptionTypes.Hash) {
if (!webviewRef.current) return;
const hash: string = options.value;
webviewRef.current.send('scrollToHash', hash);
webviewRef.current?.send('scrollToHash', hash);
editorRef.current.jumpToHash(hash);
} else if (options.type === ScrollOptionTypes.Percent) {
const percent = options.value as number;
@@ -342,6 +342,7 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
} else if (event.kind === EditorEventType.Change) {
codeMirror_change(event.value);
} else if (event.kind === EditorEventType.SelectionRangeChange) {
props.onCursorMotion({ markdown: event.from });
setSelectionRange({ from: event.from, to: event.to });
} else if (event.kind === EditorEventType.UpdateSearchDialog) {
if (lastSearchState.current?.searchText !== event.searchState.searchText) {
@@ -355,7 +356,7 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
} else if (event.kind === EditorEventType.FollowLink) {
void CommandService.instance().execute('openItem', event.link);
}
}, [editor_scroll, codeMirror_change, props.setLocalSearch, props.setShowLocalSearch]);
}, [editor_scroll, codeMirror_change, props.setLocalSearch, props.setShowLocalSearch, props.onCursorMotion]);
const onSelectPastBeginning = useCallback(() => {
void CommandService.instance().execute('focusElement', 'noteTitle');
@@ -400,15 +401,17 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
props.tabMovesFocus,
]);
// Update the editor's value
useEffect(() => {
// Include the noteId in the update props to give plugins access
// to the current note ID.
const updateProps = { noteId: props.noteId };
if (editorRef.current?.updateBody(props.content, updateProps)) {
editorRef.current?.clearHistory();
}
}, [props.content, props.noteId]);
const initialCursorLocationRef = useRef(0);
initialCursorLocationRef.current = props.initialCursorLocation.markdown ?? 0;
useSyncEditorValue({
content: props.content,
visiblePanes: props.visiblePanes,
onMessage: props.onMessage,
editorRef,
noteId: props.noteId,
initialCursorLocationRef,
});
const renderEditor = () => {
return (
@@ -416,6 +419,7 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
<Editor
style={styles.editor}
initialText={props.content}
initialSelectionRef={initialCursorLocationRef}
initialNoteId={props.noteId}
ref={editorRef}
settings={editorSettings}

View File

@@ -1,5 +1,5 @@
import * as React from 'react';
import { ForwardedRef } from 'react';
import { ForwardedRef, RefObject } from 'react';
import { useEffect, useState, useRef, forwardRef, useImperativeHandle } from 'react';
import { EditorProps, LogMessageCallback, OnEventCallback, ContentScriptData } from '@joplin/editor/types';
import createEditor from '@joplin/editor/CodeMirror/createEditor';
@@ -11,7 +11,6 @@ import PluginService from '@joplin/lib/services/plugins/PluginService';
import setupVim from '@joplin/editor/CodeMirror/utils/setupVim';
import { dirname } from 'path';
import useKeymap from './utils/useKeymap';
import useEditorSearch from '../utils/useEditorSearchExtension';
import CommandService from '@joplin/lib/services/CommandService';
import { SearchMarkers } from '../../../utils/useSearchMarkers';
import localisation from './utils/localisation';
@@ -23,6 +22,7 @@ import getResourceBaseUrl from '../../../utils/getResourceBaseUrl';
interface Props extends EditorProps {
style: React.CSSProperties;
pluginStates: PluginStates;
initialSelectionRef: RefObject<number>;
onEditorPaste: (event: Event)=> void;
externalSearch: SearchMarkers;
@@ -43,8 +43,6 @@ const Editor = (props: Props, ref: ForwardedRef<CodeMirrorControl>) => {
onLogMessageRef.current = props.onLogMessage;
}, [props.onEvent, props.onLogMessage]);
useEditorSearch(editor);
useEffect(() => {
if (!editor) {
return () => {};
@@ -127,6 +125,9 @@ const Editor = (props: Props, ref: ForwardedRef<CodeMirrorControl>) => {
direction: 'unset',
},
});
const cursor = props.initialSelectionRef.current;
editor.select(cursor, cursor);
setEditor(editor);
return () => {

View File

@@ -0,0 +1,50 @@
import { useEffect, useRef, RefObject } from 'react';
import { OnMessage } from '../../../../utils/types';
import CodeMirrorControl from '@joplin/editor/CodeMirror/CodeMirrorControl';
interface Props {
content: string;
visiblePanes: string[];
onMessage: OnMessage;
editorRef: RefObject<CodeMirrorControl>;
noteId: string;
initialCursorLocationRef: RefObject<number>;
}
// Updates the editor's value as necessary
const useSyncEditorValue = ({ content, visiblePanes, onMessage, editorRef, noteId, initialCursorLocationRef }: Props) => {
const visiblePanesRef = useRef(visiblePanes);
visiblePanesRef.current = visiblePanes;
const onMessageRef = useRef(onMessage);
onMessageRef.current = onMessage;
const lastNoteIdRef = useRef(noteId);
useEffect(() => {
// Include the noteId in the update props to give plugins access
// to the current note ID.
const updateProps = { noteId: noteId };
if (editorRef.current?.updateBody(content, updateProps)) {
editorRef.current?.clearHistory();
// Only reset the cursor location when switching notes. If, for example,
// the note is updated from a secondary window, the cursor location shouldn't
// reset.
const noteChanged = lastNoteIdRef.current !== noteId;
if (noteChanged) {
const cursorLocation = initialCursorLocationRef.current;
editorRef.current?.select(cursorLocation, cursorLocation);
}
lastNoteIdRef.current = noteId;
// If the viewer isn't visible, the content should be considered rendered
// after the editor has finished updating:
if (!visiblePanesRef.current.includes('viewer')) {
onMessageRef.current({ channel: 'noteRenderComplete' });
}
}
}, [content, noteId, editorRef, initialCursorLocationRef]);
};
export default useSyncEditorValue;

View File

@@ -23,7 +23,7 @@ import { themeStyle } from '@joplin/lib/theme';
import { loadScript } from '../../../utils/loadScript';
import bridge from '../../../../services/bridge';
import { TinyMceEditorEvents } from './utils/types';
import type { Editor, EditorEvent } from 'tinymce';
import type { Bookmark, Editor, EditorEvent } from 'tinymce';
import { joplinCommandToTinyMceCommands, TinyMceCommand } from './utils/joplinCommandToTinyMceCommands';
import shouldPasteResources from './utils/shouldPasteResources';
import lightTheme from '@joplin/lib/themes/light';
@@ -47,6 +47,7 @@ import Setting from '@joplin/lib/models/Setting';
import useTextPatternsLookup, { TextPatternContext } from './utils/useTextPatternsLookup';
import { toFileProtocolPath } from '@joplin/utils/path';
import { RenderResultPluginAsset } from '@joplin/renderer/types';
import useCursorPositioning from './utils/useCursorPositioning';
const logger = Logger.create('TinyMCE');
@@ -918,8 +919,6 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
editor.on('SetContent', () => {
preprocessContent();
props_onMessage.current({ channel: 'noteRenderComplete' });
});
},
});
@@ -1046,6 +1045,12 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
return true;
}
const { onRestoreCursorPosition } = useCursorPositioning({
initialCursorLocation: props.initialCursorLocation.richText as Bookmark,
onCursorUpdate: props.onCursorMotion,
editor,
});
const lastNoteIdRef = useRef(props.noteId);
useEffect(() => {
if (!editor) return () => {};
@@ -1113,8 +1118,12 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
// times would result in an empty note.
// https://github.com/laurent22/joplin/issues/3534
editor.undoManager.reset();
// Only restore the cursor position from the global state when switching notes.
// See https://github.com/laurent22/joplin/issues/13579
onRestoreCursorPosition();
} else {
// Restore the cursor location
// Restore the cursor location from the current note
editor.selection.bookmarkManager.moveToBookmark(bookmark);
}
@@ -1123,6 +1132,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
resourceInfos: props.resourceInfos,
contentKey: props.contentKey,
};
props_onMessage.current({ channel: 'noteRenderComplete' });
}
const allAssetsOptions: NoteStyleOptions = {

View File

@@ -0,0 +1,52 @@
import { useCallback, useEffect, useRef } from 'react';
import { Bookmark, Editor } from 'tinymce';
import { OnCursorMotion } from '../../../utils/types';
interface Props {
initialCursorLocation: Bookmark;
editor: Editor;
onCursorUpdate: OnCursorMotion;
}
const useCursorPositioning = ({ initialCursorLocation, editor, onCursorUpdate }: Props) => {
const initialCursorLocationRef = useRef(initialCursorLocation);
initialCursorLocationRef.current = initialCursorLocation;
const appliedInitialCursorLocationRef = useRef(false);
const onRestoreCursorPosition = useCallback(() => {
if (editor) {
if (initialCursorLocationRef.current) {
editor.selection.moveToBookmark(initialCursorLocationRef.current);
}
appliedInitialCursorLocationRef.current = true;
}
}, [editor]);
useEffect(() => {
if (!editor) return () => {};
const onSelectionChange = () => {
// Wait until the initial cursor position has been set. This avoids resetting
// the initial cursor position to zero when the editor first loads.
if (!appliedInitialCursorLocationRef.current) return;
// Use an offset bookmark -- the default bookmark type is not preserved after unloading
// and reloading the editor.
const offsetBookmarkId = 2;
onCursorUpdate({
richText: editor.selection.getBookmark(offsetBookmarkId, true),
});
};
editor.on('SelectionChange', onSelectionChange);
return () => {
editor.off('SelectionChange', onSelectionChange);
};
}, [editor, onCursorUpdate, onRestoreCursorPosition]);
return { onRestoreCursorPosition };
};
export default useCursorPositioning;

View File

@@ -18,7 +18,7 @@ import { NoteEditorProps, FormNote, OnChangeEvent, AllAssetsOptions, NoteBodyEdi
import CommandService from '@joplin/lib/services/CommandService';
import Button, { ButtonLevel } from '../Button/Button';
import eventManager, { EventName } from '@joplin/lib/eventManager';
import { AppState } from '../../app.reducer';
import { AppState, EditorCursorLocations } from '../../app.reducer';
import ToolbarButtonUtils, { ToolbarButtonInfo } from '@joplin/lib/services/commands/ToolbarButtonUtils';
import { _, _n } from '@joplin/lib/locale';
import NoteTitleBar from './NoteTitle/NoteTitleBar';
@@ -57,6 +57,7 @@ import StatusBar from './StatusBar';
import useVisiblePluginEditorViewIds from '@joplin/lib/hooks/plugins/useVisiblePluginEditorViewIds';
import useConnectToEditorPlugin from './utils/useConnectToEditorPlugin';
import getResourceBaseUrl from './utils/getResourceBaseUrl';
import useInitialCursorLocation from './utils/useInitialCursorLocation';
const debounce = require('debounce');
@@ -329,13 +330,14 @@ function NoteEditorContent(props: NoteEditorProps) {
});
}, [formNote, setFormNote, handleProvisionalFlag, props.dispatch]);
const { scrollWhenReady, clearScrollWhenReady } = useScrollWhenReadyOptions({
const { scrollWhenReadyRef, clearScrollWhenReady } = useScrollWhenReadyOptions({
noteId: formNote.id,
selectedNoteHash: props.selectedNoteHash,
lastEditorScrollPercents: props.lastEditorScrollPercents,
editorRef,
editorName: props.bodyEditor,
});
const onMessage = useMessageHandler(scrollWhenReady, clearScrollWhenReady, windowId, editorRef, setLocalSearchResultCount, props.dispatch, formNote, htmlToMarkdown, markupToHtml);
const onMessage = useMessageHandler(scrollWhenReadyRef, clearScrollWhenReady, windowId, editorRef, setLocalSearchResultCount, props.dispatch, formNote, htmlToMarkdown, markupToHtml);
useResourceUnwatcher({ noteId: formNote.id, windowId });
@@ -409,6 +411,14 @@ function NoteEditorContent(props: NoteEditorProps) {
});
}, [props.dispatch]);
const onCursorMotion = useCallback((location: EditorCursorLocations) => {
props.dispatch({
type: 'EDITOR_CURSOR_POSITION_SET',
noteId: formNoteRef.current.id,
location,
});
}, [props.dispatch]);
function renderNoNotes(rootStyle: React.CSSProperties) {
const emptyDivStyle = {
backgroundColor: 'black',
@@ -419,6 +429,9 @@ function NoteEditorContent(props: NoteEditorProps) {
}
const searchMarkers = useSearchMarkers(showLocalSearch, localSearchMarkerOptions, props.searches, props.selectedSearchId, props.highlightedWords);
const initialCursorLocation = useInitialCursorLocation({
lastEditorCursorLocations: props.lastEditorCursorLocations, noteId: props.noteId,
});
const markupLanguage = formNote.markup_language;
const editorProps: NoteBodyEditorPropsAndRef = {
@@ -432,6 +445,7 @@ function NoteEditorContent(props: NoteEditorProps) {
content: formNote.body,
contentMarkupLanguage: markupLanguage,
contentOriginalCss: formNote.originalCss,
initialCursorLocation,
resourceInfos: resourceInfos,
resourceDirectory: Setting.value('resourceDir'),
htmlToMarkdown: htmlToMarkdown,
@@ -442,6 +456,7 @@ function NoteEditorContent(props: NoteEditorProps) {
dispatch: props.dispatch,
noteToolbar: null,
onScroll: onScroll,
onCursorMotion,
setLocalSearchResultCount: setLocalSearchResultCount,
setLocalSearch: localSearch_change,
setShowLocalSearch,
@@ -729,6 +744,7 @@ const mapStateToProps = (state: AppState, ownProps: ConnectProps) => {
notesParentType: windowState.notesParentType,
selectedNoteTags: windowState.selectedNoteTags,
lastEditorScrollPercents: state.lastEditorScrollPercents,
lastEditorCursorLocations: state.lastEditorCursorLocations,
selectedNoteHash: windowState.selectedNoteHash,
searches: state.searches,
selectedSearchId: windowState.selectedSearchId,

View File

@@ -14,6 +14,7 @@ import { ScrollbarSize } from '@joplin/lib/models/settings/builtInMetadata';
import { RefObject, SetStateAction } from 'react';
import * as React from 'react';
import { ResourceEntity, ResourceLocalStateEntity } from '@joplin/lib/services/database/types';
import { EditorCursorLocations, NoteIdToEditorCursorLocations, NoteIdToScrollPercent } from '../../../app.reducer';
export interface AllAssetsOptions {
contentMaxWidthTarget?: string;
@@ -40,8 +41,8 @@ export interface NoteEditorProps {
notesParentType: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
selectedNoteTags: any[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
lastEditorScrollPercents: any;
lastEditorScrollPercents: NoteIdToScrollPercent;
lastEditorCursorLocations: NoteIdToEditorCursorLocations;
selectedNoteHash: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
searches: any[];
@@ -83,6 +84,14 @@ export interface NoteBodyEditorRef {
export { MarkupToHtmlOptions };
export type MarkupToHtmlHandler = (markupLanguage: MarkupLanguage, markup: string, options: MarkupToHtmlOptions)=> Promise<RenderResult>;
export type HtmlToMarkdownHandler = (markupLanguage: number, html: string, originalCss: string, parseOptions?: ParseOptions)=> Promise<string>;
export type OnCursorMotion = (event: EditorCursorLocations)=> void;
export interface MessageEvent {
channel: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Partially refactored old code before rule was applied
args?: any[];
}
export type OnMessage = (event: MessageEvent)=> void;
export interface NoteBodyEditorProps {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
@@ -102,12 +111,13 @@ export interface NoteBodyEditorProps {
contentKey: string;
contentMarkupLanguage: number;
contentOriginalCss: string;
initialCursorLocation: EditorCursorLocations;
onChange(event: OnChangeEvent): void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
onWillChange(event: any): void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
onMessage(event: any): void;
onMessage: OnMessage;
onScroll(event: { percent: number }): void;
onCursorMotion: OnCursorMotion;
markupToHtml: MarkupToHtmlHandler;
htmlToMarkdown: HtmlToMarkdownHandler;
allAssets: (markupLanguage: MarkupLanguage, options: AllAssetsOptions)=> Promise<RenderResultPluginAsset[]>;

View File

@@ -0,0 +1,17 @@
import { useMemo } from 'react';
import { EditorCursorLocations, NoteIdToEditorCursorLocations } from '../../../app.reducer';
interface Props {
lastEditorCursorLocations: NoteIdToEditorCursorLocations;
noteId: string;
}
const useInitialCursorLocation = ({ noteId, lastEditorCursorLocations }: Props) => {
const lastCursorLocation = lastEditorCursorLocations[noteId];
return useMemo((): EditorCursorLocations => {
return lastCursorLocation ?? { };
}, [lastCursorLocation]);
};
export default useInitialCursorLocation;

View File

@@ -1,5 +1,5 @@
import { useCallback } from 'react';
import { FormNote, HtmlToMarkdownHandler, MarkupToHtmlHandler, ScrollOptions } from './types';
import { RefObject, useCallback } from 'react';
import { FormNote, HtmlToMarkdownHandler, MarkupToHtmlHandler, ScrollOptions, MessageEvent } from './types';
import contextMenu from './contextMenu';
import CommandService from '@joplin/lib/services/CommandService';
import PostMessageService from '@joplin/lib/services/PostMessageService';
@@ -8,7 +8,7 @@ import { reg } from '@joplin/lib/registry';
import bridge from '../../../services/bridge';
export default function useMessageHandler(
scrollWhenReady: ScrollOptions|null,
scrollWhenReadyRef: RefObject<ScrollOptions|null>,
clearScrollWhenReady: ()=> void,
windowId: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
@@ -21,8 +21,7 @@ export default function useMessageHandler(
htmlToMd: HtmlToMarkdownHandler,
mdToHtml: MarkupToHtmlHandler,
) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
return useCallback(async (event: any) => {
return useCallback(async (event: MessageEvent) => {
const msg = event.channel ? event.channel : '';
const args = event.args;
const arg0 = args && args.length >= 1 ? args[0] : null;
@@ -35,8 +34,8 @@ export default function useMessageHandler(
s.splice(0, 1);
reg.logger().error(s.join(':'));
} else if (msg === 'noteRenderComplete') {
if (scrollWhenReady) {
const options = { ...scrollWhenReady };
if (scrollWhenReadyRef.current) {
const options = { ...scrollWhenReadyRef.current };
clearScrollWhenReady();
editorRef.current.scrollTo(options);
}
@@ -78,5 +77,5 @@ export default function useMessageHandler(
// bridge().showErrorMessageBox(_('Unsupported link or message: %s', msg));
}
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [dispatch, setLocalSearchResultCount, scrollWhenReady, formNote]);
}, [dispatch, setLocalSearchResultCount, scrollWhenReadyRef, formNote]);
}

View File

@@ -1,41 +1,48 @@
import { RefObject, useCallback, useEffect, useRef, useState } from 'react';
import { RefObject, useCallback, useRef } from 'react';
import { NoteBodyEditorRef, ScrollOptions, ScrollOptionTypes } from './types';
import usePrevious from '@joplin/lib/hooks/usePrevious';
import type { EditorScrollPercents } from '../../../app.reducer';
import type { NoteIdToScrollPercent } from '../../../app.reducer';
import useNowEffect from '@joplin/lib/hooks/useNowEffect';
interface Props {
noteId: string;
editorName: string;
selectedNoteHash: string;
lastEditorScrollPercents: EditorScrollPercents;
lastEditorScrollPercents: NoteIdToScrollPercent;
editorRef: RefObject<NoteBodyEditorRef>;
}
const useScrollWhenReadyOptions = ({ noteId, selectedNoteHash, lastEditorScrollPercents, editorRef }: Props) => {
const [scrollWhenReady, setScrollWhenReady] = useState<ScrollOptions|null>(null);
const useScrollWhenReadyOptions = ({ noteId, editorName, selectedNoteHash, lastEditorScrollPercents, editorRef }: Props) => {
const scrollWhenReadyRef = useRef<ScrollOptions|null>(null);
const previousNoteId = usePrevious(noteId);
const lastScrollPercentsRef = useRef<EditorScrollPercents>(null);
const noteIdChanged = noteId !== previousNoteId;
const previousEditor = usePrevious(editorName);
const editorChanged = editorName !== previousEditor;
const lastScrollPercentsRef = useRef<NoteIdToScrollPercent>(null);
lastScrollPercentsRef.current = lastEditorScrollPercents;
useEffect(() => {
if (noteId === previousNoteId) return;
// This needs to be a nowEffect to prevent race conditions
useNowEffect(() => {
if (!editorChanged && !noteIdChanged) return () => {};
if (editorRef.current) {
editorRef.current.resetScroll();
}
const lastScrollPercent = lastScrollPercentsRef.current[noteId] || 0;
setScrollWhenReady({
scrollWhenReadyRef.current = {
type: selectedNoteHash ? ScrollOptionTypes.Hash : ScrollOptionTypes.Percent,
value: selectedNoteHash ? selectedNoteHash : lastScrollPercent,
});
}, [noteId, previousNoteId, selectedNoteHash, editorRef]);
};
return () => {};
}, [editorChanged, noteIdChanged, noteId, selectedNoteHash, editorRef]);
const clearScrollWhenReady = useCallback(() => {
setScrollWhenReady(null);
scrollWhenReadyRef.current = null;
}, []);
return { scrollWhenReady, clearScrollWhenReady };
return { scrollWhenReadyRef, clearScrollWhenReady };
};
export default useScrollWhenReadyOptions;

View File

@@ -501,7 +501,12 @@ class NotePropertiesDialog extends React.Component<Props, State> {
<div role='table' aria-labelledby='note-properties-dialog-title'>
{noteComps}
</div>
<DialogButtonRow themeId={this.props.themeId} okButtonShow={!this.isReadOnly()} okButtonRef={this.okButton} onClick={this.buttonRow_click}/>
<DialogButtonRow
themeId={this.props.themeId}
okButtonShow={!this.isReadOnly()}
okButtonRef={this.okButton}
onClick={this.buttonRow_click}
/>
</Dialog>
);
}

View File

@@ -71,7 +71,7 @@ async function initialize() {
panes: Setting.value('noteVisiblePanes'),
});
InteropService.instance().document = document;
InteropService.instance().domParser = new DOMParser();
InteropService.instance().xmlSerializer = new XMLSerializer();
}

View File

@@ -1,9 +1,9 @@
import * as React from 'react';
import Dialog from '../Dialog';
import DialogButtonRow, { ClickEvent, ButtonSpec } from '../DialogButtonRow';
import DialogButtonRow, { ClickEvent } from '../DialogButtonRow';
import DialogTitle from '../DialogTitle';
import { _ } from '@joplin/lib/locale';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { FolderEntity } from '@joplin/lib/services/database/types';
import Folder from '@joplin/lib/models/Folder';
import ShareService, { ApiShare } from '@joplin/lib/services/share/ShareService';
@@ -129,7 +129,6 @@ function ShareFolderDialog(props: Props) {
const [share, setShare] = useState<StateShare>(null);
const [shareUsers, setShareUsers] = useState<StateShareUser[]>([]);
const [shareState, setShareState] = useState<ShareState>(ShareState.Idle);
const [customButtons, setCustomButtons] = useState<ButtonSpec[]>([]);
const [recipientsBeingUpdated, setRecipientsBeingUpdated] = useState<Record<string, boolean>>({});
async function synchronize(event: AsyncEffectEvent = null) {
@@ -163,13 +162,6 @@ function ShareFolderDialog(props: Props) {
void ShareService.instance().refreshShareUsers(share.id);
}, [share]);
useEffect(() => {
setCustomButtons(share ? [{
name: 'unshare',
label: _('Unshare'),
}] : []);
}, [share]);
useEffect(() => {
if (!share) return;
const sus = props.shareUsers[share.id];
@@ -177,10 +169,6 @@ function ShareFolderDialog(props: Props) {
setShareUsers(sus);
}, [share, props.shareUsers]);
useEffect(() => {
void ShareService.instance().refreshShares();
}, [props.folderId]);
const permissionsFromString = (p: string): SharePermissions => {
return {
can_read: 1,
@@ -269,7 +257,7 @@ function ShareFolderDialog(props: Props) {
}, []);
function renderAddRecipient() {
const disabled = shareState !== ShareState.Idle;
const disabled = shareState !== ShareState.Idle && shareState !== ShareState.Synchronizing;
const dropdown = !props.canUseSharePermissions ? null : <Dropdown className="permission-dropdown" options={permissionOptions} value={recipientPermissions} onChange={recipientPermissions_change}/>;
@@ -395,6 +383,17 @@ function ShareFolderDialog(props: Props) {
props.onClose();
}
const customButtons = useMemo(() => {
return share ? [{
name: 'unshare',
label: _('Unshare'),
// Don't allow unsharing the folder during the "create" action. Doing so might
// be able to cause issues similar to #13518 (e.g. if the "unshare" action completes while
// the "share" action is still in progress).
disabled: shareState === ShareState.Creating || shareState === ShareState.Synchronizing,
}] : [];
}, [share, shareState]);
function renderContent() {
return (
<StyledRoot className="share-folder-dialog">

View File

@@ -356,6 +356,7 @@ const useOnRenderItem = (props: Props) => {
onClick={tagItem_click}
onTagDrop={onTagDrop_}
onContextMenu={onItemContextMenu}
label={item.label}
tag={tag}
itemCount={itemCount}
index={index}
@@ -384,7 +385,7 @@ const useOnRenderItem = (props: Props) => {
anchorRef={anchorRef}
selected={selected}
folderId={folder.id}
folderTitle={Folder.displayTitle(folder)}
folderTitle={item.label}
folderIcon={Folder.unserializeIcon(folder.icon)}
depth={item.depth}
isExpanded={isExpanded}

View File

@@ -49,6 +49,24 @@ const getParentOffset = (childIndex: number, listItems: ListItem[]): number|null
return null;
};
const findNextTypeAheadMatch = (selectedIndex: number, query: string, listItems: ListItem[]) => {
const matches = (item: ListItem) => {
return item.label.startsWith(query);
};
const indexBefore = listItems.slice(0, selectedIndex).findIndex(matches);
// Search in all results **after** the current. This prevents the current item from
// always being identified as the next match, if the user repeatedly presses the
// same key.
const startAfter = selectedIndex + 1;
let indexAfter = listItems.slice(startAfter).findIndex(matches);
if (indexAfter !== -1) {
indexAfter += startAfter;
}
// Prefer jumping to the next match, rather than the previous
const matchingIndex = indexAfter !== -1 ? indexAfter : indexBefore;
return matchingIndex;
};
const useOnSidebarKeyDownHandler = (props: Props) => {
const { updateSelectedIndex, listItems, selectedIndex, collapsedFolderIds, dispatch } = props;
@@ -56,6 +74,8 @@ const useOnSidebarKeyDownHandler = (props: Props) => {
const selectedItem = listItems[selectedIndex];
let indexChange = 0;
const ctrlAltOrMeta = event.ctrlKey || event.altKey || event.metaKey;
if (selectedItem && isToggleShortcut(event.code, selectedItem, collapsedFolderIds)) {
event.preventDefault();
@@ -82,9 +102,22 @@ const useOnSidebarKeyDownHandler = (props: Props) => {
indexChange = 1;
} else if ((event.ctrlKey || event.metaKey) && event.code === 'KeyA') { // ctrl+a or cmd+a
event.preventDefault();
} else if (event.code === 'Home') {
event.preventDefault();
updateSelectedIndex(0);
indexChange = 0;
} else if (event.code === 'End') {
event.preventDefault();
updateSelectedIndex(listItems.length - 1);
indexChange = 0;
} else if (event.code === 'Enter' && !event.shiftKey) {
event.preventDefault();
void CommandService.instance().execute('focusElement', 'noteList');
} else if (selectedIndex && selectedIndex >= 0 && event.key.length === 1 && !ctrlAltOrMeta) {
const nextMatch = findNextTypeAheadMatch(selectedIndex, event.key, listItems);
if (nextMatch !== -1) {
indexChange = nextMatch - selectedIndex;
}
}
if (indexChange !== 0) {

View File

@@ -4,6 +4,8 @@ import { FolderEntity, TagsWithNoteCountEntity } from '@joplin/lib/services/data
import { buildFolderTree, renderFolders, renderTags } from '@joplin/lib/components/shared/side-menu-shared';
import { _ } from '@joplin/lib/locale';
import toggleHeader from './utils/toggleHeader';
import Folder from '@joplin/lib/models/Folder';
import Tag from '@joplin/lib/models/Tag';
interface Props {
tags: TagsWithNoteCountEntity[];
@@ -18,6 +20,7 @@ const useSidebarListData = (props: Props): ListItem[] => {
return renderTags<ListItem>(props.tags, (tag): TagListItem => {
return {
kind: ListItemType.Tag,
label: Tag.displayTitle(tag),
tag,
key: tag.id,
depth: 1,
@@ -38,6 +41,7 @@ const useSidebarListData = (props: Props): ListItem[] => {
return renderFolders<ListItem>(renderProps, (folder, hasChildren, depth): FolderListItem => {
return {
kind: ListItemType.Folder,
label: Folder.displayTitle(folder),
folder,
hasChildren,
// The toplevel headers have depth 1, so the toplevel notebook needs
@@ -65,9 +69,9 @@ const useSidebarListData = (props: Props): ListItem[] => {
hasChildren: folderItems.items.length > 0,
};
const foldersSectionContent: ListItem[] = props.folderHeaderIsExpanded ? [
{ kind: ListItemType.AllNotes, key: 'all-notes', depth: 2, hasChildren: false },
{ kind: ListItemType.AllNotes, label: _('All notes'), key: 'all-notes', depth: 2, hasChildren: false },
...folderItems.items,
{ kind: ListItemType.Spacer, key: 'after-folders-spacer', depth: 1, hasChildren: false },
{ kind: ListItemType.Spacer, label: '', key: 'after-folders-spacer', depth: 1, hasChildren: false },
] : [];
const tagsHeader: HeaderListItem = {

View File

@@ -7,7 +7,6 @@ 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 { _ } from '@joplin/lib/locale';
import { connect } from 'react-redux';
import EmptyExpandLink from './EmptyExpandLink';
import ListItemWrapper, { ListItemRef } from './ListItemWrapper';
@@ -70,7 +69,7 @@ const AllNotesItem: React.FC<Props> = props => {
onClick={onAllNotesClick_}
onContextMenu={toggleAllNotesContextMenu}
>
{_('All notes')}
{props.item.label}
</StyledListItemAnchor>
</ListItemWrapper>
);

View File

@@ -1,5 +1,5 @@
import * as React from 'react';
import { useCallback, useState } from 'react';
import { useCallback } from 'react';
import { StyledHeader, StyledHeaderIcon, StyledHeaderLabel } from '../styles';
import { HeaderId, HeaderListItem } from '../types';
import bridge from '../../../services/bridge';
@@ -25,8 +25,6 @@ const HeaderItem: React.FC<Props> = props => {
const item = props.item;
const onItemClick = item.onClick;
const itemId = item.id;
const [isHovered, setIsHovered] = useState(false);
const expanded = item.expanded;
const onClick: React.MouseEventHandler<HTMLElement> = useCallback(event => {
if (onItemClick) {
@@ -46,14 +44,6 @@ const HeaderItem: React.FC<Props> = props => {
}
}, [itemId]);
const handleMouseEnter = useCallback(() => {
setIsHovered(true);
}, []);
const handleMouseLeave = useCallback(() => {
setIsHovered(false);
}, []);
return (
<ListItemWrapper
containerRef={props.anchorRef}
@@ -70,10 +60,8 @@ const HeaderItem: React.FC<Props> = props => {
>
<StyledHeader
onClick={onClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<StyledHeaderIcon aria-hidden='true' role='img' className={isHovered ? `fas ${expanded ? 'fa-caret-down' : 'fa-caret-right'}` : item.iconName}/>
<StyledHeaderIcon aria-hidden='true' role='img' className={item.iconName}/>
<StyledHeaderLabel>{item.label}</StyledHeaderLabel>
</StyledHeader>
</ListItemWrapper>

View File

@@ -5,7 +5,6 @@ import { StyledListItemAnchor, StyledSpanFix } from '../styles';
import { TagsWithNoteCountEntity } from '@joplin/lib/services/database/types';
import BaseModel from '@joplin/lib/BaseModel';
import NoteCount from './NoteCount';
import Tag from '@joplin/lib/models/Tag';
import EmptyExpandLink from './EmptyExpandLink';
import ListItemWrapper, { ListItemRef } from './ListItemWrapper';
@@ -15,6 +14,7 @@ interface Props {
anchorRef: ListItemRef;
selected: boolean;
tag: TagsWithNoteCountEntity;
label: string;
onTagDrop: React.DragEventHandler<HTMLElement>;
onContextMenu: React.MouseEventHandler<HTMLElement>;
onClick: (event: TagLinkClickEvent)=> void;
@@ -58,7 +58,7 @@ const TagItem = (props: Props) => {
onContextMenu={props.onContextMenu}
onClick={onClickHandler}
>
<StyledSpanFix className="tag-label">{Tag.displayTitle(tag)}</StyledSpanFix>
<StyledSpanFix className="tag-label">{props.label}</StyledSpanFix>
{noteCount}
</StyledListItemAnchor>
</ListItemWrapper>

View File

@@ -16,6 +16,8 @@ export enum ListItemType {
interface BaseListItem {
key: string;
// Used for typeahead
label: string;
depth: number;
hasChildren: boolean;
}
@@ -26,7 +28,6 @@ interface ToplevelListItem extends BaseListItem {
export interface HeaderListItem extends ToplevelListItem {
kind: ListItemType.Header;
label: string;
expanded: boolean;
iconName: string;
id: HeaderId;

View File

@@ -38,7 +38,7 @@ describe('NoteListUtils', () => {
const mockStore = {
getState: () => {
return {
...createAppDefaultWindowState(),
...createAppDefaultWindowState(null),
settings: {},
};
},

View File

@@ -180,8 +180,8 @@ test.describe('markdownEditor', () => {
await expect(matches).toHaveCount(1);
// Should continue searching after switching to view-only mode
await noteEditor.toggleEditorLayoutButton.click();
await noteEditor.toggleEditorLayoutButton.click();
await noteEditor.toggleEditorLayout();
await noteEditor.toggleEditorLayout();
await expect(noteEditor.codeMirrorEditor).not.toBeVisible();
await expect(noteEditor.editorSearchInput).not.toBeVisible();
await expect(noteEditor.viewerSearchInput).toBeVisible();
@@ -194,7 +194,7 @@ test.describe('markdownEditor', () => {
await expect(matches).toHaveCount(0);
// After showing the viewer again, search should still be hidden
await noteEditor.toggleEditorLayoutButton.click();
await noteEditor.toggleEditorLayout();
await expect(noteEditor.codeMirrorEditor).toBeVisible();
await expect(noteEditor.editorSearchInput).not.toBeVisible();
});
@@ -274,5 +274,68 @@ test.describe('markdownEditor', () => {
expect(imageSize[0]).toBeGreaterThan(0);
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();
await mainScreen.createNewNote('Original');
const noteEditor = mainScreen.noteEditor;
await noteEditor.hideViewer();
await noteEditor.focusCodeMirrorEditor();
await mainWindow.keyboard.type('# Test');
await mainWindow.keyboard.press('Enter');
await mainWindow.keyboard.type('## Test 2');
await mainWindow.keyboard.press('Enter');
await mainWindow.keyboard.type('### Test 3');
const editorContent = await noteEditor.contentLocator();
// Extract the note ID
const note1Locator = mainScreen.noteList.getNoteItemByTitle('Original');
await note1Locator.dragTo(editorContent);
const linkExpression = /\[[^\]]*\]\(:\/([a-z0-9]{32})\)/;
await noteEditor.expectToHaveText(linkExpression);
const targetNoteId = (await editorContent.textContent()).match(linkExpression)[1];
await mainScreen.createNewNote('Test note links');
// Create a new link to a header
await noteEditor.focusCodeMirrorEditor();
await mainWindow.keyboard.press('Enter');
await mainWindow.keyboard.press('Enter');
await mainWindow.keyboard.type('[link](:/');
await mainWindow.keyboard.type(targetNoteId);
await mainWindow.keyboard.type('#test-2');
await mainWindow.keyboard.type(')');
await mainWindow.keyboard.press('Enter');
// Clicking the link should navigate to note1
const link = editorContent.getByText(/\[?link\]?/);
await link.click({ modifiers: ['ControlOrMeta'] });
await expect(noteEditor.noteTitleInput).toHaveValue('Original');
await noteEditor.expectToHaveText(/^# Test/);
await expect.poll(() => editorContent.evaluate(async editor => {
const selection = getSelection();
return editor.contains(selection.anchorNode);
})).toBe(true);
// The cursor should be positioned on the linked-to header
await expect.poll(async () => {
await mainWindow.keyboard.type('[[cursor]]');
await noteEditor.expectToHaveText(/## Test 2\[\[cursor\]\]/);
return true;
}).toBe(true);
});
test('should still support the legacy Markdown editor', async ({ electronApp, mainWindow }) => {
const mainScreen = await new MainScreen(mainWindow).setup();
await mainScreen.waitFor();
await setSettingValue(electronApp, mainWindow, 'editor.legacyMarkdown', true);
await mainScreen.createNewNote('Test');
// Should show the legacy editor
await expect(mainWindow.locator('.rli-editor .CodeMirror5')).toBeVisible();
});
});

View File

@@ -65,12 +65,20 @@ export default class NoteEditorPage {
}
}
public async expectToHaveText(content: string) {
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.
await expect.poll(
async () => (await this.contentLocator()).textContent(),
).toBe(content);
const expectResult = expect.poll(
// Use .innerText: textContent doesn't handle line breaks correctly in the CodeMirror
// editor.
async () => (await this.contentLocator()).innerText(),
);
// Allow `expected` to be either an exact match (a string) or a pattern
if (typeof expected === 'string') {
await expectResult.toBe(expected);
} else {
await expectResult.toMatch(expected);
}
}
public getNoteViewerFrameLocator() {
@@ -117,4 +125,14 @@ export default class NoteEditorPage {
await expect(backButton).not.toBeDisabled();
await backButton.click();
}
public async toggleEditorLayout() {
await this.toggleEditorLayoutButton.click();
}
public async hideViewer() {
await expect(this.noteViewerContainer).toBeVisible();
await this.toggleEditorLayout();
await expect(this.noteViewerContainer).not.toBeVisible();
}
}

View File

@@ -30,7 +30,7 @@ test.describe('pluginApi', () => {
await mainScreen.createNewNote('First note');
const editor = mainScreen.noteEditor;
await editor.expectToHaveText('');
await editor.expectToHaveText('\n');
await mainScreen.goToAnything.runCommand(app, 'showTestDialog');
// Wait for the iframe to load

View File

@@ -44,6 +44,28 @@ test.describe('sidebar', () => {
await expect(mainWindow.locator(':focus')).toHaveText('All notes');
});
test('should allow changing the focused folder by pressing the first character of the title', async ({ electronApp, mainWindow }) => {
const mainScreen = await new MainScreen(mainWindow).setup();
const sidebar = mainScreen.sidebar;
const folderAHeader = await sidebar.createNewFolder('1-Test A');
await expect(folderAHeader).toBeVisible();
const folderBHeader = await sidebar.createNewFolder('Folder b');
await expect(folderBHeader).toBeVisible();
await folderBHeader.click();
await sidebar.forceUpdateSorting(electronApp);
await folderBHeader.click();
await mainWindow.keyboard.type('1');
await expect(mainWindow.locator(':focus')).toHaveText('1-Test A');
await mainWindow.keyboard.type('F');
await expect(mainWindow.locator(':focus')).toHaveText('Folder b');
await mainWindow.keyboard.type('A');
await expect(mainWindow.locator(':focus')).toHaveText('All notes');
});
test('left/right arrow keys should expand/collapse notebooks', async ({ electronApp, mainWindow }) => {
const mainScreen = await new MainScreen(mainWindow).setup();
const sidebar = mainScreen.sidebar;

View File

@@ -1,6 +1,6 @@
{
"name": "@joplin/app-desktop",
"version": "3.5.3",
"version": "3.5.6",
"description": "Joplin for Desktop",
"main": "main.bundle.js",
"private": true,
@@ -12,10 +12,11 @@
"electronRebuild": "gulp electronRebuild",
"tsc": "tsc --project tsconfig.json",
"watch": "tsc --watch --preserveWatchOutput --project tsconfig.json",
"start": "gulp before-start && JOPLIN_SOURCE_MAP_ENABLED=1 electron . --env dev --log-level debug --open-dev-tools --no-welcome",
"start": "gulp before-start && electron . --env dev --log-level debug --open-dev-tools --no-welcome",
"test": "jest",
"test-ui": "gulp before-start && playwright test",
"test-ci": "yarn test",
"resolve-sourcemap": "node tools/resolveSourceMap.js",
"modifyReleaseAssets": "node tools/modifyReleaseAssets.js"
},
"repository": {
@@ -148,7 +149,7 @@
"@testing-library/react-hooks": "8.0.1",
"@types/jest": "29.5.14",
"@types/mustache": "4.2.6",
"@types/node": "18.19.118",
"@types/node": "18.19.130",
"@types/react": "18.3.23",
"@types/react-dom": "18.3.7",
"@types/react-redux": "7.1.33",
@@ -160,7 +161,7 @@
"compare-versions": "6.1.1",
"countable": "3.0.1",
"debounce": "1.2.1",
"electron": "37.4.0",
"electron": "37.7.0",
"electron-builder": "24.13.3",
"electron-updater": "6.6.2",
"electron-window-state": "5.0.3",
@@ -201,12 +202,12 @@
"taboverride": "4.0.3",
"tesseract.js": "6.0.1",
"tinymce": "6.8.5",
"ts-jest": "29.3.4",
"ts-jest": "29.4.1",
"ts-node": "10.9.2",
"typescript": "5.8.3"
},
"dependencies": {
"@electron/remote": "2.1.2",
"@electron/remote": "2.1.3",
"@joplin/onenote-converter": "~3.5",
"fs-extra": "11.2.0",
"keytar": "7.9.0",

View File

@@ -4,13 +4,25 @@ const { execSync } = require('child_process');
const { chdir, cwd } = require('process');
const { mkdirpSync, moveSync, pathExists } = require('fs-extra');
const { readdirSync, writeFileSync } = require('fs');
const { dirname } = require('path');
const signToolName = 'CodeSignTool.bat';
const getTempDir = () => {
if (process.env.RUNNER_TEMP) return process.env.RUNNER_TEMP;
if (process.env.GITHUB_WORKSPACE) return process.env.GITHUB_WORKSPACE;
const output = `${dirname(dirname(__dirname))}/temp`;
mkdirpSync(output);
return output;
};
const tempDir = getTempDir();
const downloadSignTool = async () => {
const signToolUrl = 'https://www.ssl.com/download/codesigntool-for-windows/';
const downloadDir = `${__dirname}/signToolDownloadTemp`;
const extractDir = `${__dirname}/signToolExtractTemp`;
const downloadDir = `${tempDir}/signToolDownloadTemp`;
const extractDir = `${tempDir}/signToolExtractTemp`;
if (await pathExists(`${extractDir}/${signToolName}`)) {
console.info('sign.js: Sign tool has already been downloaded - skipping');
@@ -55,6 +67,8 @@ exports.default = async (configuration) => {
console.info('sign.js: File to sign:', inputFilePath);
console.info('sign.js: Using temp dir:', tempDir);
if (SIGN_APPLICATION !== '1') {
console.info('sign.js: SIGN_APPLICATION != 1 - not signing application');
return;
@@ -63,9 +77,8 @@ exports.default = async (configuration) => {
console.info('sign.js: SIGN_APPLICATION = 1 - signing application');
const signToolDir = await downloadSignTool();
const tempDir = `${__dirname}/temp`;
mkdirpSync(tempDir);
const signToolOutDir = `${tempDir}/signedToolOutDir`;
mkdirpSync(signToolOutDir);
const previousDir = cwd();
chdir(signToolDir);
@@ -74,7 +87,7 @@ exports.default = async (configuration) => {
const cmd = [
`${signToolName} sign`,
`-input_file_path="${inputFilePath}"`,
`-output_dir_path="${tempDir}"`,
`-output_dir_path="${signToolOutDir}"`,
`-credential_id="${SSL_ESIGNER_CREDENTIAL_ID}"`,
`-username="${SSL_ESIGNER_USER_NAME}"`,
`-password="${SSL_ESIGNER_USER_PASSWORD}"`,
@@ -83,10 +96,10 @@ exports.default = async (configuration) => {
execSync(cmd.join(' '));
const createdFiles = readdirSync(tempDir);
const createdFiles = readdirSync(signToolOutDir);
console.info('sign.js: Created files:', createdFiles);
moveSync(`${tempDir}/${createdFiles[0]}`, inputFilePath, { overwrite: true });
moveSync(`${signToolOutDir}/${createdFiles[0]}`, inputFilePath, { overwrite: true });
} catch (error) {
console.error('sign.js: Could not sign file:', error);
process.exit(1);

View File

@@ -9,7 +9,7 @@ const baseNodeModules = join(baseDir, 'node_modules');
// Note: Roughly based on js-draw's use of esbuild:
// https://github.com/personalizedrefrigerator/js-draw/blob/6fe6d6821402a08a8d17f15a8f48d95e5d7b084f/packages/build-tool/src/BundledFile.ts#L64
const makeBuildContext = (entryPoint: string, renderer: boolean, computeFileSizeStats: boolean) => {
const makeBuildContext = (entryPoint: string, renderer: boolean, addDebugStats: boolean) => {
return esbuild.context({
entryPoints: [entryPoint],
outfile: `${filename(entryPoint)}.bundle.js`,
@@ -19,7 +19,7 @@ const makeBuildContext = (entryPoint: string, renderer: boolean, computeFileSize
format: 'iife', // Immediately invoked function expression
sourcemap: true,
sourcesContent: false, // Do not embed full source file content in the .map file
metafile: computeFileSizeStats,
metafile: addDebugStats,
platform: 'node',
target: ['node20.0'],
mainFields: renderer ? ['browser', 'main'] : ['main'],
@@ -92,26 +92,29 @@ const makeBuildContext = (entryPoint: string, renderer: boolean, computeFileSize
{
name: 'joplin--smaller-source-map-size',
setup: build => {
// Exclude dependencies from node_modules. This significantly reduces the size of the
// Unless bundling with additional debug information, exclude 3rd-party
// dependencies from source maps. This significantly reduces the size of the
// source map, improving startup performance.
//
// See https://github.com/evanw/esbuild/issues/1685#issuecomment-944916409
// and https://github.com/evanw/esbuild/issues/4130
const emptyMapData = Buffer.from(
JSON.stringify({ version: 3, sources: [null], mappings: 'AAAA' }),
'utf-8',
).toString('base64');
const emptyMapUrl = `data:application/json;base64,${emptyMapData}`;
if (!addDebugStats) {
const emptyMapData = Buffer.from(
JSON.stringify({ version: 3, sources: [null], mappings: 'AAAA' }),
'utf-8',
).toString('base64');
const emptyMapUrl = `data:application/json;base64,${emptyMapData}`;
build.onLoad({ filter: /node_modules.*js$/ }, args => {
return {
contents: [
readFileSync(args.path, 'utf8'),
`//# sourceMappingURL=${emptyMapUrl}`,
].join('\n'),
loader: 'default',
};
});
build.onLoad({ filter: /node_modules.*js$/ }, args => {
return {
contents: [
readFileSync(args.path, 'utf8'),
`//# sourceMappingURL=${emptyMapUrl}`,
].join('\n'),
loader: 'default',
};
});
}
},
},
],

View File

@@ -0,0 +1,55 @@
import { dirname, relative } from 'path';
import * as yargs from 'yargs';
const { wrapCallSite } = require('source-map-support');
/* eslint-disable no-console */
const resolveLine = (lineNumber: number, columnNumber: number, filePath: string) => {
// Note: This is an undocumented function provided by source-map-support. It
// may change in the future:
const frame = wrapCallSite({
getFileName: () => filePath,
isEval: ()=>false,
isNative: ()=>false,
getLineNumber: ()=>lineNumber,
getColumnNumber: ()=>columnNumber,
});
const baseDir = dirname(dirname(dirname(__dirname)));
const relativeFilePath = relative(baseDir, frame.getFileName());
return `${relativeFilePath}:${frame.getLineNumber()}`;
};
const resolvePosition = (position: string, sourceMap: string) => {
const match = /^(\d{1,10}):(\d{1,10})$/.exec(position.trim());
if (!match) {
throw new Error('Invalid format. Expected line:col');
}
const lineNumber = Number(match[1]);
const columnNumber = Number(match[2]);
return resolveLine(lineNumber, columnNumber, sourceMap);
};
void yargs
.usage('$0 [args]')
.command(
'$0 <position>',
'Resolves a position based on a source map. If resolving a position in a specific error message, be sure to use the source map generated by "yarn bundle" from that specific commit.',
(yargs) => {
return yargs.options({
'position': { type: 'string', help: 'A line:col position (e.g. 123:4567)' },
'sourcemap': {
type: 'string',
default: './main-html.bundle.js',
help: 'The path to the source map. This source map should be a source map compiled from the commit/release that created the error.',
},
});
},
async (args) => {
console.log(await resolvePosition(args.position, args.sourcemap));
process.exit(0);
},
)
.help()
.argv;

View File

@@ -1,6 +1,8 @@
// source-map-support can add 1-3 seconds to the application startup
// time -- disable it unless requested:
if (process.env.JOPLIN_SOURCE_MAP_ENABLED) {
// time. In the future, it may make sense to either:
// - Use Sentry for resolving source maps: https://docs.sentry.io/platforms/javascript/guides/electron/sourcemaps/
// - Use NodeJS source map support (if https://github.com/electron/electron/issues/38875 is resolved)
if (!process.env.JOPLIN_SOURCE_MAP_DISABLED) {
require('source-map-support').install();
}

View File

@@ -132,6 +132,7 @@ const EditorToolbar: React.FC<Props> = props => {
style={styles.content}
contentContainerStyle={styles.contentContainer}
onLayout={onContainerLayout}
keyboardShouldPersistTaps="always"
>
{buttonInfos.map(renderButton)}
<View style={styles.spacer}/>

View File

@@ -48,6 +48,11 @@ const useStyles = (themeId: number) => {
invisibleHeading: {
flexGrow: 1,
},
// Use compact mode on the button and expand the padding to match the original styling, to work around an Android issue #13120
buttonStyle: {
paddingLeft: 16,
paddingRight: 16,
},
});
}, [themeId]);
};
@@ -55,15 +60,22 @@ const useStyles = (themeId: number) => {
const ModalDialog: React.FC<Props> = props => {
const styles = useStyles(props.themeId);
const theme = themeStyle(props.themeId);
const containerStyle = !props.modalProps.containerStyle ? styles.container : {
...styles.container,
...props.modalProps.containerStyle,
};
const modalProps = {
...props.modalProps,
containerStyle,
} as Partial<ModalElementProps>;
return (
<Modal
transparent={true}
visible={true}
onRequestClose={null}
containerStyle={styles.container}
backgroundColor={theme.backgroundColorTransparent2}
{...props.modalProps}
{...modalProps}
>
<View style={styles.contentWrapper}>{props.children}</View>
<View style={styles.buttonRow}>
@@ -76,8 +88,8 @@ const ModalDialog: React.FC<Props> = props => {
accessible={true}
style={styles.invisibleHeading}
/>
<Button disabled={!props.buttonBarEnabled} onPress={props.onCancelPress}>{props.cancelTitle}</Button>
<PrimaryButton disabled={!props.buttonBarEnabled} onPress={props.onOkPress}>{props.okTitle}</PrimaryButton>
<Button compact contentStyle={styles.buttonStyle} disabled={!props.buttonBarEnabled} onPress={props.onCancelPress}>{props.cancelTitle}</Button>
<PrimaryButton compact contentStyle={styles.buttonStyle} disabled={!props.buttonBarEnabled} onPress={props.onOkPress}>{props.okTitle}</PrimaryButton>
</View>
</Modal>
);

View File

@@ -0,0 +1,76 @@
import * as React from 'react';
import { describe, it, beforeEach } from '@jest/globals';
import { render, waitFor } from '../../utils/testing/testingLibrary';
import Setting from '@joplin/lib/models/Setting';
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
import TestProviderStack from '../testing/TestProviderStack';
import createMockReduxStore from '../../utils/testing/createMockReduxStore';
import createTestEditorProps from './testing/createTestEditorProps';
import { EditorEvent, EditorEventType } from '@joplin/editor/events';
import { RefObject, useCallback } from 'react';
import { EditorCommandType, EditorControl } from '@joplin/editor/types';
import MarkdownEditor from './MarkdownEditor';
interface WrapperProps {
ref?: RefObject<EditorControl>;
onBodyChange: (newBody: string)=> void;
noteBody: string;
}
const defaultEditorProps = createTestEditorProps();
const testStore = createMockReduxStore();
const WrappedEditor: React.FC<WrapperProps> = (
{
noteBody,
onBodyChange,
ref,
}: WrapperProps,
) => {
const onEvent = useCallback((event: EditorEvent) => {
if (event.kind === EditorEventType.Change) {
onBodyChange(event.value);
}
}, [onBodyChange]);
return <TestProviderStack store={testStore}>
<MarkdownEditor
{...defaultEditorProps}
onEditorEvent={onEvent}
initialText={noteBody}
editorRef={ref ?? defaultEditorProps.editorRef}
/>
</TestProviderStack>;
};
describe('MarkdownEditor', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(0);
await switchClient(0);
Setting.setValue('editor.codeView', true);
});
// Regression test for #13193. This verifies that the editor can be reached
// over IPC.
it('should support the "textBold" command', async () => {
let editorBody = 'test';
const editorRef = React.createRef<EditorControl|null>();
render(<WrappedEditor
ref={editorRef}
noteBody={editorBody}
onBodyChange={newValue => { editorBody = newValue; }}
/>);
// Should mark the command as supported
expect(await editorRef.current.supportsCommand(EditorCommandType.ToggleBolded));
// Command should run
await editorRef.current.execCommand(EditorCommandType.SelectAll);
await editorRef.current.execCommand(EditorCommandType.ToggleBolded);
await waitFor(() => {
expect(editorBody).toBe('**test**');
});
});
});

View File

@@ -121,6 +121,7 @@ const useStyles = (theme: Theme) => {
height: buttonSize,
backgroundColor: theme.backgroundColor4,
color: theme.color4,
margin: 2,
},
buttonText: buttonTextStyle,
activeButtonText: {
@@ -352,7 +353,7 @@ export const SearchPanel = (props: SearchPanelProps) => {
);
const advancedLayout = (
<View style={{ flexDirection: 'column', alignItems: 'center' }}>
<View style={{ flexDirection: 'column' }}>
<View style={{ flexDirection: 'row' }}>
{ closeButton }
{ labeledSearchInput }

View File

@@ -10,6 +10,7 @@ import JoplinCloudIcon from './JoplinCloudIcon';
import NavService from '@joplin/lib/services/NavService';
import { StyleSheet, View } from 'react-native';
import CardButton from '../buttons/CardButton';
import Setting from '@joplin/lib/models/Setting';
interface Props {
dispatch: Dispatch;
@@ -86,6 +87,11 @@ const SyncWizard: React.FC<Props> = ({ themeId, visible, dispatch }) => {
});
}, [dispatch]);
const onManualDismiss = useCallback(() => {
Setting.setValue('sync.wizard.autoShowOnStartup', false);
onDismiss();
}, [onDismiss]);
const onSelectJoplinCloud = useCallback(async () => {
onDismiss();
await NavService.go('JoplinCloudLogin');
@@ -99,7 +105,7 @@ const SyncWizard: React.FC<Props> = ({ themeId, visible, dispatch }) => {
return <DismissibleDialog
themeId={themeId}
visible={visible}
onDismiss={onDismiss}
onDismiss={onManualDismiss}
size={DialogVariant.SmallResize}
scrollOverflow={true}
heading={_('Sync')}

View File

@@ -54,7 +54,6 @@ const useStyles = (themeId: number, headerStyle: TextStyle|undefined) => {
},
tagBoxRoot: {
flexDirection: 'column',
flexGrow: 0.5,
flexShrink: 1,
},
tagBoxScrollView: {
@@ -86,6 +85,7 @@ const useStyles = (themeId: number, headerStyle: TextStyle|undefined) => {
backgroundColor: theme.dividerColor,
},
tagSearch: {
flexGrow: 1,
flexShrink: 1,
},
noTagsLabel: {

View File

@@ -1,7 +1,7 @@
import * as React from 'react';
import { Card, TouchableRipple } from 'react-native-paper';
import { useMemo } from 'react';
import { StyleSheet, View, ViewStyle } from 'react-native';
import { Platform, StyleSheet, View, ViewStyle } from 'react-native';
export enum InstallState {
NotInstalled,
@@ -20,16 +20,25 @@ interface Props {
const useStyles = (disabled: boolean) => {
return useMemo(() => {
// For the TouchableRipple to work on Android, the card needs a transparent background.
const baseCard = { backgroundColor: 'transparent' };
const borderRadius = 12;
const baseCard = { backgroundColor: 'transparent', borderRadius };
return StyleSheet.create({
cardOuterWrapper: {
margin: 0,
padding: 0,
borderRadius: 12,
borderRadius,
overflow: 'hidden',
// Accessibility: Prevent the 'overflow: hidden' from hiding the focus indicator
// on web. Only apply to web, as this causes the touchable ripple
// from being completely contained within the card on non-web platforms.
...(Platform.OS === 'web' ? {
margin: -2,
padding: 2,
} : {}),
},
cardInnerWrapper: {
width: '100%',
borderRadius,
},
card: disabled ? {
...baseCard,

View File

@@ -629,7 +629,7 @@ class ConfigScreenComponent extends BaseScreenComponent<ConfigScreenProps, Confi
);
}
addSettingLink('donate_link', _('Make a donation'), 'https://joplinapp.org/donate/');
if (Platform.OS !== 'ios') addSettingLink('donate_link', _('Make a donation'), 'https://joplinapp.org/donate/');
addSettingLink('website_link', _('Joplin website'), 'https://joplinapp.org/');
addSettingLink('privacy_link', _('Privacy Policy'), 'https://joplinapp.org/privacy/');

View File

@@ -515,6 +515,7 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
paddingLeft: theme.marginLeft,
borderBottomColor: theme.dividerColor,
borderBottomWidth: 1,
maxHeight: '40%',
};
styles.titleContainerTodo = { ...styles.titleContainer };
@@ -659,6 +660,17 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
});
}
// Reset undo/redo button state when switching to edit mode or when switching between markdown and rich text editors, since the editor is
// recreated and loses its undo/redo history
if (this.state.mode === 'edit' && (prevState.mode !== this.state.mode || prevProps.editorType !== this.props.editorType)) {
this.setState({
undoRedoButtonState: {
canUndo: false,
canRedo: false,
},
});
}
if (prevProps.noteId && this.props.noteId && prevProps.noteId !== this.props.noteId) {
// Easier to just go back, then go to the note since
// the Note screen doesn't handle reloading a different note
@@ -690,6 +702,10 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
if (prevState.note.body !== this.state.note.body) {
this.emitEditorPluginUpdate_();
}
if (prevState.multiline !== this.state.multiline && this.titleTextFieldRef.current) {
focus('Note::focusUpdate::title', this.titleTextFieldRef.current);
}
}
public componentWillUnmount() {
@@ -1703,6 +1719,7 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
<View style={titleContainerStyle}>
{isTodo && <Checkbox style={this.styles().checkbox} checked={!!Number(note.todo_completed)} onChange={this.todoCheckbox_change} />}
<TextInput
key={this.state.multiline ? 'multiLine' : 'singleLine'}
ref={this.titleTextFieldRef}
underlineColorAndroid="#ffffff00"
autoCapitalize="sentences"

View File

@@ -1,6 +1,6 @@
import * as React from 'react';
import { connect } from 'react-redux';
import { View, StyleSheet, TextInput, Platform } from 'react-native';
import { View, StyleSheet, TextInput, Platform, ScrollView, Text as TextNative } from 'react-native';
import { AppState } from '../../utils/types';
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
import Revision from '@joplin/lib/models/Revision';
@@ -111,6 +111,7 @@ const useStyles = (themeId: number) => {
flex: 0,
flexDirection: 'row',
flexBasis: 'auto',
maxHeight: '40%',
},
titleText: {
flex: 1,
@@ -224,12 +225,25 @@ const NoteRevisionViewer: React.FC<Props> = props => {
const titleComponent = (
<View style={styles.titleViewContainer}>
<TextInput
style={styles.titleText}
value={note?.title ?? ''}
editable={false}
multiline={multiline}
/>
{
multiline ?
<ScrollView
style={{ flex: 1 }}
showsVerticalScrollIndicator={false}
>
<TextNative
selectable
style={styles.titleText}
>
{note?.title ?? ''}
</TextNative>
</ScrollView> :
<TextInput
style={styles.titleText}
value={note?.title ?? ''}
editable={false}
/>
}
{ titleToggleButton }
</View>
);

View File

@@ -9,6 +9,7 @@ import TagEditor, { TagEditorMode } from '../TagEditor';
import { _ } from '@joplin/lib/locale';
import { useCallback, useEffect, useState } from 'react';
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
import { ViewStyle } from 'react-native';
interface Props {
themeId: number;
@@ -22,6 +23,9 @@ const modalPropOverrides = {
// Prevent the keyboard from auto-dismissing when tapping outside the search input
keyboardShouldPersistTaps: true,
},
containerStyle: {
height: '100%',
} as ViewStyle,
};
const NoteTagsDialogComponent: React.FC<Props> = props => {

View File

@@ -0,0 +1,50 @@
import * as React from 'react';
import { AppState } from '../../../utils/types';
import { Store } from 'redux';
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
import createMockReduxStore from '../../../utils/testing/createMockReduxStore';
import setupGlobalStore from '../../../utils/testing/setupGlobalStore';
import Note from '@joplin/lib/models/Note';
import { render, screen } from '../../../utils/testing/testingLibrary';
import SearchResults from './SearchResults';
import SearchEngine from '@joplin/lib/services/search/SearchEngine';
import Folder from '@joplin/lib/models/Folder';
import TestProviderStack from '../../testing/TestProviderStack';
const createNotes = async (count: number) => {
const folder = await Folder.save({ title: 'Test Note' });
for (let i = 0; i < count; i++) {
await Note.save({ title: `abcd ${i}`, body: 'body', parent_id: folder.id });
}
await SearchEngine.instance().syncTables();
};
let store: Store<AppState>;
interface WrapperProps {
query: string;
paused: boolean;
}
const WrappedSearchResults: React.FC<WrapperProps> = props => (
<TestProviderStack store={store}>
<SearchResults paused={props.paused} query={props.query} onHighlightedWordsChange={() => { }} ftsEnabled={1} />
</TestProviderStack>
);
describe('SearchResult', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
store = createMockReduxStore();
setupGlobalStore(store);
});
test('should show results when unpaused', async () => {
const noteCount = 8;
await createNotes(noteCount);
render(<WrappedSearchResults query='abcd' paused={false}/>);
const items = await screen.findAllByText(/abcd \d\d?\d?/);
expect(items.length).toBe(noteCount);
});
});

View File

@@ -13,6 +13,7 @@ import shim from '@joplin/lib/shim';
interface Props {
query: string;
paused: boolean;
onHighlightedWordsChange: (highlightedWords: (ComplexTerm | string)[])=> void;
ftsEnabled: number;
@@ -28,7 +29,7 @@ const useResults = (props: Props) => {
let notes: NoteEntity[] = [];
setIsProcessing(true);
try {
if (query) {
if (query && !props.paused) {
if (ftsEnabled) {
const r = await SearchEngineUtils.notesForQuery(query, true, { appendWildCards: true });
notes = r.notes;
@@ -57,7 +58,7 @@ const useResults = (props: Props) => {
} finally {
setIsProcessing(false);
}
}, [query, ftsEnabled], { interval: 200 });
}, [query, props.paused, ftsEnabled], { interval: 200 });
return {
notes,

View File

@@ -53,11 +53,36 @@ const useStyles = (theme: ThemeStyle, visible: boolean) => {
}, [theme, visible]);
};
// Workaround for https://github.com/laurent22/joplin/issues/12823:
// Disable search-as-you-type for short 0-2 character searches that
// are likely to match the start of a large number of words.
const useSearchPaused = (query: string) => {
const [pauseDisabled, setPauseDisabled] = useState(false);
// Only disable search-as-you-type for a subset of all characters.
// This is, for example, to ensure that search-as-you-type remains
// enabled for CJK characters (e.g. U+6570 has length 1).
const paused = query.match(/^[a-z0-9]{0,2}$/i);
const onOverridePause = useCallback(() => {
setPauseDisabled(true);
}, []);
useEffect(() => {
setPauseDisabled(false);
}, [query]);
return {
paused: paused && !pauseDisabled,
onOverridePause,
};
};
const SearchScreenComponent: React.FC<Props> = props => {
const theme = themeStyle(props.themeId);
const styles = useStyles(theme, props.visible);
const [query, setQuery] = useState(props.query);
const { paused, onOverridePause } = useSearchPaused(query);
const globalQueryRef = useRef(props.query);
globalQueryRef.current = props.query;
@@ -99,6 +124,7 @@ const SearchScreenComponent: React.FC<Props> = props => {
autoFocus={props.visible}
underlineColorAndroid="#ffffff00"
onChangeText={setQuery}
onSubmitEditing={onOverridePause}
value={query}
selectionColor={theme.textSelectionColor}
keyboardAppearance={theme.keyboardAppearance}
@@ -114,6 +140,7 @@ const SearchScreenComponent: React.FC<Props> = props => {
<SearchResults
query={query}
paused={paused}
ftsEnabled={props.ftsEnabled}
onHighlightedWordsChange={onHighlightedWordsChange}
/>

View File

@@ -88,7 +88,7 @@ const useWebViewSetup = ({
`;
const injectedJavaScript = useMemo(() => `
if (typeof markdownEditorBundle === 'undefined') {
if (typeof window.markdownEditorBundle === 'undefined') {
${shim.injectedJs('markdownEditorBundle')};
window.markdownEditorBundle = markdownEditorBundle;
markdownEditorBundle.setUpLogger();

View File

@@ -535,7 +535,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
CURRENT_PROJECT_VERSION = 145;
CURRENT_PROJECT_VERSION = 146;
DEVELOPMENT_TEAM = A9BXAFS6CT;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Joplin/Info.plist;
@@ -570,7 +570,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
CURRENT_PROJECT_VERSION = 145;
CURRENT_PROJECT_VERSION = 146;
DEVELOPMENT_TEAM = A9BXAFS6CT;
INFOPLIST_FILE = Joplin/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
@@ -771,7 +771,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 145;
CURRENT_PROJECT_VERSION = 146;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = A9BXAFS6CT;
GCC_C_LANGUAGE_STANDARD = gnu11;
@@ -814,7 +814,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 145;
CURRENT_PROJECT_VERSION = 146;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = A9BXAFS6CT;
GCC_C_LANGUAGE_STANDARD = gnu11;

View File

@@ -6,7 +6,7 @@ PODS:
- ReactCommon/turbomodule/core
- EXConstants (17.1.7):
- ExpoModulesCore
- Expo (53.0.19):
- Expo (53.0.20):
- DoubleConversion
- ExpoModulesCore
- glog
@@ -35,7 +35,7 @@ PODS:
- Yoga
- ExpoAsset (11.1.7):
- ExpoModulesCore
- ExpoCamera (16.1.10):
- ExpoCamera (16.1.11):
- ExpoModulesCore
- ZXingObjC/OneD
- ZXingObjC/PDF417
@@ -45,7 +45,7 @@ PODS:
- ExpoModulesCore
- ExpoLocalAuthentication (16.0.5):
- ExpoModulesCore
- ExpoModulesCore (2.4.2):
- ExpoModulesCore (2.5.0):
- DoubleConversion
- glog
- hermes-engine
@@ -1408,7 +1408,7 @@ PODS:
- ReactCommon/turbomodule/core
- react-native-alarm-notification (3.5.0):
- React
- react-native-document-picker (10.1.3):
- react-native-document-picker (10.1.5):
- DoubleConversion
- glog
- hermes-engine
@@ -1522,7 +1522,7 @@ PODS:
- React-Core
- react-native-version-info (1.1.1):
- React-Core
- react-native-webview (13.14.2):
- react-native-webview (13.15.0):
- DoubleConversion
- glog
- hermes-engine
@@ -1890,7 +1890,7 @@ PODS:
- React
- RNSecureRandom (1.0.1):
- React
- RNShare (12.0.11):
- RNShare (12.1.0):
- DoubleConversion
- glog
- hermes-engine
@@ -2293,13 +2293,13 @@ SPEC CHECKSUMS:
DoubleConversion: 76ab83afb40bddeeee456813d9c04f67f78771b5
EXAV: ae28256069c4cdde93d185c007d8f68d92902c2e
EXConstants: 98bcf0f22b820f9b28f9fee55ff2daededadd2f8
Expo: 4b1c6de7c441e1caa1918671ae0aa34d51f019a5
Expo: b527631da3b11e085809e877b845f9e6cdd68f9c
ExpoAsset: ef06e880126c375f580d4923fdd1cdf4ee6ee7d6
ExpoCamera: 7edf99216d92e40b991d4e7ed69eba9527c94cda
ExpoCamera: e1879906d41184e84b57d7643119f8509414e318
ExpoFileSystem: 7f92f7be2f5c5ed40a7c9efc8fa30821181d9d63
ExpoFont: cf508bc2e6b70871e05386d71cab927c8524cc8e
ExpoLocalAuthentication: c35f18692dcb35775a1be0f37b2131096951a6bd
ExpoModulesCore: e2c98670a94932b744f5bc4e394520e1c63b5462
ExpoModulesCore: f55e7872391bae03ee5547c83152c81750d89508
fast_float: 06eeec4fe712a76acc9376682e4808b05ce978b6
FBLazyVector: 84b955f7b4da8b895faf5946f73748267347c975
fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd
@@ -2340,7 +2340,7 @@ SPEC CHECKSUMS:
React-Mapbuffer: c3f4b608e4a59dd2f6a416ef4d47a14400194468
React-microtasksnativemodule: 054f34e9b82f02bd40f09cebd4083828b5b2beb6
react-native-alarm-notification: a4326a743df72a94d361a4c3a21515556f650341
react-native-document-picker: da39c5e4f279d39c0356dca157b98f9dc349e5bb
react-native-document-picker: d7580f6e287bbf2c31c071d6b3f252ae1c6586f1
react-native-geolocation: ec15ffebc53790314885eb9e5f2132132fbc2600
react-native-get-random-values: d16467cf726c618e9c7a8c3c39c31faa2244bbba
react-native-image-picker: 7babe45e727db306b3f00d08c72eda3586d6e9c1
@@ -2352,7 +2352,7 @@ SPEC CHECKSUMS:
react-native-safe-area-context: dde2052b903c11d677c320b599c3244021c34ce8
react-native-sqlite-storage: 0c84826214baaa498796c7e46a5ccc9a82e114ed
react-native-version-info: f0b04e16111c4016749235ff6d9a757039189141
react-native-webview: 2d9ffd72b87cf905cdf8821d7d27d551188bac70
react-native-webview: 0dceb35a9d050f5fa55f7fe2d8c4d1903651eb7d
React-NativeModulesApple: 2c4377e139522c3d73f5df582e4f051a838ff25e
React-oscompat: ef5df1c734f19b8003e149317d041b8ce1f7d29c
React-perflogger: 9a151e0b4c933c9205fd648c246506a83f31395d
@@ -2395,7 +2395,7 @@ SPEC CHECKSUMS:
RNLocalize: 6a87f0490f1793d7a70042e4c55eb9a1ba6dd5b4
RNQuickAction: c2c8f379e614428be0babe4d53a575739667744d
RNSecureRandom: b64d263529492a6897e236a22a2c4249aa1b53dc
RNShare: 675e8e4a84f0137baf33057cac8f7334b0bb4b98
RNShare: 9528acd4e374d3cb76b994b9e167d4a75cd8f452
RNSVG: 295a96bc43f2baa5958d64aeec9847a1d8ca7a3d
RNVectorIcons: d53917643fddb261b22bd6d889776f336893622b
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748

View File

@@ -29,7 +29,7 @@
"@joplin/renderer": "~3.5",
"@joplin/utils": "~3.5",
"@react-native-clipboard/clipboard": "1.16.3",
"@react-native-community/datetimepicker": "8.4.2",
"@react-native-community/datetimepicker": "8.4.3",
"@react-native-community/geolocation": "3.4.0",
"@react-native-community/netinfo": "11.4.1",
"@react-native-community/push-notification-ios": "1.11.0",
@@ -41,9 +41,9 @@
"crypto-browserify": "3.12.1",
"deprecated-react-native-prop-types": "5.0.0",
"events": "3.3.0",
"expo": "53.0.19",
"expo": "53.0.20",
"expo-av": "15.1.7",
"expo-camera": "16.1.10",
"expo-camera": "16.1.11",
"expo-local-authentication": "16.0.5",
"lodash": "4.17.21",
"md5": "2.3.0",
@@ -66,9 +66,9 @@
"react-native-quick-actions": "0.3.13",
"react-native-quick-crypto": "0.7.17",
"react-native-rsa-native": "2.0.5",
"react-native-safe-area-context": "5.4.1",
"react-native-safe-area-context": "5.5.2",
"react-native-securerandom": "1.0.1",
"react-native-share": "12.0.11",
"react-native-share": "12.1.2",
"react-native-sqlite-storage": "6.0.1",
"react-native-svg": "15.13.0",
"react-native-url-polyfill": "2.0.0",
@@ -99,23 +99,23 @@
"@react-native-community/cli": "16.0.3",
"@react-native-community/cli-platform-android": "16.0.3",
"@react-native-community/cli-platform-ios": "16.0.3",
"@react-native/babel-preset": "0.79.5",
"@react-native/babel-preset": "0.80.1",
"@react-native/metro-config": "0.79.5",
"@react-native/typescript-config": "0.79.5",
"@react-native/typescript-config": "0.80.2",
"@sqlite.org/sqlite-wasm": "3.46.0-build2",
"@testing-library/react-native": "13.2.0",
"@types/fs-extra": "11.0.4",
"@types/jest": "29.5.14",
"@types/node": "18.19.118",
"@types/node": "18.19.130",
"@types/react": "19.0.14",
"@types/react-redux": "7.1.33",
"@types/serviceworker": "0.0.141",
"@types/serviceworker": "0.0.149",
"@types/tar-stream": "3.1.4",
"babel-jest": "29.7.0",
"babel-loader": "9.1.3",
"babel-plugin-module-resolver": "4.1.0",
"babel-plugin-react-native-web": "0.20.0",
"esbuild": "0.25.6",
"esbuild": "0.25.8",
"fast-deep-equal": "3.1.3",
"fs-extra": "11.2.0",
"gulp": "4.0.2",
@@ -133,7 +133,7 @@
"sharp": "0.34.3",
"sqlite3": "5.1.6",
"timers-browserify": "2.0.12",
"ts-jest": "29.3.4",
"ts-jest": "29.4.1",
"ts-loader": "9.5.2",
"ts-node": "10.9.2",
"typescript": "5.8.3",

View File

@@ -3,11 +3,11 @@
// files: First here we convert the JS file to a plain string, and that string
// is then loaded by eg. the Mermaid plugin, and finally injected in the WebView.
import { dirname, extname, basename } from 'path';
import { dirname, extname, basename, resolve } from 'path';
import * as esbuild from 'esbuild';
import copyAssets from './copyAssets';
import { writeFile } from 'fs-extra';
import { writeFile, readFile } from 'fs-extra';
export default class BundledFile {
private readonly bundleOutputPathBase_: string;
@@ -54,6 +54,32 @@ export default class BundledFile {
});
},
},
{
// Supports require(...)ing SVG images
name: 'joplin--require-svg',
setup: build => {
// A relative path to an SVG:
build.onResolve({ filter: /^\.{1,2}\/.*\.svg$/ }, args => ({
path: resolve(args.resolveDir, args.path),
namespace: 'joplin-require-svg',
}));
build.onLoad({ filter: /^.*$/, namespace: 'joplin-require-svg' }, async args => {
const fileContent = await readFile(args.path, 'utf-8');
return { contents: `
let svg = null;
export default () => {
svg ??= (() => {
const parser = new DOMParser();
const doc = parser.parseFromString(${JSON.stringify(fileContent)}, 'image/svg+xml');
return doc.querySelector('svg');
})();
return svg.cloneNode(true);
};
` };
});
},
},
{
name: 'joplin--copy-final',
setup: build => {

View File

@@ -67,6 +67,11 @@ const appReducer = (state = appDefaultState, action: any) => {
newState.selectedNoteHash = '';
if (currentRoute.routeName === 'Search' && action.routeName === 'Notes') {
// Force a reload of the note list
newState.notesSource = '';
}
if (action.routeName === 'Search') {
newState.notesParentType = 'Search';
}

View File

@@ -488,6 +488,14 @@ const buildStartupTasks = (
// await printTestData();
});
addTask('buildStartupTasks/optionally show sync wizard', async () => {
if (Setting.value('sync.wizard.autoShowOnStartup') && Setting.value('sync.target') === 0) {
dispatch({
type: 'SYNC_WIZARD_VISIBLE_CHANGE',
visible: true,
});
}
});
return startupTasks;
};

View File

@@ -11,7 +11,7 @@ const useSafeAreaPadding = () => {
paddingRight: safeAreaInsets.right,
paddingLeft: safeAreaInsets.left,
paddingTop: safeAreaInsets.top,
paddingBottom: 0,
paddingBottom: safeAreaInsets.bottom,
} : {
paddingTop: safeAreaInsets.top,
paddingBottom: safeAreaInsets.bottom,

View File

@@ -29,10 +29,14 @@ interface CssDecorationSpec extends DecorationRange {
id?: number;
}
interface RemoveMarkDecorationSpec {
id: number;
}
const addLineDecorationEffect = StateEffect.define<CssDecorationSpec>(mapRangeConfig);
const removeLineDecorationEffect = StateEffect.define<CssDecorationSpec>(mapRangeConfig);
const addMarkDecorationEffect = StateEffect.define<CssDecorationSpec>(mapRangeConfig);
const removeMarkDecorationEffect = StateEffect.define<CssDecorationSpec>(mapRangeConfig);
const removeMarkDecorationEffect = StateEffect.define<RemoveMarkDecorationSpec>();
const refreshOverlaysEffect = StateEffect.define();
export interface LineWidgetOptions {
@@ -190,12 +194,13 @@ export default class Decorator {
decorations = decorations.update({
add: [decoration.range(from, to)],
});
} else if (effect.is(removeLineDecorationEffect) || effect.is(removeMarkDecorationEffect)) {
} else if (effect.is(removeLineDecorationEffect)) {
const doc = transaction.state.doc;
const targetFrom = doc.lineAt(effect.value.from).from;
const targetTo = doc.lineAt(effect.value.to).to;
const { from, to } = effect.value;
// Handle the case where { from, to } point to an outdated document
const targetFrom = doc.lineAt(from).from;
const targetTo = doc.lineAt(to).to;
const targetId = effect.value.id;
const targetDecoration = this.classNameToCssDecoration(
effect.value.cssClass, effect.is(removeLineDecorationEffect),
);
@@ -203,12 +208,15 @@ export default class Decorator {
decorations = decorations.update({
// Returns true only for decorations that should be kept.
filter: (from, to, value) => {
if (targetId !== undefined) {
return value.spec.id !== effect.value.id;
}
const isInRange = from >= targetFrom && to <= targetTo;
return isInRange && value.eq(targetDecoration);
return !isInRange || !value.eq(targetDecoration);
},
});
} else if (effect.is(removeMarkDecorationEffect)) {
decorations = decorations.update({
// Returns true only for decorations that should be kept.
filter: (_from, _to, value) => {
return value.spec.id !== effect.value.id;
},
});
} else if (effect.is(addLineWidgetEffect)) {
@@ -384,9 +392,10 @@ export default class Decorator {
}
public markText(from: number, to: number, options?: MarkTextOptions) {
const id = this._nextLineWidgetId++;
const effectOptions: CssDecorationSpec = {
cssClass: options.className ?? '',
id: this._nextLineWidgetId++,
id,
from,
to,
};
@@ -398,7 +407,7 @@ export default class Decorator {
return {
clear: () => {
this.editor.dispatch({
effects: removeMarkDecorationEffect.of(effectOptions),
effects: removeMarkDecorationEffect.of({ id }),
});
},
};

View File

@@ -89,8 +89,14 @@ export default class CodeMirrorControl extends CodeMirror5Emulation implements E
}
public select(anchor: number, head: number) {
const maximumPosition = this.editor.state.doc.length;
this.editor.dispatch(this.editor.state.update({
selection: { anchor, head },
selection: {
// Ensure that (anchor, head) are in range.
// (CodeMirror throws when (anchor, head) are out-of-range.)
anchor: Math.min(anchor, maximumPosition),
head: Math.min(head, maximumPosition),
},
scrollIntoView: true,
}));
}

View File

@@ -1,7 +1,7 @@
import { EditorView } from 'prosemirror-view';
import { EditorCommandType } from '../types';
import { EditorCommandType } from '../../types';
import commands from './commands';
import createTestEditor from './testing/createTestEditor';
import createTestEditor from '../testing/createTestEditor';
const selectAll = (editor: EditorView) => {
commands[EditorCommandType.SelectAll](editor.state, editor.dispatch, editor);

View File

@@ -1,19 +1,19 @@
import { Command, EditorState, Transaction } from 'prosemirror-state';
import { EditorCommandType } from '../types';
import { EditorCommandType } from '../../types';
import { redo, undo } from 'prosemirror-history';
import { autoJoin, selectAll, setBlockType, toggleMark } from 'prosemirror-commands';
import { focus } from '@joplin/lib/utils/focusHandler';
import schema from './schema';
import schema from '../schema';
import { liftListItem, sinkListItem, wrapRangeInList } from 'prosemirror-schema-list';
import { NodeType } from 'prosemirror-model';
import { getSearchVisible, setSearchVisible } from './plugins/searchPlugin';
import { getSearchVisible, setSearchVisible } from '../plugins/searchPlugin';
import { findNext, findPrev, replaceAll, replaceNext } from 'prosemirror-search';
import { getEditorApi } from './plugins/joplinEditorApiPlugin';
import { EditorEventType } from '../events';
import extractSelectedLinesTo from './utils/extractSelectedLinesTo';
import { getEditorApi } from '../plugins/joplinEditorApiPlugin';
import { EditorEventType } from '../../events';
import extractSelectedLinesTo from '../utils/extractSelectedLinesTo';
import { EditorView } from 'prosemirror-view';
import jumpToHash from './utils/jumpToHash';
import canReplaceSelectionWith from './utils/canReplaceSelectionWith';
import jumpToHash from '../utils/jumpToHash';
import canReplaceSelectionWith from '../utils/canReplaceSelectionWith';
import focusEditor from './focusEditor';
type Dispatch = (tr: Transaction)=> void;
type ExtendedCommand = (state: EditorState, dispatch: Dispatch, view?: EditorView, options?: string[])=> boolean;
@@ -81,12 +81,7 @@ const commands: Record<EditorCommandType, ExtendedCommand|null> = {
[EditorCommandType.Undo]: undo,
[EditorCommandType.Redo]: redo,
[EditorCommandType.SelectAll]: selectAll,
[EditorCommandType.Focus]: (_state, _dispatch?, view?) => {
if (view) {
focus('commands::focus', view);
}
return true;
},
[EditorCommandType.Focus]: focusEditor,
[EditorCommandType.ToggleBolded]: toggleMark(schema.marks.strong),
[EditorCommandType.ToggleItalicized]: toggleMark(schema.marks.emphasis),
[EditorCommandType.ToggleCode]: toggleCode,

View File

@@ -0,0 +1,11 @@
import { Command } from 'prosemirror-state';
import { focus } from '@joplin/lib/utils/focusHandler';
const focusEditor: Command = (_state, _dispatch?, view?) => {
if (view) {
focus('commands::focus', view);
}
return true;
};
export default focusEditor;

View File

@@ -4,7 +4,7 @@ import { EditorState, TextSelection, Transaction } from 'prosemirror-state';
import { EditorView } from 'prosemirror-view';
import { DOMParser as ProseMirrorDomParser } from 'prosemirror-model';
import { history } from 'prosemirror-history';
import commands from './commands';
import commands from './commands/commands';
import schema from './schema';
import { gapCursor } from 'prosemirror-gapcursor';
import { dropCursor } from 'prosemirror-dropcursor';
@@ -16,7 +16,6 @@ import joplinEditablePlugin from './plugins/joplinEditablePlugin/joplinEditableP
import keymapExtension from './plugins/keymapPlugin';
import inputRulesExtension from './plugins/inputRulesPlugin';
import originalMarkupPlugin from './plugins/originalMarkupPlugin';
import { tableEditing } from 'prosemirror-tables';
import preprocessEditorInput from './utils/preprocessEditorInput';
import listPlugin from './plugins/listPlugin';
import searchExtension from './plugins/searchPlugin';
@@ -28,6 +27,7 @@ import getFileFromPasteEvent from '../utils/getFileFromPasteEvent';
import { RenderResult } from '../../renderer/types';
import postprocessEditorOutput from './utils/postprocessEditorOutput';
import detailsPlugin from './plugins/detailsPlugin';
import tablePlugin from './plugins/tablePlugin';
interface ProseMirrorControl extends EditorControl {
getSettings(): EditorSettings;
@@ -90,7 +90,7 @@ const createEditor = async (
markupTracker,
listPlugin,
linkTooltipPlugin,
tableEditing({ allowTableNodeSelection: true }),
tablePlugin,
joplinEditorApiPlugin,
imagePlugin,
].flat(),

View File

@@ -6,7 +6,7 @@ import { getEditorApi } from './joplinEditorApiPlugin';
import showModal from '../utils/dom/showModal';
import createTextArea from '../utils/dom/createTextArea';
import createExternalEditorPlugin, { OnHide } from './utils/createExternalEditorPlugin';
import createFloatingButtonPlugin, { ToolbarPosition } from './utils/createFloatingButtonPlugin';
import createFloatingButtonPlugin, { ToolbarType } from './utils/createFloatingButtonPlugin';
// See the fold example for more information about
// writing similar ProseMirror plugins:
@@ -263,7 +263,7 @@ const imagePlugin = [
}),
createFloatingButtonPlugin('image', [
{ label: _ => _('Label'), command: (_node, offset) => editAltTextAt(offset) },
], ToolbarPosition.TopRightInside),
], ToolbarType.AnchorTopRight),
];
export default imagePlugin;

View File

@@ -9,7 +9,7 @@ import postProcessRenderedHtml from './postProcessRenderedHtml';
import makeLinksClickableInElement from '../../utils/makeLinksClickableInElement';
import SelectableNodeView from '../../utils/SelectableNodeView';
import createExternalEditorPlugin, { OnHide } from '../utils/createExternalEditorPlugin';
import createFloatingButtonPlugin, { ToolbarPosition } from '../utils/createFloatingButtonPlugin';
import createFloatingButtonPlugin, { ToolbarType } from '../utils/createFloatingButtonPlugin';
// See the fold example for more information about
// writing similar ProseMirror plugins:
@@ -245,6 +245,6 @@ export default [
className: 'edit-button',
command: (_node, offset) => editAt(offset),
},
], ToolbarPosition.TopRightInside)
], ToolbarType.AnchorTopRight)
)),
];

View File

@@ -3,7 +3,7 @@ import schema from '../schema';
import { keymap } from 'prosemirror-keymap';
import { baseKeymap, chainCommands, exitCode, liftEmptyBlock, newlineInCode } from 'prosemirror-commands';
import { liftListItem, sinkListItem, splitListItem } from 'prosemirror-schema-list';
import commands from '../commands';
import commands from '../commands/commands';
import { EditorCommandType } from '../../types';
import { Command, EditorState, TextSelection, Plugin } from 'prosemirror-state';
import splitBlockAs from '../vendor/splitBlockAs';

View File

@@ -0,0 +1,40 @@
import { addColumnAfter, addRowAfter, deleteColumn, deleteRow, tableEditing } from 'prosemirror-tables';
import createFloatingButtonPlugin, { ToolbarType } from './utils/createFloatingButtonPlugin';
import addColumnRightIcon from '../vendor/icons/addColumnRight';
import addRowBelowIcon from '../vendor/icons/addRowBelow';
import removeRowIcon from '../vendor/icons/removeRow';
import removeColumnIcon from '../vendor/icons/removeColumn';
import focusEditor from '../commands/focusEditor';
import { Command } from 'prosemirror-state';
const tableCommand = (command: Command): Command => (state, dispatch, view) => {
return command(state, dispatch, view) && focusEditor(state, dispatch, view);
};
const tablePlugin = [
tableEditing({ allowTableNodeSelection: true }),
createFloatingButtonPlugin('table', [
{
icon: addRowBelowIcon,
label: (_) => _('Add row'),
command: () => tableCommand(addRowAfter),
},
{
icon: addColumnRightIcon,
label: (_) => _('Add column'),
command: () => tableCommand(addColumnAfter),
},
{
icon: removeRowIcon,
label: (_) => _('Delete row'),
command: () => tableCommand(deleteRow),
},
{
icon: removeColumnIcon,
label: (_) => _('Delete column'),
command: () => tableCommand(deleteColumn),
},
], ToolbarType.FloatAboveBelow),
];
export default tablePlugin;

View File

@@ -1,42 +1,158 @@
import { Command, EditorState, Plugin } from 'prosemirror-state';
import { Command, EditorState, Plugin, PluginView } from 'prosemirror-state';
import { LocalizationResult, OnLocalize } from '../../../types';
import { EditorView } from 'prosemirror-view';
import createButton from '../../utils/dom/createButton';
import { getEditorApi } from '../joplinEditorApiPlugin';
import { Node } from 'prosemirror-model';
import { Icon } from '../../vendor/icons/types';
type LocalizeFunction = (_: OnLocalize)=> LocalizationResult;
interface ButtonSpec {
icon?: Icon;
label: LocalizeFunction;
command: (node: Node, offset: number)=> Command;
showForNode?: (node: Node)=> boolean;
className?: string;
}
export enum ToolbarPosition {
TopLeftOutside,
TopRightInside,
export enum ToolbarType {
// Attempts to keep the toolbar visible when the node
// is visible. While showing the toolbar outside the node
// is preferred, the toolbar will be shown inside the node
// if insufficient outside space is available.
FloatAboveBelow,
// Anchors the toolbar to the top right corner of the
// associated element.
AnchorTopRight,
}
class FloatingButtonBar {
interface TargetNode {
offset: number;
node: Node;
element: Element|null;
}
class FloatingButtonBar implements PluginView {
private container_: HTMLElement;
private buttonRow_: ButtonRow;
private currentTarget_: TargetNode|null = null;
private observer_: ElementObserver;
public constructor(
view: EditorView, private targetNode_: string, private buttons_: ButtonSpec[], private position_: ToolbarPosition,
private view_: EditorView,
private targetNodeName_: string,
buttons: ButtonSpec[],
private type_: ToolbarType,
) {
this.container_ = document.createElement('div');
this.container_.classList.add('floating-button-bar');
this.buttonRow_ = new ButtonRow(this.container_, buttons);
this.observer_ = new ElementObserver(
() => this.repositionOverlay_(),
);
// Prevent other elements (e.g. checkboxes, links) from being between the toolbar button and the
// target element. If the toolbar is instead included **after** the Rich Text Editor's main content,
// then all items included directly within the Rich Text Editor come before the toolbar in the focus
// order.
view.dom.parentElement.prepend(this.container_);
this.update(view, null);
view_.dom.parentElement.prepend(this.container_);
this.update(view_, null);
if (this.type_ === ToolbarType.AnchorTopRight) {
this.container_.classList.add('-anchored');
} else if (this.type_ === ToolbarType.FloatAboveBelow) {
this.container_.classList.add('-floating');
} else {
const unreachable_: never = this.type_;
throw new Error(`Unknown toolbar type: ${unreachable_}`);
}
}
private repositionOverlay_() {
if (!this.currentTarget_) return;
const overlay = this.container_;
const view = this.view_;
const target = this.currentTarget_;
const position = this.view_.coordsAtPos(target.offset);
const targetElement = view.nodeDOM(target.offset);
// Fall back to document.body to support testing environments:
const parentBox = (this.container_.offsetParent ?? document.body).getBoundingClientRect();
const tooltipBox = this.container_.getBoundingClientRect();
const targetBox = targetElement instanceof HTMLElement ? targetElement.getBoundingClientRect() : {
...position,
width: 0,
height: 0,
};
this.container_.style.left = '';
this.container_.style.right = '';
if (this.type_ === ToolbarType.FloatAboveBelow) {
const padding = 10;
const above = targetBox.top - tooltipBox.height - parentBox.top - padding;
const below = targetBox.top + targetBox.height - parentBox.top + padding;
const viewportTop = window.visualViewport?.pageTop;
const viewportBottom = viewportTop + window.visualViewport?.height;
const cursorTop = viewportTop + view.coordsAtPos(view.state.selection.head).top;
const getOffsetTop = () => {
// If the toolbar must be displayed within the element to be visible, prefer
// less movement:
const previousTop = tooltipBox.top + viewportTop;
const insideCandidates = [
Math.max(viewportTop + padding, above),
Math.min(viewportBottom - padding - tooltipBox.height, below),
].sort((a, b) => {
const distanceA = Math.abs(a - previousTop);
const distanceB = Math.abs(b - previousTop);
return distanceA - distanceB;
}).filter(position => {
return position >= above && position <= below;
});
const positionCandidates = [
// Always prefer showing the toolbar outside the element
above, below,
// Fall back to showing the toolbar inside
...insideCandidates,
];
const validCandidates = positionCandidates.filter((position) => {
const candidateTop = position;
const candidateBottom = position + tooltipBox.height;
const candidateCenter = position + tooltipBox.height / 2;
const distanceFromCursor = Math.abs(candidateCenter - cursorTop);
return candidateTop >= viewportTop
// Avoid showing the toolbar off the bottom edge of the screen
&& candidateBottom <= viewportBottom
// Avoid showing the toolbar on the same line as the cursor
&& distanceFromCursor > tooltipBox.height / 2 + padding;
});
return validCandidates[0] ?? positionCandidates[0];
};
const targetCenter = targetBox.left + targetBox.width / 2;
const currentCenter = parentBox.left + tooltipBox.width / 2;
// Subtract (parentBox.left, parentBox.top): style.left and style.top
// are relative to the parent, but the computed position is not.
overlay.style.left = `${Math.max(targetCenter - currentCenter, 0)}px`;
overlay.style.top = `${getOffsetTop()}px`;
} else if (this.type_ === ToolbarType.AnchorTopRight) {
overlay.style.right = `${parentBox.width - targetBox.width - (targetBox.left - parentBox.left)}px`;
overlay.style.top = `${targetBox.top - parentBox.top}px`;
}
}
public update(view: EditorView, lastState: EditorState|null) {
this.view_ = view;
const state = view.state;
const sameSelection = lastState && state.selection.eq(lastState.selection);
const sameDoc = lastState && state.doc.eq(lastState.doc);
@@ -45,11 +161,12 @@ class FloatingButtonBar {
}
const findTargetNode = () => {
type TargetNode = { offset: number; node: Node };
let target: TargetNode = null;
let target: TargetNode|null = null;
state.doc.nodesBetween(state.selection.from, state.selection.to, (node, offset) => {
if (node.type.name === this.targetNode_) {
target = { node, offset };
if (node.type.name === this.targetNodeName_) {
const dom = view.nodeDOM(offset);
const domElement = dom instanceof HTMLElement ? dom : dom.parentElement;
target = { node, offset, element: domElement };
return false;
}
return true;
@@ -59,69 +176,109 @@ class FloatingButtonBar {
};
const target = findTargetNode();
this.observer_.setElement(target?.element);
this.currentTarget_ = target;
if (!target) {
this.container_.classList.add('-hidden');
} else {
this.container_.classList.remove('-hidden');
const hasCreatedButtons = this.container_.children.length === this.buttons_.length;
if (!hasCreatedButtons) {
const { localize } = getEditorApi(view.state);
this.container_.replaceChildren(...this.buttons_.map(buttonSpec => {
const button = createButton(
buttonSpec.label(localize),
() => { },
);
this.buttonRow_.updateButtons(view, target);
this.repositionOverlay_();
}
}
button.classList.add('action');
if (buttonSpec.className) {
button.classList.add(buttonSpec.className);
}
public destroy() {
this.observer_.destroy();
}
}
return button;
}));
}
// Emits changes when the element's position changes.
class ElementObserver {
private intersectionObserver_: IntersectionObserver|null;
private lastElement_: Element|null = null;
for (let i = 0; i < this.buttons_.length; i++) {
const button = this.container_.children[i] as HTMLButtonElement;
const buttonSpec = this.buttons_[i];
public constructor(private onNodeUpdate_: ()=> void) {
if (typeof IntersectionObserver !== 'undefined') {
this.intersectionObserver_ = new IntersectionObserver(() => {
this.onNodeUpdate_();
});
}
document.addEventListener('scroll', this.onNodeUpdate_);
window.addEventListener('resize', this.onNodeUpdate_);
}
const command = buttonSpec.command(target.node, target.offset);
button.onclick = () => {
command(view.state, view.dispatch, view);
};
public setElement(element: Element|null) {
if (element === this.lastElement_) return;
button.disabled = !command(view.state);
}
if (this.lastElement_) {
this.intersectionObserver_?.unobserve(this.lastElement_);
}
const position = view.coordsAtPos(target.offset);
// Fall back to document.body to support testing environments:
const parentBox = (this.container_.offsetParent ?? document.body).getBoundingClientRect();
const tooltipBox = this.container_.getBoundingClientRect();
if (element) {
this.intersectionObserver_?.observe(element);
}
this.container_.style.left = '';
this.container_.style.right = '';
this.lastElement_ = element;
}
const nodeElement = view.nodeDOM(target.offset);
const nodeBbox = nodeElement instanceof HTMLElement ? nodeElement.getBoundingClientRect() : {
...position,
width: 0,
height: 0,
public destroy() {
this.intersectionObserver_?.disconnect();
this.intersectionObserver_ = null;
document.removeEventListener('scroll', this.onNodeUpdate_);
window.removeEventListener('resize', this.onNodeUpdate_);
}
}
class ButtonRow {
private created_ = false;
public constructor(private container_: HTMLElement, private buttons_: ButtonSpec[]) { }
public updateButtons(view: EditorView, targetNode: TargetNode) {
// Late-init the buttons to allow accessing `view`:
if (!this.created_) {
this.created_ = true;
const { localize } = getEditorApi(view.state);
this.container_.replaceChildren(...this.buttons_.map(buttonSpec => {
const label = buttonSpec.label(localize);
const button = createButton(
buttonSpec.icon ? { label, icon: buttonSpec.icon() } : label,
() => { },
);
button.classList.add('action', 'action-button');
if (buttonSpec.icon) {
button.classList.add('-icon');
}
if (buttonSpec.className) {
button.classList.add(buttonSpec.className);
}
return button;
}));
}
// Update the button listeners and states based on the current view and
// target node
for (let i = 0; i < this.buttons_.length; i++) {
const button = this.container_.children[i] as HTMLButtonElement;
const buttonSpec = this.buttons_[i];
const command = buttonSpec.command(targetNode.node, targetNode.offset);
button.onclick = () => {
command(view.state, view.dispatch, view);
};
let top = nodeBbox.top - parentBox.top;
if (this.position_ === ToolbarPosition.TopLeftOutside) {
top -= tooltipBox.height;
this.container_.style.left = `${Math.max(nodeBbox.left - parentBox.left, 0)}px`;
} else if (this.position_ === ToolbarPosition.TopRightInside) {
this.container_.style.right = `${parentBox.width - nodeBbox.width - (nodeBbox.left - parentBox.left)}px`;
}
this.container_.style.top = `${top}px`;
button.disabled = !command(view.state);
}
}
}
const createFloatingButtonPlugin = (nodeName: string, actions: ButtonSpec[], position: ToolbarPosition) => {
const createFloatingButtonPlugin = (nodeName: string, actions: ButtonSpec[], position: ToolbarType) => {
return new Plugin({
view: (view) => new FloatingButtonBar(view, nodeName, actions, position),
});

View File

@@ -11,4 +11,5 @@ import './styles/link-tooltip.css';
import './styles/joplin-image-view.css';
import './styles/alt-text-editor.css';
import './styles/floating-button-bar.css';
import './styles/action-button.css';

View File

@@ -0,0 +1,20 @@
.action-button {
background-color: transparent;
color: currentColor;
border-radius: 48px;
transition: background-color 0.2s ease, opacity 0.2s ease;
}
.action-button:hover {
background-color: var(--joplin-background-color-hover3);
}
.action-button.-icon {
width: 48px;
height: 48px;
}
.action-button > .icon {
vertical-align: middle;
}

View File

@@ -2,6 +2,15 @@
.floating-button-bar {
position: absolute;
z-index: 1;
display: flex;
flex-direction: row;
box-sizing: border-box;
background-color: var(--joplin-background-color3);
color: var(--joplin-color);
box-shadow: 0px 0px 2px var(--joplin-color);
}
.floating-button-bar.-hidden {
@@ -9,5 +18,32 @@
}
.floating-button-bar > .action {
opacity: 0.9;
background: transparent;
color: inherit;
border: none;
}
.floating-button-bar.-floating {
border-radius: 64px;
height: 64px;
padding: 8px;
gap: 4px;
}
.floating-button-bar.-anchored {
border-radius: 48px;
padding: 0px;
gap: 4px;
opacity: 0.9;
transition: opacity 0.2s ease;
}
.floating-button-bar.-anchored > .action {
padding: 8px;
}
.floating-button-bar.-anchored:focus-within, .floating-button-bar.-anchored:hover {
opacity: 1;
}

View File

@@ -5,7 +5,7 @@
background-color: var(--joplin-background-color);
border: none;
border-radius: 16px;
box-shadow: 0px 0px 2px var(--joplin-color);
box-shadow: 0px 0px 3px var(--joplin-color);
color: var(--joplin-color);
width: min(80vw, 600px);

View File

@@ -2,3 +2,10 @@
table .selectedCell {
outline: 2px solid var(--joplin-text-selection-color);
}
/* Prevent table entries from having more spacing in the Rich Text Editor
than in the note viewer. Unlike the note viewer, the Rich Text Editor
always adds a <p> wrapper element around the table cells. */
th > p:only-child, td > p:only-child {
margin: unset;
}

View File

@@ -3,9 +3,28 @@ import createTextNode from './createTextNode';
type OnClick = ()=> void;
const createButton = (label: LocalizationResult, onClick: OnClick) => {
type Content = LocalizationResult|{
icon: Element;
label: LocalizationResult;
};
const isLocalizationResult = (content: Content): content is LocalizationResult => {
return typeof content === 'string' || !('icon' in content);
};
const createButton = (content: Content, onClick: OnClick) => {
const button = document.createElement('button');
button.appendChild(createTextNode(label));
if (isLocalizationResult(content)) {
button.appendChild(createTextNode(content));
} else {
button.appendChild(content.icon);
void (async () => {
const label = await content.label;
button.ariaLabel = label;
button.title = label;
})();
}
button.onclick = onClick;

View File

@@ -22,7 +22,6 @@ const removeListItemWrapperParagraphs = (container: HTMLElement) => {
}
};
const restoreOriginalLinks = (container: HTMLElement) => {
// Restore HREFs
const links = container.querySelectorAll<HTMLAnchorElement>('a[href="#"][data-original-href]');
@@ -32,6 +31,18 @@ const restoreOriginalLinks = (container: HTMLElement) => {
}
};
const removeTableItemExtraPadding = (container: HTMLElement) => {
const cells = container.querySelectorAll<HTMLTableCellElement>('th, td');
for (const cell of cells) {
// Table cells can exist in Markdown without the need for invisible
// content.
// Remove single nonbreaking space padding:
if (cell.textContent === '\u00A0') {
cell.textContent = '';
}
}
};
const postprocessEditorOutput = (node: Node|DocumentFragment) => {
// By default, if `src` is specified on an image, the browser will try to load the image, even if it isn't added
// to the DOM. (A similar problem is described here: https://stackoverflow.com/q/62019538).
@@ -52,6 +63,7 @@ const postprocessEditorOutput = (node: Node|DocumentFragment) => {
fixResourceUrls(html);
restoreOriginalLinks(html);
removeListItemWrapperParagraphs(html);
removeTableItemExtraPadding(html);
return html;
};

View File

@@ -0,0 +1,208 @@
The icons included in this folder are vendored from https://fonts.google.com/icons.
Changes made:
- File names have been changed.
License:
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#1f1f1f"><path d="M160-760v560h240v-560H160ZM80-120v-720h720v160h-80v-80H480v560h240v-80h80v160H80Zm400-360Zm-80 0h80-80Zm0 0Zm320 120v-80h-80v-80h80v-80h80v80h80v80h-80v80h-80Z"/></svg>

After

Width:  |  Height:  |  Size: 284 B

View File

@@ -0,0 +1,3 @@
import icon from "./icon";
export default icon(require('./addColumnRight.svg'));

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