1
0
mirror of https://github.com/laurent22/joplin.git synced 2026-02-01 07:49:31 +02:00

Compare commits

...

159 Commits

Author SHA1 Message Date
Laurent Cozic
306773ec8d update 2023-10-19 17:09:03 +01:00
Laurent Cozic
d0f2ff6929 update 2023-10-19 12:24:03 +01:00
Laurent Cozic
c19f9eb705 update 2023-10-18 17:54:29 +01:00
Laurent Cozic
8b811111d6 init 2023-10-17 17:27:41 +01:00
Laurent Cozic
11f7915a54 Doc: Fixed typo 2023-10-17 11:24:57 +01:00
renovate[bot]
4501ecff3b Update dependency react-native-image-picker to v5.7.0 (#9077)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-17 09:50:25 +01:00
renovate[bot]
387ba2c50f Update dependency markdown-it to v13.0.2 (#9073)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-16 21:20:44 +00:00
renovate[bot]
7e085ef0bc Update dependency @testing-library/react-native to v12.3.0 (#9074)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-16 21:39:21 +01:00
renovate[bot]
cfb1f11956 Update dependency @types/react to v18.2.23 (#9071)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-16 19:18:49 +00:00
renovate[bot]
6668a52478 Update dependency @types/styled-components to v5.1.28 (#9067)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-15 21:40:24 +00:00
renovate[bot]
92230fce72 Update dependency react-native-zip-archive to v6.1.0 (#9068)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-15 20:04:26 +01:00
Laurent Cozic
f2269e9820 Chore: Fixed node-glob issues 2023-10-15 18:51:37 +01:00
Laurent Cozic
fbef66b65a Tools: Add node-glob to Renovate ignore list 2023-10-15 13:52:13 +01:00
renovate[bot]
8795da83d2 Update dependency @types/yargs to v17.0.25 (#9064)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-15 10:46:21 +00:00
renovate[bot]
79f9b93b58 Update dependency tar to v6.2.0 (#9065)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-15 10:28:52 +01:00
Helmut K. C. Tessarek
5fd4c19b8d All: Translation: Update da_DK.po (thanks ERYpTION) 2023-10-14 05:58:13 -04:00
renovate[bot]
7621dde8e7 Update dependency @types/nodemailer to v6.4.11 (#9062)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-14 09:16:58 +00:00
renovate[bot]
02a797abe9 Update dependency @types/node-rsa to v1.1.2 (#9061)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-14 06:30:24 +00:00
renovate[bot]
41d4734bd3 Update dependency @types/node-fetch to v2.6.6 (#9060)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-14 03:50:59 +00:00
renovate[bot]
0c91e2c947 Update dependency @types/node to v18.17.19 (#9059)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-14 02:03:04 +00:00
Henry Heino
2d06fd9d13 Chore: Desktop: Set up integration testing with Playwright (#9043) 2023-10-13 17:32:10 +03:00
pedr
5733017637 Chore: Move useful clipper logic to the lib package to be used in other places (#9053) 2023-10-13 17:31:13 +03:00
renovate[bot]
b1e1db7831 Update dependency glob to v10.3.6 (#9057)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-13 03:21:27 +00:00
pedr
c5c03ab04e Chore: Improve isNode to be more inclusive (#9054) 2023-10-12 15:27:48 +03:00
Laurent Cozic
5cecfde085 Update translations 2023-10-12 15:26:55 +03:00
Joplin Bot
0c701f59c7 Doc: Auto-update documentation
Auto-updated using release-website.sh
2023-10-12 12:23:15 +00:00
Joplin Bot
05a4affd5a Doc: Auto-update documentation
Auto-updated using release-website.sh
2023-10-12 06:21:15 +00:00
renovate[bot]
84ff840c15 Update dependency react-native-share to v9.4.1 (#9052)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-11 15:43:00 +00:00
Laurent Cozic
4989d402a4 Fix config 2023-10-11 16:13:19 +03:00
Laurent Cozic
3ac25104c3 Merge branch 'dev' of github.com:laurent22/joplin into dev 2023-10-11 13:05:30 +03:00
renovate[bot]
2a73010c9d Update dependency react-native-share to v9.4.0 (#9050)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-11 12:46:20 +03:00
Henry Heino
0f005c5039 Desktop: Fixes #8881: Fix markdown editor context menu not displaying on some devices (#9030) 2023-10-11 10:18:32 +01:00
Laurent Cozic
0402fa624d All: Support for plural translations (#9033) 2023-10-11 10:17:46 +01:00
renovate[bot]
a98c323bf3 Update dependency react-select to v5.7.5 (#9048)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-11 09:16:28 +00:00
Laurent Cozic
d975d8d626 Tools: Auto-apply Electron minor and patch updates 2023-10-11 10:15:22 +01:00
renovate[bot]
19c694760f Update dependency react-native-paper to v5.10.6 (#9047)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-11 07:25:54 +00:00
renovate[bot]
9236f68016 Update dependency glob to v10.3.5 (#9046)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-11 04:20:09 +00:00
renovate[bot]
a052983fb6 Update dependency @types/react to v18.2.22 (#9044)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-10 16:43:35 +00:00
renovate[bot]
089c6afd1a Update react monorepo (#9032)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-10 13:59:23 +03:00
renovate[bot]
4b7f807b5b Update dependency react-native-webview to v13.5.1 (#9029)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-10 13:59:03 +03:00
renovate[bot]
d4ccf06f98 Update dependency @types/node to v18.17.18 (#9042)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-10 07:34:31 +00:00
renovate[bot]
2d9980dd36 Update dependency follow-redirects to v1.15.3 (#9041)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-10 00:47:17 +00:00
renovate[bot]
db4e08b757 Update dependency dayjs to v1.11.10 (#9040)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-09 18:44:28 +00:00
renovate[bot]
10cef6e146 Update dependency react-native-share to v9.3.0 (#9038)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-09 19:40:55 +03:00
renovate[bot]
d33df54969 Update dependency sharp to v0.32.6 (#9035)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-09 04:10:07 +00:00
renovate[bot]
90feb65b44 Update dependency react-native-paper to v5.10.5 (#9034)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-09 01:21:47 +00:00
Laurent Cozic
cbe260eed2 Chore: Convert build-translations to TS 2023-10-08 19:20:53 +01:00
renovate[bot]
2f7801a267 Update dependency @react-native-community/datetimepicker to v7.5.0 (#9031)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-08 18:57:07 +01:00
renovate[bot]
d58f62ca9d Update contributor-assistant/github-action action to v2.3.1 (#9025)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-08 01:57:38 +00:00
renovate[bot]
d6f272c74f Update dependency react-native-device-info to v10.9.0 (#9026)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-07 22:23:08 +01:00
Laurent Cozic
cd55a9a40f Mobile: Fix sidebar folder icon 2023-10-07 19:48:38 +01:00
Laurent Cozic
5cb54a57ac Android 2.13.2 2023-10-07 18:09:36 +01:00
Laurent Cozic
0e0c1d8395 Mobile: Fix icon after react-native-vector-icon upgrade 2023-10-07 17:25:03 +01:00
Laurent Cozic
26362146bc Android 2.13.1 2023-10-07 14:54:01 +01:00
Laurent Cozic
1b740a76cf fix ts errors 2023-10-06 21:19:57 +01:00
Laurent Cozic
b1461d699f Doc: Handle archived prices 2023-10-06 20:53:23 +01:00
Laurent Cozic
33c64f98ef Doc: Mention custom banner in Joplin Cloud page 2023-10-06 20:33:25 +01:00
Joplin Bot
822716c146 Doc: Auto-update documentation
Auto-updated using release-website.sh
2023-10-06 18:18:42 +00:00
Laurent Cozic
520d9df90f Plugin Generator release v2.13.1 2023-10-06 18:28:30 +01:00
Laurent Cozic
da40036a34 Doc: Fixed plugin doc 2023-10-06 18:27:40 +01:00
Laurent Cozic
3ac2fe94a4 Plugins: Fix building plugins on Windows 2023-10-06 18:05:41 +01:00
Laurent Cozic
789aaa6db6 Desktop release v2.13.2 2023-10-06 17:08:59 +01:00
Laurent Cozic
20374b6817 lock files 2023-10-06 17:08:14 +01:00
Laurent Cozic
e49ca26a36 API: Allow accessing revision data 2023-10-06 15:03:32 +01:00
renovate[bot]
634fa8a61f Update dependency @types/node to v18.17.17 (#9022)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-06 11:12:41 +00:00
renovate[bot]
7372a2f928 Update dependency @types/jsdom to v21.1.3 (#9020)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-06 03:55:51 +00:00
renovate[bot]
2b381badb2 Update dependency @types/js-yaml to v4.0.6 (#9019)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-06 01:45:00 +00:00
renovate[bot]
006cf970ad Update dependency react-native-vector-icons to v10 (#9016)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-05 18:28:48 +01:00
renovate[bot]
70347a733b Update dependency @types/node to v18.17.16 (#9015)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-05 12:57:05 +00:00
Self Not Found
b8aab34d1b Android: Fixed #8533: Fix sharing image to Joplin (#8970) 2023-10-05 11:51:46 +01:00
CptMeetKat
ef6d94973c Desktop: Fixes #8937: Preserve previous values when updating a note's date fields (#8948) 2023-10-05 11:50:07 +01:00
CptMeetKat
0320d9f479 Desktop: Fixes #8946: Whitelisted Joplin markdown links for copy pasting in WYSIWYG mode (#8972) 2023-10-05 11:49:25 +01:00
Jens Böttge
e70f31baea All: Enable ignoreTlsErrors and custom certificates for S3 sync (#8980) 2023-10-05 10:56:25 +01:00
Henry Heino
992807eb8f Chore: Fixes #9001: Fix error logged to console when a partial link is partially selected in Beta Editor (#9002) 2023-10-05 10:55:24 +01:00
Henry Heino
feb95451fd Desktop: Fixes #9003: Fix pasting text into Beta Editor with Cmd+V/Ctrl+V (#9004) 2023-10-05 10:54:10 +01:00
renovate[bot]
85f1fe259e Update dependency react-native-webview to v13.4.0 (#9013)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-05 10:53:22 +01:00
renovate[bot]
8eccc0ca4d Update dependency url to v0.11.3 (#9012)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-05 03:35:26 +00:00
renovate[bot]
ebd0d79b33 Update dependency @types/uuid to v9.0.4 (#9010)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-05 00:03:23 +00:00
renovate[bot]
956578417b Update dependency @types/fs-extra to v11.0.2 (#9011)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-04 23:28:29 +00:00
renovate[bot]
cc650d2adc Update dependency @types/node-fetch to v2.6.5 (#9008)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-04 12:51:10 +00:00
renovate[bot]
edd081f4ed Update dependency @codemirror/autocomplete to v6.9.1 (#9009)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-04 10:39:25 +00:00
renovate[bot]
9fca885246 Update dependency @types/koa to v2.13.9 (#9007)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-04 07:09:58 +00:00
Hicham El Bouaaichi
4658b97d85 All: Translation: Update ar.po (#8959) 2023-10-02 15:15:13 -04:00
Henry Heino
53c82434be Mobile: Add share button to log screen (#8364)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2023-10-02 11:14:08 -07:00
renovate[bot]
f2d058dceb Update dependency @types/tar-stream to v2.2.3 (#9000)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-02 16:06:48 +00:00
Henry Heino
849427d1bf Mobile: Add support for drawing pictures (#7588) 2023-10-02 15:15:51 +01:00
renovate[bot]
487112fd4d Update dependency uuid to v9.0.1 (#8998)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-02 11:41:52 +00:00
renovate[bot]
7cb3357f97 Update dependency react-native-url-polyfill to v2 (#8997)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-02 11:51:38 +01:00
Laurent Cozic
f504cf1a83 All: Fix search engine ranking algorithm 2023-10-02 10:42:13 +01:00
renovate[bot]
3585412fcb Update dependency sprintf-js to v1.1.3 (#8995)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-01 19:32:55 +00:00
renovate[bot]
37efd2d91f Update dependency katex to v0.16.8 (#8993)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-01 12:59:51 +01:00
Laurent Cozic
597e3d4b67 CI: Setup katex and mermaid so that they can be updated by Renovate 2023-10-01 11:05:52 +01:00
renovate[bot]
c65fa3087f Update eslint (major) (#8990)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-01 10:00:00 +00:00
renovate[bot]
746b291d2a Update dependency @types/styled-components to v5.1.27 (#8992)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-01 09:01:56 +00:00
renovate[bot]
d5e63aa904 Update dependency @types/nodemailer to v6.4.10 (#8991)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-01 07:12:25 +00:00
renovate[bot]
36a235deb8 Update eslint (#8989)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-01 03:59:27 +00:00
Joplin Bot
c8f1699caa Doc: Auto-update documentation
Auto-updated using release-website.sh
2023-10-01 00:44:48 +00:00
renovate[bot]
59952d486a Update dependency url to v0.11.2 (#8981)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-09-29 04:30:40 +00:00
renovate[bot]
04395976db Update dependency @types/node to v18.17.15 (#8983)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-09-29 01:28:12 +00:00
github-actions[bot]
677d668402 @J-U-B has signed the CLA in laurent22/joplin#8980 2023-09-28 16:40:31 +00:00
renovate[bot]
608767fe22 Update dependency compare-versions to v6.1.0 (#8969)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-09-27 10:28:20 +01:00
renovate[bot]
823970cfd7 Update dependency sass to v1.66.1 (#8968)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-09-27 06:18:49 +00:00
renovate[bot]
a9bcf05b44 Update dependency nodemon to v3.0.1 (#8966)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-09-27 04:14:38 +00:00
renovate[bot]
03ad00b2cd Update dependency compare-versions to v6 (#8964)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2023-09-27 00:01:52 +01:00
renovate[bot]
84d18429d2 Update dependency sass to v1.66.0 (#8967)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-09-27 00:01:34 +01:00
renovate[bot]
d0fc1d3ac3 Update dependency nodemon to v3 (#8965)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-09-26 22:43:49 +01:00
renovate[bot]
ade67475fd Update dependency nodemailer to v6.9.5 (#8962)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-09-26 10:01:19 +00:00
Joplin Bot
d22abe69b6 Doc: Auto-update documentation
Auto-updated using release-website.sh
2023-09-25 18:17:59 +00:00
Henry Heino
b6c3dc32ad Mobile: Fixes #8956: Fix unordered list button creates checklists (#8957) 2023-09-25 16:45:25 +01:00
Laurent Cozic
d71a404636 Fixed tests 2023-09-25 16:26:50 +01:00
Laurent Cozic
34c4b832ba Revert: Desktop: Fixes #5955: Changing the currently opened note from plugins or the data API does not refresh the note content
Causes an infinite rendering loop when creating a new note
2023-09-25 16:16:07 +01:00
Laurent Cozic
1f19072f8f Revert "Desktop: Resolves #8817: Scrollbar thumb is barely visible in Light theme (#8831)"
This reverts commit c824f25262.
2023-09-25 15:34:39 +01:00
Laurent Cozic
c88cabe47a Clipper: Update library used to clip simplified pages 2023-09-25 15:29:42 +01:00
Laurent Cozic
52effd7760 Desktop, Cli: Fixes #8823: Ensure that ENEX resources with invalid filenames are imported correctly 2023-09-25 14:40:56 +01:00
Laurent Cozic
5ab6a89046 Desktop, Cli: Fixes #8802: Improved import of invalid Markdown+FrontMatter files 2023-09-25 13:47:49 +01:00
Laurent Cozic
1ea61c8505 Desktop, Cli: Fixes #8363: ENEX files that contain resources with invalid mime types are imported correctly 2023-09-25 12:25:55 +01:00
renovate[bot]
7f34e3f2f4 Update dependency react-native-paper to v5.10.4 (#8951)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-09-25 04:45:11 +00:00
Laurent Cozic
fb9974c76f Desktop: Fixes #8305: Ensure that notes that contain invalid links can still be exported as HTML or PDF 2023-09-24 23:58:58 +01:00
Laurent Cozic
329f5a0dda Desktop: Resolves #8225: No strikethrough on completed to-dos in note list to improve readability 2023-09-24 23:38:20 +01:00
Laurent Cozic
9f14e61aff Mobile: Fixes #8050: Fix complex queries that contain quotes or filters 2023-09-24 23:22:36 +01:00
Laurent Cozic
d14b694b6c Desktop, Mobile: Resolves #8021: Remember whether "All notes", a notebook or a tag was opened when re-opening the app 2023-09-24 20:21:58 +01:00
Laurent Cozic
24097edc20 Tools: Trying to fix memory allocation crash on CI 2023-09-24 20:03:00 +01:00
Laurent Cozic
3a4942a5a4 Tools: Trying to fix memory allocation crash on CI 2023-09-24 19:01:19 +01:00
Laurent Cozic
24952c2f4a Tools: Trying to fix memory allocation crash on CI 2023-09-24 17:06:32 +01:00
Laurent Cozic
dd25f77393 Clipper: Fixes #7925: Fixes certain pages that may end up invisible once in Joplin 2023-09-24 16:58:14 +01:00
Laurent Cozic
bcf054fd08 All: Resolves #6978: Improved handling of invalid sync info 2023-09-24 16:26:01 +01:00
Laurent Cozic
da58d1f0d7 comments 2023-09-24 15:38:32 +01:00
Laurent Cozic
f34e08ad58 Tools: Trying to fix random CI failures (#8949) 2023-09-24 15:36:54 +01:00
Laurent Cozic
cd1e1cdf94 Desktop: Resolves #6736: Significantly improve performance when displaying large tables in Rich Text editor 2023-09-24 15:35:00 +01:00
Laurent Cozic
28384ddfd9 Desktop: Fixes #6514: Do no automatically set focus when switching layout to fix certain plugin issues 2023-09-24 14:41:42 +01:00
Laurent Cozic
061ec7902c Removed unneeded dependency 2023-09-24 13:29:09 +01:00
Laurent Cozic
e94b20d57a Doc: Fixed plan page layout 2023-09-24 13:20:22 +01:00
Laurent Cozic
04b52d7c1e fix test 2023-09-24 11:55:30 +01:00
renovate[bot]
63695637b9 Update dependency node-mocks-http to v1.13.0 (#8944)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-09-23 18:07:09 +01:00
Laurent Cozic
a1821d607e Desktop: Fixes #5955: Changing the currently opened note from plugins or the data API does not refresh the note content 2023-09-23 17:50:24 +01:00
Laurent Cozic
cc459a68d5 lock file 2023-09-23 13:01:06 +01:00
Laurent Cozic
89b3c41d65 Doc: Add Joplin Cloud feature descriptions to Plans page 2023-09-23 12:56:18 +01:00
Gen Neko
62c4fd4b7d All: Translation: Update ja_JP.po (#8942) 2023-09-23 04:29:19 -04:00
github-actions[bot]
c24fe372fc @genneko has signed the CLA in laurent22/joplin#8942 2023-09-23 05:35:21 +00:00
renovate[bot]
fa0cd2e2a4 Update dependency @types/node to v18.17.14 (#8940)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-09-23 03:47:08 +00:00
renovate[bot]
5078d975c8 Update dependency @types/react-redux to v7.1.26 (#8928)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-09-23 02:02:51 +01:00
renovate[bot]
b742e36ea3 Update dependency @bam.tech/react-native-image-resizer to v3.0.7 (#8927)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-09-23 02:01:51 +01:00
renovate[bot]
2da5b6d848 Update dependency lint-staged to v13.3.0 (#8939)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-09-23 02:00:59 +01:00
Joplin Bot
662fc08cb1 Doc: Auto-update documentation
Auto-updated using release-website.sh
2023-09-22 18:17:33 +00:00
renovate[bot]
1fe5fd17b8 Update dependency @types/node to v18.17.13 (#8936)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-09-22 13:18:29 +00:00
Henry Heino
aff1bf501f Desktop: Resolves #8932: Fix pasting images into the beta editor (#8934) 2023-09-22 10:48:59 +01:00
renovate[bot]
2f3d207096 Update dependency npm-package-json-lint to v7 (#8938)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-09-22 10:45:51 +01:00
Laurent Cozic
fc515313cf Merge branch 'release-2.12' into dev 2023-09-21 15:22:31 +01:00
Laurent Cozic
a0a4a81cc7 Desktop release v2.12.18 2023-09-21 15:16:07 +01:00
Laurent Cozic
01f37dfc32 Server: Fixed publishing logo 2023-09-21 11:30:15 +01:00
renovate[bot]
54966adbbb Update dependency react-native-safe-area-context to v4.7.2 (#8925)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-09-21 10:19:28 +00:00
Laurent Cozic
003d47fb72 lock file 2023-09-21 09:24:53 +01:00
Henry Heino
84c6de9b56 Desktop: Add new beta Markdown editor based on CodeMirror 6 (#8793) 2023-09-21 09:12:40 +01:00
Laurent Cozic
c3971ff226 Tools: Group buildTools on Renovate updates 2023-09-21 08:34:55 +01:00
renovate[bot]
874b39fe95 Update dependency react-native-modal-datetime-picker to v17.1.0 (#8888)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2023-09-21 08:31:52 +01:00
renovate[bot]
41a78c9602 Update dependency eslint to v8.47.0 (#8924)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-09-21 08:28:02 +01:00
Henry Heino
fad4f6d1dd Desktop: Upgrade to Electron 25.8.1 (#8921) 2023-09-20 13:42:47 +01:00
Laurent Cozic
b9cb9e42ff Desktop release v2.12.17 2023-09-14 22:06:59 +01:00
Laurent Cozic
7882237e24 All: Fixes #8699: Prevent application from being stuck when importing an invalid ENEX file 2023-09-14 22:06:25 +01:00
331 changed files with 160621 additions and 56391 deletions

View File

@@ -38,6 +38,10 @@ packages/app-clipper/popup/config/webpack.config.js
packages/app-clipper/popup/node_modules
packages/app-clipper/popup/scripts/build.js
packages/app-desktop/build/
packages/app-desktop/test-results/
packages/app-desktop/playwright-report/
packages/app-desktop/playwright/.cache/
packages/app-desktop/integration-tests/test-profile/
packages/app-desktop/dist
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/plugins/lists.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/supportedLocales.js
@@ -47,7 +51,7 @@ packages/app-desktop/packageInfo.js
packages/app-desktop/services/electron-context-menu.js
packages/app-desktop/vendor/lib/
packages/app-mobile/android
packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.bundle.js
packages/app-mobile/components/NoteEditor/**/*.bundle.js
packages/app-mobile/ios
packages/app-mobile/lib/rnInjectedJs/
packages/app-mobile/locales
@@ -215,12 +219,11 @@ packages/app-desktop/gui/MenuBar.js
packages/app-desktop/gui/MultiNoteActions.js
packages/app-desktop/gui/Navigator.js
packages/app-desktop/gui/NoteContentPropertiesDialog.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/CodeMirror.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/Editor.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/Toolbar.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/styles/index.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/index.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/setupVim.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/types.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useContextMenu.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useCursorUtils.test.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useCursorUtils.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useEditorSearch.js
@@ -232,6 +235,13 @@ packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useLineSorting.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useListIdent.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollHandler.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollUtils.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useStyles.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useWebviewIpcMessage.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v5/CodeMirror.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v5/Editor.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/CodeMirror.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/Editor.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/useEditorCommands.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/styles/index.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/joplinCommandToTinyMceCommands.js
@@ -365,6 +375,13 @@ packages/app-desktop/gui/style/StyledTextInput.js
packages/app-desktop/gui/utils/NoteListUtils.js
packages/app-desktop/gui/utils/convertToScreenCoordinates.js
packages/app-desktop/gui/utils/loadScript.js
packages/app-desktop/integration-tests/main.spec.js
packages/app-desktop/integration-tests/models/MainScreen.js
packages/app-desktop/integration-tests/models/NoteEditorScreen.js
packages/app-desktop/integration-tests/models/SettingsScreen.js
packages/app-desktop/integration-tests/util/activateMainMenuItem.js
packages/app-desktop/integration-tests/util/test.js
packages/app-desktop/playwright.config.js
packages/app-desktop/plugins/GotoAnything.js
packages/app-desktop/services/bridge.js
packages/app-desktop/services/commands/stateToWhenClauseContext.js
@@ -407,28 +424,24 @@ packages/app-mobile/components/ExtendedWebView.js
packages/app-mobile/components/FolderPicker.js
packages/app-mobile/components/Modal.js
packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.js
packages/app-mobile/components/NoteBodyViewer/hooks/useEditPopup.test.js
packages/app-mobile/components/NoteBodyViewer/hooks/useEditPopup.js
packages/app-mobile/components/NoteBodyViewer/hooks/useOnMessage.js
packages/app-mobile/components/NoteBodyViewer/hooks/useOnResourceLongPress.js
packages/app-mobile/components/NoteBodyViewer/hooks/useSource.js
packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.test.js
packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.js
packages/app-mobile/components/NoteEditor/CodeMirror/decoratorExtension.js
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.bulletedVsChecklist.test.js
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.test.js
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.toggleList.test.js
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.js
packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.test.js
packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.js
packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.test.js
packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.js
packages/app-mobile/components/NoteEditor/CodeMirror/syntaxHighlightingLanguages.js
packages/app-mobile/components/NoteEditor/CodeMirror/testUtil/createEditor.js
packages/app-mobile/components/NoteEditor/CodeMirror/testUtil/forceFullParse.js
packages/app-mobile/components/NoteEditor/CodeMirror/testUtil/loadLanguages.js
packages/app-mobile/components/NoteEditor/CodeMirror/theme.js
packages/app-mobile/components/NoteEditor/CodeMirror/types.js
packages/app-mobile/components/NoteEditor/CodeMirror/webviewLogger.js
packages/app-mobile/components/NoteEditor/EditLinkDialog.js
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/js-draw/applyTemplateToEditor.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/createJsDrawEditor.test.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/createJsDrawEditor.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/startAutosaveLoop.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/types.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/watchEditorForTemplateChanges.js
packages/app-mobile/components/NoteEditor/ImageEditor/promptRestoreAutosave.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/MarkdownToolbar.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToggleOverflowButton.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToggleSpaceButton.js
@@ -439,7 +452,6 @@ packages/app-mobile/components/NoteEditor/MarkdownToolbar/types.js
packages/app-mobile/components/NoteEditor/NoteEditor.test.js
packages/app-mobile/components/NoteEditor/NoteEditor.js
packages/app-mobile/components/NoteEditor/SearchPanel.js
packages/app-mobile/components/NoteEditor/SelectionFormatting.js
packages/app-mobile/components/NoteEditor/types.js
packages/app-mobile/components/NoteList.js
packages/app-mobile/components/ProfileSwitcher/ProfileEditor.js
@@ -461,6 +473,7 @@ packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/NoteExport
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/NoteExportButton.js
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/exportAllFolders.js
packages/app-mobile/components/screens/ConfigScreen/configScreenStyles.js
packages/app-mobile/components/screens/LogScreen.js
packages/app-mobile/components/screens/Note.js
packages/app-mobile/components/screens/Notes.js
packages/app-mobile/components/screens/UpgradeSyncTargetScreen.js
@@ -479,6 +492,7 @@ packages/app-mobile/services/voiceTyping/vosk.ios.js
packages/app-mobile/setupQuickActions.js
packages/app-mobile/tools/buildInjectedJs.js
packages/app-mobile/utils/ShareExtension.js
packages/app-mobile/utils/ShareUtils.test.js
packages/app-mobile/utils/ShareUtils.js
packages/app-mobile/utils/TlsUtils.js
packages/app-mobile/utils/autodetectTheme.js
@@ -489,6 +503,39 @@ packages/app-mobile/utils/fs-driver-rn.js
packages/app-mobile/utils/setupNotifications.js
packages/app-mobile/utils/shareHandler.js
packages/app-mobile/utils/types.js
packages/editor/CodeMirror/CodeMirror5Emulation/CodeMirror5Emulation.test.js
packages/editor/CodeMirror/CodeMirror5Emulation/CodeMirror5Emulation.js
packages/editor/CodeMirror/CodeMirror5Emulation/Decorator.js
packages/editor/CodeMirror/CodeMirrorControl.test.js
packages/editor/CodeMirror/CodeMirrorControl.js
packages/editor/CodeMirror/PluginLoader.js
packages/editor/CodeMirror/configFromSettings.js
packages/editor/CodeMirror/createEditor.test.js
packages/editor/CodeMirror/createEditor.js
packages/editor/CodeMirror/editorCommands/editorCommands.js
packages/editor/CodeMirror/editorCommands/supportsCommand.js
packages/editor/CodeMirror/editorCommands/swapLine.js
packages/editor/CodeMirror/getScrollFraction.js
packages/editor/CodeMirror/markdown/computeSelectionFormatting.test.js
packages/editor/CodeMirror/markdown/computeSelectionFormatting.js
packages/editor/CodeMirror/markdown/decoratorExtension.js
packages/editor/CodeMirror/markdown/markdownCommands.bulletedVsChecklist.test.js
packages/editor/CodeMirror/markdown/markdownCommands.test.js
packages/editor/CodeMirror/markdown/markdownCommands.toggleList.test.js
packages/editor/CodeMirror/markdown/markdownCommands.js
packages/editor/CodeMirror/markdown/markdownMathParser.test.js
packages/editor/CodeMirror/markdown/markdownMathParser.js
packages/editor/CodeMirror/markdown/markdownReformatter.test.js
packages/editor/CodeMirror/markdown/markdownReformatter.js
packages/editor/CodeMirror/markdown/syntaxHighlightingLanguages.js
packages/editor/CodeMirror/testUtil/createEditorSettings.js
packages/editor/CodeMirror/testUtil/createTestEditor.js
packages/editor/CodeMirror/testUtil/forceFullParse.js
packages/editor/CodeMirror/testUtil/loadLanguages.js
packages/editor/CodeMirror/theme.js
packages/editor/SelectionFormatting.js
packages/editor/events.js
packages/editor/types.js
packages/fork-htmlparser2/src/CollectingHandler.js
packages/fork-htmlparser2/src/FeedHandler.spec.js
packages/fork-htmlparser2/src/FeedHandler.js
@@ -539,6 +586,7 @@ packages/lib/WelcomeUtils.js
packages/lib/array.js
packages/lib/callbackUrlUtils.test.js
packages/lib/callbackUrlUtils.js
packages/lib/clipperUtils.js
packages/lib/commands/historyBackward.js
packages/lib/commands/historyForward.js
packages/lib/commands/index.js
@@ -767,6 +815,7 @@ packages/lib/services/rest/routes/master_keys.js
packages/lib/services/rest/routes/notes.js
packages/lib/services/rest/routes/ping.js
packages/lib/services/rest/routes/resources.js
packages/lib/services/rest/routes/revisions.js
packages/lib/services/rest/routes/search.js
packages/lib/services/rest/routes/tags.js
packages/lib/services/rest/utils/collectionToPaginatedResults.js
@@ -918,6 +967,7 @@ packages/renderer/noteStyle.js
packages/renderer/pathUtils.js
packages/renderer/utils.js
packages/tools/build-release-stats.js
packages/tools/build-translation.js
packages/tools/build-welcome.js
packages/tools/buildServerDocker.test.js
packages/tools/buildServerDocker.js

View File

@@ -86,7 +86,7 @@ if [ "$IS_PULL_REQUEST" == "1" ] || [ "$IS_DEV_BRANCH" = "1" ]; then
# Allocation failed - JavaScript heap out of memory
#
# https://stackoverflow.com/questions/38558989
export NODE_OPTIONS="--max-old-space-size=4096"
export NODE_OPTIONS="--max-old-space-size=32768"
yarn run test-ci
testResult=$?
if [ $testResult -ne 0 ]; then

View File

@@ -13,7 +13,7 @@ jobs:
- name: "CLA Assistant"
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
# Beta Release
uses: contributor-assistant/github-action@v2.3.0
uses: contributor-assistant/github-action@v2.3.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# the below token should have repo scope and must be manually added by you in the repository's secret

View File

@@ -3,7 +3,9 @@ on: [push, pull_request]
jobs:
pre_job:
if: github.repository == 'laurent22/joplin'
runs-on: ubuntu-latest
# Do not use unbuntu-latest because it causes `The operation was canceled` failures:
# https://github.com/actions/runner-images/issues/6709
runs-on: ubuntu-20.04
outputs:
should_skip: ${{ steps.skip_check.outputs.should_skip }}
steps:
@@ -19,7 +21,9 @@ jobs:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [macos-latest, ubuntu-latest, windows-2019]
# Do not use unbuntu-latest because it causes `The operation was canceled` failures:
# https://github.com/actions/runner-images/issues/6709
os: [macos-latest, ubuntu-20.04, windows-2019]
steps:
# Trying to fix random networking issues on Windows
@@ -51,6 +55,9 @@ jobs:
sudo apt-get install -y libsecret-1-dev
sudo apt-get install -y translate-toolkit
sudo apt-get install -y rsync
# Provides a virtual display on Linux. Used for Playwright integration
# testing.
sudo apt-get install -y xvfb
- name: Install Docker Engine
# if: runner.os == 'Linux' && startsWith(github.ref, 'refs/tags/server-v')
@@ -139,7 +146,9 @@ jobs:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest]
# Do not use unbuntu-latest because it causes `The operation was canceled` failures:
# https://github.com/actions/runner-images/issues/6709
os: [ubuntu-20.04]
steps:
- name: Install Docker Engine

86
.gitignore vendored
View File

@@ -201,12 +201,11 @@ packages/app-desktop/gui/MenuBar.js
packages/app-desktop/gui/MultiNoteActions.js
packages/app-desktop/gui/Navigator.js
packages/app-desktop/gui/NoteContentPropertiesDialog.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/CodeMirror.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/Editor.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/Toolbar.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/styles/index.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/index.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/setupVim.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/types.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useContextMenu.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useCursorUtils.test.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useCursorUtils.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useEditorSearch.js
@@ -218,6 +217,13 @@ packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useLineSorting.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useListIdent.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollHandler.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollUtils.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useStyles.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useWebviewIpcMessage.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v5/CodeMirror.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v5/Editor.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/CodeMirror.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/Editor.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/useEditorCommands.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/styles/index.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/joplinCommandToTinyMceCommands.js
@@ -351,6 +357,13 @@ packages/app-desktop/gui/style/StyledTextInput.js
packages/app-desktop/gui/utils/NoteListUtils.js
packages/app-desktop/gui/utils/convertToScreenCoordinates.js
packages/app-desktop/gui/utils/loadScript.js
packages/app-desktop/integration-tests/main.spec.js
packages/app-desktop/integration-tests/models/MainScreen.js
packages/app-desktop/integration-tests/models/NoteEditorScreen.js
packages/app-desktop/integration-tests/models/SettingsScreen.js
packages/app-desktop/integration-tests/util/activateMainMenuItem.js
packages/app-desktop/integration-tests/util/test.js
packages/app-desktop/playwright.config.js
packages/app-desktop/plugins/GotoAnything.js
packages/app-desktop/services/bridge.js
packages/app-desktop/services/commands/stateToWhenClauseContext.js
@@ -393,28 +406,24 @@ packages/app-mobile/components/ExtendedWebView.js
packages/app-mobile/components/FolderPicker.js
packages/app-mobile/components/Modal.js
packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.js
packages/app-mobile/components/NoteBodyViewer/hooks/useEditPopup.test.js
packages/app-mobile/components/NoteBodyViewer/hooks/useEditPopup.js
packages/app-mobile/components/NoteBodyViewer/hooks/useOnMessage.js
packages/app-mobile/components/NoteBodyViewer/hooks/useOnResourceLongPress.js
packages/app-mobile/components/NoteBodyViewer/hooks/useSource.js
packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.test.js
packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.js
packages/app-mobile/components/NoteEditor/CodeMirror/decoratorExtension.js
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.bulletedVsChecklist.test.js
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.test.js
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.toggleList.test.js
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.js
packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.test.js
packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.js
packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.test.js
packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.js
packages/app-mobile/components/NoteEditor/CodeMirror/syntaxHighlightingLanguages.js
packages/app-mobile/components/NoteEditor/CodeMirror/testUtil/createEditor.js
packages/app-mobile/components/NoteEditor/CodeMirror/testUtil/forceFullParse.js
packages/app-mobile/components/NoteEditor/CodeMirror/testUtil/loadLanguages.js
packages/app-mobile/components/NoteEditor/CodeMirror/theme.js
packages/app-mobile/components/NoteEditor/CodeMirror/types.js
packages/app-mobile/components/NoteEditor/CodeMirror/webviewLogger.js
packages/app-mobile/components/NoteEditor/EditLinkDialog.js
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/js-draw/applyTemplateToEditor.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/createJsDrawEditor.test.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/createJsDrawEditor.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/startAutosaveLoop.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/types.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/watchEditorForTemplateChanges.js
packages/app-mobile/components/NoteEditor/ImageEditor/promptRestoreAutosave.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/MarkdownToolbar.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToggleOverflowButton.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToggleSpaceButton.js
@@ -425,7 +434,6 @@ packages/app-mobile/components/NoteEditor/MarkdownToolbar/types.js
packages/app-mobile/components/NoteEditor/NoteEditor.test.js
packages/app-mobile/components/NoteEditor/NoteEditor.js
packages/app-mobile/components/NoteEditor/SearchPanel.js
packages/app-mobile/components/NoteEditor/SelectionFormatting.js
packages/app-mobile/components/NoteEditor/types.js
packages/app-mobile/components/NoteList.js
packages/app-mobile/components/ProfileSwitcher/ProfileEditor.js
@@ -447,6 +455,7 @@ packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/NoteExport
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/NoteExportButton.js
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/exportAllFolders.js
packages/app-mobile/components/screens/ConfigScreen/configScreenStyles.js
packages/app-mobile/components/screens/LogScreen.js
packages/app-mobile/components/screens/Note.js
packages/app-mobile/components/screens/Notes.js
packages/app-mobile/components/screens/UpgradeSyncTargetScreen.js
@@ -465,6 +474,7 @@ packages/app-mobile/services/voiceTyping/vosk.ios.js
packages/app-mobile/setupQuickActions.js
packages/app-mobile/tools/buildInjectedJs.js
packages/app-mobile/utils/ShareExtension.js
packages/app-mobile/utils/ShareUtils.test.js
packages/app-mobile/utils/ShareUtils.js
packages/app-mobile/utils/TlsUtils.js
packages/app-mobile/utils/autodetectTheme.js
@@ -475,6 +485,39 @@ packages/app-mobile/utils/fs-driver-rn.js
packages/app-mobile/utils/setupNotifications.js
packages/app-mobile/utils/shareHandler.js
packages/app-mobile/utils/types.js
packages/editor/CodeMirror/CodeMirror5Emulation/CodeMirror5Emulation.test.js
packages/editor/CodeMirror/CodeMirror5Emulation/CodeMirror5Emulation.js
packages/editor/CodeMirror/CodeMirror5Emulation/Decorator.js
packages/editor/CodeMirror/CodeMirrorControl.test.js
packages/editor/CodeMirror/CodeMirrorControl.js
packages/editor/CodeMirror/PluginLoader.js
packages/editor/CodeMirror/configFromSettings.js
packages/editor/CodeMirror/createEditor.test.js
packages/editor/CodeMirror/createEditor.js
packages/editor/CodeMirror/editorCommands/editorCommands.js
packages/editor/CodeMirror/editorCommands/supportsCommand.js
packages/editor/CodeMirror/editorCommands/swapLine.js
packages/editor/CodeMirror/getScrollFraction.js
packages/editor/CodeMirror/markdown/computeSelectionFormatting.test.js
packages/editor/CodeMirror/markdown/computeSelectionFormatting.js
packages/editor/CodeMirror/markdown/decoratorExtension.js
packages/editor/CodeMirror/markdown/markdownCommands.bulletedVsChecklist.test.js
packages/editor/CodeMirror/markdown/markdownCommands.test.js
packages/editor/CodeMirror/markdown/markdownCommands.toggleList.test.js
packages/editor/CodeMirror/markdown/markdownCommands.js
packages/editor/CodeMirror/markdown/markdownMathParser.test.js
packages/editor/CodeMirror/markdown/markdownMathParser.js
packages/editor/CodeMirror/markdown/markdownReformatter.test.js
packages/editor/CodeMirror/markdown/markdownReformatter.js
packages/editor/CodeMirror/markdown/syntaxHighlightingLanguages.js
packages/editor/CodeMirror/testUtil/createEditorSettings.js
packages/editor/CodeMirror/testUtil/createTestEditor.js
packages/editor/CodeMirror/testUtil/forceFullParse.js
packages/editor/CodeMirror/testUtil/loadLanguages.js
packages/editor/CodeMirror/theme.js
packages/editor/SelectionFormatting.js
packages/editor/events.js
packages/editor/types.js
packages/fork-htmlparser2/src/CollectingHandler.js
packages/fork-htmlparser2/src/FeedHandler.spec.js
packages/fork-htmlparser2/src/FeedHandler.js
@@ -525,6 +568,7 @@ packages/lib/WelcomeUtils.js
packages/lib/array.js
packages/lib/callbackUrlUtils.test.js
packages/lib/callbackUrlUtils.js
packages/lib/clipperUtils.js
packages/lib/commands/historyBackward.js
packages/lib/commands/historyForward.js
packages/lib/commands/index.js
@@ -753,6 +797,7 @@ packages/lib/services/rest/routes/master_keys.js
packages/lib/services/rest/routes/notes.js
packages/lib/services/rest/routes/ping.js
packages/lib/services/rest/routes/resources.js
packages/lib/services/rest/routes/revisions.js
packages/lib/services/rest/routes/search.js
packages/lib/services/rest/routes/tags.js
packages/lib/services/rest/utils/collectionToPaginatedResults.js
@@ -904,6 +949,7 @@ packages/renderer/noteStyle.js
packages/renderer/pathUtils.js
packages/renderer/utils.js
packages/tools/build-release-stats.js
packages/tools/build-translation.js
packages/tools/build-welcome.js
packages/tools/buildServerDocker.test.js
packages/tools/buildServerDocker.js

View File

@@ -5,6 +5,7 @@
"exceptions": [
"@joplin/lib",
"@joplin/renderer",
"@joplin/editor",
"@joplin/pdf-viewer",
"@joplin/fork-htmlparser2",
"@joplin/fork-sax",

View File

@@ -667,6 +667,30 @@ footer .bottom-links-row p {
color: #0557ba;
}
.joplin-cloud-feature-list .feature-description {
max-width: 600px;
font-size: .8em;
color: #555555;
margin-top: 5px;
}
.joplin-cloud-feature-list .feature-title {
text-decoration: none;
color: #000000;
margin-left: 10px;
border: 2px solid black;
display: inline-block;
width: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 100px;
height: 20px;
font-weight: bold;
font-size: 0.8em;
opacity: 0.5;
}
/*****************************************************************
WHAT'S NEW PAGE
*****************************************************************/

View File

@@ -86,11 +86,7 @@ https://github.com/laurent22/joplin/blob/dev/{{{sourceMarkdownFile}}}
{{> footer}}
</div>
<script
src="{{jsBaseUrl}}/bootstrap5.0.2.bundle.min.js"
rel="preload"
as="script"
></script>
<script src="{{jsBaseUrl}}/bootstrap5.0.2.bundle.min.js" rel="preload" as="script"></script>
<script src="{{{assetUrls.js.script}}}"></script>
{{> analytics}}

View File

@@ -138,6 +138,14 @@
} else {
applyPeriod('yearly');
}
$('.feature-description').hide();
$('.feature-title').click((event) => {
event.preventDefault();
const featureId = event.currentTarget.getAttribute('data-id');
$('.feature-description-' + featureId).toggle(200);
});
});
</script>
</div>

View File

@@ -22,11 +22,11 @@ Three types of applications are available: for **desktop** (Windows, macOS and L
Operating System | Download
---|---
Windows (32 and 64-bit) | <a href='https://objects.joplinusercontent.com/v2.12.17/Joplin-Setup-2.12.17.exe?source=JoplinWebsite&type=New'><img alt='Get it on Windows' width="134px" src='https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/BadgeWindows.png'/></a>
macOS | <a href='https://objects.joplinusercontent.com/v2.12.17/Joplin-2.12.17.dmg?source=JoplinWebsite&type=New'><img alt='Get it on macOS' width="134px" src='https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/BadgeMacOS.png'/></a>
Linux | <a href='https://objects.joplinusercontent.com/v2.12.17/Joplin-2.12.17.AppImage?source=JoplinWebsite&type=New'><img alt='Get it on Linux' width="134px" src='https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/BadgeLinux.png'/></a>
Windows (32 and 64-bit) | <a href='https://objects.joplinusercontent.com/v2.12.18/Joplin-Setup-2.12.18.exe?source=JoplinWebsite&type=New'><img alt='Get it on Windows' width="134px" src='https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/BadgeWindows.png'/></a>
macOS | <a href='https://objects.joplinusercontent.com/v2.12.18/Joplin-2.12.18.dmg?source=JoplinWebsite&type=New'><img alt='Get it on macOS' width="134px" src='https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/BadgeMacOS.png'/></a>
Linux | <a href='https://objects.joplinusercontent.com/v2.12.18/Joplin-2.12.18.AppImage?source=JoplinWebsite&type=New'><img alt='Get it on Linux' width="134px" src='https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/BadgeLinux.png'/></a>
**On Windows**, you may also use the <a href='https://objects.joplinusercontent.com/v2.12.17/JoplinPortable.exe?source=JoplinWebsite&type=New'>Portable version</a>. The [portable application](https://en.wikipedia.org/wiki/Portable_application) allows installing the software on a portable device such as a USB key. Simply copy the file JoplinPortable.exe in any directory on that USB key ; the application will then create a directory called "JoplinProfile" next to the executable file.
**On Windows**, you may also use the <a href='https://objects.joplinusercontent.com/v2.12.18/JoplinPortable.exe?source=JoplinWebsite&type=New'>Portable version</a>. The [portable application](https://en.wikipedia.org/wiki/Portable_application) allows installing the software on a portable device such as a USB key. Simply copy the file JoplinPortable.exe in any directory on that USB key ; the application will then create a directory called "JoplinProfile" next to the executable file.
**On Linux**, the recommended way is to use the following installation script as it will handle the desktop icon too:
@@ -537,47 +537,47 @@ Current translations:
<!-- LOCALE-TABLE-AUTO-GENERATED -->
&nbsp; | Language | Po File | Last translator | Percent done
---|---|---|---|---
<img src="https://joplinapp.org/images/flags/country-4x3/arableague.png" width="16px"/> | Arabic | [ar](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ar.po) | [Whaell O](mailto:Whaell@protonmail.com) | 77%
<img src="https://joplinapp.org/images/flags/es/basque_country.png" width="16px"/> | Basque | [eu](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/eu.po) | juan.abasolo@ehu.eus | 22%
<img src="https://joplinapp.org/images/flags/country-4x3/ba.png" width="16px"/> | Bosnian (Bosna i Hercegovina) | [bs_BA](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/bs_BA.po) | [Derviš T.](mailto:dervis.t@pm.me) | 56%
<img src="https://joplinapp.org/images/flags/country-4x3/bg.png" width="16px"/> | Bulgarian (България) | [bg_BG](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/bg_BG.po) | | 44%
<img src="https://joplinapp.org/images/flags/es/catalonia.png" width="16px"/> | Catalan | [ca](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ca.po) | [Xavi Ivars](mailto:xavi.ivars@gmail.com) | 86%
<img src="https://joplinapp.org/images/flags/country-4x3/hr.png" width="16px"/> | Croatian (Hrvatska) | [hr_HR](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/hr_HR.po) | [Milo Ivir](mailto:mail@milotype.de) | 98%
<img src="https://joplinapp.org/images/flags/country-4x3/cz.png" width="16px"/> | Czech (Česká republika) | [cs_CZ](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/cs_CZ.po) | Fejby | 96%
<img src="https://joplinapp.org/images/flags/country-4x3/dk.png" width="16px"/> | Dansk (Danmark) | [da_DK](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/da_DK.po) | ERYpTION | 96%
<img src="https://joplinapp.org/images/flags/country-4x3/de.png" width="16px"/> | Deutsch (Deutschland) | [de_DE](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/de_DE.po) | [Mr-Kanister](mailto:viger_gtrc@simplelogin.com) | 98%
<img src="https://joplinapp.org/images/flags/country-4x3/ee.png" width="16px"/> | Eesti Keel (Eesti) | [et_EE](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/et_EE.po) | | 43%
<img src="https://joplinapp.org/images/flags/country-4x3/arableague.png" width="16px"/> | Arabic | [ar](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ar.po) | [Whaell O](mailto:Whaell@protonmail.com) | 84%
<img src="https://joplinapp.org/images/flags/es/basque_country.png" width="16px"/> | Basque | [eu](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/eu.po) | juan.abasolo@ehu.eus | 21%
<img src="https://joplinapp.org/images/flags/country-4x3/ba.png" width="16px"/> | Bosnian (Bosna i Hercegovina) | [bs_BA](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/bs_BA.po) | [Derviš T.](mailto:dervis.t@pm.me) | 54%
<img src="https://joplinapp.org/images/flags/country-4x3/bg.png" width="16px"/> | Bulgarian (България) | [bg_BG](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/bg_BG.po) | | 42%
<img src="https://joplinapp.org/images/flags/es/catalonia.png" width="16px"/> | Catalan | [ca](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ca.po) | [Xavi Ivars](mailto:xavi.ivars@gmail.com) | 92%
<img src="https://joplinapp.org/images/flags/country-4x3/hr.png" width="16px"/> | Croatian (Hrvatska) | [hr_HR](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/hr_HR.po) | [Milo Ivir](mailto:mail@milotype.de) | 97%
<img src="https://joplinapp.org/images/flags/country-4x3/cz.png" width="16px"/> | Czech (Česká republika) | [cs_CZ](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/cs_CZ.po) | Fejby | 94%
<img src="https://joplinapp.org/images/flags/country-4x3/dk.png" width="16px"/> | Dansk (Danmark) | [da_DK](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/da_DK.po) | ERYpTION | 97%
<img src="https://joplinapp.org/images/flags/country-4x3/de.png" width="16px"/> | Deutsch (Deutschland) | [de_DE](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/de_DE.po) | [Mr-Kanister](mailto:viger_gtrc@simplelogin.com) | 97%
<img src="https://joplinapp.org/images/flags/country-4x3/ee.png" width="16px"/> | Eesti Keel (Eesti) | [et_EE](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/et_EE.po) | | 42%
<img src="https://joplinapp.org/images/flags/country-4x3/gb.png" width="16px"/> | English (United Kingdom) | [en_GB](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/en_GB.po) | | 100%
<img src="https://joplinapp.org/images/flags/country-4x3/us.png" width="16px"/> | English (United States of America) | [en_US](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/en_US.po) | | 100%
<img src="https://joplinapp.org/images/flags/country-4x3/es.png" width="16px"/> | Español (España) | [es_ES](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/es_ES.po) | [Francisco Villaverde](mailto:teko.gr@gmail.com) | 95%
<img src="https://joplinapp.org/images/flags/esperanto.png" width="16px"/> | Esperanto | [eo](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/eo.po) | Marton Paulo | 25%
<img src="https://joplinapp.org/images/flags/country-4x3/fi.png" width="16px"/> | Finnish (Suomi) | [fi_FI](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/fi_FI.po) | mrkaato0 | 96%
<img src="https://joplinapp.org/images/flags/country-4x3/es.png" width="16px"/> | Español (España) | [es_ES](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/es_ES.po) | [Francisco Villaverde](mailto:teko.gr@gmail.com) | 93%
<img src="https://joplinapp.org/images/flags/esperanto.png" width="16px"/> | Esperanto | [eo](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/eo.po) | Marton Paulo | 24%
<img src="https://joplinapp.org/images/flags/country-4x3/fi.png" width="16px"/> | Finnish (Suomi) | [fi_FI](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/fi_FI.po) | mrkaato0 | 94%
<img src="https://joplinapp.org/images/flags/country-4x3/fr.png" width="16px"/> | Français (France) | [fr_FR](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/fr_FR.po) | Laurent Cozic | 100%
<img src="https://joplinapp.org/images/flags/es/galicia.png" width="16px"/> | Galician (España) | [gl_ES](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/gl_ES.po) | [Marcos Lans](mailto:marcoslansgarza@gmail.com) | 28%
<img src="https://joplinapp.org/images/flags/country-4x3/id.png" width="16px"/> | Indonesian (Indonesia) | [id_ID](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/id_ID.po) | [Wisnu Adi Santoso](mailto:waditos@gmail.com) | 86%
<img src="https://joplinapp.org/images/flags/country-4x3/it.png" width="16px"/> | Italiano (Italia) | [it_IT](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/it_IT.po) | [Manuel Tassi](mailto:mannivuwiki@gmail.com) | 78%
<img src="https://joplinapp.org/images/flags/country-4x3/hu.png" width="16px"/> | Magyar (Magyarország) | [hu_HU](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/hu_HU.po) | [Magyari Balázs](mailto:balmag@gmail.com) | 75%
<img src="https://joplinapp.org/images/flags/country-4x3/be.png" width="16px"/> | Nederlands (België, Belgique, Belgien) | [nl_BE](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/nl_BE.po) | | 76%
<img src="https://joplinapp.org/images/flags/country-4x3/nl.png" width="16px"/> | Nederlands (Nederland) | [nl_NL](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/nl_NL.po) | [MHolkamp](mailto:mholkamp@users.noreply.github.com) | 86%
<img src="https://joplinapp.org/images/flags/country-4x3/no.png" width="16px"/> | Norwegian (Norge, Noreg) | [nb_NO](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/nb_NO.po) | [Mats Estensen](mailto:code@mxe.no) | 85%
<img src="https://joplinapp.org/images/flags/country-4x3/ir.png" width="16px"/> | Persian | [fa](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/fa.po) | [Kourosh Firoozbakht](mailto:kourox@protonmail.com) | 53%
<img src="https://joplinapp.org/images/flags/country-4x3/pl.png" width="16px"/> | Polski (Polska) | [pl_PL](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/pl_PL.po) | [X3NO](mailto:X3NO@disroot.org) | 88%
<img src="https://joplinapp.org/images/flags/country-4x3/br.png" width="16px"/> | Português (Brasil) | [pt_BR](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/pt_BR.po) | [Douglas Leão](mailto:djlsplays@gmail.com) | 85%
<img src="https://joplinapp.org/images/flags/country-4x3/pt.png" width="16px"/> | Português (Portugal) | [pt_PT](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/pt_PT.po) | [Diogo Caveiro](mailto:dcaveiro@yahoo.com) | 70%
<img src="https://joplinapp.org/images/flags/country-4x3/ro.png" width="16px"/> | Română | [ro](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ro.po) | [Cristi Duluta](mailto:cristi.duluta@gmail.com) | 49%
<img src="https://joplinapp.org/images/flags/country-4x3/si.png" width="16px"/> | Slovenian (Slovenija) | [sl_SI](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/sl_SI.po) | [Martin Korelič](mailto:martin.korelic@protonmail.com) | 78%
<img src="https://joplinapp.org/images/flags/es/galicia.png" width="16px"/> | Galician (España) | [gl_ES](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/gl_ES.po) | [Marcos Lans](mailto:marcoslansgarza@gmail.com) | 27%
<img src="https://joplinapp.org/images/flags/country-4x3/id.png" width="16px"/> | Indonesian (Indonesia) | [id_ID](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/id_ID.po) | [Wisnu Adi Santoso](mailto:waditos@gmail.com) | 84%
<img src="https://joplinapp.org/images/flags/country-4x3/it.png" width="16px"/> | Italiano (Italia) | [it_IT](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/it_IT.po) | [Manuel Tassi](mailto:mannivuwiki@gmail.com) | 76%
<img src="https://joplinapp.org/images/flags/country-4x3/hu.png" width="16px"/> | Magyar (Magyarország) | [hu_HU](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/hu_HU.po) | [Magyari Balázs](mailto:balmag@gmail.com) | 73%
<img src="https://joplinapp.org/images/flags/country-4x3/be.png" width="16px"/> | Nederlands (België, Belgique, Belgien) | [nl_BE](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/nl_BE.po) | | 74%
<img src="https://joplinapp.org/images/flags/country-4x3/nl.png" width="16px"/> | Nederlands (Nederland) | [nl_NL](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/nl_NL.po) | [MHolkamp](mailto:mholkamp@users.noreply.github.com) | 97%
<img src="https://joplinapp.org/images/flags/country-4x3/no.png" width="16px"/> | Norwegian (Norge, Noreg) | [nb_NO](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/nb_NO.po) | [Mats Estensen](mailto:code@mxe.no) | 83%
<img src="https://joplinapp.org/images/flags/country-4x3/ir.png" width="16px"/> | Persian | [fa](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/fa.po) | [Kourosh Firoozbakht](mailto:kourox@protonmail.com) | 52%
<img src="https://joplinapp.org/images/flags/country-4x3/pl.png" width="16px"/> | Polski (Polska) | [pl_PL](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/pl_PL.po) | [X3NO](mailto:X3NO@disroot.org) | 85%
<img src="https://joplinapp.org/images/flags/country-4x3/br.png" width="16px"/> | Português (Brasil) | [pt_BR](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/pt_BR.po) | [Fernando Nagase](mailto:nagase.fernando@gmail.com) | 97%
<img src="https://joplinapp.org/images/flags/country-4x3/pt.png" width="16px"/> | Português (Portugal) | [pt_PT](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/pt_PT.po) | [Diogo Caveiro](mailto:dcaveiro@yahoo.com) | 68%
<img src="https://joplinapp.org/images/flags/country-4x3/ro.png" width="16px"/> | Română | [ro](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ro.po) | [Cristi Duluta](mailto:cristi.duluta@gmail.com) | 48%
<img src="https://joplinapp.org/images/flags/country-4x3/si.png" width="16px"/> | Slovenian (Slovenija) | [sl_SI](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/sl_SI.po) | [Martin Korelič](mailto:martin.korelic@protonmail.com) | 76%
<img src="https://joplinapp.org/images/flags/country-4x3/se.png" width="16px"/> | Svenska | [sv](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/sv.po) | [Jonatan Nyberg](mailto:jonatan@autistici.org) | 97%
<img src="https://joplinapp.org/images/flags/country-4x3/th.png" width="16px"/> | Thai (ประเทศไทย) | [th_TH](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/th_TH.po) | | 35%
<img src="https://joplinapp.org/images/flags/country-4x3/vn.png" width="16px"/> | Tiếng Việt | [vi](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/vi.po) | | 75%
<img src="https://joplinapp.org/images/flags/country-4x3/th.png" width="16px"/> | Thai (ประเทศไทย) | [th_TH](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/th_TH.po) | | 34%
<img src="https://joplinapp.org/images/flags/country-4x3/vn.png" width="16px"/> | Tiếng Việt | [vi](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/vi.po) | | 73%
<img src="https://joplinapp.org/images/flags/country-4x3/tr.png" width="16px"/> | Türkçe (Türkiye) | [tr_TR](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/tr_TR.po) | [Arda Kılıçdağı](mailto:arda@kilicdagi.com) | 97%
<img src="https://joplinapp.org/images/flags/country-4x3/ua.png" width="16px"/> | Ukrainian (Україна) | [uk_UA](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/uk_UA.po) | [Vyacheslav Andreykiv](mailto:vandreykiv@gmail.com) | 98%
<img src="https://joplinapp.org/images/flags/country-4x3/gr.png" width="16px"/> | Ελληνικά (Ελλάδα) | [el_GR](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/el_GR.po) | [Harris Arvanitis](mailto:xaris@tuta.io) | 97%
<img src="https://joplinapp.org/images/flags/country-4x3/ru.png" width="16px"/> | Русский (Россия) | [ru_RU](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ru_RU.po) | [Dmitriy K](mailto:dmitry@atsip.ru) | 97%
<img src="https://joplinapp.org/images/flags/country-4x3/rs.png" width="16px"/> | српски језик (Србија) | [sr_RS](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/sr_RS.po) | | 63%
<img src="https://joplinapp.org/images/flags/country-4x3/cn.png" width="16px"/> | 中文 (简体) | [zh_CN](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/zh_CN.po) | [qx100](mailto:ztymaxwell@gmail.com) | 98%
<img src="https://joplinapp.org/images/flags/country-4x3/tw.png" width="16px"/> | 中文 (繁體) | [zh_TW](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/zh_TW.po) | [Kevin Hsu](mailto:kevin.hsu.hws@gmail.com) | 86%
<img src="https://joplinapp.org/images/flags/country-4x3/jp.png" width="16px"/> | 日本語 (日本) | [ja_JP](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ja_JP.po) | [genneko](mailto:genneko217@gmail.com) | 88%
<img src="https://joplinapp.org/images/flags/country-4x3/kr.png" width="16px"/> | 한국어 | [ko](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ko.po) | [Ji-Hyeon Gim](mailto:potatogim@potatogim.net) | 86%
<img src="https://joplinapp.org/images/flags/country-4x3/ua.png" width="16px"/> | Ukrainian (Україна) | [uk_UA](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/uk_UA.po) | [Vyacheslav Andreykiv](mailto:vandreykiv@gmail.com) | 95%
<img src="https://joplinapp.org/images/flags/country-4x3/gr.png" width="16px"/> | Ελληνικά (Ελλάδα) | [el_GR](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/el_GR.po) | [Harris Arvanitis](mailto:xaris@tuta.io) | 94%
<img src="https://joplinapp.org/images/flags/country-4x3/ru.png" width="16px"/> | Русский (Россия) | [ru_RU](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ru_RU.po) | [Титан](mailto:fignin@ya.ru) | 96%
<img src="https://joplinapp.org/images/flags/country-4x3/rs.png" width="16px"/> | српски језик (Србија) | [sr_RS](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/sr_RS.po) | | 61%
<img src="https://joplinapp.org/images/flags/country-4x3/cn.png" width="16px"/> | 中文 (简体) | [zh_CN](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/zh_CN.po) | [qx100](mailto:ztymaxwell@gmail.com) | 97%
<img src="https://joplinapp.org/images/flags/country-4x3/tw.png" width="16px"/> | 中文 (繁體) | [zh_TW](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/zh_TW.po) | [Kevin Hsu](mailto:kevin.hsu.hws@gmail.com) | 84%
<img src="https://joplinapp.org/images/flags/country-4x3/jp.png" width="16px"/> | 日本語 (日本) | [ja_JP](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ja_JP.po) | [genneko](mailto:genneko217@gmail.com) | 97%
<img src="https://joplinapp.org/images/flags/country-4x3/kr.png" width="16px"/> | 한국어 | [ko](https://github.com/laurent22/joplin/blob/dev/packages/tools/locales/ko.po) | [Ji-Hyeon Gim](mailto:potatogim@potatogim.net) | 84%
<!-- LOCALE-TABLE-AUTO-GENERATED -->
# Contributors

View File

@@ -68,10 +68,10 @@
"devDependencies": {
"@joplin/utils": "~2.12",
"@seiyab/eslint-plugin-react-hooks": "4.5.1-beta.0",
"@typescript-eslint/eslint-plugin": "5.62.0",
"@typescript-eslint/parser": "5.62.0",
"@typescript-eslint/eslint-plugin": "6.0.0",
"@typescript-eslint/parser": "6.0.0",
"cspell": "5.21.2",
"eslint": "8.46.0",
"eslint": "8.47.0",
"eslint-interactive": "10.8.0",
"eslint-plugin-import": "2.28.1",
"eslint-plugin-jest": "27.2.3",
@@ -79,27 +79,27 @@
"eslint-plugin-react": "7.33.2",
"execa": "5.1.1",
"fs-extra": "11.1.1",
"glob": "10.3.4",
"glob": "10.3.10",
"gulp": "4.0.2",
"husky": "3.1.0",
"lerna": "3.22.1",
"lint-staged": "13.2.3",
"lint-staged": "13.3.0",
"madge": "6.1.0",
"npm-package-json-lint": "6.4.0",
"npm-package-json-lint": "7.0.0",
"typescript": "5.1.6"
},
"dependencies": {
"@types/fs-extra": "11.0.1",
"@types/fs-extra": "11.0.2",
"eslint-plugin-github": "4.9.2",
"http-server": "14.1.1",
"node-gyp": "9.4.0",
"nodemon": "2.0.22"
"nodemon": "3.0.1"
},
"packageManager": "yarn@3.6.3",
"resolutions": {
"react-native-camera@4.2.1": "patch:react-native-camera@npm%3A4.2.1#./.yarn/patches/react-native-camera-npm-4.2.1-24b2600a7e.patch",
"react-native-vosk@0.1.12": "patch:react-native-vosk@npm%3A0.1.12#./.yarn/patches/react-native-vosk-npm-0.1.12-76b1caaae8.patch",
"eslint": "patch:eslint@npm%3A8.39.0#./.yarn/patches/eslint-npm-8.39.0-d92bace04d.patch",
"eslint": "patch:eslint@8.47.0#./.yarn/patches/eslint-npm-8.39.0-d92bace04d.patch",
"app-builder-lib@24.4.0": "patch:app-builder-lib@npm%3A24.4.0#./.yarn/patches/app-builder-lib-npm-24.4.0-05322ff057.patch",
"react-native@0.71.10": "patch:react-native@npm%3A0.71.10#./.yarn/patches/react-native-animation-fix/react-native-npm-0.71.10-f9c32562d8.patch"
}

View File

@@ -50,6 +50,9 @@ class Command extends BaseCommand {
{
type: BaseModel.TYPE_TAG,
},
{
type: BaseModel.TYPE_REVISION,
},
];
const lines = [];
@@ -411,7 +414,7 @@ async function fetchAllNotes() {
lines.push('');
lines.push('If no `cursor` property is provided, the API will respond with the latest change ID. That can be used to retrieve future events later on.');
lines.push('');
lines.push('The results are paginated so will need to may multiple calls to retrieve all the events. Use the `has_more` property to know if more can be retrieved.');
lines.push('The results are paginated so you may need multiple calls to retrieve all the events. Use the `has_more` property to know if more can be retrieved.');
lines.push('');
lines.push('## GET /events/:id');
lines.push('');

View File

@@ -57,8 +57,8 @@
"proper-lockfile": "4.1.2",
"read-chunk": "2.1.0",
"server-destroy": "1.0.1",
"sharp": "0.32.5",
"sprintf-js": "1.1.2",
"sharp": "0.32.6",
"sprintf-js": "1.1.3",
"sqlite3": "5.1.6",
"string-padding": "1.0.2",
"strip-ansi": "6.0.1",
@@ -71,9 +71,9 @@
},
"devDependencies": {
"@joplin/tools": "~2.13",
"@types/fs-extra": "11.0.1",
"@types/fs-extra": "11.0.2",
"@types/jest": "29.5.4",
"@types/node": "18.17.12",
"@types/node": "18.17.19",
"@types/proper-lockfile": "^4.1.2",
"gulp": "4.0.2",
"jest": "29.6.4",

View File

@@ -0,0 +1,204 @@
<?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>Boomwhackers - Rio</title>
<created>20230412T112328Z</created>
<updated>20230524T133616Z</updated>
<note-attributes>
</note-attributes>
<content>
<![CDATA[<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE en-note SYSTEM "http://xml.evernote.com/pub/enml2.dtd"><en-note><div><br/></div><en-media hash="b687cb1ab2d8f3f10b95c3a500841c66" type="application/zip" /><div><br/></div></en-note> ]]>
</content>
<resource>
<data encoding="base64">
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
</data>
<mime>application/zip</mime>
<resource-attributes>
<file-name>Boomwhackers - Rio.mscz</file-name>
<source-url>en-cache://tokenKey%3D%22AuthToken%3AUser%3A75937676%22+32040eaf-3c97-1f46-f34c-1d4e8608c682+b687cb1ab2d8f3f10b95c3a500841c66+https://www.evernote.com/shard/s438/res/9521d2af-a8de-26f3-4f86-8f7f4deebb6b</source-url>
</resource-attributes>
</resource>
</note>
</en-export>

File diff suppressed because one or more lines are too long

View File

@@ -2,5 +2,20 @@ joplin.plugins.register({
onStart: async function() {
const folder = await joplin.data.post(['folders'], null, { title: "my plugin folder" });
await joplin.data.post(['notes'], null, { parent_id: folder.id, title: "testing plugin!" });
// await joplin.commands.register({
// name: 'updateCurrentNote',
// label: 'Update current note via the data API',
// iconName: 'fas fa-music',
// execute: async () => {
// const noteIds = await joplin.workspace.selectedNoteIds();
// const noteId = noteIds.length === 1 ? noteIds[0] : null;
// if (!noteId) return;
// console.info('Modifying current note...');
// await joplin.data.put(['notes', noteId], null, { body: "New note body " + Date.now() });
// },
// });
// await joplin.views.toolbarButtons.create('updateCurrentNoteButton', 'updateCurrentNote', 'editorToolbar');
},
});

View File

@@ -0,0 +1,7 @@
---
title: Example
---
note body

View File

@@ -0,0 +1,4 @@
---
title: Example
---
note body

View File

@@ -1,6 +1,5 @@
// v0.4.1 - https://github.com/mozilla/readability/commit/28843b6de84447dd6cef04058fda336938e628dc
// v0.4.4 - https://github.com/mozilla/readability/commit/49d345a455da1f4aa93f8b41e0f50422f9959c7c
/* eslint-env es6:false */
/*
* Copyright (c) 2010 Arc90 Inc
*
@@ -55,7 +54,7 @@ var REGEXPS = {
var defaultOptions = { minScore: 20, minContentLength: 140, visibilityChecker: isNodeVisible };
options = Object.assign(defaultOptions, options);
var nodes = doc.querySelectorAll("p, pre");
var nodes = doc.querySelectorAll("p, pre, article");
// Get <div> nodes which have <br> node(s) and append them into the `nodes` variable.
// Some articles' DOM structures might look like
@@ -106,5 +105,6 @@ var REGEXPS = {
}
if (typeof module === "object") {
/* global module */
module.exports = isProbablyReaderable;
}

View File

@@ -1,6 +1,5 @@
// v0.4.1 - https://github.com/mozilla/readability/commit/28843b6de84447dd6cef04058fda336938e628dc
// v0.4.4 - https://github.com/mozilla/readability/commit/49d345a455da1f4aa93f8b41e0f50422f9959c7c
/*eslint-env es6:false*/
/*
* Copyright (c) 2010 Arc90 Inc
*
@@ -27,7 +26,7 @@
* @param {HTMLDocument} doc The document to parse.
* @param {Object} options The options object.
*/
function Readability(doc, options) {
function Readability(doc, options) {
// In some older versions, people passed a URI as the first argument. Cope:
if (options && options.documentElement) {
doc = options;
@@ -56,6 +55,7 @@
return el.innerHTML;
};
this._disableJSONLD = !!options.disableJSONLD;
this._allowedVideoRegex = options.allowedVideoRegex || this.REGEXPS.videos;
// Start with all flags set
this._flags = this.FLAG_STRIP_UNLIKELYS |
@@ -75,12 +75,7 @@
return `<${node.localName} ${attrPairs}>`;
};
this.log = function () {
if (typeof dump !== "undefined") {
var msg = Array.prototype.map.call(arguments, function(x) {
return (x && x.nodeName) ? logNode(x) : x;
}).join(" ");
dump("Reader: (Readability) " + msg + "\n");
} else if (typeof console !== "undefined") {
if (typeof console !== "undefined") {
let args = Array.from(arguments, arg => {
if (arg && arg.nodeType == this.ELEMENT_NODE) {
return logNode(arg);
@@ -89,6 +84,12 @@
});
args.unshift("Reader: (Readability)");
console.log.apply(console, args);
} else if (typeof dump !== "undefined") {
/* global dump */
var msg = Array.prototype.map.call(arguments, function(x) {
return (x && x.nodeName) ? logNode(x) : x;
}).join(" ");
dump("Reader: (Readability) " + msg + "\n");
}
};
} else {
@@ -142,6 +143,9 @@
hashUrl: /^#.+/,
srcsetUrl: /(\S+)(\s+[\d.]+[xw])?(\s*(?:,|$))/g,
b64DataUrl: /^data:\s*([^\s;,]+)\s*;\s*base64\s*,/i,
// Commas as used in Latin, Sindhi, Chinese and various other scripts.
// see: https://en.wikipedia.org/wiki/Comma#Comma_variants
commas: /\u002C|\u060C|\uFE50|\uFE10|\uFE11|\u2E41|\u2E34|\u2E32|\uFF0C/g,
// See: https://schema.org/Article
jsonLdArticleTypes: /^Article|AdvertiserContentArticle|NewsArticle|AnalysisNewsArticle|AskPublicNewsArticle|BackgroundNewsArticle|OpinionNewsArticle|ReportageNewsArticle|ReviewNewsArticle|Report|SatiricalArticle|ScholarlyArticle|MedicalScholarlyArticle|SocialMediaPosting|BlogPosting|LiveBlogPosting|DiscussionForumPosting|TechArticle|APIReference$/
},
@@ -898,6 +902,11 @@
let shouldRemoveTitleHeader = true;
while (node) {
if (node.tagName === "HTML") {
this._articleLang = node.getAttribute("lang");
}
var matchString = node.className + " " + node.id;
if (!this._isProbablyVisible(node)) {
@@ -906,6 +915,12 @@
continue;
}
// User is not able to see elements applied with both "aria-modal = true" and "role = dialog"
if (node.getAttribute("aria-modal") == "true" && node.getAttribute("role") == "dialog") {
node = this._removeAndGetNext(node);
continue;
}
// Check to see if this node is a byline, and remove it if it is.
if (this._checkByline(node, matchString)) {
node = this._removeAndGetNext(node);
@@ -1020,7 +1035,7 @@
contentScore += 1;
// Add points for any commas within this paragraph.
contentScore += innerText.split(",").length;
contentScore += innerText.split(this.REGEXPS.commas).length;
// For every 100 characters in this paragraph, add another point. Up to 3 points.
contentScore += Math.min(Math.floor(innerText.length / 100), 3);
@@ -1359,72 +1374,88 @@
_getJSONLD: function (doc) {
var scripts = this._getAllNodesWithTag(doc, ["script"]);
var jsonLdElement = this._findNode(scripts, function(el) {
return el.getAttribute("type") === "application/ld+json";
});
var metadata;
if (jsonLdElement) {
try {
// Strip CDATA markers if present
var content = jsonLdElement.textContent.replace(/^\s*<!\[CDATA\[|\]\]>\s*$/g, "");
var parsed = JSON.parse(content);
var metadata = {};
if (
!parsed["@context"] ||
!parsed["@context"].match(/^https?\:\/\/schema\.org$/)
) {
return metadata;
}
if (!parsed["@type"] && Array.isArray(parsed["@graph"])) {
parsed = parsed["@graph"].find(function(it) {
return (it["@type"] || "").match(
this.REGEXPS.jsonLdArticleTypes
);
});
}
if (
!parsed ||
!parsed["@type"] ||
!parsed["@type"].match(this.REGEXPS.jsonLdArticleTypes)
) {
return metadata;
}
if (typeof parsed.name === "string") {
metadata.title = parsed.name.trim();
} else if (typeof parsed.headline === "string") {
metadata.title = parsed.headline.trim();
}
if (parsed.author) {
if (typeof parsed.author.name === "string") {
metadata.byline = parsed.author.name.trim();
} else if (Array.isArray(parsed.author) && parsed.author[0] && typeof parsed.author[0].name === "string") {
metadata.byline = parsed.author
.filter(function(author) {
return author && typeof author.name === "string";
})
.map(function(author) {
return author.name.trim();
})
.join(", ");
this._forEachNode(scripts, function(jsonLdElement) {
if (!metadata && jsonLdElement.getAttribute("type") === "application/ld+json") {
try {
// Strip CDATA markers if present
var content = jsonLdElement.textContent.replace(/^\s*<!\[CDATA\[|\]\]>\s*$/g, "");
var parsed = JSON.parse(content);
if (
!parsed["@context"] ||
!parsed["@context"].match(/^https?\:\/\/schema\.org$/)
) {
return;
}
if (!parsed["@type"] && Array.isArray(parsed["@graph"])) {
parsed = parsed["@graph"].find(function(it) {
return (it["@type"] || "").match(
this.REGEXPS.jsonLdArticleTypes
);
});
}
if (
!parsed ||
!parsed["@type"] ||
!parsed["@type"].match(this.REGEXPS.jsonLdArticleTypes)
) {
return;
}
metadata = {};
if (typeof parsed.name === "string" && typeof parsed.headline === "string" && parsed.name !== parsed.headline) {
// we have both name and headline element in the JSON-LD. They should both be the same but some websites like aktualne.cz
// put their own name into "name" and the article title to "headline" which confuses Readability. So we try to check if either
// "name" or "headline" closely matches the html title, and if so, use that one. If not, then we use "name" by default.
var title = this._getArticleTitle();
var nameMatches = this._textSimilarity(parsed.name, title) > 0.75;
var headlineMatches = this._textSimilarity(parsed.headline, title) > 0.75;
if (headlineMatches && !nameMatches) {
metadata.title = parsed.headline;
} else {
metadata.title = parsed.name;
}
} else if (typeof parsed.name === "string") {
metadata.title = parsed.name.trim();
} else if (typeof parsed.headline === "string") {
metadata.title = parsed.headline.trim();
}
if (parsed.author) {
if (typeof parsed.author.name === "string") {
metadata.byline = parsed.author.name.trim();
} else if (Array.isArray(parsed.author) && parsed.author[0] && typeof parsed.author[0].name === "string") {
metadata.byline = parsed.author
.filter(function(author) {
return author && typeof author.name === "string";
})
.map(function(author) {
return author.name.trim();
})
.join(", ");
}
}
if (typeof parsed.description === "string") {
metadata.excerpt = parsed.description.trim();
}
if (
parsed.publisher &&
typeof parsed.publisher.name === "string"
) {
metadata.siteName = parsed.publisher.name.trim();
}
return;
} catch (err) {
this.log(err.message);
}
if (typeof parsed.description === "string") {
metadata.excerpt = parsed.description.trim();
}
if (
parsed.publisher &&
typeof parsed.publisher.name === "string"
) {
metadata.siteName = parsed.publisher.name.trim();
}
return metadata;
} catch (err) {
this.log(err.message);
}
}
return {};
});
return metadata ? metadata : {};
},
/**
@@ -1623,12 +1654,7 @@
* @param Element
**/
_removeScripts: function(doc) {
this._removeNodes(this._getAllNodesWithTag(doc, ["script"]), function(scriptNode) {
scriptNode.nodeValue = "";
scriptNode.removeAttribute("src");
return true;
});
this._removeNodes(this._getAllNodesWithTag(doc, ["noscript"]));
this._removeNodes(this._getAllNodesWithTag(doc, ["script", "noscript"]));
},
/**
@@ -1818,13 +1844,13 @@
if (isEmbed) {
// First, check the elements attributes to see if any of them contain youtube or vimeo
for (var i = 0; i < element.attributes.length; i++) {
if (this.REGEXPS.videos.test(element.attributes[i].value)) {
if (this._allowedVideoRegex.test(element.attributes[i].value)) {
return false;
}
}
// For embed with <object> tag, check inner HTML as well.
if (element.tagName === "object" && this.REGEXPS.videos.test(element.innerHTML)) {
if (element.tagName === "object" && this._allowedVideoRegex.test(element.innerHTML)) {
return false;
}
}
@@ -2093,13 +2119,13 @@
for (var i = 0; i < embeds.length; i++) {
// If this embed has attribute that matches video regex, don't delete it.
for (var j = 0; j < embeds[i].attributes.length; j++) {
if (this.REGEXPS.videos.test(embeds[i].attributes[j].value)) {
if (this._allowedVideoRegex.test(embeds[i].attributes[j].value)) {
return false;
}
}
// For embed with <object> tag, check inner HTML as well.
if (embeds[i].tagName === "object" && this.REGEXPS.videos.test(embeds[i].innerHTML)) {
if (embeds[i].tagName === "object" && this._allowedVideoRegex.test(embeds[i].innerHTML)) {
return false;
}
@@ -2117,6 +2143,21 @@
(!isList && weight < 25 && linkDensity > 0.2) ||
(weight >= 25 && linkDensity > 0.5) ||
((embedCount === 1 && contentLength < 75) || embedCount > 1);
// Allow simple lists of images to remain in pages
if (isList && haveToRemove) {
for (var x = 0; x < node.children.length; x++) {
let child = node.children[x];
// Don't filter in lists with li's that contain more than one child
if (child.children.length > 1) {
return haveToRemove;
}
}
let li_count = node.getElementsByTagName("li").length;
// Only allow the list to remain if every li contains an image
if (img == li_count) {
return false;
}
}
return haveToRemove;
}
return false;
@@ -2249,6 +2290,7 @@
title: this._articleTitle,
byline: metadata.byline || this._articleByline,
dir: this._articleDir,
lang: this._articleLang,
content: this._serializer(articleContent),
textContent: textContent,
length: textContent.length,
@@ -2259,5 +2301,6 @@
};
if (typeof module === "object") {
/* global module */
module.exports = Readability;
}

View File

@@ -0,0 +1,114 @@
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
exports.getStyleSheets = exports.getImageSizes = void 0;
function absoluteUrl(url) {
if (!url) { return url; }
const protocol = url.toLowerCase().split(':')[0];
if (['http', 'https', 'file', 'data'].indexOf(protocol) >= 0) { return url; }
if (url.indexOf('//') === 0) {
return location.protocol + url;
} else if (url[0] === '/') {
return `${location.protocol}//${location.host}${url}`;
} else {
return `${baseUrl()}/${url}`;
}
}
function pageLocationOrigin() {
// location.origin normally returns the protocol + domain + port (eg. https://example.com:8080)
// but for file:// protocol this is browser dependant and in particular Firefox returns "null"
// in this case.
if (location.protocol === 'file:') {
return 'file://';
} else {
return location.origin;
}
}
function baseUrl() {
let output = pageLocationOrigin() + location.pathname;
if (output[output.length - 1] !== '/') {
const output2 = output.split('/');
output2.pop();
output = output2.join('/');
}
return output;
}
function getJoplinClipperSvgClassName(svg) {
for (const className of svg.classList) {
if (className.indexOf('joplin-clipper-svg-') === 0) { return className; }
}
return '';
}
function getImageSizes(element, forceAbsoluteUrls = false) {
const output = {};
const images = element.getElementsByTagName('img');
for (let i = 0; i < images.length; i++) {
const img = images[i];
if (img.classList && img.classList.contains('joplin-clipper-hidden')) { continue; }
let src = imageSrc(img);
src = forceAbsoluteUrls ? absoluteUrl(src) : src;
if (!output[src]) { output[src] = []; }
output[src].push({
width: img.width,
height: img.height,
naturalWidth: img.naturalWidth,
naturalHeight: img.naturalHeight,
});
}
const svgs = element.getElementsByTagName('svg');
for (let i = 0; i < svgs.length; i++) {
const svg = svgs[i];
if (svg.classList && svg.classList.contains('joplin-clipper-hidden')) { continue; }
const className = getJoplinClipperSvgClassName(svg); // 'joplin-clipper-svg-' + i;
if (!className) {
console.warn('SVG without a Joplin class:', svg);
continue;
}
if (!svg.classList.contains(className)) {
svg.classList.add(className);
}
const rect = svg.getBoundingClientRect();
if (!output[className]) { output[className] = []; }
output[className].push({
width: rect.width,
height: rect.height,
});
}
return output;
}
exports.getImageSizes = getImageSizes;
// In general we should use currentSrc because that's the image that's currently displayed,
// especially within <picture> tags or with srcset. In these cases there can be multiple
// sources and the best one is probably the one being displayed, thus currentSrc.
function imageSrc(image) {
if (image.currentSrc) { return image.currentSrc; }
return image.src;
}
// Given a document, return a <style> tag that contains all the styles
// required to render the page. Not currently used but could be as an
// option to clip pages as HTML.
// eslint-disable-next-line
function getStyleSheets(doc) {
const output = [];
for (let i = 0; i < doc.styleSheets.length; i++) {
const sheet = doc.styleSheets[i];
try {
for (const cssRule of sheet.cssRules) {
output.push({ type: 'text', value: cssRule.cssText });
}
} catch (error) {
// Calling sheet.cssRules will throw a CORS error on Chrome if the stylesheet is on a different domain.
// In that case, we skip it and add it to the list of stylesheet URLs. These URls will be downloaded
// by the desktop application, since it doesn't have CORS restrictions.
// eslint-disable-next-line
console.info('Could not retrieve stylesheet now:', sheet.href);
// eslint-disable-next-line
console.info('It will downloaded by the main application.');
// eslint-disable-next-line
console.info(error);
output.push({ type: 'url', value: sheet.href });
}
}
return output;
}
exports.getStyleSheets = getStyleSheets;
// # sourceMappingURL=clipperUtils.js.map

View File

@@ -20,20 +20,6 @@
browserSupportsPromises_ = false;
}
function absoluteUrl(url) {
if (!url) return url;
const protocol = url.toLowerCase().split(':')[0];
if (['http', 'https', 'file', 'data'].indexOf(protocol) >= 0) return url;
if (url.indexOf('//') === 0) {
return location.protocol + url;
} else if (url[0] === '/') {
return `${location.protocol}//${location.host}${url}`;
} else {
return `${baseUrl()}/${url}`;
}
}
function escapeHtml(s) {
return s
.replace(/&/g, '&amp;')
@@ -49,85 +35,6 @@
return document.title.trim();
}
function pageLocationOrigin() {
// location.origin normally returns the protocol + domain + port (eg. https://example.com:8080)
// but for file:// protocol this is browser dependant and in particular Firefox returns "null"
// in this case.
if (location.protocol === 'file:') {
return 'file://';
} else {
return location.origin;
}
}
function baseUrl() {
let output = pageLocationOrigin() + location.pathname;
if (output[output.length - 1] !== '/') {
output = output.split('/');
output.pop();
output = output.join('/');
}
return output;
}
function getJoplinClipperSvgClassName(svg) {
for (const className of svg.classList) {
if (className.indexOf('joplin-clipper-svg-') === 0) return className;
}
return '';
}
function getImageSizes(element, forceAbsoluteUrls = false) {
const output = {};
const images = element.getElementsByTagName('img');
for (let i = 0; i < images.length; i++) {
const img = images[i];
if (img.classList && img.classList.contains('joplin-clipper-hidden')) continue;
let src = imageSrc(img);
src = forceAbsoluteUrls ? absoluteUrl(src) : src;
if (!output[src]) output[src] = [];
output[src].push({
width: img.width,
height: img.height,
naturalWidth: img.naturalWidth,
naturalHeight: img.naturalHeight,
});
}
const svgs = element.getElementsByTagName('svg');
for (let i = 0; i < svgs.length; i++) {
const svg = svgs[i];
if (svg.classList && svg.classList.contains('joplin-clipper-hidden')) continue;
const className = getJoplinClipperSvgClassName(svg);// 'joplin-clipper-svg-' + i;
if (!className) {
console.warn('SVG without a Joplin class:', svg);
continue;
}
if (!svg.classList.contains(className)) {
svg.classList.add(className);
}
const rect = svg.getBoundingClientRect();
if (!output[className]) output[className] = [];
output[className].push({
width: rect.width,
height: rect.height,
});
}
return output;
}
function getAnchorNames(element) {
const output = [];
// Anchor names are normally in A tags but can be in SPAN too
@@ -146,14 +53,6 @@
return output;
}
// In general we should use currentSrc because that's the image that's currently displayed,
// especially within <picture> tags or with srcset. In these cases there can be multiple
// sources and the best one is probably the one being displayed, thus currentSrc.
function imageSrc(image) {
if (image.currentSrc) return image.currentSrc;
return image.src;
}
// Cleans up element by removing all its invisible children (which we don't want to render as Markdown)
// And hard-code the image dimensions so that the information can be used by the clipper server to
// display them at the right sizes in the notes.
@@ -181,6 +80,7 @@
}
if (nodeName === 'img') {
// eslint-disable-next-line no-undef
const src = absoluteUrl(imageSrc(node));
node.setAttribute('src', src);
if (!(src in imageIndexes)) imageIndexes[src] = 0;
@@ -199,6 +99,7 @@
}
if (nodeName === 'svg') {
// eslint-disable-next-line no-undef
const className = getJoplinClipperSvgClassName(node);
if (!(className in imageIndexes)) imageIndexes[className] = 0;
@@ -216,11 +117,13 @@
}
if (nodeName === 'embed') {
// eslint-disable-next-line no-undef
const src = absoluteUrl(node.src);
node.setAttribute('src', src);
}
if (nodeName === 'object') {
// eslint-disable-next-line no-undef
const data = absoluteUrl(node.data);
node.setAttribute('data', data);
}
@@ -300,6 +203,7 @@
let svgId = 0;
for (const svg of svgs) {
// eslint-disable-next-line no-undef
if (!getJoplinClipperSvgClassName(svg)) {
svg.classList.add(`joplin-clipper-svg-${svgId}`);
svgId++;
@@ -307,30 +211,6 @@
}
}
// Given a document, return a <style> tag that contains all the styles
// required to render the page. Not currently used but could be as an
// option to clip pages as HTML.
function getStyleSheets(doc) {
const output = [];
for (let i = 0; i < doc.styleSheets.length; i++) {
const sheet = doc.styleSheets[i];
try {
for (const cssRule of sheet.cssRules) {
output.push({ type: 'text', value: cssRule.cssText });
}
} catch (error) {
// Calling sheet.cssRules will throw a CORS error on Chrome if the stylesheet is on a different domain.
// In that case, we skip it and add it to the list of stylesheet URLs. These URls will be downloaded
// by the desktop application, since it doesn't have CORS restrictions.
console.info('Could not retrieve stylesheet now:', sheet.href);
console.info('It will downloaded by the main application.');
console.info(error);
output.push({ type: 'url', value: sheet.href });
}
}
return output;
}
function documentForReadability() {
// Readability directly change the passed document so clone it so as
// to preserve the original web page.
@@ -372,7 +252,9 @@
name: shouldSendToJoplin ? 'sendContentToJoplin' : 'clippedContent',
title: title,
html: html,
// eslint-disable-next-line no-undef
base_url: baseUrl(),
// eslint-disable-next-line no-undef
url: pageLocationOrigin() + location.pathname + location.search,
parent_id: command.parent_id,
tags: command.tags || '',
@@ -397,6 +279,7 @@
response.warning = 'Could not retrieve simplified version of page - full page has been saved instead.';
return response;
}
// eslint-disable-next-line no-undef
return clippedContentResponse(article.title, article.body, getImageSizes(document), getAnchorNames(document));
} else if (command.name === 'isProbablyReaderable') {
@@ -408,6 +291,7 @@
} else if (command.name === 'completePageHtml') {
if (isPagePdf()) {
// eslint-disable-next-line no-undef
return clippedContentResponse(pageTitle(), embedPageUrl(), getImageSizes(document), getAnchorNames(document));
}
@@ -417,11 +301,34 @@
// Because cleanUpElement is going to modify the DOM and remove elements we don't want to work
// directly on the document, so we make a copy of it first.
const cleanDocument = document.body.cloneNode(true);
// eslint-disable-next-line no-undef
const imageSizes = getImageSizes(document, true);
const imageIndexes = {};
cleanUpElement(convertToMarkup, cleanDocument, imageSizes, imageIndexes);
// eslint-disable-next-line no-undef
const stylesheets = convertToMarkup === 'html' ? getStyleSheets(document) : null;
// The <BODY> tag may have a style in the CSS stylesheets. This
// style can be overriden by setting the `style` attribute on the
// BODY tag. Since we don't keep the body tag, it means we may be
// missing some styling, which may break the page.
//
// For example, on this page:
// https://devblogs.microsoft.com/oldnewthing/20180529-00/?p=98855
// The BODY tag has visibility set to hidden in the stylesheet, and
// made visible by setting the style attribute. Because of that,
// previously that imported note would show blank content, while now
// it will be visible.
//
// Fixes https://github.com/laurent22/joplin/issues/7925
if (document.body.getAttribute('style')) {
stylesheets.push({
type: 'text',
value: `body { ${document.body.getAttribute('style')} }`,
});
}
return clippedContentResponse(pageTitle(), cleanDocument.innerHTML, imageSizes, getAnchorNames(document), stylesheets);
} else if (command.name === 'selectedHtml') {
@@ -441,9 +348,11 @@
container.appendChild(range.cloneContents());
}
// eslint-disable-next-line no-undef
const imageSizes = getImageSizes(document, true);
const imageIndexes = {};
cleanUpElement(convertToMarkup, container, imageSizes, imageIndexes);
// eslint-disable-next-line no-undef
return clippedContentResponse(pageTitle(), container.innerHTML, getImageSizes(document), getAnchorNames(document));
} else if (command.name === 'screenshot') {
@@ -546,6 +455,7 @@
const content = {
title: pageTitle(),
crop_rect: selectionArea,
// eslint-disable-next-line no-undef
url: pageLocationOrigin() + location.pathname + location.search,
parent_id: command.parent_id,
tags: command.tags,
@@ -570,7 +480,9 @@
} else if (command.name === 'pageUrl') {
// eslint-disable-next-line no-undef
const url = pageLocationOrigin() + location.pathname + location.search;
// eslint-disable-next-line no-undef
return clippedContentResponse(pageTitle(), url, getImageSizes(document), getAnchorNames(document));
} else {

View File

@@ -1,7 +1,7 @@
{
"manifest_version": 2,
"name": "Joplin Web Clipper [DEV]",
"version": "2.13.0",
"version": "2.13.1",
"description": "Capture and save web pages and screenshots from your browser to Joplin.",
"homepage_url": "https://joplinapp.org",
"content_security_policy": "script-src 'self'; object-src 'self'",

View File

@@ -1,6 +1,8 @@
const fs = require('fs-extra');
const sourcePath = `${__dirname}/../../lib/randomClipperPort.js`;
const clipperUtilsPath = `${__dirname}/../../lib/clipperUtils.js`;
// Mozilla insists on building the clipper from a tarball, not from the repository
// so we add this check and only copy the file if it's present. Normally it rarely
@@ -10,6 +12,10 @@ if (fs.pathExistsSync(sourcePath)) {
fs.copySync(sourcePath, `${__dirname}/src/randomClipperPort.js`);
}
if (fs.pathExistsSync(clipperUtilsPath)) {
fs.copySync(clipperUtilsPath, `${__dirname}/../content_scripts/clipperUtils.js`);
}
// These files give warnings when loading the extension in Chrome, in dev mode
fs.removeSync(`${__dirname}/node_modules/public-encrypt/test/test_key.pem`);
fs.removeSync(`${__dirname}/node_modules/public-encrypt/test/test_rsa_pubkey.pem`);

View File

@@ -182,6 +182,7 @@ class AppComponent extends Component {
await bridge().tabsExecuteScript({ file: '/content_scripts/JSDOMParser.js' });
await bridge().tabsExecuteScript({ file: '/content_scripts/Readability.js' });
await bridge().tabsExecuteScript({ file: '/content_scripts/Readability-readerable.js' });
await bridge().tabsExecuteScript({ file: '/content_scripts/clipperUtils.js' });
await bridge().tabsExecuteScript({ file: '/content_scripts/index.js' });
}

View File

@@ -14,3 +14,7 @@ style.min.css
build/lib/
vendor/*
!vendor/loadEmojiLib.js
test-results/
playwright-report/
playwright/.cache/
integration-tests/test-profile/

View File

@@ -67,6 +67,7 @@ import eventManager from '@joplin/lib/eventManager';
import path = require('path');
import { checkPreInstalledDefaultPlugins, installDefaultPlugins, setSettingsForDefaultPlugins } from '@joplin/lib/services/plugins/defaultPlugins/defaultPluginsUtils';
import userFetcher, { initializeUserFetcher } from '@joplin/lib/utils/userFetcher';
import { parseNotesParent } from '@joplin/lib/reducer';
const pluginClasses = [
require('./plugins/GotoAnything').default,
@@ -192,7 +193,7 @@ class Application extends BaseApplication {
// The '*' and '!important' parts are necessary to make sure Russian text is displayed properly
// https://github.com/laurent22/joplin/issues/155
const css = `.CodeMirror * { font-family: ${fontFamilies.join(', ')} !important; }`;
const css = `.CodeMirror *, .cm-editor .cm-content { font-family: ${fontFamilies.join(', ')} !important; }`;
const styleTag = document.createElement('style');
styleTag.type = 'text/css';
styleTag.appendChild(document.createTextNode(css));
@@ -447,10 +448,35 @@ class Application extends BaseApplication {
// items: masterKeys,
// });
this.store().dispatch({
type: 'FOLDER_SELECT',
id: Setting.value('activeFolderId'),
});
const getNotesParent = async () => {
let notesParent = parseNotesParent(Setting.value('notesParent'), Setting.value('activeFolderId'));
if (notesParent.type === 'Tag' && !(await Tag.load(notesParent.selectedItemId))) {
notesParent = {
type: 'Folder',
selectedItemId: Setting.value('activeFolderId'),
};
}
return notesParent;
};
const notesParent = await getNotesParent();
if (notesParent.type === 'SmartFilter') {
this.store().dispatch({
type: 'SMART_FILTER_SELECT',
id: notesParent.selectedItemId,
});
} else if (notesParent.type === 'Tag') {
this.store().dispatch({
type: 'TAG_SELECT',
id: notesParent.selectedItemId,
});
} else {
this.store().dispatch({
type: 'FOLDER_SELECT',
id: notesParent.selectedItemId,
});
}
this.store().dispatch({
type: 'FOLDER_SET_COLLAPSED_ALL',

View File

@@ -6,7 +6,7 @@ import KvStore from '@joplin/lib/services/KvStore';
import * as ArrayUtils from '@joplin/lib/ArrayUtils';
import { CheckForUpdateOptions, extractVersionInfo, GitHubRelease } from './utils/checkForUpdatesUtils';
const packageInfo = require('./packageInfo.js');
const compareVersions = require('compare-versions');
import { compareVersions } from 'compare-versions';
const logger = Logger.create('checkForUpdates');

View File

@@ -260,7 +260,7 @@ const EncryptionConfigScreen = (props: Props) => {
<br/>
<MacOSMissingPasswordHelpLink
theme={theme}
text={_('%s: Missing password', _('Help'))}
text={_('%s: Missing password.', _('Help'))}
/>
</p>
);

View File

@@ -79,6 +79,7 @@ interface Props {
startupPluginsLoaded: boolean;
shareInvitations: ShareInvitation[];
isSafeMode: boolean;
enableBetaMarkdownEditor: boolean;
needApiAuth: boolean;
processingShareInvitationResponse: boolean;
isResettingLayout: boolean;
@@ -737,7 +738,11 @@ class MainScreenComponent extends React.Component<Props, State> {
},
editor: () => {
const bodyEditor = this.props.settingEditorCodeView ? 'CodeMirror' : 'TinyMCE';
let bodyEditor = this.props.settingEditorCodeView ? 'CodeMirror' : 'TinyMCE';
if (this.props.settingEditorCodeView && this.props.enableBetaMarkdownEditor) {
bodyEditor = 'CodeMirror6';
}
return <NoteEditor key={key} bodyEditor={bodyEditor} />;
},
};
@@ -909,6 +914,7 @@ const mapStateToProps = (state: AppState) => {
shareInvitations: state.shareService.shareInvitations,
processingShareInvitationResponse: state.shareService.processingShareInvitationResponse,
isSafeMode: state.settings.isSafeMode,
enableBetaMarkdownEditor: state.settings['editor.beta'],
needApiAuth: state.needApiAuth,
showInstallTemplatesPlugin: state.hasLegacyTemplates && !state.pluginService.plugins['joplin.plugin.templates'],
isResettingLayout: state.isResettingLayout,

View File

@@ -1,60 +0,0 @@
import { NoteBodyEditorProps } from '../../../utils/types';
const { buildStyle } = require('@joplin/lib/theme');
export default function styles(props: NoteBodyEditorProps) {
return buildStyle(['CodeMirror', props.fontSize], props.themeId, (theme: any) => {
return {
root: {
position: 'relative',
display: 'flex',
flexDirection: 'column',
...props.style,
},
rowToolbar: {
position: 'relative',
display: 'flex',
flexDirection: 'row',
},
rowEditorViewer: {
position: 'relative',
display: 'flex',
flexDirection: 'row',
flex: 1,
paddingTop: 10,
},
cellEditor: {
position: 'relative',
display: 'flex',
flex: 1,
},
cellViewer: {
position: 'relative',
display: 'flex',
flex: 1,
borderLeftWidth: 1,
borderLeftColor: theme.dividerColor,
borderLeftStyle: 'solid',
},
viewer: {
display: 'flex',
overflow: 'hidden',
verticalAlign: 'top',
boxSizing: 'border-box',
width: '100%',
},
editor: {
display: 'flex',
width: 'auto',
height: 'auto',
flex: 1,
overflowY: 'hidden',
paddingTop: 0,
lineHeight: `${Math.round(17 * props.fontSize / 12)}px`,
fontSize: `${props.fontSize}px`,
color: theme.color,
backgroundColor: theme.backgroundColor,
codeMirrorTheme: theme.codeMirrorTheme, // Defined in theme.js
},
};
});
}

View File

@@ -0,0 +1,12 @@
import CodeMirrorControl from '@joplin/editor/CodeMirror/CodeMirrorControl';
const setupVim = (CodeMirror: CodeMirrorControl) => {
CodeMirror.Vim.defineAction('swapLineDown', CodeMirror.commands.swapLineDown);
CodeMirror.Vim.mapCommand('<A-j>', 'action', 'swapLineDown', {}, { context: 'normal', isEdit: true });
CodeMirror.Vim.defineAction('swapLineUp', CodeMirror.commands.swapLineUp);
CodeMirror.Vim.mapCommand('<A-k>', 'action', 'swapLineUp', {}, { context: 'normal', isEdit: true });
CodeMirror.Vim.defineAction('insertListElement', CodeMirror.commands.vimInsertListElement);
CodeMirror.Vim.mapCommand('o', 'action', 'insertListElement', { after: true }, { context: 'normal', isEdit: true, interlaceInsertRepeat: true });
};
export default setupVim;

View File

@@ -0,0 +1,168 @@
import { ContextMenuEvent, ContextMenuParams } from 'electron';
import { useEffect, RefObject } from 'react';
import { _ } from '@joplin/lib/locale';
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
import { MenuItemLocation } from '@joplin/lib/services/plugins/api/types';
import MenuUtils from '@joplin/lib/services/commands/MenuUtils';
import CommandService from '@joplin/lib/services/CommandService';
import SpellCheckerService from '@joplin/lib/services/spellChecker/SpellCheckerService';
import { EditContextMenuFilterObject } from '@joplin/lib/services/plugins/api/JoplinWorkspace';
import type CodeMirrorControl from '@joplin/editor/CodeMirror/CodeMirrorControl';
import eventManager from '@joplin/lib/eventManager';
import bridge from '../../../../../services/bridge';
import Setting from '@joplin/lib/models/Setting';
const Menu = bridge().Menu;
const MenuItem = bridge().MenuItem;
const menuUtils = new MenuUtils(CommandService.instance());
interface ContextMenuProps {
plugins: PluginStates;
editorCutText: ()=> void;
editorCopyText: ()=> void;
editorPaste: ()=> void;
editorRef: RefObject<CodeMirrorControl>;
editorClassName: string;
}
const useContextMenu = (props: ContextMenuProps) => {
const editorRef = props.editorRef;
// The below code adds support for spellchecking when it is enabled
// It might be buggy, refer to the below issue
// https://github.com/laurent22/joplin/pull/3974#issuecomment-718936703
useEffect(() => {
const isAncestorOfCodeMirrorEditor = (elem: Element) => {
for (; elem.parentElement; elem = elem.parentElement) {
if (elem.classList.contains(props.editorClassName)) {
return true;
}
}
return false;
};
const convertFromScreenCoordinates = (zoomPercent: number, screenXY: number) => {
const zoomFraction = zoomPercent / 100;
return screenXY / zoomFraction;
};
function pointerInsideEditor(params: ContextMenuParams) {
const x = params.x, y = params.y, isEditable = params.isEditable;
const elements = document.getElementsByClassName(props.editorClassName);
// Note: We can't check inputFieldType here. When spellcheck is enabled,
// params.inputFieldType is "none". When spellcheck is disabled,
// params.inputFieldType is "plainText". Thus, such a check would be inconsistent.
if (!elements.length || !isEditable) return false;
// Checks whether the element the pointer clicked on is inside the editor.
// This logic will need to be changed if the editor is eventually wrapped
// in an iframe, as elementFromPoint will return the iframe container (and not
// a child of the editor).
const zoom = Setting.value('windowContentZoomFactor');
const xScreen = convertFromScreenCoordinates(zoom, x);
const yScreen = convertFromScreenCoordinates(zoom, y);
const intersectingElement = document.elementFromPoint(xScreen, yScreen);
return intersectingElement && isAncestorOfCodeMirrorEditor(intersectingElement);
}
async function onContextMenu(event: ContextMenuEvent, params: ContextMenuParams) {
if (!pointerInsideEditor(params)) return;
// Don't show the default menu.
event.preventDefault();
const menu = new Menu();
const hasSelectedText = editorRef.current && !!editorRef.current.getSelection() ;
menu.append(
new MenuItem({
label: _('Cut'),
enabled: hasSelectedText,
click: async () => {
props.editorCutText();
},
}),
);
menu.append(
new MenuItem({
label: _('Copy'),
enabled: hasSelectedText,
click: async () => {
props.editorCopyText();
},
}),
);
menu.append(
new MenuItem({
label: _('Paste'),
enabled: true,
click: async () => {
props.editorPaste();
},
}),
);
const spellCheckerMenuItems = SpellCheckerService.instance().contextMenuItems(params.misspelledWord, params.dictionarySuggestions);
for (const item of spellCheckerMenuItems) {
menu.append(new MenuItem(item));
}
// CodeMirror 5 only:
// Typically CodeMirror handles all interactions itself (highlighting etc.)
// But in the case of clicking a mispelled word, we need electron to handle the click
// The result is that CodeMirror doesn't know what's been selected and doesn't
// move the cursor into the correct location.
// and when the user selects a new spelling it will be inserted in the wrong location
// So in this situation, we use must manually align the internal codemirror selection
// to the contextmenu selection
if (editorRef.current && !editorRef.current.cm6 && spellCheckerMenuItems.length > 0) {
(editorRef.current as any).alignSelection(params);
}
let filterObject: EditContextMenuFilterObject = {
items: [],
};
filterObject = await eventManager.filterEmit('editorContextMenu', filterObject);
for (const item of filterObject.items) {
menu.append(new MenuItem({
label: item.label,
click: async () => {
const args = item.commandArgs || [];
void CommandService.instance().execute(item.commandName, ...args);
},
type: item.type,
}));
}
// eslint-disable-next-line github/array-foreach -- Old code before rule was applied
menuUtils.pluginContextMenuItems(props.plugins, MenuItemLocation.EditorContextMenu).forEach((item: any) => {
menu.append(new MenuItem(item));
});
menu.popup();
}
// Prepend the event listener so that it gets called before
// the listener that shows the default menu.
bridge().window().webContents.prependListener('context-menu', onContextMenu);
return () => {
bridge().window().webContents.off('context-menu', onContextMenu);
};
}, [
props.plugins, props.editorClassName, editorRef,
props.editorCutText, props.editorCopyText, props.editorPaste,
]);
};
export default useContextMenu;

View File

@@ -4,6 +4,7 @@ import KeymapService, { KeymapItem } from '@joplin/lib/services/KeymapService';
import { EditorCommand } from '../../../utils/types';
import shim from '@joplin/lib/shim';
import { reg } from '@joplin/lib/registry';
import setupVim from './setupVim';
export default function useKeymap(CodeMirror: any) {
@@ -17,14 +18,6 @@ export default function useKeymap(CodeMirror: any) {
CodeMirror.keyMap.emacs['Shift-Tab'] = 'smartListUnindent';
}
function setupVim() {
CodeMirror.Vim.defineAction('swapLineDown', CodeMirror.commands.swapLineDown);
CodeMirror.Vim.mapCommand('<A-j>', 'action', 'swapLineDown', {}, { context: 'normal', isEdit: true });
CodeMirror.Vim.defineAction('swapLineUp', CodeMirror.commands.swapLineUp);
CodeMirror.Vim.mapCommand('<A-k>', 'action', 'swapLineUp', {}, { context: 'normal', isEdit: true });
CodeMirror.Vim.defineAction('insertListElement', CodeMirror.commands.vimInsertListElement);
CodeMirror.Vim.mapCommand('o', 'action', 'insertListElement', { after: true }, { context: 'normal', isEdit: true, interlaceInsertRepeat: true });
}
function isEditorCommand(command: string) {
return command.startsWith('editor.');
}
@@ -184,7 +177,7 @@ export default function useKeymap(CodeMirror: any) {
keymapService.on('keymapChange', registerKeymap);
setupEmacs();
setupVim();
setupVim(CodeMirror);
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, []);
}

View File

@@ -0,0 +1,82 @@
import { Theme } from '@joplin/lib/themes/type';
import { NoteBodyEditorProps } from '../../../utils/types';
import { buildStyle } from '@joplin/lib/theme';
import { useMemo } from 'react';
const useStyles = (props: NoteBodyEditorProps) => {
return useMemo(() => {
return buildStyle(['CodeMirror', props.fontSize], props.themeId, (theme: Theme) => {
return {
root: {
position: 'relative',
display: 'flex',
flexDirection: 'column',
minHeight: 0,
...props.style,
},
rowToolbar: {
position: 'relative',
display: 'flex',
flexDirection: 'row',
},
rowEditorViewer: {
position: 'relative',
display: 'flex',
flexDirection: 'row',
flex: 1,
paddingTop: 10,
// Allow the editor container to shrink (allowing the editor to scroll)
minHeight: 0,
},
cellEditor: {
position: 'relative',
display: 'flex',
flex: 1,
},
cellViewer: {
position: 'relative',
display: 'flex',
flex: 1,
borderLeftWidth: 1,
borderLeftColor: theme.dividerColor,
borderLeftStyle: 'solid',
},
viewer: {
display: 'flex',
overflow: 'hidden',
verticalAlign: 'top',
boxSizing: 'border-box',
width: '100%',
},
editor: {
display: 'flex',
width: 'auto',
height: 'auto',
flex: 1,
overflowY: 'hidden',
paddingTop: 0,
lineHeight: `${Math.round(17 * props.fontSize / 12)}px`,
fontSize: `${props.fontSize}px`,
color: theme.color,
backgroundColor: theme.backgroundColor,
// CM5 only
codeMirrorTheme: theme.codeMirrorTheme, // Defined in theme.js
},
// CM6 only
globalTheme: {
...theme,
fontFamily: 'inherit',
fontSize: props.fontSize,
fontSizeUnits: 'px',
isDesktop: true,
},
};
});
}, [props.style, props.themeId, props.fontSize]);
};
export default useStyles;

View File

@@ -0,0 +1,46 @@
import type CodeMirror5Emulation from '@joplin/editor/CodeMirror/CodeMirror5Emulation/CodeMirror5Emulation';
import shared from '@joplin/lib/components/shared/note-screen-shared';
import { useCallback, RefObject } from 'react';
interface Props {
onMessage(event: any): void;
getLineScrollPercent(): number;
setEditorPercentScroll(fraction: number): void;
editorRef: RefObject<CodeMirror5Emulation>;
content: string;
}
const useWebviewIpcMessage = (props: Props) => {
const editorRef = props.editorRef;
return useCallback((event: any) => {
const msg = event.channel ? event.channel : '';
const args = event.args;
const arg0 = args && args.length >= 1 ? args[0] : null;
if (msg.indexOf('checkboxclick:') === 0) {
const { line, from, to } = shared.toggleCheckboxRange(msg, props.content);
if (editorRef.current) {
// To cancel CodeMirror's layout drift, the scroll position
// is recorded before updated, and then it is restored.
// Ref. https://github.com/laurent22/joplin/issues/5890
const percent = props.getLineScrollPercent();
editorRef.current.replaceRange(line, from, to);
props.setEditorPercentScroll(percent);
}
} else if (msg === 'percentScroll') {
const percent = arg0;
props.setEditorPercentScroll(percent);
} else {
props.onMessage(event);
}
}, [
props.onMessage,
props.content,
editorRef,
props.getLineScrollPercent,
props.setEditorPercentScroll,
]);
};
export default useWebviewIpcMessage;

View File

@@ -1,55 +1,45 @@
import * as React from 'react';
import { useState, useEffect, useRef, forwardRef, useCallback, useImperativeHandle, useMemo } from 'react';
import { useState, useEffect, useRef, forwardRef, useCallback, useImperativeHandle, useMemo, ForwardedRef } from 'react';
// eslint-disable-next-line no-unused-vars
import { EditorCommand, NoteBodyEditorProps } from '../../utils/types';
import { commandAttachFileToBody, getResourcesFromPasteEvent } from '../../utils/resourceHandling';
import { ScrollOptions, ScrollOptionTypes } from '../../utils/types';
import { CommandValue } from '../../utils/types';
import { usePrevious, cursorPositionToTextOffset } from './utils';
import useScrollHandler from './utils/useScrollHandler';
import { EditorCommand, NoteBodyEditorProps, NoteBodyEditorRef } from '../../../utils/types';
import { commandAttachFileToBody, getResourcesFromPasteEvent } from '../../../utils/resourceHandling';
import { ScrollOptions, ScrollOptionTypes } from '../../../utils/types';
import { CommandValue } from '../../../utils/types';
import { usePrevious, cursorPositionToTextOffset } from '../utils';
import useScrollHandler from '../utils/useScrollHandler';
import useElementSize from '@joplin/lib/hooks/useElementSize';
import Toolbar from './Toolbar';
import styles_ from './styles';
import { RenderedBody, defaultRenderedBody } from './utils/types';
import NoteTextViewer from '../../../NoteTextViewer';
import Toolbar from '../Toolbar';
import { RenderedBody, defaultRenderedBody } from '../utils/types';
import NoteTextViewer from '../../../../NoteTextViewer';
import Editor from './Editor';
import usePluginServiceRegistration from '../../utils/usePluginServiceRegistration';
import usePluginServiceRegistration from '../../../utils/usePluginServiceRegistration';
import Setting from '@joplin/lib/models/Setting';
import Note from '@joplin/lib/models/Note';
import { _ } from '@joplin/lib/locale';
import bridge from '../../../../services/bridge';
import bridge from '../../../../../services/bridge';
import markdownUtils from '@joplin/lib/markdownUtils';
import shim from '@joplin/lib/shim';
import { MenuItemLocation } from '@joplin/lib/services/plugins/api/types';
import MenuUtils from '@joplin/lib/services/commands/MenuUtils';
import CommandService from '@joplin/lib/services/CommandService';
import { themeStyle } from '@joplin/lib/theme';
import { ThemeAppearance } from '@joplin/lib/themes/type';
import SpellCheckerService from '@joplin/lib/services/spellChecker/SpellCheckerService';
import dialogs from '../../../dialogs';
import convertToScreenCoordinates from '../../../utils/convertToScreenCoordinates';
import dialogs from '../../../../dialogs';
import { MarkupToHtml } from '@joplin/renderer';
const { clipboard } = require('electron');
const debounce = require('debounce');
import shared from '@joplin/lib/components/shared/note-screen-shared';
const Menu = bridge().Menu;
const MenuItem = bridge().MenuItem;
import { reg } from '@joplin/lib/registry';
import ErrorBoundary from '../../../ErrorBoundary';
import { MarkupToHtmlOptions } from '../../utils/useMarkupToHtml';
import eventManager from '@joplin/lib/eventManager';
import { EditContextMenuFilterObject } from '@joplin/lib/services/plugins/api/JoplinWorkspace';
import type { ContextMenuEvent, ContextMenuParams } from 'electron';
const menuUtils = new MenuUtils(CommandService.instance());
import { reg } from '@joplin/lib/registry';
import ErrorBoundary from '../../../../ErrorBoundary';
import { MarkupToHtmlOptions } from '../../../utils/useMarkupToHtml';
import useStyles from '../utils/useStyles';
import useContextMenu from '../utils/useContextMenu';
import useWebviewIpcMessage from '../utils/useWebviewIpcMessage';
function markupRenderOptions(override: MarkupToHtmlOptions = null): MarkupToHtmlOptions {
return { ...override };
}
function CodeMirror(props: NoteBodyEditorProps, ref: any) {
const styles = styles_(props);
function CodeMirror(props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditorRef>) {
const styles = useStyles(props);
const [renderedBody, setRenderedBody] = useState<RenderedBody>(defaultRenderedBody()); // Viewer content
const [renderedBodyContentKey, setRenderedBodyContentKey] = useState<string>(null);
@@ -599,29 +589,13 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) {
setWebviewReady(true);
}, []);
const webview_ipcMessage = useCallback((event: any) => {
const msg = event.channel ? event.channel : '';
const args = event.args;
const arg0 = args && args.length >= 1 ? args[0] : null;
if (msg.indexOf('checkboxclick:') === 0) {
const { line, from, to } = shared.toggleCheckboxRange(msg, props.content);
if (editorRef.current) {
// To cancel CodeMirror's layout drift, the scroll position
// is recorded before updated, and then it is restored.
// Ref. https://github.com/laurent22/joplin/issues/5890
const percent = getLineScrollPercent();
editorRef.current.replaceRange(line, from, to);
setEditorPercentScroll(percent);
}
} else if (msg === 'percentScroll') {
const percent = arg0;
setEditorPercentScroll(percent);
} else {
props.onMessage(event);
}
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [props.onMessage, props.content, setEditorPercentScroll]);
const webview_ipcMessage = useWebviewIpcMessage({
editorRef,
setEditorPercentScroll,
getLineScrollPercent,
content: props.content,
onMessage: props.onMessage,
});
useEffect(() => {
let cancelled = false;
@@ -750,19 +724,29 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) {
return output;
}, [styles.cellViewer, props.visiblePanes]);
const editorPaneVisible = props.visiblePanes.indexOf('editor') >= 0;
// Disable this effect to fix this:
//
// https://github.com/laurent22/joplin/issues/6514 It doesn't seem essential
// to automatically focus the editor when the layout changes. The workaround
// is to toggle the layout Cmd+L, then manually focus the editor Cmd+Shift+B.
//
// On the other hand, if we automatically focus the editor, and the user
// does not want this, there's no workaround, so it's better to have this
// disabled.
useEffect(() => {
if (!editorRef.current) return;
// const editorPaneVisible = props.visiblePanes.indexOf('editor') >= 0;
// Anytime the user toggles the visible panes AND the editor is visible as a result
// we should focus the editor
// The intuition is that a panel toggle (with editor in view) is the equivalent of
// an editor interaction so users should expect the editor to be focused
if (editorPaneVisible) {
editorRef.current.focus();
}
}, [editorPaneVisible]);
// useEffect(() => {
// if (!editorRef.current) return;
// // Anytime the user toggles the visible panes AND the editor is visible as a result
// // we should focus the editor
// // The intuition is that a panel toggle (with editor in view) is the equivalent of
// // an editor interaction so users should expect the editor to be focused
// if (editorPaneVisible) {
// editorRef.current.focus();
// }
// }, [editorPaneVisible]);
useEffect(() => {
if (!editorRef.current) return;
@@ -779,142 +763,12 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) {
editorRef.current.refresh();
}, [rootSize, styles.editor, props.visiblePanes]);
// The below code adds support for spellchecking when it is enabled
// It might be buggy, refer to the below issue
// https://github.com/laurent22/joplin/pull/3974#issuecomment-718936703
useEffect(() => {
const isAncestorOfCodeMirrorEditor = (elem: HTMLElement) => {
for (; elem.parentElement; elem = elem.parentElement) {
if (elem.classList.contains('codeMirrorEditor')) {
return true;
}
}
return false;
};
let lastInCodeMirrorContextMenuTimestamp = 0;
// The browser's contextmenu event provides additional information about the
// target of the event, not provided by the Electron context-menu event.
const onBrowserContextMenu = (event: Event) => {
if (isAncestorOfCodeMirrorEditor(event.target as HTMLElement)) {
lastInCodeMirrorContextMenuTimestamp = Date.now();
}
};
function pointerInsideEditor(params: ContextMenuParams) {
const x = params.x, y = params.y, isEditable = params.isEditable;
const elements = document.getElementsByClassName('codeMirrorEditor');
// Note: We can't check inputFieldType here. When spellcheck is enabled,
// params.inputFieldType is "none". When spellcheck is disabled,
// params.inputFieldType is "plainText". Thus, such a check would be inconsistent.
if (!elements.length || !isEditable) return false;
const maximumMsSinceBrowserEvent = 100;
if (Date.now() - lastInCodeMirrorContextMenuTimestamp > maximumMsSinceBrowserEvent) {
return false;
}
const rect = convertToScreenCoordinates(Setting.value('windowContentZoomFactor'), elements[0].getBoundingClientRect());
return rect.x < x && rect.y < y && rect.right > x && rect.bottom > y;
}
async function onContextMenu(event: ContextMenuEvent, params: ContextMenuParams) {
if (!pointerInsideEditor(params)) return;
// Don't show the default menu.
event.preventDefault();
const menu = new Menu();
const hasSelectedText = editorRef.current && !!editorRef.current.getSelection() ;
menu.append(
new MenuItem({
label: _('Cut'),
enabled: hasSelectedText,
click: async () => {
editorCutText();
},
}),
);
menu.append(
new MenuItem({
label: _('Copy'),
enabled: hasSelectedText,
click: async () => {
editorCopyText();
},
}),
);
menu.append(
new MenuItem({
label: _('Paste'),
enabled: true,
click: async () => {
editorPaste();
},
}),
);
const spellCheckerMenuItems = SpellCheckerService.instance().contextMenuItems(params.misspelledWord, params.dictionarySuggestions);
for (const item of spellCheckerMenuItems) {
menu.append(new MenuItem(item));
}
// Typically CodeMirror handles all interactions itself (highlighting etc.)
// But in the case of clicking a mispelled word, we need electron to handle the click
// The result is that CodeMirror doesn't know what's been selected and doesn't
// move the cursor into the correct location.
// and when the user selects a new spelling it will be inserted in the wrong location
// So in this situation, we use must manually align the internal codemirror selection
// to the contextmenu selection
if (editorRef.current && spellCheckerMenuItems.length > 0) {
editorRef.current.alignSelection(params);
}
let filterObject: EditContextMenuFilterObject = {
items: [],
};
filterObject = await eventManager.filterEmit('editorContextMenu', filterObject);
for (const item of filterObject.items) {
menu.append(new MenuItem({
label: item.label,
click: async () => {
const args = item.commandArgs || [];
void CommandService.instance().execute(item.commandName, ...args);
},
type: item.type,
}));
}
// eslint-disable-next-line github/array-foreach -- Old code before rule was applied
menuUtils.pluginContextMenuItems(props.plugins, MenuItemLocation.EditorContextMenu).forEach((item: any) => {
menu.append(new MenuItem(item));
});
menu.popup();
}
// Prepend the event listener so that it gets called before
// the listener that shows the default menu.
bridge().window().webContents.prependListener('context-menu', onContextMenu);
window.addEventListener('contextmenu', onBrowserContextMenu);
return () => {
bridge().window().webContents.off('context-menu', onContextMenu);
window.removeEventListener('contextmenu', onBrowserContextMenu);
};
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [props.plugins]);
useContextMenu({
plugins: props.plugins,
editorCutText, editorCopyText, editorPaste,
editorRef,
editorClassName: 'codeMirrorEditor',
});
function renderEditor() {

View File

@@ -12,15 +12,15 @@ import 'codemirror/addon/scroll/annotatescrollbar';
import 'codemirror/addon/search/matchesonscrollbar';
import 'codemirror/addon/search/searchcursor';
import useListIdent from './utils/useListIdent';
import useScrollUtils from './utils/useScrollUtils';
import useCursorUtils from './utils/useCursorUtils';
import useLineSorting from './utils/useLineSorting';
import useEditorSearch from './utils/useEditorSearch';
import useJoplinMode from './utils/useJoplinMode';
import useKeymap from './utils/useKeymap';
import useExternalPlugins from './utils/useExternalPlugins';
import useJoplinCommands from './utils/useJoplinCommands';
import useListIdent from '../utils/useListIdent';
import useScrollUtils from '../utils/useScrollUtils';
import useCursorUtils from '../utils/useCursorUtils';
import useLineSorting from '../utils/useLineSorting';
import useEditorSearch from '../utils/useEditorSearch';
import useJoplinMode from '../utils/useJoplinMode';
import useKeymap from '../utils/useKeymap';
import useExternalPlugins from '../utils/useExternalPlugins';
import useJoplinCommands from '../utils/useJoplinCommands';
import 'codemirror/keymap/emacs';
import 'codemirror/keymap/vim';

View File

@@ -0,0 +1,439 @@
import * as React from 'react';
import { useState, useEffect, useRef, forwardRef, useCallback, useImperativeHandle, useMemo, ForwardedRef } from 'react';
import { EditorCommand, NoteBodyEditorProps, NoteBodyEditorRef, OnChangeEvent } from '../../../utils/types';
import { getResourcesFromPasteEvent } from '../../../utils/resourceHandling';
import { ScrollOptions, ScrollOptionTypes } from '../../../utils/types';
import NoteTextViewer from '../../../../NoteTextViewer';
import Editor from './Editor';
import usePluginServiceRegistration from '../../../utils/usePluginServiceRegistration';
import Setting from '@joplin/lib/models/Setting';
import Note from '@joplin/lib/models/Note';
import { _ } from '@joplin/lib/locale';
import bridge from '../../../../../services/bridge';
import shim from '@joplin/lib/shim';
import { MarkupToHtml } from '@joplin/renderer';
const { clipboard } = require('electron');
import { reg } from '@joplin/lib/registry';
import ErrorBoundary from '../../../../ErrorBoundary';
import { MarkupToHtmlOptions } from '../../../utils/useMarkupToHtml';
import { EditorKeymap, EditorLanguageType, EditorSettings } from '@joplin/editor/types';
import useStyles from '../utils/useStyles';
import { EditorEvent, EditorEventType } from '@joplin/editor/events';
import useScrollHandler from '../utils/useScrollHandler';
import Logger from '@joplin/utils/Logger';
import useEditorCommands from './useEditorCommands';
import CodeMirrorControl from '@joplin/editor/CodeMirror/CodeMirrorControl';
import useContextMenu from '../utils/useContextMenu';
import useWebviewIpcMessage from '../utils/useWebviewIpcMessage';
import Toolbar from '../Toolbar';
const logger = Logger.create('CodeMirror6');
const logDebug = (message: string) => logger.debug(message);
interface RenderedBody {
html: string;
pluginAssets: any[];
}
function defaultRenderedBody(): RenderedBody {
return {
html: '',
pluginAssets: [],
};
}
function markupRenderOptions(override: MarkupToHtmlOptions = null): MarkupToHtmlOptions {
return { ...override };
}
const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditorRef>) => {
const styles = useStyles(props);
const [renderedBody, setRenderedBody] = useState<RenderedBody>(defaultRenderedBody()); // Viewer content
const [renderedBodyContentKey, setRenderedBodyContentKey] = useState<string>(null);
const [webviewReady, setWebviewReady] = useState(false);
const editorRef = useRef<CodeMirrorControl>(null);
const rootRef = useRef(null);
const webviewRef = useRef(null);
type OnChangeCallback = (event: OnChangeEvent)=> void;
const props_onChangeRef = useRef<OnChangeCallback>(null);
props_onChangeRef.current = props.onChange;
const [selectionRange, setSelectionRange] = useState({ from: 0, to: 0 });
const {
resetScroll, editor_scroll, setEditorPercentScroll, setViewerPercentScroll, getLineScrollPercent,
} = useScrollHandler(editorRef, webviewRef, props.onScroll);
usePluginServiceRegistration(ref);
const codeMirror_change = useCallback((newBody: string) => {
if (newBody !== props.content) {
props_onChangeRef.current({ changeId: null, content: newBody });
}
}, [props.content]);
const onEditorPaste = useCallback(async (event: any = null) => {
const resourceMds = await getResourcesFromPasteEvent(event);
if (!resourceMds.length) return;
if (editorRef.current) {
editorRef.current.insertText(resourceMds.join('\n'));
}
}, []);
const editorCutText = useCallback(() => {
if (editorRef.current) {
const selections = editorRef.current.getSelections();
if (selections.length > 0 && selections[0]) {
clipboard.writeText(selections[0]);
// Easy way to wipe out just the first selection
selections[0] = '';
editorRef.current.replaceSelections(selections);
} else {
const cursor = editorRef.current.getCursor();
const line = editorRef.current.getLine(cursor.line);
clipboard.writeText(`${line}\n`);
const startLine = editorRef.current.getCursor('head');
startLine.ch = 0;
const endLine = {
line: startLine.line + 1,
ch: 0,
};
editorRef.current.replaceRange('', startLine, endLine);
}
}
}, []);
const editorCopyText = useCallback(() => {
if (editorRef.current) {
const selections = editorRef.current.getSelections();
// Handle the case when there is a selection - copy the selection to the clipboard
// When there is no selection, the selection array contains an empty string.
if (selections.length > 0 && selections[0]) {
clipboard.writeText(selections[0]);
} else {
// This is the case when there is no selection - copy the current line to the clipboard
const cursor = editorRef.current.getCursor();
const line = editorRef.current.getLine(cursor.line);
clipboard.writeText(line);
}
}
}, []);
const editorPasteText = useCallback(async () => {
if (editorRef.current) {
const modifiedMd = await Note.replaceResourceExternalToInternalLinks(clipboard.readText(), { useAbsolutePaths: true });
editorRef.current.insertText(modifiedMd);
}
}, []);
const editorPaste = useCallback(() => {
const clipboardText = clipboard.readText();
if (clipboardText) {
void editorPasteText();
} else {
// To handle pasting images
void onEditorPaste();
}
}, [editorPasteText, onEditorPaste]);
const commands = useEditorCommands({
webviewRef,
editorRef,
selectionRange,
editorCopyText, editorCutText, editorPaste,
editorContent: props.content,
visiblePanes: props.visiblePanes,
});
useImperativeHandle(ref, () => {
return {
content: () => props.content,
resetScroll: () => {
resetScroll();
},
scrollTo: (options: ScrollOptions) => {
if (options.type === ScrollOptionTypes.Hash) {
if (!webviewRef.current) return;
webviewRef.current.send('scrollToHash', options.value as string);
} else if (options.type === ScrollOptionTypes.Percent) {
const percent = options.value as number;
setEditorPercentScroll(percent);
setViewerPercentScroll(percent);
} else {
throw new Error(`Unsupported scroll options: ${options.type}`);
}
},
supportsCommand: (name: string) => {
return name in commands || editorRef.current.supportsCommand(name);
},
execCommand: async (cmd: EditorCommand) => {
if (!editorRef.current) return false;
logger.debug('execCommand', cmd);
let commandOutput = null;
if (cmd.name in commands) {
commandOutput = (commands as any)[cmd.name](cmd.value);
} else if (editorRef.current.supportsCommand(cmd.name)) {
commandOutput = editorRef.current.execCommand(cmd.name);
} else if (editorRef.current.supportsJoplinCommand(cmd.name)) {
commandOutput = editorRef.current.execJoplinCommand(cmd.name);
} else {
reg.logger().warn('CodeMirror: unsupported Joplin command: ', cmd);
}
return commandOutput;
},
};
}, [props.content, commands, resetScroll, setEditorPercentScroll, setViewerPercentScroll]);
const webview_domReady = useCallback(() => {
setWebviewReady(true);
}, []);
const webview_ipcMessage = useWebviewIpcMessage({
editorRef,
setEditorPercentScroll,
getLineScrollPercent,
content: props.content,
onMessage: props.onMessage,
});
useEffect(() => {
let cancelled = false;
// When a new note is loaded (contentKey is different), we want the note to be displayed
// right away. However once that's done, we put a small delay so that the view is not
// being constantly updated while the user changes the note.
const interval = renderedBodyContentKey !== props.contentKey ? 0 : 500;
const timeoutId = shim.setTimeout(async () => {
let bodyToRender = props.content;
if (!bodyToRender.trim() && props.visiblePanes.indexOf('viewer') >= 0 && props.visiblePanes.indexOf('editor') < 0) {
// Fixes https://github.com/laurent22/joplin/issues/217
bodyToRender = `<i>${_('This note has no content. Click on "%s" to toggle the editor and edit the note.', _('Layout'))}</i>`;
}
const result = await props.markupToHtml(props.contentMarkupLanguage, bodyToRender, markupRenderOptions({
resourceInfos: props.resourceInfos,
contentMaxWidth: props.contentMaxWidth,
mapsToLine: true,
// Always using useCustomPdfViewer for now, we can add a new setting for it in future if we need to.
useCustomPdfViewer: props.useCustomPdfViewer,
noteId: props.noteId,
vendorDir: bridge().vendorDir(),
}));
if (cancelled) return;
setRenderedBody(result);
// Since we set `renderedBodyContentKey` here, it means this effect is going to
// be triggered again, but that's hard to avoid and the second call would be cheap
// anyway since the renderered markdown is cached by MdToHtml. We could use a ref
// to avoid this, but a second rendering might still happens anyway to render images,
// resources, or for other reasons. So it's best to focus on making any second call
// to this effect as cheap as possible with caching, etc.
setRenderedBodyContentKey(props.contentKey);
}, interval);
return () => {
cancelled = true;
shim.clearTimeout(timeoutId);
};
}, [
props.content, props.contentKey, renderedBodyContentKey, props.contentMarkupLanguage,
props.visiblePanes, props.resourceInfos, props.markupToHtml, props.contentMaxWidth,
props.noteId, props.useCustomPdfViewer,
]);
useEffect(() => {
if (!webviewReady) return;
let lineCount = 0;
if (editorRef.current) {
lineCount = editorRef.current.editor.state.doc.lines;
}
const options: any = {
pluginAssets: renderedBody.pluginAssets,
downloadResources: Setting.value('sync.resourceDownloadMode'),
markupLineCount: lineCount,
};
// It seems when there's an error immediately when the component is
// mounted, webviewReady might be true, but webviewRef.current will be
// undefined. Maybe due to the error boundary that unmount components.
// Since we can't do much about it we just print an error.
if (webviewRef.current) {
// To keep consistency among CodeMirror's editing and scroll percents
// of Editor and Viewer.
const percent = getLineScrollPercent();
setEditorPercentScroll(percent);
options.percent = percent;
webviewRef.current.send('setHtml', renderedBody.html, options);
} else {
console.error('Trying to set HTML on an undefined webview ref');
}
}, [renderedBody, webviewReady, getLineScrollPercent, setEditorPercentScroll]);
const cellEditorStyle = useMemo(() => {
const output = { ...styles.cellEditor };
if (!props.visiblePanes.includes('editor')) {
output.display = 'none'; // Seems to work fine since the refactoring
}
return output;
}, [styles.cellEditor, props.visiblePanes]);
const cellViewerStyle = useMemo(() => {
const output = { ...styles.cellViewer };
if (!props.visiblePanes.includes('viewer')) {
// Note: setting webview.display to "none" is currently not supported due
// to this bug: https://github.com/electron/electron/issues/8277
// So instead setting the width 0.
output.width = 1;
output.maxWidth = 1;
} else if (!props.visiblePanes.includes('editor')) {
output.borderLeftStyle = 'none';
}
return output;
}, [styles.cellViewer, props.visiblePanes]);
// Disable this effect to fix this:
//
// https://github.com/laurent22/joplin/issues/6514 It doesn't seem essential
// to automatically focus the editor when the layout changes. The workaround
// is to toggle the layout Cmd+L, then manually focus the editor Cmd+Shift+B.
//
// On the other hand, if we automatically focus the editor, and the user
// does not want this, there's no workaround, so it's better to have this
// disabled.
// const editorPaneVisible = props.visiblePanes.indexOf('editor') >= 0;
// useEffect(() => {
// if (!editorRef.current) return;
// // Anytime the user toggles the visible panes AND the editor is visible as a result
// // we should focus the editor
// // The intuition is that a panel toggle (with editor in view) is the equivalent of
// // an editor interaction so users should expect the editor to be focused
// if (editorPaneVisible) {
// editorRef.current.focus();
// }
// }, [editorPaneVisible]);
useContextMenu({
plugins: props.plugins,
editorCutText, editorCopyText, editorPaste,
editorRef,
editorClassName: 'cm-editor',
});
const onEditorEvent = useCallback((event: EditorEvent) => {
if (event.kind === EditorEventType.Scroll) {
editor_scroll();
} else if (event.kind === EditorEventType.Change) {
codeMirror_change(event.value);
} else if (event.kind === EditorEventType.SelectionRangeChange) {
setSelectionRange({ from: event.from, to: event.to });
}
}, [editor_scroll, codeMirror_change]);
const editorSettings = useMemo((): EditorSettings => {
const isHTMLNote = props.contentMarkupLanguage === MarkupToHtml.MARKUP_LANGUAGE_HTML;
let keyboardMode = EditorKeymap.Default;
if (props.keyboardMode === 'vim') {
keyboardMode = EditorKeymap.Vim;
} else if (props.keyboardMode === 'emacs') {
keyboardMode = EditorKeymap.Emacs;
}
return {
language: isHTMLNote ? EditorLanguageType.Html : EditorLanguageType.Markdown,
readOnly: props.disabled || props.visiblePanes.indexOf('editor') < 0,
katexEnabled: Setting.value('markdown.plugin.katex'),
themeData: {
...styles.globalTheme,
monospaceFont: Setting.value('style.editor.monospaceFontFamily'),
},
automatchBraces: Setting.value('editor.autoMatchingBraces'),
useExternalSearch: false,
ignoreModifiers: true,
spellcheckEnabled: Setting.value('editor.spellcheckBeta'),
keymap: keyboardMode,
indentWithTabs: true,
};
}, [
props.contentMarkupLanguage, props.disabled, props.visiblePanes,
props.keyboardMode, styles.globalTheme,
]);
// Update the editor's value
useEffect(() => {
if (editorRef.current?.updateBody(props.content)) {
editorRef.current?.clearHistory();
}
}, [props.content]);
const renderEditor = () => {
return (
<div style={cellEditorStyle}>
<Editor
style={styles.editor}
initialText={props.content}
ref={editorRef}
settings={editorSettings}
pluginStates={props.plugins}
onEvent={onEditorEvent}
onLogMessage={logDebug}
onEditorPaste={onEditorPaste}
/>
</div>
);
};
const renderViewer = () => {
return (
<div style={cellViewerStyle}>
<NoteTextViewer
ref={webviewRef}
themeId={props.themeId}
viewerStyle={styles.viewer}
onIpcMessage={webview_ipcMessage}
onDomReady={webview_domReady}
contentMaxWidth={props.contentMaxWidth}
/>
</div>
);
};
return (
<ErrorBoundary message="The text editor encountered a fatal error and could not continue. The error might be due to a plugin, so please try to disable some of them and try again.">
<div style={styles.root} ref={rootRef}>
<div style={styles.rowToolbar}>
<Toolbar themeId={props.themeId}/>
{props.noteToolbar}
</div>
<div style={styles.rowEditorViewer}>
{renderEditor()}
{renderViewer()}
</div>
</div>
</ErrorBoundary>
);
};
export default forwardRef(CodeMirror);

View File

@@ -0,0 +1,121 @@
import * as React from 'react';
import { ForwardedRef } from 'react';
import { useEffect, useState, useRef, forwardRef, useImperativeHandle } from 'react';
import { EditorProps, LogMessageCallback, OnEventCallback, PluginData } from '@joplin/editor/types';
import createEditor from '@joplin/editor/CodeMirror/createEditor';
import CodeMirrorControl from '@joplin/editor/CodeMirror/CodeMirrorControl';
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
import { ContentScriptType } from '@joplin/lib/services/plugins/api/types';
import shim from '@joplin/lib/shim';
import PluginService from '@joplin/lib/services/plugins/PluginService';
import setupVim from '../utils/setupVim';
interface Props extends EditorProps {
style: React.CSSProperties;
pluginStates: PluginStates;
onEditorPaste: (event: Event)=> void;
}
const Editor = (props: Props, ref: ForwardedRef<CodeMirrorControl>) => {
const editorContainerRef = useRef<HTMLDivElement>();
const [editor, setEditor] = useState<CodeMirrorControl|null>(null);
// The editor will only be created once, so callbacks that could
// change need to be stored as references.
const onEventRef = useRef<OnEventCallback>(props.onEvent);
const onLogMessageRef = useRef<LogMessageCallback>(props.onLogMessage);
useEffect(() => {
onEventRef.current = props.onEvent;
onLogMessageRef.current = props.onLogMessage;
}, [props.onEvent, props.onLogMessage]);
useEffect(() => {
if (!editor) {
return () => {};
}
const pasteEventHandler = (_editor: any, event: Event) => {
props.onEditorPaste(event);
};
editor.on('paste', pasteEventHandler);
return () => {
editor.off('paste', pasteEventHandler);
};
}, [editor, props.onEditorPaste]);
useImperativeHandle(ref, () => {
return editor;
}, [editor]);
useEffect(() => {
if (!editor) {
return;
}
const plugins: PluginData[] = [];
for (const pluginId in props.pluginStates) {
const pluginState = props.pluginStates[pluginId];
const codeMirrorContentScripts = pluginState.contentScripts[ContentScriptType.CodeMirrorPlugin] ?? [];
for (const contentScript of codeMirrorContentScripts) {
plugins.push({
pluginId,
contentScriptId: contentScript.id,
contentScriptJs: () => shim.fsDriver().readFile(contentScript.path),
postMessageHandler: (message: any) => {
const plugin = PluginService.instance().pluginById(pluginId);
return plugin.emitContentScriptMessage(contentScript.id, message);
},
});
}
}
void editor.setPlugins(plugins);
}, [editor, props.pluginStates]);
useEffect(() => {
if (!editorContainerRef.current) return () => {};
const editorProps: EditorProps = {
...props,
onEvent: event => onEventRef.current(event),
onLogMessage: message => onLogMessageRef.current(message),
};
const editor = createEditor(editorContainerRef.current, editorProps);
editor.addStyles({
'.cm-scroller': { overflow: 'auto' },
});
setEditor(editor);
return () => {
editor.remove();
};
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Should run just once
}, []);
useEffect(() => {
editor?.updateSettings(props.settings);
}, [props.settings, editor]);
useEffect(() => {
if (!editor) {
return;
}
setupVim(editor);
}, [editor]);
return (
<div
style={props.style}
ref={editorContainerRef}
></div>
);
};
export default forwardRef(Editor);

View File

@@ -0,0 +1,136 @@
import { RefObject, useMemo } from 'react';
import { CommandValue } from '../../../utils/types';
import { commandAttachFileToBody } from '../../../utils/resourceHandling';
import { _ } from '@joplin/lib/locale';
import dialogs from '../../../../dialogs';
import { EditorCommandType } from '@joplin/editor/types';
import Logger from '@joplin/utils/Logger';
import CodeMirrorControl from '@joplin/editor/CodeMirror/CodeMirrorControl';
const logger = Logger.create('CodeMirror 6 commands');
const wrapSelectionWithStrings = (editor: CodeMirrorControl, string1: string, string2 = '', defaultText = '') => {
if (editor.somethingSelected()) {
editor.wrapSelections(string1, string2);
} else {
editor.wrapSelections(string1 + defaultText, string2);
// Now select the default text so the user can replace it
const selections = editor.listSelections();
const newSelections = [];
for (let i = 0; i < selections.length; i++) {
const s = selections[i];
const anchor = { line: s.anchor.line, ch: s.anchor.ch + string1.length };
const head = { line: s.head.line, ch: s.head.ch - string2.length };
newSelections.push({ anchor: anchor, head: head });
}
editor.setSelections(newSelections);
}
};
interface Props {
webviewRef: RefObject<any>;
editorRef: RefObject<CodeMirrorControl>;
editorContent: string;
editorCutText(): void;
editorCopyText(): void;
editorPaste(): void;
selectionRange: { from: number; to: number };
visiblePanes: string[];
}
const useEditorCommands = (props: Props) => {
const editorRef = props.editorRef;
return useMemo(() => {
const selectedText = () => {
if (!editorRef.current) return '';
return editorRef.current.getSelection();
};
return {
dropItems: async (cmd: any) => {
if (cmd.type === 'notes') {
editorRef.current.insertText(cmd.markdownTags.join('\n'));
} else if (cmd.type === 'files') {
const pos = props.selectionRange.from;
const newBody = await commandAttachFileToBody(props.editorContent, cmd.paths, { createFileURL: !!cmd.createFileURL, position: pos });
editorRef.current.updateBody(newBody);
} else {
logger.warn('CodeMirror: unsupported drop item: ', cmd);
}
},
selectedText: () => {
return selectedText();
},
selectedHtml: () => {
return selectedText();
},
replaceSelection: (value: string) => {
return editorRef.current.insertText(value);
},
textCopy: () => {
props.editorCopyText();
},
textCut: () => {
props.editorCutText();
},
textPaste: () => {
props.editorPaste();
},
textSelectAll: () => {
return editorRef.current.execCommand(EditorCommandType.SelectAll);
},
textLink: async () => {
const url = await dialogs.prompt(_('Insert Hyperlink'));
editorRef.current.focus();
if (url) wrapSelectionWithStrings(editorRef.current, '[', `](${url})`);
},
insertText: (value: any) => editorRef.current.insertText(value),
attachFile: async () => {
const newBody = await commandAttachFileToBody(
props.editorContent, null, { position: props.selectionRange.from },
);
if (newBody) {
editorRef.current.updateBody(newBody);
}
},
textHorizontalRule: () => editorRef.current.insertText('* * *'),
'editor.execCommand': (value: CommandValue) => {
if (!('args' in value)) value.args = [];
if ((editorRef.current as any)[value.name]) {
const result = (editorRef.current as any)[value.name](...value.args);
return result;
} else if (editorRef.current.commandExists(value.name)) {
const result = editorRef.current.execCommand(value.name);
return result;
} else {
logger.warn('CodeMirror execCommand: unsupported command: ', value.name);
}
},
'editor.focus': () => {
if (props.visiblePanes.indexOf('editor') >= 0) {
editorRef.current.editor.focus();
} else {
// If we just call focus() then the iframe is focused,
// but not its content, such that scrolling up / down
// with arrow keys fails
props.webviewRef.current.send('focus');
}
},
search: () => {
editorRef.current.execCommand(EditorCommandType.ShowSearch);
},
};
}, [
props.visiblePanes, props.editorContent, props.editorCopyText, props.editorCutText, props.editorPaste,
props.selectionRange,
props.webviewRef, editorRef,
]);
};
export default useEditorCommands;

View File

@@ -1,7 +1,6 @@
import * as React from 'react';
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import TinyMCE from './NoteBody/TinyMCE/TinyMCE';
import CodeMirror from './NoteBody/CodeMirror/CodeMirror';
import { connect } from 'react-redux';
import MultiNoteActions from '../MultiNoteActions';
import { htmlToMarkdown, formNoteToNote } from './utils';
@@ -46,6 +45,8 @@ import { ModelType } from '@joplin/lib/BaseModel';
import BaseItem from '@joplin/lib/models/BaseItem';
import { ErrorCode } from '@joplin/lib/errors';
import ItemChange from '@joplin/lib/models/ItemChange';
import CodeMirror6 from './NoteBody/CodeMirror/v6/CodeMirror';
import CodeMirror5 from './NoteBody/CodeMirror/v5/CodeMirror';
const commands = [
require('./commands/showRevisions'),
@@ -380,7 +381,7 @@ function NoteEditor(props: NoteEditorProps) {
};
}, [setShowRevisions]);
const onScroll = useCallback((event: any) => {
const onScroll = useCallback((event: { percent: number }) => {
props.dispatch({
type: 'EDITOR_SCROLL_PERCENT_SET',
// In callbacks of setTimeout()/setInterval(), props/state cannot be used
@@ -462,7 +463,9 @@ function NoteEditor(props: NoteEditorProps) {
if (props.bodyEditor === 'TinyMCE') {
editor = <TinyMCE {...editorProps}/>;
} else if (props.bodyEditor === 'CodeMirror') {
editor = <CodeMirror {...editorProps}/>;
editor = <CodeMirror5 {...editorProps}/>;
} else if (props.bodyEditor === 'CodeMirror6') {
editor = <CodeMirror6 {...editorProps}/>;
} else {
throw new Error(`Invalid editor: ${props.bodyEditor}`);
}
@@ -602,7 +605,7 @@ function NoteEditor(props: NoteEditorProps) {
disabled={isReadOnly}
/>
{renderSearchInfo()}
<div style={{ display: 'flex', flex: 1, paddingLeft: theme.editorPaddingLeft, maxHeight: '100%' }}>
<div style={{ display: 'flex', flex: 1, paddingLeft: theme.editorPaddingLeft, maxHeight: '100%', minHeight: '0' }}>
{editor}
</div>
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}>

View File

@@ -48,6 +48,15 @@ export interface NoteEditorProps {
syncUserId: string;
}
export interface NoteBodyEditorRef {
content(): string|Promise<string>;
resetScroll(): void;
scrollTo(options: ScrollOptions): void;
supportsCommand(name: string): boolean;
execCommand(command: CommandValue): Promise<void>;
}
export interface NoteBodyEditorProps {
style: any;
ref: any;
@@ -59,7 +68,7 @@ export interface NoteBodyEditorProps {
onChange(event: OnChangeEvent): void;
onWillChange(event: any): void;
onMessage(event: any): void;
onScroll(event: any): void;
onScroll(event: { percent: number }): void;
markupToHtml: (markupLanguage: MarkupLanguage, markup: string, options: MarkupToHtmlOptions)=> Promise<RenderResult>;
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
htmlToMarkdown: Function;

View File

@@ -3,6 +3,16 @@ import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/
import { renderHook } from '@testing-library/react-hooks';
import useFormNote, { HookDependencies } from './useFormNote';
const defaultFormNoteProps: HookDependencies = {
syncStarted: false,
decryptionStarted: false,
noteId: '',
isProvisional: false,
titleInputRef: null,
editorRef: null,
onBeforeLoad: ()=>{},
onAfterLoad: ()=>{},
};
describe('useFormNote', () => {
beforeEach(async () => {
@@ -15,14 +25,10 @@ describe('useFormNote', () => {
const makeFormNoteProps = (syncStarted: boolean, decryptionStarted: boolean): HookDependencies => {
return {
...defaultFormNoteProps,
syncStarted,
decryptionStarted,
noteId: testNote.id,
isProvisional: false,
titleInputRef: null,
editorRef: null,
onBeforeLoad: ()=>{},
onAfterLoad: ()=>{},
};
};
@@ -70,4 +76,37 @@ describe('useFormNote', () => {
});
});
});
// It seems this test is crashing the worker on CI (out of memory), so disabling it for now.
// it('should reload the note when it is changed outside of the editor', async () => {
// const note = await Note.save({ title: 'Test Note!' });
// const makeFormNoteProps = (dbNote: DbNote): HookDependencies => {
// return {
// ...defaultFormNoteProps,
// noteId: note.id,
// dbNote,
// };
// };
// const formNote = renderHook(props => useFormNote(props), {
// initialProps: makeFormNoteProps({ id: note.id, updated_time: note.updated_time }),
// });
// await formNote.waitFor(() => {
// expect(formNote.result.current.formNote.title).toBe('Test Note!');
// });
// // Simulate the note being modified outside the editor
// const modifiedNote = await Note.save({ id: note.id, title: 'Modified' });
// // NoteEditor then would update `dbNote`
// formNote.rerender(makeFormNoteProps({ id: note.id, updated_time: modifiedNote.updated_time }));
// await formNote.waitFor(() => {
// expect(formNote.result.current.formNote.title).toBe('Modified');
// });
// });
});

View File

@@ -271,7 +271,7 @@ class NotePropertiesDialog extends React.Component<Props, State> {
controlComp = (
<Datetime
ref="editField"
defaultValue={value}
initialValue={value}
dateFormat={time.dateFormat()}
timeFormat={time.timeFormat()}
inputProps={{

View File

@@ -0,0 +1,17 @@
# Integration tests
The integration tests in this directory can be run with `yarn playwright test`.
- Tests use a `test-profile` directory that should be re-created before every test.
- Only one Electron application should be instantiated per test file.
- Files in the `models/` directory follow [the page object model](https://playwright.dev/docs/pom).
# References
The following sources are helpful for designing and implementing Electron integration tests
with Playwright:
- [A setup guide from an organisation that uses Playwright](https://dev.to/kubeshop/testing-electron-apps-with-playwright-3f89)
and [that organisation's test suite](https://github.com/kubeshop/monokle/blob/main/tests/base.test.ts).
- [The Playwright ElectronApp docs](https://playwright.dev/docs/api/class-electronapplication)
- [Electron Playwright example repository](https://github.com/spaceagetv/electron-playwright-example)
- [Playwright best practices](https://playwright.dev/docs/best-practices)

View File

@@ -0,0 +1,79 @@
import { test, expect } from './util/test';
import MainScreen from './models/MainScreen';
import activateMainMenuItem from './util/activateMainMenuItem';
import SettingsScreen from './models/SettingsScreen';
test.describe('main', () => {
test('app should launch', async ({ mainWindow }) => {
// A window should open with the correct title
expect(await mainWindow.title()).toMatch(/^Joplin/);
const mainPage = new MainScreen(mainWindow);
await mainPage.waitFor();
});
test('should be able to create and edit a new note', async ({ mainWindow }) => {
const mainScreen = new MainScreen(mainWindow);
await mainScreen.newNoteButton.click();
const editor = mainScreen.noteEditor;
await editor.waitFor();
// Wait for the title input to have the correct placeholder
await mainWindow.locator('input[placeholder^="Creating new note"]').waitFor();
// Fill the title
await editor.noteTitleInput.click();
await editor.noteTitleInput.fill('Test note');
// Note list should contain the new note
await expect(mainScreen.noteListContainer.getByText('Test note')).toBeVisible();
// Focus the editor
await editor.codeMirrorEditor.click();
// Type some text
await mainWindow.keyboard.type('# Test note!');
await mainWindow.keyboard.press('Enter');
await mainWindow.keyboard.press('Enter');
await mainWindow.keyboard.type('New note content!');
// Should render
const viewerFrame = editor.getNoteViewerIframe();
await expect(viewerFrame.locator('h1')).toHaveText('Test note!');
});
test('should be possible to remove sort order buttons in settings', async ({ electronApp, mainWindow }) => {
const mainScreen = new MainScreen(mainWindow);
await mainScreen.waitFor();
// Sort order buttons should be visible by default
await expect(mainScreen.noteListContainer.locator('[title^="Toggle sort order"]')).toBeVisible();
// Open settings (check both labels so that this works on MacOS)
expect(
await activateMainMenuItem(electronApp, 'Preferences...') || await activateMainMenuItem(electronApp, 'Options'),
).toBe(true);
// Should be on the settings screen
const settingsScreen = new SettingsScreen(mainWindow);
await settingsScreen.waitFor();
// Open the appearance tab
await settingsScreen.appearanceTabButton.click();
// Find the sort order visible checkbox
const sortOrderVisibleCheckbox = mainWindow.getByLabel(/^Show sort order/);
await expect(sortOrderVisibleCheckbox).toBeChecked();
await sortOrderVisibleCheckbox.click();
await expect(sortOrderVisibleCheckbox).not.toBeChecked();
// Save settings & close
await settingsScreen.okayButton.click();
await mainScreen.waitFor();
await expect(mainScreen.noteListContainer.locator('[title^="Toggle sort order"]')).not.toBeVisible();
});
});

View File

@@ -0,0 +1,20 @@
import { Page, Locator } from '@playwright/test';
import NoteEditorScreen from './NoteEditorScreen';
export default class MainScreen {
public readonly newNoteButton: Locator;
public readonly noteListContainer: Locator;
public readonly noteEditor: NoteEditorScreen;
public constructor(page: Page) {
this.newNoteButton = page.locator('.new-note-button');
this.noteListContainer = page.locator('.rli-noteList');
this.noteEditor = new NoteEditorScreen(page);
}
public async waitFor() {
await this.newNoteButton.waitFor();
await this.noteEditor.waitFor();
await this.noteListContainer.waitFor();
}
}

View File

@@ -0,0 +1,26 @@
import { Locator, Page } from '@playwright/test';
export default class NoteEditorPage {
public readonly codeMirrorEditor: Locator;
public readonly noteTitleInput: Locator;
private readonly containerLocator: Locator;
public constructor(private readonly page: Page) {
this.containerLocator = page.locator('.rli-editor');
this.codeMirrorEditor = this.containerLocator.locator('.codeMirrorEditor');
this.noteTitleInput = this.containerLocator.locator('.title-input');
}
public getNoteViewerIframe() {
// The note viewer can change content when the note re-renders. As such,
// a new locator needs to be created after re-renders (and this can't be a
// static property).
return this.page.frame({ url: /.*note-viewer[/\\]index.html.*/ });
}
public async waitFor() {
await this.codeMirrorEditor.waitFor();
await this.noteTitleInput.waitFor();
}
}

View File

@@ -0,0 +1,17 @@
import { Page, Locator } from '@playwright/test';
export default class SettingsScreen {
public readonly okayButton: Locator;
public readonly appearanceTabButton: Locator;
public constructor(page: Page) {
this.okayButton = page.locator('button', { hasText: 'OK' });
this.appearanceTabButton = page.getByText('Appearance');
}
public async waitFor() {
await this.okayButton.waitFor();
await this.appearanceTabButton.waitFor();
}
}

View File

@@ -0,0 +1,13 @@
#!/bin/sh
echo "Running desktop integration tests..."
export CI=true
if test "$RUNNER_OS" = "Linux" ; then
# The Ubuntu Github CI doesn't have a display server.
# Start a virtual one with xvfb-run.
xvfb-run -- yarn run playwright test
else
yarn run playwright test
fi

View File

@@ -0,0 +1,36 @@
import type { ElectronApplication } from '@playwright/test';
import type { MenuItem } from 'electron';
// Roughly based on
// https://github.com/spaceagetv/electron-playwright-helpers/blob/main/src/menu_helpers.ts
// `menuItemPath` should be a list of menu labels (e.g. [["&JoplinMainMenu", "&File"], "Synchronise"]).
const activateMainMenuItem = (electronApp: ElectronApplication, menuItemLabel: string) => {
return electronApp.evaluate(async ({ Menu }, menuItemLabel) => {
const activateItemInSubmenu = (submenu: MenuItem[]) => {
for (const item of submenu) {
if (item.label === menuItemLabel && item.visible) {
// Found!
item.click();
return true;
} else if (item.submenu) {
const foundItem = activateItemInSubmenu(item.submenu.items);
if (foundItem) {
return true;
}
}
}
// No item found
return false;
};
const appMenu = Menu.getApplicationMenu();
return activateItemInSubmenu(appMenu.items);
}, menuItemLabel);
};
export default activateMainMenuItem;

View File

@@ -0,0 +1,45 @@
import { resolve, join, dirname } from 'path';
import { remove, mkdirp } from 'fs-extra';
import { _electron as electron, Page, ElectronApplication, test as base } from '@playwright/test';
import uuid from '@joplin/lib/uuid';
type JoplinFixtures = {
electronApp: ElectronApplication;
mainWindow: Page;
};
// A custom fixture that loads an electron app. See
// https://playwright.dev/docs/test-fixtures
export const test = base.extend<JoplinFixtures>({
// Playwright fails if we don't use the object destructuring
// pattern in the first argument.
//
// See https://github.com/microsoft/playwright/issues/8798
//
// eslint-disable-next-line no-empty-pattern
electronApp: async ({ }, use) => {
const profilePath = resolve(join(dirname(__dirname), 'test-profile'));
const profileSubdir = join(profilePath, uuid.createNano());
await mkdirp(profileSubdir);
const startupArgs = ['main.js', '--env', 'dev', '--profile', profileSubdir];
const electronApp = await electron.launch({ args: startupArgs });
await use(electronApp);
await electronApp.firstWindow();
await electronApp.close();
await remove(profileSubdir);
},
mainWindow: async ({ electronApp }, use) => {
const window = await electronApp.firstWindow();
await use(window);
},
});
export { expect } from '@playwright/test';

View File

@@ -40,18 +40,17 @@ a {
}
::-webkit-scrollbar-thumb {
background: rgba(150, 149, 149, 0.4);
background: rgba(100, 100, 100, 0.3);
border-radius: 5px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(223, 223, 223, 0.6);
}
::-webkit-scrollbar-track:hover {
background: rgba(0, 0, 0, 0.1);
}
::-webkit-scrollbar-thumb:hover {
background: rgba(100, 100, 100, 0.7);
}
.fade_out {
-webkit-transition: 0.15s;

View File

@@ -1,6 +1,6 @@
{
"name": "@joplin/app-desktop",
"version": "2.13.1",
"version": "2.13.2",
"description": "Joplin for Desktop",
"main": "main.js",
"private": true,
@@ -14,7 +14,8 @@
"watch": "tsc --watch --preserveWatchOutput --project tsconfig.json",
"start": "gulp build && electron . --env dev --log-level debug --open-dev-tools",
"test": "jest",
"test-ci": "yarn test",
"test-ui": "playwright test",
"test-ci": "yarn test && sh ./integration-tests/run-ci.sh",
"renameReleaseAssets": "node tools/renameReleaseAssets.js"
},
"repository": {
@@ -116,15 +117,16 @@
"devDependencies": {
"@electron/rebuild": "3.3.0",
"@joplin/tools": "~2.13",
"@playwright/test": "1.38.1",
"@testing-library/react-hooks": "8.0.1",
"@types/jest": "29.5.4",
"@types/node": "18.17.12",
"@types/react": "18.2.21",
"@types/node": "18.17.19",
"@types/react": "18.2.23",
"@types/react-redux": "7.1.26",
"@types/styled-components": "5.1.26",
"electron": "25.3.1",
"@types/styled-components": "5.1.28",
"electron": "25.8.1",
"electron-builder": "24.4.0",
"glob": "10.3.4",
"glob": "10.3.10",
"gulp": "4.0.2",
"jest": "29.6.4",
"jest-environment-jsdom": "29.6.4",
@@ -143,6 +145,7 @@
"@electron/remote": "2.0.11",
"@fortawesome/fontawesome-free": "5.15.4",
"@joeattardi/emoji-button": "4.6.4",
"@joplin/editor": "~2.13",
"@joplin/lib": "~2.13",
"@joplin/renderer": "~2.13",
"@joplin/utils": "~2.13",
@@ -150,7 +153,7 @@
"async-mutex": "0.4.0",
"codemirror": "5.65.9",
"color": "3.2.1",
"compare-versions": "3.6.0",
"compare-versions": "6.1.0",
"countable": "3.0.1",
"debounce": "1.2.1",
"electron-window-state": "5.0.3",
@@ -172,7 +175,7 @@
"react-datetime": "3.2.0",
"react-dom": "18.2.0",
"react-redux": "8.1.2",
"react-select": "5.7.4",
"react-select": "5.7.5",
"react-toggle-button": "2.2.0",
"react-tooltip": "4.5.1",
"redux": "4.2.1",

View File

@@ -0,0 +1,34 @@
import { defineConfig } from '@playwright/test';
// See https://playwright.dev/docs/test-configuration.
export default defineConfig({
testDir: './integration-tests',
// Only match .ts files (no compiled .js files)
testMatch: '*.spec.ts',
// Allow running tests in parallel (note: each Joplin instance
// is given its own profile directory).
fullyParallel: true,
// Fail the build on CI if you accidentally left test.only in the source code.
forbidOnly: !!process.env.CI,
// Retry on CI only
retries: process.env.CI ? 2 : 0,
// Opt out of parallel tests on CI
workers: process.env.CI ? 1 : undefined,
// Reporter to use. See https://playwright.dev/docs/test-reporters
reporter: process.env.CI ? 'line' : 'html',
// Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions.
use: {
// Base URL to use in actions like `await page.goto('/')`.
// baseURL: 'http://127.0.0.1:3000',
// Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer
trace: 'on-first-retry',
},
});

View File

@@ -5,17 +5,22 @@
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
TEMP_PATH=~/src/plugin-tests
PLUGIN_PATH=~/src/joplin/packages/app-cli/tests/support/plugins/note_list_renderer
NEED_COMPILING=0
PLUGIN_PATH=~/src/joplin/packages/app-cli/tests/support/plugins/simple
mkdir -p "$TEMP_PATH"
PLUGIN_NAME=$(echo "$PLUGIN_PATH" | awk -F/ '{print $NF}')
TEMP_PLUGIN_PATH="$TEMP_PATH/$PLUGIN_NAME"
if [[ $NEED_COMPILING == 1 ]]; then
mkdir -p "$TEMP_PATH"
PLUGIN_NAME=$(echo "$PLUGIN_PATH" | awk -F/ '{print $NF}')
TEMP_PLUGIN_PATH="$TEMP_PATH/$PLUGIN_NAME"
echo "Copying from: $PLUGIN_PATH"
echo "To: $TEMP_PLUGIN_PATH"
echo "Copying from: $PLUGIN_PATH"
echo "To: $TEMP_PLUGIN_PATH"
rsync -a --delete "$PLUGIN_PATH/" "$TEMP_PLUGIN_PATH/"
rsync -a --delete "$PLUGIN_PATH/" "$TEMP_PLUGIN_PATH/"
npm install --prefix="$TEMP_PLUGIN_PATH" && yarn start --dev-plugins "$TEMP_PLUGIN_PATH"
npm install --prefix="$TEMP_PLUGIN_PATH" && yarn start --dev-plugins "$TEMP_PLUGIN_PATH"
else
yarn start --dev-plugins "$PLUGIN_PATH"
fi
# Add eg "--profile $HOME/.config/joplindev-desktop-1" to test with a different profile

View File

@@ -66,9 +66,10 @@ yarn-error.log
lib/csstojs/
lib/rnInjectedJs/
dist/
components/NoteEditor/CodeMirror/CodeMirror.bundle.js
components/NoteEditor/CodeMirror/CodeMirror.bundle.min.js
components/NoteEditor/**/*.bundle.js
components/NoteEditor/**/*.bundle.js.md5
components/NoteEditor/**/*.bundle.min.js
components/NoteEditor/**/*.bundle.js.LICENSE.txt
utils/fs-driver-android.js
android/app/build-*

View File

@@ -110,8 +110,8 @@ android {
applicationId "net.cozic.joplin"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 2097720
versionName "2.13.0"
versionCode 2097722
versionName "2.13.2"
ndk {
abiFilters "armeabi-v7a", "x86", "arm64-v8a", "x86_64"
}

View File

@@ -49,7 +49,7 @@ const ActionButton = (props: ActionButtonProps) => {
};
}), [props.buttons]);
const closedIcon = useIcon(props.mainButton?.icon ?? 'md-add');
const closedIcon = useIcon(props.mainButton?.icon ?? 'add');
const openIcon = useIcon('close');
return (

View File

@@ -158,11 +158,11 @@ class CameraView extends Component {
}
public render() {
const photoIcon = this.state.snapping ? 'md-checkmark' : 'md-camera';
const photoIcon = this.state.snapping ? 'checkmark' : 'camera';
const displayRatios = this.supportsRatios() && this.state.ratios.length > 1;
const reverseCameraButton = this.renderButton(this.reverse_onPress, 'md-camera-reverse', { flex: 1, flexDirection: 'row', justifyContent: 'flex-start', marginLeft: 20 });
const reverseCameraButton = this.renderButton(this.reverse_onPress, 'camera-reverse', { flex: 1, flexDirection: 'row', justifyContent: 'flex-start', marginLeft: 20 });
const ratioButton = !displayRatios ? <View style={{ flex: 1 }}/> : this.renderButton(this.ratio_onPress, <Text style={{ fontWeight: 'bold', fontSize: 20 }}>{Setting.value('camera.ratio')}</Text>, { flex: 1, flexDirection: 'row', justifyContent: 'flex-end', marginRight: 20 });
let cameraRatio = '4:3';
@@ -202,7 +202,7 @@ class CameraView extends Component {
<TouchableOpacity onPress={this.back_onPress}>
<View style={{ marginLeft: 5, marginTop: 5, borderColor: '#00000040', borderWidth: 1, borderStyle: 'solid', borderRadius: 90, width: 50, height: 50, display: 'flex', backgroundColor: '#ffffff77', justifyContent: 'center', alignItems: 'center' }}>
<Icon
name={'md-arrow-back'}
name={'arrow-back'}
style={{
fontSize: 40,
color: 'black',

View File

@@ -1,7 +1,7 @@
import { useRef, useCallback } from 'react';
import useSource from './hooks/useSource';
import useOnMessage from './hooks/useOnMessage';
import useOnMessage, { HandleMessageCallback, OnMarkForDownloadCallback } from './hooks/useOnMessage';
import useOnResourceLongPress from './hooks/useOnResourceLongPress';
const React = require('react');
@@ -19,14 +19,11 @@ interface Props {
noteResources: any;
paddingBottom: number;
noteHash: string;
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
onJoplinLinkClick: Function;
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
onCheckboxChange?: Function;
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
onMarkForDownload?: Function;
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
onLoadEnd?: Function;
onJoplinLinkClick: HandleMessageCallback;
onCheckboxChange?: HandleMessageCallback;
onRequestEditResource?: HandleMessageCallback;
onMarkForDownload?: OnMarkForDownloadCallback;
onLoadEnd?: ()=> void;
}
const webViewStyle = {
@@ -47,16 +44,22 @@ export default function NoteBodyViewer(props: Props) {
);
const onResourceLongPress = useOnResourceLongPress(
props.onJoplinLinkClick,
{
onJoplinLinkClick: props.onJoplinLinkClick,
onRequestEditResource: props.onRequestEditResource,
},
dialogBoxRef,
);
const onMessage = useOnMessage(
props.onCheckboxChange,
props.noteBody,
props.onMarkForDownload,
props.onJoplinLinkClick,
onResourceLongPress,
{
onCheckboxChange: props.onCheckboxChange,
onMarkForDownload: props.onMarkForDownload,
onJoplinLinkClick: props.onJoplinLinkClick,
onRequestEditResource: props.onRequestEditResource,
onResourceLongPress,
},
);
const onLoadEnd = useCallback(() => {

View File

@@ -0,0 +1,86 @@
/**
* @jest-environment jsdom
*/
import { writeFileSync } from 'fs-extra';
import { join } from 'path';
import Setting from '@joplin/lib/models/Setting';
// Mock react-native-vector-icons -- it uses ESM imports, which, by default, are not
// supported by jest.
jest.doMock('react-native-vector-icons/Ionicons', () => {
return {
default: {
getImageSourceSync: () => {
// Create an empty file that can be read/used as an image resource.
const iconPath = join(Setting.value('cacheDir'), 'test-icon.png');
writeFileSync(iconPath, '', 'utf-8');
return { uri: iconPath };
},
},
};
});
import lightTheme from '@joplin/lib/themes/light';
import { editPopupClass, getEditPopupSource } from './useEditPopup';
import { describe, it, expect, beforeAll, jest } from '@jest/globals';
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
const createEditPopup = (target: HTMLElement) => {
const { createEditPopupSyntax } = getEditPopupSource(lightTheme);
eval(`(${createEditPopupSyntax})`)(target, 'someresourceid', '() => {}');
};
const destroyEditPopup = () => {
const { destroyEditPopupSyntax } = getEditPopupSource(lightTheme);
eval(`(${destroyEditPopupSyntax})`)();
};
describe('useEditPopup', () => {
beforeAll(async () => {
// useEditPopup relies on the resourceDir setting, which is set by
// switchClient.
await setupDatabaseAndSynchronizer(0);
await switchClient(0);
});
it('should attach an edit popup to an image', () => {
const container = document.createElement('div');
const targetImage = document.createElement('img');
container.appendChild(targetImage);
createEditPopup(targetImage);
// Popup should be present in the document
expect(container.querySelector(`.${editPopupClass}`)).not.toBeNull();
// Destroy the edit popup
jest.useFakeTimers();
destroyEditPopup();
// Give time for the popup's fade out animation to run.
jest.advanceTimersByTime(1000 * 10);
// Popup should be destroyed.
expect(container.querySelector(`.${editPopupClass}`)).toBeNull();
targetImage.remove();
});
it('should auto-remove the edit popup after a delay', () => {
jest.useFakeTimers();
const container = document.createElement('div');
const targetImage = document.createElement('img');
container.appendChild(targetImage);
jest.useFakeTimers();
createEditPopup(targetImage);
expect(container.querySelector(`.${editPopupClass}`)).not.toBeNull();
jest.advanceTimersByTime(1000 * 20); // ms
expect(container.querySelector(`.${editPopupClass}`)).toBeNull();
});
});

View File

@@ -0,0 +1,145 @@
import { _ } from '@joplin/lib/locale';
import Setting from '@joplin/lib/models/Setting';
import { themeStyle } from '@joplin/lib/theme';
import { Theme } from '@joplin/lib/themes/type';
import { useMemo } from 'react';
import { extname } from 'path';
import shim from '@joplin/lib/shim';
const Icon = require('react-native-vector-icons/Ionicons').default;
export const editPopupClass = 'joplin-editPopup';
const getEditIconSrc = (theme: Theme) => {
const iconUri = Icon.getImageSourceSync('pencil', 20, theme.color2).uri;
// Copy to a location that can be read within a WebView
// (necessary on iOS)
const destPath = `${Setting.value('resourceDir')}/edit-icon${extname(iconUri)}`;
// Copy in the background -- the edit icon popover script doesn't need the
// icon immediately.
void (async () => {
await shim.fsDriver().copy(iconUri, destPath);
})();
return destPath;
};
// Creates JavaScript/CSS that can be used to create an "Edit" button.
// Exported to facilitate testing.
export const getEditPopupSource = (theme: Theme) => {
const fadeOutDelay = 400;
const editPopupDestroyDelay = 5000;
const editPopupCss = `
@keyframes fade-in {
0% { opacity: 0; }
100% { opacity: 1; }
}
@keyframes fade-out {
0% { opacity: 1; }
100% { opacity: 0; }
}
.${editPopupClass} {
display: inline-block;
position: relative;
/* Don't take up any space in the line, overlay the button */
width: 0;
height: 0;
overflow: visible;
--edit-popup-width: 40px;
--edit-popup-padding: 10px;
/* Shift the popup such that it overlaps with the previous element. */
left: calc(0px - var(--edit-popup-width));
/* Match the top of the image */
vertical-align: top;
}
.${editPopupClass} > button {
padding: var(--edit-popup-padding);
width: var(--edit-popup-width);
animation: fade-in 0.4s ease;
background-color: ${theme.backgroundColor2};
color: ${theme.color2};
border: none;
}
.${editPopupClass} img {
/* Make the image take up as much space as possible (minus padding) */
width: calc(var(--edit-popup-width) - var(--edit-popup-padding));
}
.${editPopupClass}.fadeOut {
animation: fade-out ${fadeOutDelay}ms ease;
}
`;
const destroyEditPopupSyntax = `() => {
if (!window.editPopup) {
return;
}
const popup = editPopup;
popup.classList.add('fadeOut');
window.editPopup = null;
setTimeout(() => {
popup.remove();
}, ${fadeOutDelay});
}`;
const createEditPopupSyntax = `(parent, resourceId, onclick) => {
if (window.editPopupTimeout) {
clearTimeout(window.editPopupTimeout);
window.editPopupTimeout = undefined;
}
window.editPopupTimeout = setTimeout(${destroyEditPopupSyntax}, ${editPopupDestroyDelay});
if (window.lastEditPopupTarget !== parent) {
(${destroyEditPopupSyntax})();
} else if (window.editPopup) {
return;
}
window.editPopup = document.createElement('div');
const popupButton = document.createElement('button');
const popupIcon = new Image();
popupIcon.alt = ${JSON.stringify(_('Edit'))};
popupIcon.title = popupIcon.alt;
popupIcon.src = ${JSON.stringify(getEditIconSrc(theme))};
popupButton.appendChild(popupIcon);
popupButton.onclick = onclick;
editPopup.appendChild(popupButton);
editPopup.classList.add(${JSON.stringify(editPopupClass)});
parent.insertAdjacentElement('afterEnd', editPopup);
// Ensure that the edit popup is focused immediately by screen
// readers.
editPopup.focus();
window.lastEditPopupTarget = parent;
}`;
return { createEditPopupSyntax, destroyEditPopupSyntax, editPopupCss };
};
const useEditPopup = (themeId: number) => {
return useMemo(() => {
return getEditPopupSource(themeStyle(themeId));
}, [themeId]);
};
export default useEditPopup;

View File

@@ -1,8 +1,31 @@
import { useCallback } from 'react';
import shared from '@joplin/lib/components/shared/note-screen-shared';
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
export default function useOnMessage(onCheckboxChange: Function, noteBody: string, onMarkForDownload: Function, onJoplinLinkClick: Function, onResourceLongPress: Function) {
export type HandleMessageCallback = (message: string)=> void;
export type OnMarkForDownloadCallback = (resource: { resourceId: string })=> void;
interface MessageCallbacks {
onMarkForDownload?: OnMarkForDownloadCallback;
onJoplinLinkClick: HandleMessageCallback;
onResourceLongPress: HandleMessageCallback;
onRequestEditResource?: HandleMessageCallback;
onCheckboxChange: HandleMessageCallback;
}
export default function useOnMessage(
noteBody: string,
callbacks: MessageCallbacks,
) {
// Dectructure callbacks. Because we have that ({ a: 1 }) !== ({ a: 1 }),
// we can expect the `callbacks` variable from the last time useOnMessage was called to
// not equal the current` callbacks` variable, even if the callbacks themselves are the
// same.
//
// Thus, useCallback should depend on each callback individually.
const {
onMarkForDownload, onResourceLongPress, onCheckboxChange, onRequestEditResource, onJoplinLinkClick,
} = callbacks;
return useCallback((event: any) => {
// 2021-05-19: Historically this was unescaped twice as it was
// apparently needed after an upgrade to RN 58 (or 59). However this is
@@ -17,13 +40,15 @@ export default function useOnMessage(onCheckboxChange: Function, noteBody: strin
if (msg.indexOf('checkboxclick:') === 0) {
const newBody = shared.toggleCheckbox(msg, noteBody);
if (onCheckboxChange) onCheckboxChange(newBody);
onCheckboxChange?.(newBody);
} else if (msg.indexOf('markForDownload:') === 0) {
const splittedMsg = msg.split(':');
const resourceId = splittedMsg[1];
if (onMarkForDownload) onMarkForDownload({ resourceId: resourceId });
onMarkForDownload?.({ resourceId: resourceId });
} else if (msg.startsWith('longclick:')) {
onResourceLongPress(msg);
} else if (msg.startsWith('edit:')) {
onRequestEditResource?.(msg);
} else if (msg.startsWith('joplin:')) {
onJoplinLinkClick(msg);
} else if (msg.startsWith('error:')) {
@@ -31,5 +56,12 @@ export default function useOnMessage(onCheckboxChange: Function, noteBody: strin
} else {
onJoplinLinkClick(msg);
}
}, [onCheckboxChange, noteBody, onMarkForDownload, onJoplinLinkClick, onResourceLongPress]);
}, [
noteBody,
onCheckboxChange,
onMarkForDownload,
onJoplinLinkClick,
onResourceLongPress,
onRequestEditResource,
]);
}

View File

@@ -6,20 +6,40 @@ import { reg } from '@joplin/lib/registry';
const { dialogs } = require('../../../utils/dialogs.js');
import Resource from '@joplin/lib/models/Resource';
import { copyToCache } from '../../../utils/ShareUtils';
import isEditableResource from '../../NoteEditor/ImageEditor/isEditableResource';
const Share = require('react-native-share').default;
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
export default function useOnResourceLongPress(onJoplinLinkClick: Function, dialogBoxRef: any) {
interface Callbacks {
onJoplinLinkClick: (link: string)=> void;
onRequestEditResource: (message: string)=> void;
}
export default function useOnResourceLongPress(callbacks: Callbacks, dialogBoxRef: any) {
const { onJoplinLinkClick, onRequestEditResource } = callbacks;
return useCallback(async (msg: string) => {
try {
const resourceId = msg.split(':')[1];
const resource = await Resource.load(resourceId);
const name = resource.title ? resource.title : resource.file_name;
const action = await dialogs.pop({ dialogbox: dialogBoxRef.current }, name, [
{ text: _('Open'), id: 'open' },
{ text: _('Share'), id: 'share' },
]);
// Handle the case where it's a long press on a link with no resource
if (!resource) {
reg.logger().warn(`Long-press: Resource with ID ${resourceId} does not exist (may be a note).`);
return;
}
const name = resource.title ? resource.title : resource.file_name;
const mime: string|undefined = resource.mime;
const actions = [];
actions.push({ text: _('Open'), id: 'open' });
if (mime && isEditableResource(mime)) {
actions.push({ text: _('Edit'), id: 'edit' });
}
actions.push({ text: _('Share'), id: 'share' });
const action = await dialogs.pop({ dialogbox: dialogBoxRef.current }, name, actions);
if (action === 'open') {
onJoplinLinkClick(`joplin://${resourceId}`);
@@ -32,11 +52,12 @@ export default function useOnResourceLongPress(onJoplinLinkClick: Function, dial
url: `file://${fileToShare}`,
failOnCancel: false,
});
} else if (action === 'edit') {
onRequestEditResource(`edit:${resourceId}`);
}
} catch (e) {
reg.logger().error('Could not handle link long press', e);
ToastAndroid.show('An error occurred, check log for details', ToastAndroid.SHORT);
}
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [onJoplinLinkClick]);
}, [onJoplinLinkClick, onRequestEditResource, dialogBoxRef]);
}

View File

@@ -3,6 +3,7 @@ import shim from '@joplin/lib/shim';
import Setting from '@joplin/lib/models/Setting';
const { themeStyle } = require('../../global-style.js');
import markupLanguageUtils from '@joplin/lib/markupLanguageUtils';
import useEditPopup from './useEditPopup';
import Logger from '@joplin/utils/Logger';
const { assetsToHeaders } = require('@joplin/renderer');
@@ -90,6 +91,8 @@ export default function useSource(noteBody: string, noteMarkupLanguage: number,
const onlyNoteBodyHasChanged = Object.keys(changedDeps).length === 1 && changedDeps[0];
const onlyCheckboxesHaveChanged = previousDeps[0] && changedDeps[0] && onlyCheckboxHasChangedHack(previousDeps[0], noteBody);
const { createEditPopupSyntax, destroyEditPopupSyntax, editPopupCss } = useEditPopup(themeId);
useEffect(() => {
if (onlyNoteBodyHasChanged && onlyCheckboxesHaveChanged) {
logger.info('Only a checkbox has changed - not updating HTML');
@@ -112,6 +115,11 @@ export default function useSource(noteBody: string, noteMarkupLanguage: number,
codeTheme: theme.codeThemeCss,
postMessageSyntax: 'window.joplinPostMessage_',
enableLongPress: true,
// Show an 'edit' popup over SVG images
editPopupFiletypes: ['image/svg+xml'],
createEditPopupSyntax,
destroyEditPopupSyntax,
};
// Whenever a resource state changes, for example when it goes from "not downloaded" to "downloaded", the "noteResources"
@@ -209,7 +217,9 @@ export default function useSource(noteBody: string, noteMarkupLanguage: number,
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
${shim.mobilePlatform() === 'ios' ? `${iOSSpecificCss}\n${defaultCss}` : defaultCss}
${defaultCss}
${shim.mobilePlatform() === 'ios' ? iOSSpecificCss : ''}
${editPopupCss}
</style>
${assetsToHeaders(result.pluginAssets, { asHtml: true })}
</head>

View File

@@ -1,59 +0,0 @@
/**
* @jest-environment jsdom
*/
import { EditorSettings } from '../types';
import { initCodeMirror } from './CodeMirror';
import { themeStyle } from '@joplin/lib/theme';
import Setting from '@joplin/lib/models/Setting';
import { forceParsing } from '@codemirror/language';
import loadLangauges from './testUtil/loadLanguages';
import { expect, describe, it } from '@jest/globals';
const createEditorSettings = (themeId: number) => {
const themeData = themeStyle(themeId);
const editorSettings: EditorSettings = {
katexEnabled: true,
spellcheckEnabled: true,
readOnly: false,
themeId,
themeData,
};
return editorSettings;
};
describe('CodeMirror', () => {
// This checks for a regression -- occasionally, when updating packages,
// syntax highlighting in the CodeMirror editor stops working. This is usually
// fixed by
// 1. removing all `@codemirror/` and `@lezer/` dependencies from yarn.lock,
// 2. upgrading all CodeMirror packages to the latest versions in package.json, and
// 3. re-running `yarn install`.
//
// See https://github.com/laurent22/joplin/issues/7253
it('should give headings a different style', async () => {
const headerLineText = '# Testing...';
const initialText = `${headerLineText}\nThis is a test.`;
const editorSettings = createEditorSettings(Setting.THEME_LIGHT);
await loadLangauges();
const editor = initCodeMirror(document.body, initialText, editorSettings);
// Force the generation of the syntax tree now.
forceParsing(editor.editor);
// CodeMirror nests the tag that styles the header within .cm-headerLine:
// <div class='cm-headerLine'><span class='someclass'>Testing...</span></div>
const headerLineContent = document.body.querySelector('.cm-headerLine > span')!;
expect(headerLineContent.textContent).toBe(headerLineText);
const style = getComputedStyle(headerLineContent);
expect(style.borderBottom).not.toBe('');
expect(style.fontSize).toBe('1.6em');
});
});

View File

@@ -9,448 +9,24 @@
// wrapper to access CodeMirror functionalities. Anything else should be done
// from NoteEditor.tsx.
import { MarkdownMathExtension } from './markdownMathParser';
import createTheme from './theme';
import decoratorExtension from './decoratorExtension';
import syntaxHighlightingLanguages from './syntaxHighlightingLanguages';
import { EditorState } from '@codemirror/state';
import { markdown } from '@codemirror/lang-markdown';
import { GFM as GitHubFlavoredMarkdownExtension } from '@lezer/markdown';
import { indentOnInput, indentUnit, syntaxTree } from '@codemirror/language';
import {
openSearchPanel, closeSearchPanel, SearchQuery, setSearchQuery, getSearchQuery,
/* highlightSelectionMatches, */ search, findNext, findPrevious, replaceAll, replaceNext,
} from '@codemirror/search';
import {
EditorView, drawSelection, highlightSpecialChars, ViewUpdate, Command,
} from '@codemirror/view';
import { undo, redo, history, undoDepth, redoDepth, indentWithTab } from '@codemirror/commands';
import { keymap, KeyBinding } from '@codemirror/view';
import { searchKeymap } from '@codemirror/search';
import { historyKeymap, defaultKeymap } from '@codemirror/commands';
import { CodeMirrorControl } from './types';
import { EditorSettings, ListType, SearchState } from '../types';
import { ChangeEvent, SelectionChangeEvent, Selection } from '../types';
import SelectionFormatting from '../SelectionFormatting';
import { EditorSettings } from '@joplin/editor/types';
import createEditor from '@joplin/editor/CodeMirror/createEditor';
import { logMessage, postMessage } from './webviewLogger';
import {
decreaseIndent, increaseIndent,
toggleBolded, toggleCode,
toggleHeaderLevel, toggleItalicized,
toggleList, toggleMath, updateLink,
} from './markdownCommands';
interface CodeMirrorResult extends CodeMirrorControl {
editor: EditorView;
}
import CodeMirrorControl from '@joplin/editor/CodeMirror/CodeMirrorControl';
export function initCodeMirror(
parentElement: any, initialText: string, settings: EditorSettings,
): CodeMirrorResult {
logMessage('Initializing CodeMirror...');
const theme = settings.themeData;
parentElement: HTMLElement, initialText: string, settings: EditorSettings,
): CodeMirrorControl {
return createEditor(parentElement, {
initialText,
settings,
let searchVisible = false;
let schedulePostUndoRedoDepthChangeId_: any = 0;
const schedulePostUndoRedoDepthChange = (editor: EditorView, doItNow = false) => {
if (schedulePostUndoRedoDepthChangeId_) {
if (doItNow) {
clearTimeout(schedulePostUndoRedoDepthChangeId_);
} else {
return;
}
}
schedulePostUndoRedoDepthChangeId_ = setTimeout(() => {
schedulePostUndoRedoDepthChangeId_ = null;
postMessage('onUndoRedoDepthChange', {
undoDepth: undoDepth(editor.state),
redoDepth: redoDepth(editor.state),
});
}, doItNow ? 0 : 1000);
};
const notifyDocChanged = (viewUpdate: ViewUpdate) => {
if (viewUpdate.docChanged) {
const event: ChangeEvent = {
value: editor.state.doc.toString(),
};
postMessage('onChange', event);
schedulePostUndoRedoDepthChange(editor);
}
};
const notifyLinkEditRequest = () => {
postMessage('onRequestLinkEdit', null);
};
const showSearchDialog = () => {
const query = getSearchQuery(editor.state);
const searchState: SearchState = {
searchText: query.search,
replaceText: query.replace,
useRegex: query.regexp,
caseSensitive: query.caseSensitive,
dialogVisible: true,
};
postMessage('onRequestShowSearch', searchState);
searchVisible = true;
};
const hideSearchDialog = () => {
postMessage('onRequestHideSearch', null);
searchVisible = false;
};
const notifySelectionChange = (viewUpdate: ViewUpdate) => {
if (!viewUpdate.state.selection.eq(viewUpdate.startState.selection)) {
const mainRange = viewUpdate.state.selection.main;
const selection: Selection = {
start: mainRange.from,
end: mainRange.to,
};
const event: SelectionChangeEvent = {
selection,
};
postMessage('onSelectionChange', event);
}
};
const notifySelectionFormattingChange = (viewUpdate?: ViewUpdate) => {
// If we can't determine the previous formatting, post the update regardless
if (!viewUpdate) {
const formatting = computeSelectionFormatting(editor.state);
postMessage('onSelectionFormattingChange', formatting.toJSON());
} else if (viewUpdate.docChanged || !viewUpdate.state.selection.eq(viewUpdate.startState.selection)) {
// Only post the update if something changed
const oldFormatting = computeSelectionFormatting(viewUpdate.startState);
const newFormatting = computeSelectionFormatting(viewUpdate.state);
if (!oldFormatting.eq(newFormatting)) {
postMessage('onSelectionFormattingChange', newFormatting.toJSON());
}
}
};
const computeSelectionFormatting = (state: EditorState): SelectionFormatting => {
const range = state.selection.main;
const formatting: SelectionFormatting = new SelectionFormatting();
formatting.selectedText = state.doc.sliceString(range.from, range.to);
formatting.spellChecking = editor.contentDOM.spellcheck;
const parseLinkData = (nodeText: string) => {
const linkMatch = nodeText.match(/\[([^\]]*)\]\(([^)]*)\)/);
if (linkMatch) {
return {
linkText: linkMatch[1],
linkURL: linkMatch[2],
};
}
return null;
};
// Find nodes that overlap/are within the selected region
syntaxTree(state).iterate({
from: range.from, to: range.to,
enter: node => {
// Checklists don't have a specific containing node. As such,
// we're in a checklist if we've selected a 'Task' node.
if (node.name === 'Task') {
formatting.inChecklist = true;
}
// Only handle notes that contain the entire range.
if (node.from > range.from || node.to < range.to) {
return;
}
// Lazily compute the node's text
const nodeText = () => state.doc.sliceString(node.from, node.to);
switch (node.name) {
case 'StrongEmphasis':
formatting.bolded = true;
break;
case 'Emphasis':
formatting.italicized = true;
break;
case 'ListItem':
formatting.listLevel += 1;
break;
case 'BulletList':
formatting.inUnorderedList = true;
break;
case 'OrderedList':
formatting.inOrderedList = true;
break;
case 'TaskList':
formatting.inChecklist = true;
break;
case 'InlineCode':
case 'FencedCode':
formatting.inCode = true;
formatting.unspellCheckableRegion = true;
break;
case 'InlineMath':
case 'BlockMath':
formatting.inMath = true;
formatting.unspellCheckableRegion = true;
break;
case 'ATXHeading1':
formatting.headerLevel = 1;
break;
case 'ATXHeading2':
formatting.headerLevel = 2;
break;
case 'ATXHeading3':
formatting.headerLevel = 3;
break;
case 'ATXHeading4':
formatting.headerLevel = 4;
break;
case 'ATXHeading5':
formatting.headerLevel = 5;
break;
case 'URL':
formatting.inLink = true;
formatting.linkData.linkURL = nodeText();
formatting.unspellCheckableRegion = true;
break;
case 'Link':
formatting.inLink = true;
formatting.linkData = parseLinkData(nodeText());
break;
}
},
});
// The markdown parser marks checklists as unordered lists. Ensure
// that they aren't marked as such.
if (formatting.inChecklist) {
if (!formatting.inUnorderedList) {
// Even if the selection contains a Task, because an unordered list node
// must contain a valid Task node, we're only in a checklist if we're also in
// an unordered list.
formatting.inChecklist = false;
} else {
formatting.inUnorderedList = false;
}
}
if (formatting.unspellCheckableRegion) {
formatting.spellChecking = false;
}
return formatting;
};
// Returns a keyboard command that returns true (so accepts the keybind)
const keyCommand = (key: string, run: Command): KeyBinding => {
return {
key,
run,
preventDefault: true,
};
};
const editor = new EditorView({
state: EditorState.create({
// See https://github.com/codemirror/basic-setup/blob/main/src/codemirror.ts
// for a sample configuration.
extensions: [
markdown({
extensions: [
GitHubFlavoredMarkdownExtension,
// Don't highlight KaTeX if the user disabled it
settings.katexEnabled ? MarkdownMathExtension : [],
],
codeLanguages: syntaxHighlightingLanguages,
}),
...createTheme(theme),
history(),
search({
createPanel(_: EditorView) {
return {
// The actual search dialog is implemented with react native,
// use a dummy element.
dom: document.createElement('div'),
mount() {
showSearchDialog();
},
destroy() {
hideSearchDialog();
},
};
},
}),
drawSelection(),
highlightSpecialChars(),
// highlightSelectionMatches(),
indentOnInput(),
// By default, indent with four spaces
indentUnit.of(' '),
EditorState.tabSize.of(4),
// Apply styles to entire lines (block-display decorations)
decoratorExtension,
EditorView.lineWrapping,
EditorView.contentAttributes.of({
autocapitalize: 'sentence',
autocorrect: settings.spellcheckEnabled ? 'true' : 'false',
spellcheck: settings.spellcheckEnabled ? 'true' : 'false',
}),
EditorView.updateListener.of((viewUpdate: ViewUpdate) => {
notifyDocChanged(viewUpdate);
notifySelectionChange(viewUpdate);
notifySelectionFormattingChange(viewUpdate);
}),
keymap.of([
// Custom mod-f binding: Toggle the external dialog implementation
// (don't show/hide the Panel dialog).
keyCommand('Mod-f', (_: EditorView) => {
if (searchVisible) {
hideSearchDialog();
} else {
showSearchDialog();
}
return true;
}),
// Markdown formatting keyboard shortcuts
keyCommand('Mod-b', toggleBolded),
keyCommand('Mod-i', toggleItalicized),
keyCommand('Mod-$', toggleMath),
keyCommand('Mod-`', toggleCode),
keyCommand('Mod-[', decreaseIndent),
keyCommand('Mod-]', increaseIndent),
keyCommand('Mod-k', (_: EditorView) => {
notifyLinkEditRequest();
return true;
}),
...defaultKeymap, ...historyKeymap, indentWithTab, ...searchKeymap,
]),
EditorState.readOnly.of(settings.readOnly),
],
doc: initialText,
}),
parent: parentElement,
onLogMessage: message => {
logMessage(message);
},
onEvent: (event): void => {
postMessage('onEditorEvent', event);
},
});
// HACK: 09/02/22: Work around https://github.com/laurent22/joplin/issues/6802 by creating a copy mousedown
// event to prevent the Editor's .preventDefault from making the context menu not appear.
// TODO: Track the upstream issue at https://github.com/codemirror/dev/issues/935 and remove this workaround
// when the upstream bug is fixed.
document.body.addEventListener('mousedown', (evt) => {
if (!evt.isTrusted) {
return;
}
// Walk up the tree -- is evt.target or any of its parent nodes the editor's input region?
for (let current: Record<string, any> = evt.target; current; current = current.parentElement) {
if (current === editor.contentDOM) {
evt.stopPropagation();
const copyEvent = new Event('mousedown', evt);
editor.contentDOM.dispatchEvent(copyEvent);
return;
}
}
}, true);
const updateSearchQuery = (newState: SearchState) => {
const query = new SearchQuery({
search: newState.searchText,
caseSensitive: newState.caseSensitive,
regexp: newState.useRegex,
replace: newState.replaceText,
});
editor.dispatch({
effects: setSearchQuery.of(query),
});
};
const editorControls = {
editor,
undo: () => {
undo(editor);
schedulePostUndoRedoDepthChange(editor, true);
},
redo: () => {
redo(editor);
schedulePostUndoRedoDepthChange(editor, true);
},
select: (anchor: number, head: number) => {
editor.dispatch(editor.state.update({
selection: { anchor, head },
scrollIntoView: true,
}));
},
scrollSelectionIntoView: () => {
editor.dispatch(editor.state.update({
scrollIntoView: true,
}));
},
insertText: (text: string) => {
editor.dispatch(editor.state.replaceSelection(text));
},
toggleFindDialog: () => {
const opened = openSearchPanel(editor);
if (!opened) {
closeSearchPanel(editor);
}
},
// Formatting
toggleBolded: () => { toggleBolded(editor); },
toggleItalicized: () => { toggleItalicized(editor); },
toggleCode: () => { toggleCode(editor); },
toggleMath: () => { toggleMath(editor); },
increaseIndent: () => { increaseIndent(editor); },
decreaseIndent: () => { decreaseIndent(editor); },
toggleList: (kind: ListType) => { toggleList(kind)(editor); },
toggleHeaderLevel: (level: number) => { toggleHeaderLevel(level)(editor); },
updateLink: (label: string, url: string) => { updateLink(label, url)(editor); },
// Search
searchControl: {
findNext: () => {
findNext(editor);
},
findPrevious: () => {
findPrevious(editor);
},
replaceCurrent: () => {
replaceNext(editor);
},
replaceAll: () => {
replaceAll(editor);
},
setSearchState: (state: SearchState) => {
updateSearchQuery(state);
},
showSearch: () => {
showSearchDialog();
},
hideSearch: () => {
hideSearchDialog();
},
},
};
return editorControls;
}

View File

@@ -1,27 +0,0 @@
import { ListType, SearchControl } from '../types';
// Controls for the CodeMirror portion of the editor
export interface CodeMirrorControl {
undo(): void;
redo(): void;
select(anchor: number, head: number): void;
insertText(text: string): void;
// Toggle whether we're in a type of region.
toggleBolded(): void;
toggleItalicized(): void;
toggleList(kind: ListType): void;
toggleCode(): void;
toggleMath(): void;
toggleHeaderLevel(level: number): void;
// Create a new link or update the currently selected link with
// the given [label] and [url].
updateLink(label: string, url: string): void;
increaseIndent(): void;
decreaseIndent(): void;
scrollSelectionIntoView(): void;
searchControl: SearchControl;
}

View File

@@ -9,8 +9,8 @@ import Modal from '../Modal';
import { themeStyle } from '@joplin/lib/theme';
import { _ } from '@joplin/lib/locale';
import { EditorControl } from './types';
import SelectionFormatting from './SelectionFormatting';
import { useCallback } from 'react';
import SelectionFormatting from '@joplin/editor/SelectionFormatting';
interface LinkDialogProps {
editorControl: EditorControl;
@@ -21,7 +21,7 @@ interface LinkDialogProps {
const EditLinkDialog = (props: LinkDialogProps) => {
// The content of the link selected in the editor (if any)
const editorLinkData = props.selectionState.linkData ?? {};
const editorLinkData = props.selectionState.linkData;
const [linkLabel, setLinkLabel] = useState('');
const [linkURL, setLinkURL] = useState('');

View File

@@ -0,0 +1,302 @@
const React = require('react');
import { _ } from '@joplin/lib/locale';
import Logger from '@joplin/utils/Logger';
import Setting from '@joplin/lib/models/Setting';
import shim from '@joplin/lib/shim';
import { themeStyle } from '@joplin/lib/theme';
import { Theme } from '@joplin/lib/themes/type';
import { MutableRefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Alert, BackHandler } from 'react-native';
import { WebViewMessageEvent } from 'react-native-webview';
import ExtendedWebView, { WebViewControl } from '../../ExtendedWebView';
import { clearAutosave, writeAutosave } from './autosave';
import { LocalizedStrings } from './js-draw/types';
const logger = Logger.create('ImageEditor');
type OnSaveCallback = (svgData: string)=> void;
type OnCancelCallback = ()=> void;
type LoadInitialSVGCallback = ()=> Promise<string>;
interface Props {
themeId: number;
loadInitialSVGData: LoadInitialSVGCallback|null;
onSave: OnSaveCallback;
onExit: OnCancelCallback;
}
const useCss = (editorTheme: Theme) => {
return useMemo(() => {
// Ensure we have contrast between the background and selection. Some themes
// have the same backgroundColor and selectionColor2. (E.g. Aritim Dark)
let selectionBackgroundColor = editorTheme.selectedColor2;
if (selectionBackgroundColor === editorTheme.backgroundColor) {
selectionBackgroundColor = editorTheme.selectedColor;
}
return `
:root .imageEditorContainer {
--background-color-1: ${editorTheme.backgroundColor};
--foreground-color-1: ${editorTheme.color};
--background-color-2: ${editorTheme.backgroundColor3};
--foreground-color-2: ${editorTheme.color3};
--background-color-3: ${editorTheme.raisedBackgroundColor};
--foreground-color-3: ${editorTheme.raisedColor};
--selection-background-color: ${editorTheme.backgroundColorHover3};
--selection-foreground-color: ${editorTheme.color3};
--primary-action-foreground-color: ${editorTheme.color4};
--primary-shadow-color: ${editorTheme.colorFaded};
width: 100vw;
height: 100vh;
box-sizing: border-box;
}
body, html {
padding: 0;
margin: 0;
}
/* Hide the scrollbar. See scrollbar accessibility concerns
(https://developer.mozilla.org/en-US/docs/Web/CSS/scrollbar-width#accessibility_concerns)
for why this isn't done in js-draw itself. */
.toolbar-tool-row::-webkit-scrollbar {
display: none;
height: 0;
}
/* Hide the save/close icons on small screens. This isn't done in the upstream
js-draw repository partially beause it isn't as well localized as Joplin
(icons can be used to suggest the meaning of a button when a translation is
unavailable). */
.toolbar-edge-toolbar:not(.one-row) .toolwidget-tag--save .toolbar-icon,
.toolbar-edge-toolbar:not(.one-row) .toolwidget-tag--exit .toolbar-icon {
display: none;
}
`;
}, [editorTheme]);
};
const ImageEditor = (props: Props) => {
const editorTheme: Theme = themeStyle(props.themeId);
const webviewRef: MutableRefObject<WebViewControl>|null = useRef(null);
const [imageChanged, setImageChanged] = useState(false);
const onRequestCloseEditor = useCallback((promptIfUnsaved: boolean) => {
const discardChangesAndClose = async () => {
await clearAutosave();
props.onExit();
};
if (!imageChanged || !promptIfUnsaved) {
void discardChangesAndClose();
return true;
}
Alert.alert(
_('Save changes?'), _('This drawing may have unsaved changes.'), [
{
text: _('Discard changes'),
onPress: discardChangesAndClose,
style: 'destructive',
},
{
text: _('Save changes'),
onPress: () => {
// saveDrawing calls props.onSave(...) which may close the
// editor.
webviewRef.current.injectJS('window.editorControl.saveThenExit()');
},
},
],
);
return true;
}, [webviewRef, props.onExit, imageChanged]);
useEffect(() => {
const hardwareBackPressListener = () => {
onRequestCloseEditor(true);
return true;
};
BackHandler.addEventListener('hardwareBackPress', hardwareBackPressListener);
return () => {
BackHandler.removeEventListener('hardwareBackPress', hardwareBackPressListener);
};
}, [onRequestCloseEditor]);
const css = useCss(editorTheme);
const html = useMemo(() => `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no"/>
<style>
${css}
</style>
</head>
<body></body>
</html>
`, [css]);
// A set of localization overrides (Joplin is better localized than js-draw).
// All localizable strings (some unused?) can be found at
// https://github.com/personalizedrefrigerator/js-draw/blob/main/.github/ISSUE_TEMPLATE/translation-js-draw-new.yml
const localizedStrings: LocalizedStrings = useMemo(() => ({
save: _('Save'),
close: _('Close'),
undo: _('Undo'),
redo: _('Redo'),
}), []);
const injectedJavaScript = useMemo(() => `
window.onerror = (message, source, lineno) => {
window.ReactNativeWebView.postMessage(
"error: " + message + " in file://" + source + ", line " + lineno
);
};
const setImageHasChanges = (hasChanges) => {
window.ReactNativeWebView.postMessage(
JSON.stringify({
action: 'set-image-has-changes',
data: hasChanges,
}),
);
};
window.updateEditorTemplate = (templateData) => {
window.ReactNativeWebView.postMessage(
JSON.stringify({
action: 'set-image-template-data',
data: templateData,
}),
);
};
const notifyReadyToLoadSVG = () => {
window.ReactNativeWebView.postMessage(
JSON.stringify({
action: 'ready-to-load-data',
})
);
};
const saveDrawing = async (drawing, isAutosave) => {
window.ReactNativeWebView.postMessage(
JSON.stringify({
action: isAutosave ? 'autosave' : 'save',
data: drawing.outerHTML,
}),
);
};
const closeEditor = (promptIfUnsaved) => {
window.ReactNativeWebView.postMessage(JSON.stringify({
action: 'close',
promptIfUnsaved,
}));
};
try {
if (window.editorControl === undefined) {
${shim.injectedJs('svgEditorBundle')}
window.editorControl = svgEditorBundle.createJsDrawEditor(
{
saveDrawing,
closeEditor,
updateEditorTemplate,
setImageHasChanges,
},
${JSON.stringify(Setting.value('imageeditor.jsdrawToolbar'))},
${JSON.stringify(Setting.value('locale'))},
${JSON.stringify(localizedStrings)},
);
// Start loading the SVG file (if present) after loading the editor.
// This shows the user that progress is being made (loading large SVGs
// from disk into memory can take several seconds).
notifyReadyToLoadSVG();
}
} catch(e) {
window.ReactNativeWebView.postMessage(
'error: ' + e.message + ': ' + JSON.stringify(e)
);
}
true;
`, [localizedStrings]);
useEffect(() => {
webviewRef.current?.injectJS(`
if (window.editorControl) {
window.editorControl.onThemeUpdate();
}
`);
}, [editorTheme]);
const onReadyToLoadData = useCallback(async () => {
const initialSVGData = await props.loadInitialSVGData?.() ?? '';
// It can take some time for initialSVGData to be transferred to the WebView.
// Thus, do so after the main content has been loaded.
webviewRef.current.injectJS(`(async () => {
if (window.editorControl) {
const initialSVGData = ${JSON.stringify(initialSVGData)};
const initialTemplateData = ${JSON.stringify(Setting.value('imageeditor.imageTemplate'))};
editorControl.loadImageOrTemplate(initialSVGData, initialTemplateData);
}
})();`);
}, [webviewRef, props.loadInitialSVGData]);
const onMessage = useCallback(async (event: WebViewMessageEvent) => {
const data = event.nativeEvent.data;
if (data.startsWith('error:')) {
logger.error('ImageEditor:', data);
return;
}
const json = JSON.parse(data);
if (json.action === 'save') {
await clearAutosave();
props.onSave(json.data);
} else if (json.action === 'autosave') {
await writeAutosave(json.data);
} else if (json.action === 'save-toolbar') {
Setting.setValue('imageeditor.jsdrawToolbar', json.data);
} else if (json.action === 'close') {
onRequestCloseEditor(json.promptIfUnsaved);
} else if (json.action === 'ready-to-load-data') {
void onReadyToLoadData();
} else if (json.action === 'set-image-has-changes') {
setImageChanged(json.data);
} else if (json.action === 'set-image-template-data') {
Setting.setValue('imageeditor.imageTemplate', json.data);
} else {
logger.error('Unknown action,', json.action);
}
}, [props.onSave, onRequestCloseEditor, onReadyToLoadData]);
const onError = useCallback((event: any) => {
logger.error('ImageEditor: WebView error: ', event);
}, []);
return (
<ExtendedWebView
themeId={props.themeId}
html={html}
injectedJavaScript={injectedJavaScript}
onMessage={onMessage}
onError={onError}
ref={webviewRef}
webviewInstanceId={'image-editor-js-draw'}
/>
);
};
export default ImageEditor;

View File

@@ -0,0 +1,26 @@
import Logger from '@joplin/utils/Logger';
import Setting from '@joplin/lib/models/Setting';
import shim from '@joplin/lib/shim';
export const autosaveFilename = 'autosaved-drawing.joplin.svg';
const logger = Logger.create('ImageEditor/autosave');
export const getAutosaveFilepath = () => {
return `${Setting.value('resourceDir')}/${autosaveFilename}`;
};
export const writeAutosave = async (data: string) => {
const filePath = getAutosaveFilepath();
logger.info(`Auto-saving drawing to ${JSON.stringify(filePath)}`);
await shim.fsDriver().writeFile(filePath, data, 'utf8');
};
export const readAutosave = async (): Promise<string|null> => {
return await shim.fsDriver().readFile(getAutosaveFilepath());
};
export const clearAutosave = async () => {
await shim.fsDriver().remove(getAutosaveFilepath());
};

View File

@@ -0,0 +1,6 @@
const isEditableResource = (resourceMime: string) => {
return resourceMime === 'image/svg+xml';
};
export default isEditableResource;

View File

@@ -0,0 +1,67 @@
import { AbstractComponent, Editor, BackgroundComponentBackgroundType, Erase, Vec2, Rect2 } from 'js-draw';
const applyTemplateToEditor = async (editor: Editor, templateData: string) => {
let backgroundComponent: AbstractComponent|null = null;
let imageSize = editor.getImportExportRect().size;
let autoresize = true;
try {
const templateJSON = JSON.parse(templateData);
const isEmptyTemplate =
!('imageSize' in templateJSON) && !('backgroundData' in templateJSON);
// If the template is empty, add a default background
if (isEmptyTemplate) {
templateJSON.backgroundData = {
'name': 'image-background',
'zIndex': 0,
'data': {
'mainColor': '#ffffff',
'backgroundType': BackgroundComponentBackgroundType.SolidColor,
},
};
}
if ('backgroundData' in templateJSON) {
backgroundComponent = AbstractComponent.deserialize(
templateJSON['backgroundData'],
);
}
if ('imageSize' in templateJSON) {
imageSize = Vec2.ofXY(templateJSON.imageSize);
}
if ('autoresize' in templateJSON) {
autoresize = !!templateJSON.autoresize;
}
} catch (e) {
console.error('Warning: Invalid image template data: ', e);
}
if (backgroundComponent) {
// Remove the old background (if any)
const previousBackground = editor.image.getBackgroundComponents();
if (previousBackground.length > 0) {
const removeBackgroundCommand = new Erase(previousBackground);
await editor.dispatchNoAnnounce(removeBackgroundCommand, false);
}
// Add the new background
const addBackgroundCommand = editor.image.addElement(backgroundComponent);
await editor.dispatchNoAnnounce(addBackgroundCommand, false);
}
// Set the image size
const imageSizeCommand = editor.setImportExportRect(new Rect2(0, 0, imageSize.x, imageSize.y));
await editor.dispatchNoAnnounce(imageSizeCommand, false);
// Enable/disable autoresize
await editor.dispatchNoAnnounce(editor.image.setAutoresizeEnabled(autoresize), false);
// And zoom to the template (false = don't make undoable)
await editor.dispatchNoAnnounce(editor.viewport.zoomTo(editor.getImportExportRect()), false);
};
export default applyTemplateToEditor;

View File

@@ -0,0 +1,109 @@
/** @jest-environment jsdom */
// Hide warnings from js-draw.
// jsdom doesn't support ResizeObserver and HTMLCanvasElement.getContext.
HTMLCanvasElement.prototype.getContext = () => null;
window.ResizeObserver = class { public observe() { } } as any;
import { describe, it, expect, jest } from '@jest/globals';
import { Color4, EditorImage, EditorSettings, Path, pathToRenderable, StrokeComponent } from 'js-draw';
import { RenderingMode } from 'js-draw';
import createJsDrawEditor from './createJsDrawEditor';
import { BackgroundComponent } from 'js-draw';
import { BackgroundComponentBackgroundType } from 'js-draw';
import { ImageEditorCallbacks } from './types';
import applyTemplateToEditor from './applyTemplateToEditor';
const createEditorWithCallbacks = (callbacks: Partial<ImageEditorCallbacks>) => {
const toolbarState = '';
const locale = 'en';
const allCallbacks: ImageEditorCallbacks = {
saveDrawing: () => {},
closeEditor: ()=> {},
setImageHasChanges: ()=> {},
updateEditorTemplate: ()=> {},
...callbacks,
};
const editorOptions: Partial<EditorSettings> = {
// Don't use a CanvasRenderer: jsdom doesn't support DrawingContext2D
renderingMode: RenderingMode.DummyRenderer,
};
const localizations = {
save: 'Save',
close: 'Close',
undo: 'Undo',
redo: 'Redo',
};
return createJsDrawEditor(allCallbacks, toolbarState, locale, localizations, editorOptions);
};
describe('createJsDrawEditor', () => {
it('should trigger autosave callback every few minutes', async () => {
let calledAutosaveCount = 0;
jest.useFakeTimers();
const editorControl = createEditorWithCallbacks({
saveDrawing: (_drawing: SVGElement, isAutosave: boolean) => {
if (isAutosave) {
calledAutosaveCount ++;
}
},
});
// Load no image and an empty template so that autosave can start
await editorControl.loadImageOrTemplate(undefined, '{}');
expect(calledAutosaveCount).toBe(0);
// Using the synchronous version of advanceTimersByTime seems to not
// run the asynchronous code used to autosave drawings in createJsDrawEditor.ts.
await jest.advanceTimersByTimeAsync(1000 * 60 * 4);
const lastAutosaveCount = calledAutosaveCount;
expect(calledAutosaveCount).toBeGreaterThanOrEqual(1);
expect(calledAutosaveCount).toBeLessThan(10);
await jest.advanceTimersByTimeAsync(1000 * 60 * 10);
expect(calledAutosaveCount).toBeGreaterThan(lastAutosaveCount);
});
it('should fire has changes callback on first change', () => {
let hasChanges = false;
const editorControl = createEditorWithCallbacks({
setImageHasChanges: (newHasChanges: boolean) => {
hasChanges = newHasChanges;
},
});
expect(hasChanges).toBe(false);
const stroke = new StrokeComponent([
// A filled shape
pathToRenderable(Path.fromString('m0,0 l10,0 l0,10'), { fill: Color4.red }),
]);
void editorControl.editor.dispatch(EditorImage.addElement(stroke));
expect(hasChanges).toBe(true);
});
it('default template should be a white grid background', async () => {
const editorControl = createEditorWithCallbacks({});
const editor = editorControl.editor;
await applyTemplateToEditor(editor, '{}');
expect(editor.image.getBackgroundComponents()).toHaveLength(1);
// Should have a white, solid background
const background = editor.image.getBackgroundComponents()[0] as BackgroundComponent;
expect(editor.estimateBackgroundColor().eq(Color4.white)).toBe(true);
expect(background.getBackgroundType()).toBe(BackgroundComponentBackgroundType.SolidColor);
});
});

View File

@@ -0,0 +1,168 @@
import { Editor, AbstractToolbar, EditorEventType, EditorSettings, getLocalizationTable, adjustEditorThemeForContrast, BaseWidget } from 'js-draw';
import { MaterialIconProvider } from '@js-draw/material-icons';
import 'js-draw/bundledStyles';
import applyTemplateToEditor from './applyTemplateToEditor';
import watchEditorForTemplateChanges from './watchEditorForTemplateChanges';
import { ImageEditorCallbacks, LocalizedStrings } from './types';
import startAutosaveLoop from './startAutosaveLoop';
declare namespace ReactNativeWebView {
const postMessage: (data: any)=> void;
}
const restoreToolbarState = (toolbar: AbstractToolbar, state: string) => {
if (state) {
// deserializeState throws on invalid argument.
try {
toolbar.deserializeState(state);
} catch (e) {
console.warn('Error deserializing toolbar state: ', e);
}
}
};
const listenToolbarState = (editor: Editor, toolbar: AbstractToolbar) => {
editor.notifier.on(EditorEventType.ToolUpdated, () => {
const state = toolbar.serializeState();
ReactNativeWebView.postMessage(
JSON.stringify({
action: 'save-toolbar',
data: state,
}),
);
});
};
export const createJsDrawEditor = (
callbacks: ImageEditorCallbacks,
initialToolbarState: string,
locale: string,
defaultLocalizations: LocalizedStrings,
// Intended for automated tests.
editorSettings: Partial<EditorSettings> = {},
) => {
const parentElement = document.body;
const editor = new Editor(parentElement, {
// Try to use the Joplin locale, but fall back to the system locale if
// js-draw doesn't support it.
localization: {
...getLocalizationTable([locale, ...navigator.languages]),
...defaultLocalizations,
},
iconProvider: new MaterialIconProvider(),
...editorSettings,
});
const toolbar = editor.addToolbar();
const maxSpacerSize = '20px';
toolbar.addSpacer({
grow: 1,
maxSize: maxSpacerSize,
});
// Override the default "Exit" label:
toolbar.addExitButton(
() => callbacks.closeEditor(true), {
label: defaultLocalizations.close,
},
);
toolbar.addSpacer({
grow: 1,
maxSize: maxSpacerSize,
});
// saveButton needs to be defined after the following callbacks.
// As such, this variable can't be made const.
// eslint-disable-next-line prefer-const
let saveButton: BaseWidget;
let lastHadChanges: boolean|null = null;
const setImageHasChanges = (hasChanges: boolean) => {
if (lastHadChanges !== hasChanges) {
saveButton.setDisabled(!hasChanges);
callbacks.setImageHasChanges(hasChanges);
lastHadChanges = hasChanges;
}
};
const saveNow = () => {
callbacks.saveDrawing(editor.toSVG({
// Grow small images to this minimum size
minDimension: 50,
}), false);
// The image is now up-to-date with the resource
setImageHasChanges(false);
};
saveButton = toolbar.addSaveButton(saveNow);
// Load and save toolbar-realated state (e.g. pen sizes/colors).
restoreToolbarState(toolbar, initialToolbarState);
listenToolbarState(editor, toolbar);
setImageHasChanges(false);
editor.notifier.on(EditorEventType.UndoRedoStackUpdated, () => {
setImageHasChanges(true);
});
// Disable save (a full save can't be done until the entire image
// has been loaded).
saveButton.setDisabled(true);
// Show a loading message until the template is loaded.
editor.showLoadingWarning(0);
editor.setReadOnly(true);
const editorControl = {
editor,
loadImageOrTemplate: async (svgData: string|undefined, templateData: string) => {
// loadFromSVG shows its own loading message. Hide the original.
editor.hideLoadingWarning();
if (svgData && svgData.length > 0) {
await editor.loadFromSVG(svgData);
} else {
await applyTemplateToEditor(editor, templateData);
// The editor expects to be saved initially (without
// unsaved changes). Save now.
saveNow();
}
// We can now edit and save safely (without data loss).
editor.setReadOnly(false);
void startAutosaveLoop(editor, callbacks.saveDrawing);
watchEditorForTemplateChanges(editor, templateData, callbacks.updateEditorTemplate);
},
onThemeUpdate: () => {
// Slightly adjusts the given editor's theme colors. This ensures that the colors chosen for
// the editor have proper contrast.
adjustEditorThemeForContrast(editor);
},
saveNow,
saveThenExit: async () => {
saveNow();
// Don't show a confirmation dialog -- it's possible that
// the code outside of the WebView still thinks changes haven't
// been saved:
const showConfirmation = false;
callbacks.closeEditor(showConfirmation);
},
};
editorControl.onThemeUpdate();
return editorControl;
};
export default createJsDrawEditor;

View File

@@ -0,0 +1,26 @@
import { Editor } from 'js-draw';
import { SaveDrawingCallback } from './types';
const startAutosaveLoop = async (
editor: Editor,
saveDrawing: SaveDrawingCallback,
) => {
// Autosave every two minutes.
const delayTime = 1000 * 60 * 2; // ms
const createAutosave = async () => {
const savedSVG = await editor.toSVGAsync();
saveDrawing(savedSVG, true);
};
while (true) {
await (new Promise<void>(resolve => {
setTimeout(() => resolve(), delayTime);
}));
await createAutosave();
}
};
export default startAutosaveLoop;

View File

@@ -0,0 +1,20 @@
export type SaveDrawingCallback = (svgElement: SVGElement, isAutosave: boolean)=> void;
export type UpdateEditorTemplateCallback = (newTemplate: string)=> void;
export interface ImageEditorCallbacks {
saveDrawing: SaveDrawingCallback;
updateEditorTemplate: UpdateEditorTemplateCallback;
closeEditor: (promptIfUnsaved: boolean)=> void;
setImageHasChanges: (hasChanges: boolean)=> void;
}
// Overrides translations in js-draw -- as of the time of this writing,
// Joplin has many common strings localized better than js-draw.
export interface LocalizedStrings {
save: string;
close: string;
undo: string;
redo: string;
}

View File

@@ -0,0 +1,61 @@
import { Editor, BackgroundComponent, EditorEventType, Vec2 } from 'js-draw';
const watchEditorForTemplateChanges = (
editor: Editor, initialTemplate: string, updateTemplate: (templateData: string)=> void,
) => {
const computeTemplate = (): string => {
let backgroundSize: Vec2|null = null;
// Only store the background size if the size isn't determined
// by the editor content. In this case, the background always
// appears to be full screen.
if (!editor.image.getAutoresizeEnabled()) {
backgroundSize = editor.getImportExportRect().size;
// Constrain the size: Don't allow an extremely small or extremely large tempalte.
// Map components to constrained components.
backgroundSize = backgroundSize.map(component => {
const minDimen = 45;
const maxDimen = 5000;
return Math.max(Math.min(component, maxDimen), minDimen);
});
}
// Find the topmost background component (if any)
let backgroundComponent: BackgroundComponent|null = null;
for (const component of editor.image.getBackgroundComponents()) {
if (component instanceof BackgroundComponent) {
backgroundComponent = component;
}
}
const templateData = {
imageSize: backgroundSize?.xy,
backgroundData: backgroundComponent?.serialize(),
autoresize: editor.image.getAutoresizeEnabled(),
};
return JSON.stringify(templateData);
};
let lastTemplate = initialTemplate;
const updateTemplateIfNecessary = () => {
const newTemplate = computeTemplate();
if (newTemplate !== lastTemplate) {
updateTemplate(newTemplate);
lastTemplate = newTemplate;
}
};
// Whenever a command is done/undone, re-calculate the template & save.
editor.notifier.on(EditorEventType.CommandDone, () => {
updateTemplateIfNecessary();
});
editor.notifier.on(EditorEventType.CommandUndone, () => {
updateTemplateIfNecessary();
});
};
export default watchEditorForTemplateChanges;

View File

@@ -0,0 +1,37 @@
import { _ } from '@joplin/lib/locale';
import shim from '@joplin/lib/shim';
import { Alert } from 'react-native';
import { clearAutosave, getAutosaveFilepath, readAutosave } from './autosave';
export type RestoreAutosaveCallback = (data: string)=> void;
const promptRestoreAutosave = async (onRestoreAutosave: RestoreAutosaveCallback) => {
const autosavePath = getAutosaveFilepath();
if (await shim.fsDriver().exists(autosavePath)) {
const title: string|null = null;
const message = _(
'An autosaved drawing was found. Attach a copy of it to the note?',
);
Alert.alert(title, message, [
{
text: _('Discard'),
onPress: async () => {
await clearAutosave();
},
},
{
text: _('Attach'),
onPress: async () => {
const autosaveData = await readAutosave();
await clearAutosave();
onRestoreAutosave(autosaveData);
},
},
]);
}
};
export default promptRestoreAutosave;

View File

@@ -14,13 +14,14 @@ import { _ } from '@joplin/lib/locale';
import time from '@joplin/lib/time';
import { useEffect } from 'react';
import { Keyboard, ViewStyle } from 'react-native';
import { EditorControl, EditorSettings, ListType, SearchState } from '../types';
import SelectionFormatting from '../SelectionFormatting';
import { EditorControl, EditorSettings } from '../types';
import { ButtonSpec, StyleSheetData } from './types';
import Toolbar from './Toolbar';
import { buttonSize } from './ToolbarButton';
import { Theme } from '@joplin/lib/themes/type';
import ToggleSpaceButton from './ToggleSpaceButton';
import { SearchState } from '@joplin/editor/types';
import SelectionFormatting from '@joplin/editor/SelectionFormatting';
type OnAttachCallback = ()=> void;
@@ -71,9 +72,7 @@ const MarkdownToolbar = (props: MarkdownToolbarProps) => {
),
description: _('Unordered list'),
active: selState.inUnorderedList,
onPress: useCallback(() => {
editorControl.toggleList(ListType.UnorderedList);
}, [editorControl]),
onPress: editorControl.toggleUnorderedList,
priority: -2,
disabled: readOnly,
@@ -85,9 +84,7 @@ const MarkdownToolbar = (props: MarkdownToolbarProps) => {
),
description: _('Ordered list'),
active: selState.inOrderedList,
onPress: useCallback(() => {
editorControl.toggleList(ListType.OrderedList);
}, [editorControl]),
onPress: editorControl.toggleOrderedList,
priority: -2,
disabled: readOnly,
@@ -99,9 +96,7 @@ const MarkdownToolbar = (props: MarkdownToolbarProps) => {
),
description: _('Task list'),
active: selState.inChecklist,
onPress: useCallback(() => {
editorControl.toggleList(ListType.CheckList);
}, [editorControl]),
onPress: editorControl.toggleTaskList,
priority: -2,
disabled: readOnly,

View File

@@ -5,29 +5,29 @@ import EditLinkDialog from './EditLinkDialog';
import { defaultSearchState, SearchPanel } from './SearchPanel';
import ExtendedWebView from '../ExtendedWebView';
const React = require('react');
import * as React from 'react';
import { forwardRef, RefObject, useImperativeHandle } from 'react';
import { useEffect, useMemo, useState, useCallback, useRef } from 'react';
import { LayoutChangeEvent, View, ViewStyle } from 'react-native';
const { editorFont } = require('../global-style');
import SelectionFormatting from './SelectionFormatting';
import {
EditorSettings, EditorControl,
ChangeEvent, UndoRedoDepthChangeEvent, Selection, SelectionChangeEvent, ListType, SearchState,
} from './types';
import { EditorControl, EditorSettings, SelectionRange } from './types';
import { _ } from '@joplin/lib/locale';
import MarkdownToolbar from './MarkdownToolbar/MarkdownToolbar';
import { ChangeEvent, EditorEvent, EditorEventType, SelectionRangeChangeEvent, UndoRedoDepthChangeEvent } from '@joplin/editor/events';
import { EditorCommandType, EditorKeymap, EditorLanguageType, PluginData, SearchState } from '@joplin/editor/types';
import supportsCommand from '@joplin/editor/CodeMirror/editorCommands/supportsCommand';
import SelectionFormatting, { defaultSelectionFormatting } from '@joplin/editor/SelectionFormatting';
type ChangeEventHandler = (event: ChangeEvent)=> void;
type UndoRedoDepthChangeHandler = (event: UndoRedoDepthChangeEvent)=> void;
type SelectionChangeEventHandler = (event: SelectionChangeEvent)=> void;
type SelectionChangeEventHandler = (event: SelectionRangeChangeEvent)=> void;
type OnAttachCallback = ()=> void;
interface Props {
themeId: number;
initialText: string;
initialSelection?: Selection;
initialSelection?: SelectionRange;
style: ViewStyle;
contentStyle?: ViewStyle;
toolbarEnabled: boolean;
@@ -110,6 +110,11 @@ function editorTheme(themeId: number) {
return {
...themeStyle(themeId),
// To allow accessibility font scaling, we also need to set the
// fontSize to a value in `em`s (relative scaling relative to
// parent font size).
fontSizeUnits: 'em',
fontSize: estimatedFontSizeInEm,
fontFamily: fontFamilyFromSettings(),
};
@@ -120,48 +125,92 @@ type OnSetVisibleCallback = (visible: boolean)=> void;
type OnSearchStateChangeCallback = (state: SearchState)=> void;
const useEditorControl = (
injectJS: OnInjectJSCallback, setLinkDialogVisible: OnSetVisibleCallback,
setSearchState: OnSearchStateChangeCallback, searchStateRef: RefObject<SearchState>,
setSearchState: OnSearchStateChangeCallback,
searchStateRef: RefObject<SearchState>,
): EditorControl => {
return useMemo(() => {
return {
const execCommand = (command: EditorCommandType) => {
injectJS(`cm.execCommand(${JSON.stringify(command)})`);
};
const setSearchStateCallback = (state: SearchState) => {
injectJS(`cm.setSearchState(${JSON.stringify(state)})`);
setSearchState(state);
};
const control: EditorControl = {
supportsCommand(command: EditorCommandType) {
return supportsCommand(command);
},
execCommand,
undo() {
injectJS('cm.undo();');
injectJS('cm.undo()');
},
redo() {
injectJS('cm.redo();');
injectJS('cm.redo()');
},
select(anchor: number, head: number) {
injectJS(
`cm.select(${JSON.stringify(anchor)}, ${JSON.stringify(head)});`,
);
},
setScrollPercent(fraction: number) {
injectJS(`cm.setScrollFraction(${JSON.stringify(fraction)})`);
},
insertText(text: string) {
injectJS(`cm.insertText(${JSON.stringify(text)});`);
},
updateBody(newBody: string) {
injectJS(`cm.updateBody(${JSON.stringify(newBody)});`);
},
updateSettings(newSettings: EditorSettings) {
injectJS(`cm.updateSettings(${JSON.stringify(newSettings)})`);
},
toggleBolded() {
injectJS('cm.toggleBolded();');
execCommand(EditorCommandType.ToggleBolded);
},
toggleItalicized() {
injectJS('cm.toggleItalicized();');
execCommand(EditorCommandType.ToggleItalicized);
},
toggleList(listType: ListType) {
injectJS(`cm.toggleList(${JSON.stringify(listType)});`);
toggleOrderedList() {
execCommand(EditorCommandType.ToggleNumberedList);
},
toggleUnorderedList() {
execCommand(EditorCommandType.ToggleBulletedList);
},
toggleTaskList() {
execCommand(EditorCommandType.ToggleCheckList);
},
toggleCode() {
injectJS('cm.toggleCode();');
execCommand(EditorCommandType.ToggleCode);
},
toggleMath() {
injectJS('cm.toggleMath();');
execCommand(EditorCommandType.ToggleMath);
},
toggleHeaderLevel(level: number) {
injectJS(`cm.toggleHeaderLevel(${level});`);
const levelToCommand = [
EditorCommandType.ToggleHeading1,
EditorCommandType.ToggleHeading2,
EditorCommandType.ToggleHeading3,
EditorCommandType.ToggleHeading4,
EditorCommandType.ToggleHeading5,
];
const index = level - 1;
if (index < 0 || index >= levelToCommand.length) {
throw new Error(`Unsupported header level ${level}`);
}
execCommand(levelToCommand[index]);
},
increaseIndent() {
injectJS('cm.increaseIndent();');
execCommand(EditorCommandType.IndentMore);
},
decreaseIndent() {
injectJS('cm.decreaseIndent();');
execCommand(EditorCommandType.IndentLess);
},
updateLink(label: string, url: string) {
injectJS(`cm.updateLink(
@@ -170,7 +219,7 @@ const useEditorControl = (
);`);
},
scrollSelectionIntoView() {
injectJS('cm.scrollSelectionIntoView();');
execCommand(EditorCommandType.ScrollSelectionIntoView);
},
showLinkDialog() {
setLinkDialogVisible(true);
@@ -181,23 +230,27 @@ const useEditorControl = (
hideKeyboard() {
injectJS('document.activeElement?.blur();');
},
setPlugins: async (plugins: PluginData[]) => {
injectJS(`cm.setPlugins(${JSON.stringify(plugins)});`);
},
setSearchState: setSearchStateCallback,
searchControl: {
findNext() {
injectJS('cm.searchControl.findNext();');
execCommand(EditorCommandType.FindNext);
},
findPrevious() {
injectJS('cm.searchControl.findPrevious();');
execCommand(EditorCommandType.FindPrevious);
},
replaceCurrent() {
injectJS('cm.searchControl.replaceCurrent();');
replaceNext() {
execCommand(EditorCommandType.ReplaceNext);
},
replaceAll() {
injectJS('cm.searchControl.replaceAll();');
},
setSearchState(state: SearchState) {
injectJS(`cm.searchControl.setSearchState(${JSON.stringify(state)})`);
setSearchState(state);
execCommand(EditorCommandType.ReplaceAll);
},
showSearch() {
setSearchState({
...searchStateRef.current,
@@ -210,8 +263,12 @@ const useEditorControl = (
dialogVisible: false,
});
},
setSearchState: setSearchStateCallback,
},
};
return control;
}, [injectJS, searchStateRef, setLinkDialogVisible, setSearchState]);
};
@@ -227,7 +284,16 @@ function NoteEditor(props: Props, ref: any) {
themeData: editorTheme(props.themeId),
katexEnabled: Setting.value('markdown.plugin.katex'),
spellcheckEnabled: Setting.value('editor.mobile.spellcheckEnabled'),
language: EditorLanguageType.Markdown,
useExternalSearch: true,
readOnly: props.readOnly,
keymap: EditorKeymap.Default,
automatchBraces: false,
ignoreModifiers: false,
indentWithTabs: false,
};
const injectedJavaScript = `
@@ -252,6 +318,12 @@ function NoteEditor(props: Props, ref: any) {
);
};
window.onunhandledrejection = (event) => {
window.ReactNativeWebView.postMessage(
"error: Unhandled promise rejection: " + event
);
};
if (!window.cm) {
// This variable is not used within this script
// but is called using "injectJavaScript" from
@@ -269,7 +341,7 @@ function NoteEditor(props: Props, ref: any) {
${setInitialSelectionJS}
window.onresize = () => {
cm.scrollSelectionIntoView();
cm.execCommand('scrollSelectionIntoView');
};
} catch (e) {
window.ReactNativeWebView.postMessage("error:" + e.message + ": " + JSON.stringify(e))
@@ -280,7 +352,7 @@ function NoteEditor(props: Props, ref: any) {
const css = useCss(props.themeId);
const html = useHtml(css);
const [selectionState, setSelectionState] = useState(new SelectionFormatting());
const [selectionState, setSelectionState] = useState<SelectionFormatting>(defaultSelectionFormatting);
const [linkDialogVisible, setLinkDialogVisible] = useState(false);
const [searchState, setSearchState] = useState(defaultSearchState);
@@ -293,7 +365,7 @@ function NoteEditor(props: Props, ref: any) {
searchStateRef.current = searchState;
}, [searchState]);
// / Runs [js] in the context of the CodeMirror frame.
// Runs [js] in the context of the CodeMirror frame.
const injectJS = (js: string) => {
webviewRef.current.injectJS(js);
};
@@ -323,36 +395,41 @@ function NoteEditor(props: Props, ref: any) {
console.info('CodeMirror:', ...event.value);
},
onChange: (event: ChangeEvent) => {
props.onChange(event);
},
onEditorEvent: (event: EditorEvent) => {
let exhaustivenessCheck: never;
switch (event.kind) {
case EditorEventType.Change:
props.onChange(event);
break;
case EditorEventType.UndoRedoDepthChange:
props.onUndoRedoDepthChange(event);
break;
case EditorEventType.SelectionRangeChange:
props.onSelectionChange(event);
break;
case EditorEventType.SelectionFormattingChange:
setSelectionState(event.formatting);
break;
case EditorEventType.EditLink:
editorControl.showLinkDialog();
break;
case EditorEventType.UpdateSearchDialog:
setSearchState(event.searchState);
onUndoRedoDepthChange: (event: UndoRedoDepthChangeEvent) => {
props.onUndoRedoDepthChange(event);
},
onSelectionChange: (event: SelectionChangeEvent) => {
props.onSelectionChange(event);
},
onSelectionFormattingChange(data: string) {
// We want a SelectionFormatting object, so are
// instantiating it from JSON.
const formatting = SelectionFormatting.fromJSON(data);
setSelectionState(formatting);
},
onRequestLinkEdit() {
editorControl.showLinkDialog();
},
onRequestShowSearch(data: SearchState) {
setSearchState(data);
editorControl.searchControl.showSearch();
},
onRequestHideSearch() {
editorControl.searchControl.hideSearch();
if (event.searchState.dialogVisible) {
editorControl.searchControl.showSearch();
} else {
editorControl.searchControl.hideSearch();
}
break;
case EditorEventType.Scroll:
// Not handled
break;
default:
exhaustivenessCheck = event;
return exhaustivenessCheck;
}
return;
},
};

View File

@@ -4,11 +4,13 @@ const React = require('react');
const { useMemo, useState, useEffect } = require('react');
const MaterialCommunityIcon = require('react-native-vector-icons/MaterialCommunityIcons').default;
import { SearchControl, SearchState, EditorSettings } from './types';
import { EditorSettings } from './types';
import { _ } from '@joplin/lib/locale';
import { BackHandler, TextInput, View, Text, StyleSheet, ViewStyle } from 'react-native';
import { Theme } from '@joplin/lib/themes/type';
import CustomButton from '../CustomButton';
import { SearchState } from '@joplin/editor/types';
import { SearchControl } from './types';
const buttonSize = 48;
@@ -284,7 +286,7 @@ export const SearchPanel = (props: SearchPanelProps) => {
themeId={themeId}
styles={styles}
iconName="swap-horizontal"
onPress={control.replaceCurrent}
onPress={control.replaceNext}
title={_('Replace')}
/>
);

View File

@@ -1,98 +0,0 @@
// Stores information about the current content of the user's selection
export default class SelectionFormatting {
public bolded = false;
public italicized = false;
public inChecklist = false;
public inCode = false;
public inUnorderedList = false;
public inOrderedList = false;
public inMath = false;
public inLink = false;
public spellChecking = false;
public unspellCheckableRegion = false;
// Link data, both fields are null if not in a link.
public linkData: { linkText?: string; linkURL?: string } = {
linkText: null,
linkURL: null,
};
// If [headerLevel], [listLevel], etc. are zero, then the
// selection isn't in a header/list
public headerLevel = 0;
public listLevel = 0;
// Content of the selection
public selectedText = '';
// List of data properties (for serializing/deseralizing)
private static propNames: string[] = [
'bolded', 'italicized', 'inChecklist', 'inCode',
'inUnorderedList', 'inOrderedList', 'inMath',
'inLink', 'linkData',
'headerLevel', 'listLevel',
'selectedText',
'spellChecking',
'unspellCheckableRegion',
];
// Returns true iff [this] is equivalent to [other]
public eq(other: SelectionFormatting): boolean {
// Cast to Records to allow usage of the indexing ([])
// operator.
const selfAsRec = this as Record<string, any>;
const otherAsRec = other as Record<string, any>;
for (const prop of SelectionFormatting.propNames) {
if (selfAsRec[prop] !== otherAsRec[prop]) {
return false;
}
}
return true;
}
public static fromJSON(json: string): SelectionFormatting {
const result = new SelectionFormatting();
// Casting result to a Record<string, any> lets us use
// the indexing [] operator.
const resultRecord = result as Record<string, any>;
const obj = JSON.parse(json) as Record<string, any>;
for (const prop of SelectionFormatting.propNames) {
if (obj[prop] !== undefined) {
// Type checking!
if (typeof obj[prop] !== typeof resultRecord[prop]) {
throw new Error([
'Deserialization Error:',
`${obj[prop]} and ${resultRecord[prop]}`,
'have different types.',
].join(' '));
}
resultRecord[prop] = obj[prop];
}
}
return result;
}
public toJSON(): string {
const resultObj: Record<string, any> = {};
// Cast this to a dictionary. This allows us to use
// the indexing [] operator.
const selfAsRecord = this as Record<string, any>;
for (const prop of SelectionFormatting.propNames) {
resultObj[prop] = selfAsRecord[prop];
}
return JSON.stringify(resultObj);
}
}

View File

@@ -1,69 +1,53 @@
// Types related to the NoteEditor
import { Theme } from '@joplin/lib/themes/type';
import { CodeMirrorControl } from './CodeMirror/types';
// Controls for the entire editor (including dialogs)
export interface EditorControl extends CodeMirrorControl {
showLinkDialog(): void;
hideLinkDialog(): void;
hideKeyboard(): void;
}
export interface EditorSettings {
// EditorSettings objects are deserialized within WebViews, where
// [themeStyle(themeId: number)] doesn't work. As such, we need both
// the [themeId] and [themeData].
themeId: number;
themeData: Theme;
katexEnabled: boolean;
spellcheckEnabled: boolean;
readOnly: boolean;
}
export interface ChangeEvent {
// New editor content
value: string;
}
export interface UndoRedoDepthChangeEvent {
undoDepth: number;
redoDepth: number;
}
export interface Selection {
start: number;
end: number;
}
export interface SelectionChangeEvent {
selection: Selection;
}
import { EditorControl as EditorBodyControl, EditorSettings as EditorBodySettings, SearchState } from '@joplin/editor/types';
export interface SearchControl {
findNext(): void;
findPrevious(): void;
replaceCurrent(): void;
replaceNext(): void;
replaceAll(): void;
setSearchState(state: SearchState): void;
showSearch(): void;
hideSearch(): void;
setSearchState(state: SearchState): void;
}
export interface SearchState {
useRegex: boolean;
caseSensitive: boolean;
// Controls for the entire editor (including dialogs)
export interface EditorControl extends EditorBodyControl {
showLinkDialog(): void;
hideLinkDialog(): void;
hideKeyboard(): void;
searchText: string;
replaceText: string;
dialogVisible: boolean;
// Additional shortcut commands (equivalent to .execCommand
// with the corresponding type).
// This reduces the need for useCallbacks in many cases.
undo(): void;
redo(): void;
increaseIndent(): void;
decreaseIndent(): void;
toggleBolded(): void;
toggleItalicized(): void;
toggleCode(): void;
toggleMath(): void;
toggleOrderedList(): void;
toggleUnorderedList(): void;
toggleTaskList(): void;
toggleHeaderLevel(level: number): void;
scrollSelectionIntoView(): void;
showLinkDialog(): void;
hideLinkDialog(): void;
hideKeyboard(): void;
searchControl: SearchControl;
}
// Possible types of lists in the editor
export enum ListType {
CheckList,
OrderedList,
UnorderedList,
export interface EditorSettings extends EditorBodySettings {
themeId: number;
}
export interface SelectionRange {
start: number;
end: number;
}

View File

@@ -308,7 +308,7 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
accessibilityHint={_('Show/hide the sidebar')}
accessibilityRole="button">
<View style={styles.sideMenuButton}>
<Icon name="md-menu" style={styles.topIcon} />
<Icon name="menu" style={styles.topIcon} />
</View>
</TouchableOpacity>
);
@@ -324,7 +324,7 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
accessibilityRole="button">
<View style={disabled ? styles.backButtonDisabled : styles.backButton}>
<Icon
name="md-arrow-back"
name="arrow-back"
style={styles.topIcon}
/>
</View>
@@ -337,7 +337,7 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
) {
if (!show) return null;
const icon = disabled ? <Icon name="md-checkmark" style={styles.savedButtonIcon} /> : <Image style={styles.saveButtonIcon} source={require('./SaveIcon.png')} />;
const icon = disabled ? <Icon name="checkmark" style={styles.savedButtonIcon} /> : <Image style={styles.saveButtonIcon} source={require('./SaveIcon.png')} />;
return (
<TouchableOpacity
@@ -407,7 +407,7 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
description={_('Select all')}
contentStyle={styles.iconButton}
>
<Icon name="md-checkmark-circle-outline" style={styles.topIcon} />
<Icon name="checkmark-circle-outline" style={styles.topIcon} />
</CustomButton>
);
}
@@ -421,7 +421,7 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
themeId={themeId}
contentStyle={styles.iconButton}
>
<Icon name="md-search" style={styles.topIcon} />
<Icon name="search" style={styles.topIcon} />
</CustomButton>
);
}
@@ -439,7 +439,7 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
}
contentStyle={disabled ? styles.iconButtonDisabled : styles.iconButton}
>
<Icon name="md-trash" style={styles.topIcon} />
<Icon name="trash" style={styles.topIcon} />
</CustomButton>
);
}
@@ -457,7 +457,7 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
}
contentStyle={disabled ? styles.iconButtonDisabled : styles.iconButton}
>
<Icon name="md-copy" style={styles.topIcon} />
<Icon name="copy" style={styles.topIcon} />
</CustomButton>
);
}
@@ -602,7 +602,7 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
<Menu onSelect={value => this.menu_select(value)} style={this.styles().contextMenu}>
<MenuTrigger style={contextMenuStyle}>
<View accessibilityLabel={_('Actions')}>
<Icon name="md-ellipsis-vertical" style={this.styles().contextMenuTrigger} />
<Icon name="ellipsis-vertical" style={this.styles().contextMenuTrigger} />
</View>
</MenuTrigger>
<MenuOptions>

View File

@@ -40,7 +40,7 @@ class Checkbox extends Component {
}
render() {
const iconName = this.state.checked ? 'md-checkbox-outline' : 'md-square-outline';
const iconName = this.state.checked ? 'checkbox-outline' : 'square-outline';
const style = this.props.style ? { ...this.props.style } : {};
style.justifyContent = 'center';

View File

@@ -0,0 +1,195 @@
import * as React from 'react';
import { FlatList, View, Text, Button, StyleSheet, Platform, Alert } from 'react-native';
import { connect } from 'react-redux';
import { reg } from '@joplin/lib/registry.js';
import { ScreenHeader } from '../ScreenHeader';
import time from '@joplin/lib/time';
const { themeStyle } = require('../global-style.js');
import Logger from '@joplin/utils/Logger';
const { BaseScreenComponent } = require('../base-screen.js');
import { _ } from '@joplin/lib/locale';
import { MenuOptionType } from '../ScreenHeader';
import { AppState } from '../../utils/types';
import Share from 'react-native-share';
import { writeTextToCacheFile } from '../../utils/ShareUtils';
import shim from '@joplin/lib/shim';
const logger = Logger.create('LogScreen');
class LogScreenComponent extends BaseScreenComponent {
private readonly menuOptions: MenuOptionType[];
public static navigationOptions(): any {
return { header: null };
}
public constructor() {
super();
this.state = {
logEntries: [],
showErrorsOnly: false,
};
this.styles_ = {};
this.menuOptions = [
{
title: _('Share'),
onPress: () => {
void this.onSharePress();
},
},
];
}
private async onSharePress() {
const limit: number|null = null; // no limit
const levels = this.getLogLevels(this.state.showErrorsOnly);
const allEntries: any[] = await reg.logger().lastEntries(limit, { levels });
const logData = allEntries.map(entry => this.formatLogEntry(entry)).join('\n');
let fileToShare;
try {
// Using a .txt file extension causes a "No valid provider found from URL" error
// and blank share sheet on iOS for larger log files (around 200 KiB).
fileToShare = await writeTextToCacheFile(logData, 'mobile-log.log');
await Share.open({
type: 'text/plain',
filename: 'log.txt',
url: `file://${fileToShare}`,
failOnCancel: false,
});
} catch (e) {
logger.error('Unable to share log data:', e);
// Display a message to the user (e.g. in the case where the user is out of disk space).
Alert.alert(_('Error'), _('Unable to share log data. Reason: %s', e.toString()));
} finally {
if (fileToShare) {
await shim.fsDriver().remove(fileToShare);
}
}
}
public styles() {
const theme = themeStyle(this.props.themeId);
if (this.styles_[this.props.themeId]) return this.styles_[this.props.themeId];
this.styles_ = {};
const styles: any = {
row: {
flexDirection: 'row',
paddingLeft: 1,
paddingRight: 1,
paddingTop: 0,
paddingBottom: 0,
},
rowText: {
fontSize: 10,
color: theme.color,
},
};
if (Platform.OS !== 'ios') {
// Crashes on iOS with error "Unrecognized font family 'monospace'"
styles.rowText.fontFamily = 'monospace';
}
styles.rowTextError = { ...styles.rowText };
styles.rowTextError.color = theme.colorError;
styles.rowTextWarn = { ...styles.rowText };
styles.rowTextWarn.color = theme.colorWarn;
this.styles_[this.props.themeId] = StyleSheet.create(styles);
return this.styles_[this.props.themeId];
}
public UNSAFE_componentWillMount() {
void this.resfreshLogEntries();
}
private getLogLevels(showErrorsOnly: boolean) {
let levels = [Logger.LEVEL_DEBUG, Logger.LEVEL_INFO, Logger.LEVEL_WARN, Logger.LEVEL_ERROR];
if (showErrorsOnly) levels = [Logger.LEVEL_WARN, Logger.LEVEL_ERROR];
return levels;
}
private async resfreshLogEntries(showErrorsOnly: boolean = null) {
if (showErrorsOnly === null) showErrorsOnly = this.state.showErrorsOnly;
const levels = this.getLogLevels(showErrorsOnly);
this.setState({
logEntries: await reg.logger().lastEntries(1000, { levels: levels }),
showErrorsOnly: showErrorsOnly,
});
}
private toggleErrorsOnly() {
void this.resfreshLogEntries(!this.state.showErrorsOnly);
}
private formatLogEntry(item: any) {
return `${time.formatMsToLocal(item.timestamp, 'MM-DDTHH:mm:ss')}: ${item.message}`;
}
public render() {
const renderRow = ({ item }: any) => {
let textStyle = this.styles().rowText;
if (item.level === Logger.LEVEL_WARN) textStyle = this.styles().rowTextWarn;
if (item.level === Logger.LEVEL_ERROR) textStyle = this.styles().rowTextError;
return (
<View style={this.styles().row}>
<Text style={textStyle}>{this.formatLogEntry(item)}</Text>
</View>
);
};
// `enableEmptySections` is to fix this warning: https://github.com/FaridSafi/react-native-gifted-listview/issues/39
return (
<View style={this.rootStyle(this.props.themeId).root}>
<ScreenHeader
title={_('Log')}
menuOptions={this.menuOptions}/>
<FlatList
data={this.state.logEntries}
renderItem={renderRow}
keyExtractor={item => { return `${item.id}`; }}
/>
<View style={{ flexDirection: 'row' }}>
<View style={{ flex: 1, marginRight: 5 }}>
<Button
title={_('Refresh')}
onPress={() => {
void this.resfreshLogEntries();
}}
/>
</View>
<View style={{ flex: 1 }}>
<Button
title={this.state.showErrorsOnly ? _('Show all') : _('Errors only')}
onPress={() => {
this.toggleErrorsOnly();
}}
/>
</View>
</View>
</View>
);
}
}
const LogScreen = connect((state: AppState) => {
return {
themeId: state.settings.theme,
};
})(LogScreenComponent as any);
export default LogScreen;

View File

@@ -6,7 +6,6 @@ import UndoRedoService from '@joplin/lib/services/UndoRedoService';
import NoteBodyViewer from '../NoteBodyViewer/NoteBodyViewer';
import checkPermissions from '../../utils/checkPermissions';
import NoteEditor from '../NoteEditor/NoteEditor';
import { ChangeEvent, UndoRedoDepthChangeEvent } from '../NoteEditor/types';
const FileViewer = require('react-native-file-viewer').default;
const React = require('react');
@@ -43,11 +42,15 @@ import { ImagePickerResponse, launchImageLibrary } from 'react-native-image-pick
import SelectDateTimeDialog from '../SelectDateTimeDialog';
import ShareExtension from '../../utils/ShareExtension.js';
import CameraView from '../CameraView';
import { NoteEntity } from '@joplin/lib/services/database/types';
import { NoteEntity, ResourceEntity } from '@joplin/lib/services/database/types';
import Logger from '@joplin/utils/Logger';
import ImageEditor from '../NoteEditor/ImageEditor/ImageEditor';
import promptRestoreAutosave from '../NoteEditor/ImageEditor/promptRestoreAutosave';
import isEditableResource from '../NoteEditor/ImageEditor/isEditableResource';
import VoiceTypingDialog from '../voiceTyping/VoiceTypingDialog';
import { voskEnabled } from '../../services/voiceTyping/vosk';
import { isSupportedLanguage } from '../../services/voiceTyping/vosk.android';
import { ChangeEvent as EditorChangeEvent, UndoRedoDepthChangeEvent } from '@joplin/editor/events';
const urlUtils = require('@joplin/lib/urlUtils');
// import Vosk from 'react-native-vosk';
@@ -76,6 +79,9 @@ class NoteScreenComponent extends BaseScreenComponent {
noteTagDialogShown: false,
fromShare: false,
showCamera: false,
showImageEditor: false,
loadImageEditorData: null,
imageEditorResource: null,
noteResources: {},
// HACK: For reasons I can't explain, when the WebView is present, the TextInput initially does not display (It's just a white rectangle with
@@ -195,8 +201,8 @@ class NoteScreenComponent extends BaseScreenComponent {
}, 5);
} else if (item.type_ === BaseModel.TYPE_RESOURCE) {
if (!(await Resource.isReady(item))) throw new Error(_('This attachment is not downloaded or not decrypted yet.'));
const resourcePath = Resource.fullPath(item);
const resourcePath = Resource.fullPath(item);
logger.info(`Opening resource: ${resourcePath}`);
await FileViewer.open(resourcePath);
} else {
@@ -255,7 +261,7 @@ class NoteScreenComponent extends BaseScreenComponent {
return this.props.useEditorBeta;
}
private onBodyChange(event: ChangeEvent) {
private onBodyChange(event: EditorChangeEvent) {
shared.noteComponent_change(this, 'body', event.value);
this.scheduleSave();
}
@@ -451,7 +457,7 @@ class NoteScreenComponent extends BaseScreenComponent {
void ResourceFetcher.instance().markForDownload(event.resourceId);
}
public componentDidUpdate(prevProps: any) {
public componentDidUpdate(prevProps: any, prevState: any) {
if (this.doFocusUpdate_) {
this.doFocusUpdate_ = false;
this.focusUpdate();
@@ -463,6 +469,22 @@ class NoteScreenComponent extends BaseScreenComponent {
options: this.sideMenuOptions(),
});
}
if (prevState.isLoading !== this.state.isLoading && !this.state.isLoading) {
// If there's autosave data, prompt the user to restore it.
void promptRestoreAutosave((drawingData: string) => {
void this.attachNewDrawing(drawingData);
});
}
// Disable opening/closing the side menu with touch gestures
// when the image editor is open.
if (prevState.showImageEditor !== this.state.showImageEditor) {
this.props.dispatch({
type: 'SET_SIDE_MENU_TOUCH_GESTURES_DISABLED',
disableSideMenuGestures: this.state.showImageEditor,
});
}
}
public componentWillUnmount() {
@@ -632,10 +654,10 @@ class NoteScreenComponent extends BaseScreenComponent {
return await saveOriginalImage();
}
public async attachFile(pickerResponse: any, fileType: string) {
public async attachFile(pickerResponse: any, fileType: string): Promise<ResourceEntity|null> {
if (!pickerResponse) {
// User has cancelled
return;
return null;
}
const localFilePath = Platform.select({
@@ -662,7 +684,7 @@ class NoteScreenComponent extends BaseScreenComponent {
reg.logger().info(`Got file: ${localFilePath}`);
reg.logger().info(`Got type: ${mimeType}`);
let resource = Resource.new();
let resource: ResourceEntity = Resource.new();
resource.id = uuid.create();
resource.mime = mimeType;
resource.title = pickerResponse.name ? pickerResponse.name : '';
@@ -675,11 +697,11 @@ class NoteScreenComponent extends BaseScreenComponent {
try {
if (mimeType === 'image/jpeg' || mimeType === 'image/jpg' || mimeType === 'image/png') {
const done = await this.resizeImage(localFilePath, targetPath, mimeType);
if (!done) return;
if (!done) return null;
} else {
if (fileType === 'image') {
if (fileType === 'image' && mimeType !== 'image/svg+xml') {
dialogs.error(this, _('Unsupported image type: %s', mimeType));
return;
return null;
} else {
await shim.fsDriver().copy(localFilePath, targetPath);
const stat = await shim.fsDriver().stat(targetPath);
@@ -693,7 +715,7 @@ class NoteScreenComponent extends BaseScreenComponent {
} catch (error) {
reg.logger().warn('Could not attach file:', error);
await dialogs.error(this, error.message);
return;
return null;
}
const itDoes = await shim.fsDriver().waitTillExists(targetPath);
@@ -735,6 +757,8 @@ class NoteScreenComponent extends BaseScreenComponent {
this.refreshResource(resource, newNote.body);
this.scheduleSave();
return resource;
}
private async attachPhoto_onPress() {
@@ -776,6 +800,82 @@ class NoteScreenComponent extends BaseScreenComponent {
this.setState({ showCamera: false });
}
private async attachNewDrawing(svgData: string) {
const filePath = `${Setting.value('resourceDir')}/saved-drawing.joplin.svg`;
await shim.fsDriver().writeFile(filePath, svgData, 'utf8');
logger.info('Saved new drawing to', filePath);
return await this.attachFile({
uri: filePath,
name: _('Drawing'),
}, 'image');
}
private drawPicture_onPress = async () => {
// Create a new empty drawing and attach it now.
const resource = await this.attachNewDrawing('');
this.setState({
showImageEditor: true,
loadImageEditorData: null,
imageEditorResource: resource,
});
};
private async updateDrawing(svgData: string) {
let resource: ResourceEntity|null = this.state.imageEditorResource;
if (!resource) {
throw new Error('No resource is loaded in the editor');
}
const resourcePath = Resource.fullPath(resource);
const filePath = resourcePath;
await shim.fsDriver().writeFile(filePath, svgData, 'utf8');
logger.info('Saved drawing to', filePath);
resource = await Resource.save(resource, { isNew: false });
await this.refreshResource(resource);
}
private onSaveDrawing = async (svgData: string) => {
await this.updateDrawing(svgData);
};
private onCloseDrawing = () => {
this.setState({ showImageEditor: false });
};
private async editDrawing(item: BaseItem) {
const filePath = Resource.fullPath(item);
this.setState({
showImageEditor: true,
loadImageEditorData: async () => {
return await shim.fsDriver().readFile(filePath);
},
imageEditorResource: item,
});
}
private onEditResource = async (message: string) => {
const messageData = /^edit:(.*)$/.exec(message);
if (!messageData) {
throw new Error('onEditResource: Error: Invalid message');
}
const resourceId = messageData[1];
const resource = await BaseItem.loadItemById(resourceId);
await Resource.requireIsReady(resource);
if (isEditableResource(resource.mime)) {
await this.editDrawing(resource);
} else {
throw new Error(_('Unable to edit resource of type %s', resource.mime));
}
};
private async attachFile_onPress() {
const response = await this.pickDocuments();
for (const asset of response) {
@@ -1007,6 +1107,12 @@ class NoteScreenComponent extends BaseScreenComponent {
});
}
output.push({
title: _('Draw picture'),
onPress: () => this.drawPicture_onPress(),
disabled: readOnly,
});
if (isTodo) {
output.push({
title: _('Set alarm'),
@@ -1194,6 +1300,13 @@ class NoteScreenComponent extends BaseScreenComponent {
if (this.state.showCamera) {
return <CameraView themeId={this.props.themeId} style={{ flex: 1 }} onPhoto={this.cameraView_onPhoto} onCancel={this.cameraView_onCancel} />;
} else if (this.state.showImageEditor) {
return <ImageEditor
loadInitialSVGData={this.state.loadImageEditorData}
themeId={this.props.themeId}
onSave={this.onSaveDrawing}
onExit={this.onCloseDrawing}
/>;
}
// Currently keyword highlighting is supported only when FTS is available.
@@ -1219,6 +1332,7 @@ class NoteScreenComponent extends BaseScreenComponent {
noteHash={this.props.noteHash}
onCheckboxChange={this.onBodyViewerCheckboxChange}
onMarkForDownload={this.onMarkForDownload}
onRequestEditResource={this.onEditResource}
onLoadEnd={this.onBodyViewerLoadEnd}
/>
);
@@ -1292,7 +1406,7 @@ class NoteScreenComponent extends BaseScreenComponent {
const editButton = {
label: _('Edit'),
icon: 'md-create',
icon: 'create',
onPress: () => {
this.setState({ mode: 'edit' });

View File

@@ -58,7 +58,7 @@ class NoteTagsDialogComponent extends React.Component {
this.renderTag = data => {
const tag = data.item;
const iconName = noteHasTag(tag.id) ? 'md-checkbox-outline' : 'md-square-outline';
const iconName = noteHasTag(tag.id) ? 'checkbox-outline' : 'square-outline';
return (
<TouchableOpacity key={tag.id} onPress={() => this.tag_press(tag.id)} style={this.styles().tag}>
<View style={this.styles().tagIconText}>

View File

@@ -236,7 +236,7 @@ class NotesScreenComponent extends BaseScreenComponent<any> {
void this.newNoteNavigate(buttonFolderId, isTodo);
},
color: '#9b59b6',
icon: 'md-checkbox-outline',
icon: 'checkbox-outline',
});
buttons.push({
@@ -246,7 +246,7 @@ class NotesScreenComponent extends BaseScreenComponent<any> {
void this.newNoteNavigate(buttonFolderId, isTodo);
},
color: '#9b59b6',
icon: 'md-document',
icon: 'document',
});
return <ActionButton buttons={buttons}/>;
}

View File

@@ -1,135 +0,0 @@
const React = require('react');
const { FlatList, View, Text, Button, StyleSheet, Platform } = require('react-native');
const { connect } = require('react-redux');
const { reg } = require('@joplin/lib/registry.js');
const { ScreenHeader } = require('../ScreenHeader');
const time = require('@joplin/lib/time').default;
const { themeStyle } = require('../global-style.js');
const Logger = require('@joplin/utils/Logger').default;
const { BaseScreenComponent } = require('../base-screen.js');
const { _ } = require('@joplin/lib/locale');
class LogScreenComponent extends BaseScreenComponent {
static navigationOptions() {
return { header: null };
}
constructor() {
super();
this.state = {
logEntries: [],
showErrorsOnly: false,
};
this.styles_ = {};
}
styles() {
const theme = themeStyle(this.props.themeId);
if (this.styles_[this.props.themeId]) return this.styles_[this.props.themeId];
this.styles_ = {};
const styles = {
row: {
flexDirection: 'row',
paddingLeft: 1,
paddingRight: 1,
paddingTop: 0,
paddingBottom: 0,
},
rowText: {
fontSize: 10,
color: theme.color,
},
};
if (Platform.OS !== 'ios') {
// Crashes on iOS with error "Unrecognized font family 'monospace'"
styles.rowText.fontFamily = 'monospace';
}
styles.rowTextError = { ...styles.rowText };
styles.rowTextError.color = theme.colorError;
styles.rowTextWarn = { ...styles.rowText };
styles.rowTextWarn.color = theme.colorWarn;
this.styles_[this.props.themeId] = StyleSheet.create(styles);
return this.styles_[this.props.themeId];
}
UNSAFE_componentWillMount() {
this.resfreshLogEntries();
}
async resfreshLogEntries(showErrorsOnly = null) {
if (showErrorsOnly === null) showErrorsOnly = this.state.showErrorsOnly;
let levels = [Logger.LEVEL_DEBUG, Logger.LEVEL_INFO, Logger.LEVEL_WARN, Logger.LEVEL_ERROR];
if (showErrorsOnly) levels = [Logger.LEVEL_WARN, Logger.LEVEL_ERROR];
this.setState({
logEntries: await reg.logger().lastEntries(1000, { levels: levels }),
showErrorsOnly: showErrorsOnly,
});
}
toggleErrorsOnly() {
this.resfreshLogEntries(!this.state.showErrorsOnly);
}
render() {
const renderRow = ({ item }) => {
let textStyle = this.styles().rowText;
if (item.level === Logger.LEVEL_WARN) textStyle = this.styles().rowTextWarn;
if (item.level === Logger.LEVEL_ERROR) textStyle = this.styles().rowTextError;
return (
<View style={this.styles().row}>
<Text style={textStyle}>{`${time.formatMsToLocal(item.timestamp, 'MM-DDTHH:mm:ss')}: ${item.message}`}</Text>
</View>
);
};
// `enableEmptySections` is to fix this warning: https://github.com/FaridSafi/react-native-gifted-listview/issues/39
return (
<View style={this.rootStyle(this.props.themeId).root}>
<ScreenHeader title={_('Log')} />
<FlatList
data={this.state.logEntries}
renderItem={renderRow}
keyExtractor={item => { return `${item.id}`; }}
/>
<View style={{ flexDirection: 'row' }}>
<View style={{ flex: 1, marginRight: 5 }}>
<Button
title={_('Refresh')}
onPress={() => {
this.resfreshLogEntries();
}}
/>
</View>
<View style={{ flex: 1 }}>
<Button
title={this.state.showErrorsOnly ? _('Show all') : _('Errors only')}
onPress={() => {
this.toggleErrorsOnly();
}}
/>
</View>
</View>
</View>
);
}
}
const LogScreen = connect(state => {
return {
themeId: state.settings.theme,
};
})(LogScreenComponent);
module.exports = { LogScreen };

View File

@@ -6,7 +6,6 @@ import ScreenHeader from '../ScreenHeader';
const Icon = require('react-native-vector-icons/Ionicons').default;
import { _ } from '@joplin/lib/locale';
import Note from '@joplin/lib/models/Note';
import gotoAnythingStyleQuery from '@joplin/lib/services/searchengine/gotoAnythingStyleQuery';
const { NoteItem } = require('../note-item.js');
const { BaseScreenComponent } = require('../base-screen.js');
const { themeStyle } = require('../global-style.js');
@@ -95,8 +94,6 @@ class SearchScreenComponent extends BaseScreenComponent {
public async refreshSearch(query: string = null) {
if (!this.props.visible) return;
query = gotoAnythingStyleQuery(query);
let notes = [];
if (query) {
@@ -193,7 +190,7 @@ class SearchScreenComponent extends BaseScreenComponent {
onPress={() => this.clearButton_press()}
accessibilityLabel={_('Clear')}
>
<Icon name="md-close-circle" style={this.styles().clearIcon} />
<Icon name="close-circle" style={this.styles().clearIcon} />
</TouchableHighlight>
</View>

View File

@@ -101,7 +101,7 @@ const SideMenuContentComponent = (props: Props) => {
styles.sideButtonSelected = { ...styles.sideButton, backgroundColor: theme.selectedColor };
styles.sideButtonText = { ...styles.buttonText };
styles.emptyFolderIcon = { ...styles.sidebarIcon, marginRight: folderIconRightMargin, width: 20 };
styles.emptyFolderIcon = { ...styles.sidebarIcon, marginRight: folderIconRightMargin, width: 21 };
return StyleSheet.create(styles);
}, [props.themeId]);
@@ -421,15 +421,15 @@ const SideMenuContentComponent = (props: Props) => {
items.push(makeDivider('divider_1'));
items.push(renderSidebarButton('newFolder_button', _('New Notebook'), 'md-folder-open', newFolderButton_press));
items.push(renderSidebarButton('newFolder_button', _('New Notebook'), 'folder-open', newFolderButton_press));
items.push(renderSidebarButton('tag_button', _('Tags'), 'md-pricetag', tagButton_press));
items.push(renderSidebarButton('tag_button', _('Tags'), 'pricetag', tagButton_press));
if (props.profileConfig && props.profileConfig.profiles.length > 1) {
items.push(renderSidebarButton('switchProfile_button', _('Switch profile'), 'md-people-circle-outline', switchProfileButton_press));
items.push(renderSidebarButton('switchProfile_button', _('Switch profile'), 'people-circle-outline', switchProfileButton_press));
}
items.push(renderSidebarButton('config_button', _('Configuration'), 'md-settings', configButton_press));
items.push(renderSidebarButton('config_button', _('Configuration'), 'settings', configButton_press));
items.push(makeDivider('divider_2'));
@@ -451,7 +451,7 @@ const SideMenuContentComponent = (props: Props) => {
if (resourceFetcherText) fullReport.push(resourceFetcherText);
if (decryptionReportText) fullReport.push(decryptionReportText);
items.push(renderSidebarButton('synchronize_button', !props.syncStarted ? _('Synchronise') : _('Cancel'), 'md-sync', synchronize_press));
items.push(renderSidebarButton('synchronize_button', !props.syncStarted ? _('Synchronise') : _('Cancel'), 'sync', synchronize_press));
if (fullReport.length) {
items.push(
@@ -480,11 +480,11 @@ const SideMenuContentComponent = (props: Props) => {
// using padding. So instead creating blank elements for padding bottom and top.
items.push(<View style={{ height: theme.marginTop }} key="bottom_top_hack" />);
items.push(renderSidebarButton('all_notes', _('All notes'), 'md-document', allNotesButton_press, props.notesParentType === 'SmartFilter'));
items.push(renderSidebarButton('all_notes', _('All notes'), 'document', allNotesButton_press, props.notesParentType === 'SmartFilter'));
items.push(makeDivider('divider_all'));
items.push(renderSidebarButton('folder_header', _('Notebooks'), 'md-folder'));
items.push(renderSidebarButton('folder_header', _('Notebooks'), 'folder'));
if (props.folders.length) {
const result = shared.renderFolders(props, renderFolderItem, false);

View File

@@ -407,6 +407,9 @@
"${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/FontAwesome5_Brands.ttf",
"${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/FontAwesome5_Regular.ttf",
"${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/FontAwesome5_Solid.ttf",
"${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/FontAwesome6_Brands.ttf",
"${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/FontAwesome6_Regular.ttf",
"${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/FontAwesome6_Solid.ttf",
"${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/Fontisto.ttf",
"${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/Foundation.ttf",
"${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/Ionicons.ttf",
@@ -427,6 +430,9 @@
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FontAwesome5_Brands.ttf",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FontAwesome5_Regular.ttf",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FontAwesome5_Solid.ttf",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FontAwesome6_Brands.ttf",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FontAwesome6_Regular.ttf",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FontAwesome6_Solid.ttf",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Fontisto.ttf",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Foundation.ttf",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Ionicons.ttf",

View File

@@ -288,7 +288,7 @@ PODS:
- React-Core
- react-native-get-random-values (1.9.0):
- React-Core
- react-native-image-picker (5.6.1):
- react-native-image-picker (5.7.0):
- React-Core
- react-native-image-resizer (3.0.7):
- React-Core
@@ -298,7 +298,7 @@ PODS:
- React
- react-native-saf-x (2.13.0):
- React-Core
- react-native-safe-area-context (4.7.1):
- react-native-safe-area-context (4.7.2):
- React-Core
- react-native-slider (4.4.3):
- React-Core
@@ -306,7 +306,7 @@ PODS:
- React-Core
- react-native-version-info (1.1.1):
- React-Core
- react-native-webview (13.3.1):
- react-native-webview (13.5.1):
- React-Core
- React-perflogger (0.71.10)
- React-RCTActionSheet (0.71.10):
@@ -398,9 +398,9 @@ PODS:
- React-Core
- RNCPushNotificationIOS (1.11.0):
- React-Core
- RNDateTimePicker (7.4.2):
- RNDateTimePicker (7.5.0):
- React-Core
- RNDeviceInfo (10.8.0):
- RNDeviceInfo (10.9.0):
- React-Core
- RNExitApp (2.0.0):
- React-Core
@@ -414,15 +414,15 @@ PODS:
- React
- RNSecureRandom (1.0.1):
- React
- RNShare (9.2.4):
- RNShare (9.4.1):
- React-Core
- RNVectorIcons (9.2.0):
- RNVectorIcons (10.0.0):
- React-Core
- RNZipArchive (6.0.9):
- RNZipArchive (6.1.0):
- React-Core
- RNZipArchive/Core (= 6.0.9)
- RNZipArchive/Core (= 6.1.0)
- SSZipArchive (~> 2.2)
- RNZipArchive/Core (6.0.9):
- RNZipArchive/Core (6.1.0):
- React-Core
- SSZipArchive (~> 2.2)
- SSZipArchive (2.4.3)
@@ -669,16 +669,16 @@ SPEC CHECKSUMS:
react-native-fingerprint-scanner: ac6656f18c8e45a7459302b84da41a44ad96dbbe
react-native-geolocation: 0f7fe8a4c2de477e278b0365cce27d089a8c5903
react-native-get-random-values: dee677497c6a740b71e5612e8dbd83e7539ed5bb
react-native-image-picker: 5fcac5a5ffcb3737837f0617d43fd767249290de
react-native-image-picker: 3269f75c251cdcd61ab51b911dd30d6fff8c6169
react-native-image-resizer: 681f7607418b97c084ba2d0999b153b103040d8a
react-native-netinfo: fefd4e98d75cbdd6e85fc530f7111a8afdf2b0c5
react-native-rsa-native: 12132eb627797529fdb1f0d22fd0f8f9678df64a
react-native-saf-x: b758d1b38a96a96b8179a16e96ed966a507ca5c2
react-native-safe-area-context: 9697629f7b2cda43cf52169bb7e0767d330648c2
react-native-safe-area-context: 7aa8e6d9d0f3100a820efb1a98af68aa747f9284
react-native-slider: 1cdd6ba29675df21f30544253bf7351d3c2d68c4
react-native-sqlite-storage: f6d515e1c446d1e6d026aa5352908a25d4de3261
react-native-version-info: a106f23009ac0db4ee00de39574eb546682579b9
react-native-webview: c2b70afb1d910cdd8810375aecc6c2894e2ba061
react-native-webview: 8baa0f5c6d336d6ba488e942bcadea5bf51f050a
React-perflogger: 217095464d5c4bb70df0742fa86bf2a363693468
React-RCTActionSheet: 8deae9b85a4cbc6a2243618ea62a374880a2c614
React-RCTAnimation: 59c62353a8b59ce206044786c5d30e4754bffa64
@@ -695,17 +695,17 @@ SPEC CHECKSUMS:
rn-fetch-blob: f065bb7ab7fb48dd002629f8bdcb0336602d3cba
RNCClipboard: 41d8d918092ae8e676f18adada19104fa3e68495
RNCPushNotificationIOS: 64218f3c776c03d7408284a819b2abfda1834bc8
RNDateTimePicker: a21bfcc694e3190f8ddb5aacb0dd16f97b6be79f
RNDeviceInfo: 5795b418ed3451ebcaf39384e6cf51f60cb931c9
RNDateTimePicker: b722c23030b744763cf51d80fc22f354653f65e0
RNDeviceInfo: 02ea8b23e2280fa18e00a06d7e62804d74028579
RNExitApp: 00036cabe7bacbb413d276d5520bf74ba39afa6a
RNFileViewer: ce7ca3ac370e18554d35d6355cffd7c30437c592
RNFS: 4ac0f0ea233904cb798630b3c077808c06931688
RNLocalize: dbea38dcb344bf80ff18a1757b1becf11f70cae4
RNQuickAction: 6d404a869dc872cde841ad3147416a670d13fa93
RNSecureRandom: 07efbdf2cd99efe13497433668e54acd7df49fef
RNShare: 255701a138fcdef6c27dc93724548fd122e91f9e
RNVectorIcons: fcc2f6cb32f5735b586e66d14103a74ce6ad61f8
RNZipArchive: 68a0c6db4b1c103f846f1559622050df254a3ade
RNShare: 32e97adc8d8c97d4a26bcdd3c45516882184f8b6
RNVectorIcons: 8b5bb0fa61d54cd2020af4f24a51841ce365c7e9
RNZipArchive: ef9451b849c45a29509bf44e65b788829ab07801
SSZipArchive: fe6a26b2a54d5a0890f2567b5cc6de5caa600aef
Yoga: e7ea9e590e27460d28911403b894722354d73479

View File

@@ -20,7 +20,7 @@ module.exports = {
// Do transform most packages in node_modules (transformations correct unrecognized
// import syntax)
transformIgnorePatterns: ['<rootDir>/node_modules/jest'],
transformIgnorePatterns: ['<rootDir>/node_modules/jest', '<rootDir>/node_modules/js-draw'],
slowTestThreshold: 40,
};

View File

@@ -14,22 +14,6 @@ import { setImmediate } from 'timers';
// so is removed by jsdom).
window.setImmediate = setImmediate;
// Prevents the CodeMirror error "getClientRects is undefined".
// See https://github.com/jsdom/jsdom/issues/3002#issue-652790925
document.createRange = () => {
const range = new Range();
range.getBoundingClientRect = jest.fn();
range.getClientRects = () => {
return {
length: 0,
item: () => null,
[Symbol.iterator]: jest.fn(),
};
};
return range;
};
shimInit({ nodeSqlite: sqlite3 });

View File

@@ -15,6 +15,7 @@ const path = require('path');
const localPackages = {
'@joplin/lib': path.resolve(__dirname, '../lib/'),
'@joplin/renderer': path.resolve(__dirname, '../renderer/'),
'@joplin/editor': path.resolve(__dirname, '../editor/'),
'@joplin/tools': path.resolve(__dirname, '../tools/'),
'@joplin/utils': path.resolve(__dirname, '../utils/'),
'@joplin/fork-htmlparser2': path.resolve(__dirname, '../fork-htmlparser2/'),

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