1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-08-24 20:19:10 +02:00

Compare commits

...

159 Commits

Author SHA1 Message Date
Laurent Cozic
5b4a300c81 iOS 13.4.1 2025-08-10 10:41:49 +01:00
Laurent Cozic
1de0a59313 Chore: Fix iOS IPHONEOS_DEPLOYMENT_TARGET 2025-08-10 10:41:25 +01:00
Laurent Cozic
f4dff92d2e Android 3.4.4 2025-08-10 10:34:02 +01:00
Laurent Cozic
a5d37a0dca Desktop release v3.4.5 2025-08-10 10:26:53 +01:00
Laurent Cozic
75ef418b39 Update translations 2025-08-10 10:26:37 +01:00
Henry Heino
6bd702ae24 Mobile: Resolves #12843: Rich Text Editor: Improve support for HTML notes (#12912) 2025-08-10 09:32:42 +01:00
Laurent Cozic
9ea1808766 Update translations 2025-08-10 09:31:13 +01:00
Suchith
59f8dd36a6 Desktop: Fixes #12358: Selected emoji for new notebooks display too large until Joplin is restarted (#12888) 2025-08-10 09:30:42 +01:00
Henry Heino
ea1d2e4878 Desktop, Mobile: Move several features from Extra Markdown Editor Settings into the main app (#12747) 2025-08-10 09:17:12 +01:00
Henry Heino
46ab00bfe4 Chore: Sync fuzzer: Re-use the same CLI process for commands run on the same client (#12913) 2025-08-10 09:14:25 +01:00
renovate[bot]
07465dd349 Update dependency dotenv to v16.5.0 (#12914)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-09 10:46:41 +01:00
renovate[bot]
a288ffe338 Update dependency @types/node to v18.19.100 (#12904)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-08 21:20:11 +00:00
renovate[bot]
dba62386b6 Update dependency @types/serviceworker to v0.0.134 (#12907)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-07 12:12:36 +01:00
Henry Heino
6704ab0d13 Mobile: Resolves #12841: Allow editing code blocks from the Rich Text Editor (#12906) 2025-08-07 10:18:09 +01:00
Arda Kılıçdağı
0312f2213d All: Translation: Update tr_TR.po (#12905) 2025-08-06 20:23:39 -04:00
Chaitanya Gupta
2ac0b66ef6 Doc: Fix link to E2EE spec (#12902) 2025-08-06 13:05:42 +01:00
Henry Heino
639b261ee4 Mobile: Fixes #12844: Rich Text Editor: Make initial search behavior match the Markdown editor (#12878) 2025-08-06 11:10:14 +01:00
mrjo118
82bc819a21 Mobile: Fixes #12822: Fix switching between note and todo on mobile (#12849) 2025-08-06 11:09:05 +01:00
w568w
72f8ebe4ff Desktop: Fixes #11871: Put crash dump files at the platform-compliant locations (#12839) 2025-08-06 11:08:29 +01:00
pedr
8c8a38e704 Desktop: Resolves #2059: Add option to transform HTML notes into Markdown (#12730)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2025-08-06 11:02:13 +01:00
mrjo118
358134038c Desktop, Mobile: Fixes #12104: Ensure merges to revisions during cleaning are synced to the target (#12444) 2025-08-06 10:52:28 +01:00
Henry Heino
1f4b32a241 Desktop: Fixes #12235: Fix switching to the Markdown editor after pasting links (#12241) 2025-08-06 10:50:17 +01:00
Henry Heino
2a216f1e61 Server: Fix notebooks remain shared after being permanently deleted by the share owner (#12583) 2025-08-06 10:37:38 +01:00
pedr
3f75d770f7 Desktop: Resolves #12224: Add an option to enable or disable search in OCR text (#12578) 2025-08-06 10:37:20 +01:00
Henry Heino
b6d32831c6 Mobile: Fixes #12880: Fix plugin support (#12890) 2025-08-06 10:23:40 +01:00
Henry Heino
788033cb5f Mobile: Fixes #12891: Fix error logged when opening the Markdown editor (#12892) 2025-08-06 10:23:07 +01:00
klaas0
4e685ec687 Mobile: Resolves #12858: Fixed missing filename when a file is shared with the app (#12895) 2025-08-06 10:22:47 +01:00
renovate[bot]
c60b703b9c Update dependency ldapts to v7.4.0 (#12900)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-06 10:19:03 +01:00
renovate[bot]
f23e10a975 Update dependency @types/node to v18.19.99 (#12899)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-06 06:25:58 +00:00
renovate[bot]
b9a71c0c3d Update dependency sharp to v0.34.1 (#12898)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-06 04:43:57 +00:00
renovate[bot]
f525c4179f Update dependency react-native-share to v12.0.11 (#12897)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-06 02:34:38 +00:00
renovate[bot]
1dd0ec619f Update dependency lint-staged to v15.5.2 (#12896)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-06 02:32:29 +00:00
renovate[bot]
d2ee5411d0 Update dependency @types/react to v18.3.21 (#12884)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-05 22:56:45 +00:00
krevad
a2472cb3b7 All: Translation: Update sv.po (#12893) 2025-08-05 18:55:12 -04:00
renovate[bot]
ca8415f74a Update dependency esbuild to v0.25.4 (#12889)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-05 20:34:24 +00:00
renovate[bot]
853b792367 Update dependency @types/node to v18.19.98 (#12881)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-05 20:31:35 +00:00
renovate[bot]
56d477f1c1 Update dependency esbuild to v0.25.4 (#12887)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-05 16:37:20 +00:00
Laurent Cozic
020ba10c56 Update renovate.json5 2025-08-05 13:35:34 +01:00
pedr
be09873c58 Desktop: Resolves #12087: Add shortcut to toggle between editors (#12869) 2025-08-05 12:43:58 +01:00
renovate[bot]
4d8a16bda7 Update dependency @types/mustache to v4.2.6 (#12867)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-05 12:28:03 +01:00
pedr
f725d3895f Transcribe: Resolves #12862: Add log statement signaling that the startup has finished (#12876) 2025-08-05 12:26:10 +01:00
pedr
0e19dce0d1 Transcribe: Fixes #12863: Improve error handling (#12873) 2025-08-05 10:53:42 +01:00
Joplin Bot
31c5058d5e Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-08-04 18:29:30 +00:00
Laurent Cozic
4d760303bc Android 3.4.3 2025-08-04 18:39:19 +01:00
Laurent Cozic
23e63e5fec Desktop release v3.4.4 2025-08-04 18:33:28 +01:00
renovate[bot]
3880352f53 Update dependency @react-native-documents/picker to v10.1.3 (#12865)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-03 20:34:46 +00:00
summoner
42a3c40702 All: Translation: Update hu_HU.po (#12864) 2025-08-03 12:56:34 -04:00
mrjo118
8e585640e7 Android: Fixes #12821: Fix on screen keyboard covers the markdown toolbar and contents on Android 15+ (#12838) 2025-08-03 17:30:15 +01:00
renovate[bot]
cd3fb4e7ad Update dependency sharp to v0.34.0 (#12854)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-03 17:27:35 +01:00
Laurent Cozic
702b5b3c63 Server: Trying to fix a request parsing error that can potentially crash the error (#12860) 2025-08-03 15:04:54 +01:00
PanWor
a80406dcb7 All: Translation: Update Polish pl_PL.po (#12857) 2025-08-02 17:06:09 -04:00
renovate[bot]
ea8b6485d8 Update dependency pg-boss to v10.2.0 (#12850)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-02 13:17:06 +01:00
renovate[bot]
1a2ef78726 Update dependency babel-plugin-react-native-web to v0.20.0 (#12848)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-02 09:23:47 +01:00
renovate[bot]
63d5ffc796 Update dependency @types/serviceworker to v0.0.133 (#12846)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-02 09:23:28 +01:00
renovate[bot]
15918a57aa Update dependency react-refresh to v0.17.0 (#12847)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-02 09:23:13 +01:00
renovate[bot]
032e8b5596 Update dependency @types/react-dom to v18.3.7 (#12845)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-01 20:14:16 +01:00
Laurent Cozic
ee091ede52 Update renovate.json5 2025-08-01 17:51:06 +01:00
Henry Heino
763e3f7479 Chore: Resolves #12814: Add additional logging to DecryptionWorker and EncryptionService (#12824) 2025-08-01 17:39:37 +01:00
Laurent Cozic
0089c62493 Transcribe v3.4.9 2025-08-01 17:32:56 +01:00
Laurent Cozic
20d6d56c02 Doc: Hide "Edit this page" link when printing 2025-08-01 17:32:33 +01:00
pedr
8b999f8dc6 Transcribe: Resolves #12831: Add support for transcribe server to the server docker compose configuration (#12832)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2025-08-01 17:31:59 +01:00
Laurent Cozic
0067ac126d Doc: Added technical info to JSB page 2025-08-01 16:28:01 +01:00
renovate[bot]
c6f47a9084 Update eslint (#12833)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2025-08-01 14:46:54 +01:00
Laurent Cozic
22817317f1 Transcribe v3.4.8 2025-08-01 13:30:54 +01:00
Laurent Cozic
9ba1c0db4e Chore: Also build ARM64 image for Transcribe 2025-08-01 13:30:34 +01:00
renovate[bot]
70d6c1225c Update types (#12834)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-01 12:50:46 +01:00
Laurent Cozic
b1f013a8c2 Transcribe v3.4.7 2025-08-01 12:14:32 +01:00
Laurent Cozic
8c66349907 Chore: Fixed credential for Transcribe release 2025-08-01 12:14:09 +01:00
Laurent Cozic
86b4f713ee Chore: Trying to disable Renovate React monorepo rule 2025-08-01 12:08:20 +01:00
Henry Heino
f50dc6a536 Chore: Work around test failure in newer NodeJS versions (#12830) 2025-08-01 11:45:09 +01:00
Henry Heino
825ce51a3c Chore: Resolves #12813: Move performance logger file labels to the corresponding log statements (#12820) 2025-08-01 11:43:05 +01:00
mrjo118
c5b6f0bca1 Mobile: Fixes #12783: Improve usability of inline search in notes (#12791) 2025-08-01 11:39:07 +01:00
Laurent Cozic
86934d502e Transcribe v3.4.6 2025-08-01 11:14:18 +01:00
Laurent Cozic
c63ad17f98 Chore: Fixed Transcribe Docker image 2025-08-01 11:13:57 +01:00
Joplin Bot
c746b5fdc2 Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-08-01 01:19:23 +00:00
Laurent Cozic
949fb85755 Transcribe v3.4.5 2025-07-31 21:36:35 +01:00
Laurent Cozic
0f94cb8c17 Chore: Updated script to allow deploying Transcribe server (#12828) 2025-07-31 21:35:03 +01:00
Joplin Bot
7ba61bb585 Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-07-31 18:28:37 +00:00
Henry Heino
00e4657a39 Desktop: Resolves #12714: Make more settings per-profile (application layout, note list style, and note list order) (#12825) 2025-07-31 17:12:31 +01:00
pedr
cbdc98553a Desktop, Server: Add transcribe functionality to Desktop though Joplin Server (#12670) 2025-07-31 16:42:03 +01:00
Laurent Cozic
e3c2589a12 Doc: Allow setting the initial hosting type on the Plans page 2025-07-31 14:48:25 +01:00
Henry Heino
56b3cc3dc2 Android: Fixes #12782: Fix save button is invisible in release builds (#12826) 2025-07-31 13:59:25 +01:00
Suchith
d59a09fd29 Desktop: Fixes #12233: Add tooltips to sidebar buttons (#12798) 2025-07-30 15:42:08 +01:00
Laurent Cozic
5a64222276 Desktop: Fixes #12816: Date/Time dialog button not visible in dark mode 2025-07-30 15:22:34 +01:00
Henry Heino
012297d52a Android: Fixes #12781: Fix editor becomes blank after dismissing search (#12818) 2025-07-30 10:53:12 +01:00
Henry Heino
5e70bce2c3 Mobile: Performance: Improve Rich Text Editor startup performance (#12819) 2025-07-30 10:52:57 +01:00
Henry Heino
4c3eca1f18 Mobile: Add a Rich Text Editor (#12748) 2025-07-29 20:25:43 +01:00
Henry Heino
c899f63a41 Chore: Resolves #12088: Desktop: Add performance logging statements to the startup code (#12812) 2025-07-29 19:57:12 +01:00
Liffindra Angga Zaaldian
c838b86413 All: Translation: Update id_ID.po (#12815) 2025-07-29 06:57:18 -04:00
pedr
90d6d1747a Transcribe: Fixes #12765: Removes file from temporary folder after storing it (#12795) 2025-07-28 18:32:10 +01:00
renovate[bot]
6e8ba8a536 Update dependency jsdom to v26 (#12809)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-28 12:24:57 +01:00
bwat47
ffeb5f887a Doc: Update s3.md for provider Cloudflare R2 (#12805) 2025-07-27 15:54:10 +01:00
Laurent Cozic
65bde86263 Doc: Added documentation for the CLA consent records tool and archives 2025-07-27 10:00:33 +01:00
Laurent Cozic
1c236ca73c Doc: Update CLA consent records 2025-07-27 09:36:28 +01:00
Laurent Cozic
2881280100 Merge branch 'cla_signatures' into dev 2025-07-27 09:33:50 +01:00
Laurent Cozic
954b48b779 Merge branch 'dev' into cla_signatures 2025-07-27 09:33:24 +01:00
Laurent Cozic
53e7b672b0 Chore: Recorded CLA consent documents 2025-07-27 09:33:22 +01:00
krevad
ceaaab77e8 All: Translation: Update sv.po (#12800) 2025-07-26 18:00:43 -04:00
Eric Duarte
c29bbe96f7 All: Translation: Update es_ES.po and ca.po (#12760) 2025-07-26 17:03:20 -04:00
Mihai Vasiliu
db323ac585 All: Translation: Update ro_RO.po and ro_MD.po (#12799) 2025-07-26 16:54:55 -04:00
krevad
dc8e3242f3 All: Translation: Update sv.po (#12797) 2025-07-26 11:00:38 -04:00
github-actions[bot]
9705941538 @krevad has signed the CLA in laurent22/joplin#12797 2025-07-26 09:58:22 +00:00
Joplin Bot
0cf9981ac7 Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-07-26 01:09:51 +00:00
Laurent Cozic
b93ee3469b Desktop release v3.4.3 2025-07-25 19:22:30 +01:00
Laurent Cozic
73e5bc74a5 Android 3.4.2 2025-07-25 19:21:50 +01:00
Henry Heino
6c761b3fb4 Desktop,Mobile: Fixes #12573: Markdown editor: Make list indentation size equivalent to four spaces (#12794) 2025-07-25 19:18:14 +01:00
Henry Heino
e13985a952 Desktop: Fixes #12790: Plugins: Fix importing sqlite3 (#12792) 2025-07-25 19:17:40 +01:00
Laurent Cozic
8b912b22cf Chore: Fixed bad merge 2025-07-25 09:25:17 +01:00
Henry Heino
4c90cd62fe Chore: Mobile: Log startup performance information (#12776)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2025-07-25 09:20:38 +01:00
Henry Heino
999ec8c11f Android: Fix title bar is partially hidden by the screen header (#12785) 2025-07-25 09:19:23 +01:00
Henry Heino
d8e73f3141 Chore: Mobile: Fix warning (#12786) 2025-07-25 09:19:11 +01:00
renovate[bot]
3b1a4e8209 Update dependency react-native-paper to v5.13.5 (#12784)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-24 23:33:23 +00:00
renovate[bot]
1ff0f0f1c8 Update dependency @types/node to v18.19.87 (#12779)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-24 13:48:29 +01:00
Joplin Bot
68863db4bd Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-07-24 12:36:28 +00:00
Laurent Cozic
f6b8462a5b Android 3.4.1 2025-07-24 12:04:31 +01:00
Laurent Cozic
f8d09ce847 Chore: Trying to fix Android build 2025-07-24 11:52:40 +01:00
Laurent Cozic
f541618ed4 Chore: Build files 2025-07-24 11:44:36 +01:00
renovate[bot]
b023ddc4db Update dependency nodemon to v3.1.10 (#12769)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-24 02:03:07 +00:00
Henry Heino
3289b2ba30 Chore: Desktop: Migrate entrypoint to TypeScript (#12773) 2025-07-23 22:24:49 +01:00
renovate[bot]
0c52ac424d Update dependency mermaid to v11.6.0 (#12775)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-23 18:29:26 +01:00
renovate[bot]
56529a1433 Update dependency glob to v11.0.2 (#12772)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-22 22:05:49 +00:00
renovate[bot]
65981e5e8b Update dependency @types/serviceworker to v0.0.132 (#12768)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-22 14:35:01 +01:00
summoner
798064b004 All: Translations: Update hu_HU.po (#12764) 2025-07-21 17:54:56 -04:00
Laurent Cozic
e5ffb7df4d Chore: Trying to fix Android build 2025-07-21 22:40:44 +01:00
Laurent Cozic
49de4461d9 Desktop release v3.4.2 2025-07-21 21:49:29 +01:00
Henry Heino
9cfd135bba Chore: Editor: Refactor editor package (#12743) 2025-07-21 21:37:45 +01:00
Henry Heino
e62cba5048 Desktop,Mobile: Fixes #12744: Fix adding lists to blank lines using toolbar buttons (#12745) 2025-07-21 18:32:14 +01:00
Henry Heino
4d5097b585 Desktop: Fixes #12341: Markdown editor: Prevent selection from extending far outside the editor boundaries (#12746) 2025-07-21 18:31:01 +01:00
Henry Heino
e6b81d42c3 Chore: Migrate urlUtils from deprecated url.parse to URL (#12750) 2025-07-21 18:28:53 +01:00
renovate[bot]
b705be33e1 Update dependency nodejs to v23.10.0 (#12762)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-21 18:27:47 +01:00
SAYAN02-DEV
984bb0f3ef Desktop: Fixes #12669: Long URL in note properties breaks the dialog layout (#12669) 2025-07-20 17:18:24 +01:00
Laurent Cozic
cd158e584e Server: Fixes #12737: Fixed broken "Delete expired authentication codes" task 2025-07-20 11:03:27 +01:00
Laurent Cozic
91b0ea609d Server: Fixed incorrectly named database migration that prevents new migrations from being applied 2025-07-20 11:01:57 +01:00
Laurent Cozic
898888088c Server: Fixed incorrectly named database migration that prevents new migrations from being applied 2025-07-20 10:52:56 +01:00
Eric Duarte
0a25b3bde6 All: Translation: Update es_ES.po (#12753) 2025-07-19 18:29:58 -04:00
renovate[bot]
ed8e709263 Update dependency bulma to v1.0.4 (#12749)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-18 23:53:00 +01:00
summoner
29e7594dc6 All: Translations: Update hu_HU.po (#12740) 2025-07-18 16:40:07 -04:00
Jozef Gaal
a0d38444bd All: Translation: Update sk_SK.po (#12735) 2025-07-18 16:38:48 -04:00
renovate[bot]
e86e381fca Update dependency @types/serviceworker to v0.0.131 (#12734)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-18 13:36:48 +00:00
Henry Heino
0a6b8fb90a Mobile: Add support for scanning multi-page documents (#12635) 2025-07-18 14:33:58 +01:00
Joplin Bot
6c5293833d Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-07-17 18:30:08 +00:00
Henry Heino
a2af3f460a Desktop: Resolves #11866: Rich Text Editor: Add less information to the log file when pasting (#12684) 2025-07-17 15:51:11 +01:00
Henry Heino
30aff62d08 Mobile: Implement tag screen redesign (#12551) 2025-07-17 15:50:37 +01:00
Laurent Cozic
53fe12ab8a Chore: Fix tagServerLatest script when latest manifest exists locally 2025-07-17 13:57:16 +01:00
Joplin Bot
d52550e272 Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-07-17 12:35:56 +00:00
github-actions[bot]
83f1fcc228 @jordanhandy has signed the CLA in laurent22/joplin#12701 2025-07-11 13:05:02 +00:00
github-actions[bot]
b03e370d2b @khemarato has signed the CLA in laurent22/joplin#12696 2025-07-11 06:21:31 +00:00
github-actions[bot]
4ddd5c4558 @laurent22 has signed the CLA in laurent22/joplin#12571 2025-07-04 16:36:54 +00:00
github-actions[bot]
7746694dca @SAYAN02-DEV has signed the CLA in laurent22/joplin#12666 2025-07-03 07:56:53 +00:00
github-actions[bot]
f1ac95a1c7 @bekemax has signed the CLA in laurent22/joplin#12586 2025-06-27 17:11:39 +00:00
github-actions[bot]
78e9ced96c @jhult has signed the CLA in laurent22/joplin#12567 2025-06-20 14:38:18 +00:00
github-actions[bot]
bba6ede569 @Robin-Sch has signed the CLA in laurent22/joplin#12563 2025-06-18 21:52:29 +00:00
github-actions[bot]
7a26d4f336 @Gustavo-V-F has signed the CLA in laurent22/joplin#12537 2025-06-15 20:44:49 +00:00
github-actions[bot]
04a976e459 @ShawnZhang31 has signed the CLA in laurent22/joplin#12345 2025-05-27 01:28:32 +00:00
github-actions[bot]
ecfef1a9da @NBA2K1 has signed the CLA in laurent22/joplin#12316 2025-05-18 07:40:04 +00:00
github-actions[bot]
e422a88bb0 @eyaaba has signed the CLA in laurent22/joplin#12260 2025-05-10 11:48:28 +00:00
github-actions[bot]
74ef89d25b @SilviaAC has signed the CLA in laurent22/joplin#12256 2025-05-09 09:43:31 +00:00
github-actions[bot]
28b7251e16 @yatishgoel has signed the CLA in laurent22/joplin#12185 2025-04-29 09:12:38 +00:00
github-actions[bot]
9218c7df1f @BellezaEmporium has signed the CLA in laurent22/joplin#12161 2025-04-28 09:42:54 +00:00
528 changed files with 62980 additions and 33234 deletions

View File

@@ -15,6 +15,23 @@
# POSTGRES_PORT=5432
# POSTGRES_HOST=localhost
# =============================================================================
# TRANSCRIBE CONFIG EXAMPLE
# -----------------------------------------------------------------------------
# This service is not required, and it will be ignored by using --profile server
# when running docker-compose. If you want to use it, you need to set the
# following environment variables.
# =============================================================================
# TRANSCRIBE_API_KEY=secret_string_shared_between_server_and_transcribe
# TRANSCRIBE_ENABLED=true
# QUEUE_DATABASE_NAME=transcribe
# QUEUE_DATABASE_USER=transcribe
# QUEUE_DATABASE_PASSWORD=transcribe
# QUEUE_DATABASE_PORT=5431
# HTR_CLI_IMAGES_FOLDER=/home/user/images_storage
# =============================================================================
# DEV CONFIG EXAMPLE
# -----------------------------------------------------------------------------

View File

@@ -11,8 +11,9 @@ QUEUE_RETRY_COUNT=2
QUEUE_MAINTENANCE_INTERVAL=30000
HTR_CLI_DOCKER_IMAGE=joplin/htr-cli:0.0.2
# Fullpath to images folder
HTR_CLI_IMAGES_FOLDER=/home/user/joplin/packages/transcribe/images
# Fullpath to images folder e.g.:
#HTR_CLI_IMAGES_FOLDER=/home/user/joplin/packages/transcribe/images
HTR_CLI_IMAGES_FOLDER=
QUEUE_DRIVER=pg
# QUEUE_DRIVER=sqlite
@@ -27,4 +28,5 @@ QUEUE_DRIVER=pg
QUEUE_DATABASE_NAME=transcribe
QUEUE_DATABASE_USER=transcribe
QUEUE_DATABASE_PASSWORD=transcribe
QUEUE_DATABASE_PORT=5432
QUEUE_DATABASE_PORT=5432
QUEUE_DATABASE_HOST=localhost

View File

@@ -55,6 +55,7 @@ packages/app-desktop/vendor/lib/
packages/app-mobile/packageInfo.js
packages/app-mobile/android
packages/app-mobile/**/*.bundle.js
packages/app-mobile/**/*.bundle.css
packages/app-mobile/web/public/pluginAssets/**/*
packages/app-mobile/ios
packages/app-mobile/lib/rnInjectedJs/
@@ -74,6 +75,7 @@ packages/lib/services/database/types.ts
packages/lib/vendor/
packages/lib/vendor/fountain.min.js
packages/lib/welcomeAssets.js
packages/editor/*/vendor/
packages/plugins/**/api
packages/plugins/**/dist
packages/server/dist/
@@ -96,6 +98,7 @@ packages/app-cli/app/app.js
packages/app-cli/app/base-command.js
packages/app-cli/app/command-apidoc.js
packages/app-cli/app/command-attach.js
packages/app-cli/app/command-batch.js
packages/app-cli/app/command-cat.js
packages/app-cli/app/command-config.js
packages/app-cli/app/command-cp.js
@@ -133,6 +136,7 @@ packages/app-cli/app/gui/StatusBarWidget.js
packages/app-cli/app/services/plugins/PluginRunner.js
packages/app-cli/app/setupCommand.js
packages/app-cli/app/utils/initializeCommandService.js
packages/app-cli/app/utils/iterateStdin.js
packages/app-cli/app/utils/shimInitCli.js
packages/app-cli/app/utils/testUtils.js
packages/app-cli/tests/HtmlToMd.js
@@ -155,6 +159,8 @@ packages/app-desktop/app.reducer.js
packages/app-desktop/app.js
packages/app-desktop/bridge.js
packages/app-desktop/checkForUpdates.js
packages/app-desktop/commands/convertNoteToMarkdown.test.js
packages/app-desktop/commands/convertNoteToMarkdown.js
packages/app-desktop/commands/copyDevCommand.js
packages/app-desktop/commands/copyToClipboard.js
packages/app-desktop/commands/editProfileConfig.js
@@ -195,6 +201,7 @@ packages/app-desktop/gui/ConfigScreen/controls/ToggleAdvancedSettingsButton.js
packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.js
packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.js
packages/app-desktop/gui/ConfigScreen/controls/plugins/SearchPlugins.js
packages/app-desktop/gui/ConversionNotification/ConversionNotification.js
packages/app-desktop/gui/Dialog.js
packages/app-desktop/gui/DialogButtonRow.js
packages/app-desktop/gui/DialogButtonRow/useKeyboardHandler.js
@@ -296,6 +303,7 @@ packages/app-desktop/gui/NoteEditor/utils/clipboardUtils.test.js
packages/app-desktop/gui/NoteEditor/utils/clipboardUtils.js
packages/app-desktop/gui/NoteEditor/utils/contextMenu.js
packages/app-desktop/gui/NoteEditor/utils/contextMenuUtils.js
packages/app-desktop/gui/NoteEditor/utils/getResourceBaseUrl.js
packages/app-desktop/gui/NoteEditor/utils/getWindowCommandPriority.js
packages/app-desktop/gui/NoteEditor/utils/index.js
packages/app-desktop/gui/NoteEditor/utils/markupRenderOptions.js
@@ -553,6 +561,8 @@ packages/app-desktop/integration-tests/util/setSettingValue.js
packages/app-desktop/integration-tests/util/test.js
packages/app-desktop/integration-tests/util/waitForNextOpenPath.js
packages/app-desktop/integration-tests/wcag.spec.js
packages/app-desktop/main-html.js
packages/app-desktop/main.js
packages/app-desktop/playwright.config.js
packages/app-desktop/plugins/GotoAnything.js
packages/app-desktop/services/autoUpdater/AutoUpdaterService.test.js
@@ -625,16 +635,21 @@ packages/app-mobile/components/CameraView/Camera/index.web.js
packages/app-mobile/components/CameraView/Camera/types.js
packages/app-mobile/components/CameraView/CameraView.test.js
packages/app-mobile/components/CameraView/CameraView.js
packages/app-mobile/components/CameraView/CameraView.web.js
packages/app-mobile/components/CameraView/CameraViewMultiPage.test.js
packages/app-mobile/components/CameraView/CameraViewMultiPage.js
packages/app-mobile/components/CameraView/PhotoPreview.js
packages/app-mobile/components/CameraView/ScannedBarcodes.js
packages/app-mobile/components/CameraView/types.js
packages/app-mobile/components/CameraView/utils/fitRectIntoBounds.js
packages/app-mobile/components/CameraView/utils/testing.js
packages/app-mobile/components/CameraView/utils/useBarcodeScanner.js
packages/app-mobile/components/Checkbox.js
packages/app-mobile/components/ComboBox.test.js
packages/app-mobile/components/ComboBox.js
packages/app-mobile/components/DialogManager/PromptButton.js
packages/app-mobile/components/DialogManager/PromptDialog.js
packages/app-mobile/components/DialogManager/TextInputDialog.js
packages/app-mobile/components/DialogManager/hooks/useDialogControl.js
packages/app-mobile/components/DialogManager/index.js
packages/app-mobile/components/DialogManager/types.js
@@ -656,64 +671,56 @@ packages/app-mobile/components/ExtendedWebView/index.jest.js
packages/app-mobile/components/ExtendedWebView/index.js
packages/app-mobile/components/ExtendedWebView/index.web.js
packages/app-mobile/components/ExtendedWebView/types.js
packages/app-mobile/components/ExtendedWebView/utils/useCss.js
packages/app-mobile/components/FolderPicker.js
packages/app-mobile/components/Icon.js
packages/app-mobile/components/IconButton.js
packages/app-mobile/components/Modal.js
packages/app-mobile/components/ModalDialog.js
packages/app-mobile/components/NestableFlatList.js
packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.test.js
packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.js
packages/app-mobile/components/NoteBodyViewer/bundledJs/Renderer.test.js
packages/app-mobile/components/NoteBodyViewer/bundledJs/Renderer.js
packages/app-mobile/components/NoteBodyViewer/bundledJs/noteBodyViewerBundle.js
packages/app-mobile/components/NoteBodyViewer/bundledJs/types.js
packages/app-mobile/components/NoteBodyViewer/bundledJs/utils/addPluginAssets.js
packages/app-mobile/components/NoteBodyViewer/bundledJs/utils/makeResourceModel.js
packages/app-mobile/components/NoteBodyViewer/hooks/useContentScripts.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/useRenderer.js
packages/app-mobile/components/NoteBodyViewer/hooks/useRerenderHandler.js
packages/app-mobile/components/NoteBodyViewer/hooks/useSource.js
packages/app-mobile/components/NoteBodyViewer/types.js
packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.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/polyfills.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/ImageEditor/utils/useEditorMessenger.js
packages/app-mobile/components/NoteEditor/MarkdownEditor.js
packages/app-mobile/components/NoteEditor/NoteEditor.test.js
packages/app-mobile/components/NoteEditor/NoteEditor.js
packages/app-mobile/components/NoteEditor/RichTextEditor.test.js
packages/app-mobile/components/NoteEditor/RichTextEditor.js
packages/app-mobile/components/NoteEditor/SearchPanel.js
packages/app-mobile/components/NoteEditor/WarningBanner.js
packages/app-mobile/components/NoteEditor/commandDeclarations.js
packages/app-mobile/components/NoteEditor/hooks/useCodeMirrorPlugins.js
packages/app-mobile/components/NoteEditor/hooks/useEditorCommandHandler.test.js
packages/app-mobile/components/NoteEditor/hooks/useEditorCommandHandler.js
packages/app-mobile/components/NoteEditor/testing/createTestEditorProps.js
packages/app-mobile/components/NoteEditor/types.js
packages/app-mobile/components/NoteItem.js
packages/app-mobile/components/NoteList.js
packages/app-mobile/components/ProfileSwitcher/ProfileEditor.js
packages/app-mobile/components/ProfileSwitcher/ProfileSwitcher.js
packages/app-mobile/components/ProfileSwitcher/useProfileConfig.js
packages/app-mobile/components/SafeAreaView.js
packages/app-mobile/components/ScreenHeader/Menu.js
packages/app-mobile/components/ScreenHeader/WarningBanner.test.js
packages/app-mobile/components/ScreenHeader/WarningBanner.js
packages/app-mobile/components/ScreenHeader/WarningBox.js
packages/app-mobile/components/ScreenHeader/WebBetaButton.js
packages/app-mobile/components/ScreenHeader/index.js
packages/app-mobile/components/SearchInput.js
packages/app-mobile/components/SelectDateTimeDialog.js
packages/app-mobile/components/SideMenu.js
packages/app-mobile/components/SideMenuContentNote.js
packages/app-mobile/components/TagEditor.test.js
packages/app-mobile/components/TagEditor.js
packages/app-mobile/components/TextInput.js
packages/app-mobile/components/accessibility/AccessibleView.test.js
packages/app-mobile/components/accessibility/AccessibleView.js
@@ -738,6 +745,7 @@ packages/app-mobile/components/getResponsiveValue.js
packages/app-mobile/components/global-style.js
packages/app-mobile/components/plugins/PluginNotification.js
packages/app-mobile/components/plugins/PluginRunner.js
packages/app-mobile/components/plugins/PluginRunnerWebView.test.js
packages/app-mobile/components/plugins/PluginRunnerWebView.js
packages/app-mobile/components/plugins/backgroundPage/initializeDialogWebView.js
packages/app-mobile/components/plugins/backgroundPage/initializePluginBackgroundIframe.js
@@ -809,6 +817,8 @@ packages/app-mobile/components/screens/ConfigScreen/plugins/utils/usePluginItem.
packages/app-mobile/components/screens/ConfigScreen/plugins/utils/useRepoApi.js
packages/app-mobile/components/screens/ConfigScreen/plugins/utils/useUpdateState.js
packages/app-mobile/components/screens/ConfigScreen/types.js
packages/app-mobile/components/screens/DocumentScanner/DocumentScanner.js
packages/app-mobile/components/screens/DocumentScanner/NotePreview.js
packages/app-mobile/components/screens/JoplinCloudLoginScreen.js
packages/app-mobile/components/screens/LogScreen.js
packages/app-mobile/components/screens/Note/Note.test.js
@@ -847,6 +857,37 @@ packages/app-mobile/components/voiceTyping/AudioRecordingBanner.js
packages/app-mobile/components/voiceTyping/RecordingControls.js
packages/app-mobile/components/voiceTyping/SpeechToTextBanner.js
packages/app-mobile/components/voiceTyping/types.js
packages/app-mobile/contentScripts/imageEditorBundle/contentScript/applyTemplateToEditor.js
packages/app-mobile/contentScripts/imageEditorBundle/contentScript/index.test.js
packages/app-mobile/contentScripts/imageEditorBundle/contentScript/index.js
packages/app-mobile/contentScripts/imageEditorBundle/contentScript/startAutosaveLoop.js
packages/app-mobile/contentScripts/imageEditorBundle/contentScript/types.js
packages/app-mobile/contentScripts/imageEditorBundle/contentScript/watchEditorForTemplateChanges.js
packages/app-mobile/contentScripts/imageEditorBundle/useWebViewSetup.js
packages/app-mobile/contentScripts/imageEditorBundle/utils/useEditorMessenger.js
packages/app-mobile/contentScripts/markdownEditorBundle/contentScript.js
packages/app-mobile/contentScripts/markdownEditorBundle/types.js
packages/app-mobile/contentScripts/markdownEditorBundle/useWebViewSetup.js
packages/app-mobile/contentScripts/rendererBundle/contentScript/Renderer.test.js
packages/app-mobile/contentScripts/rendererBundle/contentScript/Renderer.js
packages/app-mobile/contentScripts/rendererBundle/contentScript/index.js
packages/app-mobile/contentScripts/rendererBundle/contentScript/types.js
packages/app-mobile/contentScripts/rendererBundle/contentScript/utils/addPluginAssets.js
packages/app-mobile/contentScripts/rendererBundle/contentScript/utils/afterFullPageRender.js
packages/app-mobile/contentScripts/rendererBundle/contentScript/utils/makeResourceModel.js
packages/app-mobile/contentScripts/rendererBundle/types.js
packages/app-mobile/contentScripts/rendererBundle/useWebViewSetup.js
packages/app-mobile/contentScripts/rendererBundle/utils/useContentScripts.js
packages/app-mobile/contentScripts/rendererBundle/utils/useEditPopup.test.js
packages/app-mobile/contentScripts/rendererBundle/utils/useEditPopup.js
packages/app-mobile/contentScripts/richTextEditorBundle/contentScript/convertHtmlToMarkdown.js
packages/app-mobile/contentScripts/richTextEditorBundle/contentScript/index.js
packages/app-mobile/contentScripts/richTextEditorBundle/types.js
packages/app-mobile/contentScripts/richTextEditorBundle/useWebViewSetup.js
packages/app-mobile/contentScripts/types.js
packages/app-mobile/contentScripts/utils/polyfills.js
packages/app-mobile/contentScripts/utils/readFileToBase64.js
packages/app-mobile/contentScripts/utils/setUpLogger.js
packages/app-mobile/gulpfile.js
packages/app-mobile/index.web.js
packages/app-mobile/root.js
@@ -869,7 +910,7 @@ packages/app-mobile/services/voiceTyping/whisper.js
packages/app-mobile/setupQuickActions.js
packages/app-mobile/tools/buildInjectedJs/BundledFile.js
packages/app-mobile/tools/buildInjectedJs/constants.js
packages/app-mobile/tools/buildInjectedJs/copyJs.js
packages/app-mobile/tools/buildInjectedJs/copyAssets.js
packages/app-mobile/tools/buildInjectedJs/gulpTasks.js
packages/app-mobile/tools/copyAssets.js
packages/app-mobile/utils/ShareExtension.js
@@ -878,6 +919,7 @@ packages/app-mobile/utils/ShareUtils.js
packages/app-mobile/utils/TlsUtils.js
packages/app-mobile/utils/appDefaultState.js
packages/app-mobile/utils/autodetectTheme.js
packages/app-mobile/utils/buildStartupTasks.js
packages/app-mobile/utils/checkPermissions.js
packages/app-mobile/utils/createRootStyle.js
packages/app-mobile/utils/database-driver-react-native.js
@@ -905,7 +947,6 @@ packages/app-mobile/utils/image/fileToImage.web.js
packages/app-mobile/utils/image/getImageDimensions.js
packages/app-mobile/utils/image/resizeImage.js
packages/app-mobile/utils/initializeCommandService.js
packages/app-mobile/utils/injectedJs.js
packages/app-mobile/utils/ipc/RNToWebViewMessenger.js
packages/app-mobile/utils/ipc/WebViewToRNMessenger.js
packages/app-mobile/utils/lockToSingleInstance.js
@@ -951,47 +992,67 @@ packages/editor/CodeMirror/editorCommands/duplicateLine.js
packages/editor/CodeMirror/editorCommands/editorCommands.js
packages/editor/CodeMirror/editorCommands/insertLineAfter.test.js
packages/editor/CodeMirror/editorCommands/insertLineAfter.js
packages/editor/CodeMirror/editorCommands/insertNewlineContinueMarkup.test.js
packages/editor/CodeMirror/editorCommands/insertNewlineContinueMarkup.js
packages/editor/CodeMirror/editorCommands/jumpToHash.test.js
packages/editor/CodeMirror/editorCommands/jumpToHash.js
packages/editor/CodeMirror/editorCommands/markdownCommands.bulletedVsChecklist.test.js
packages/editor/CodeMirror/editorCommands/markdownCommands.test.js
packages/editor/CodeMirror/editorCommands/markdownCommands.toggleList.test.js
packages/editor/CodeMirror/editorCommands/markdownCommands.js
packages/editor/CodeMirror/editorCommands/sortSelectedLines.test.js
packages/editor/CodeMirror/editorCommands/sortSelectedLines.js
packages/editor/CodeMirror/editorCommands/supportsCommand.js
packages/editor/CodeMirror/extensions/biDirectionalTextExtension.js
packages/editor/CodeMirror/extensions/keyUpHandlerExtension.js
packages/editor/CodeMirror/extensions/links/ctrlClickLinksExtension.js
packages/editor/CodeMirror/extensions/links/followLinkTooltipExtension.test.js
packages/editor/CodeMirror/extensions/links/followLinkTooltipExtension.js
packages/editor/CodeMirror/extensions/links/referenceLinksStateField.js
packages/editor/CodeMirror/extensions/links/utils/findLineMatchingLink.test.js
packages/editor/CodeMirror/extensions/links/utils/findLineMatchingLink.js
packages/editor/CodeMirror/extensions/links/utils/getUrlAtPosition.js
packages/editor/CodeMirror/extensions/links/utils/openLink.js
packages/editor/CodeMirror/extensions/markdownDecorationExtension.test.js
packages/editor/CodeMirror/extensions/markdownDecorationExtension.js
packages/editor/CodeMirror/extensions/markdownHighlightExtension.test.js
packages/editor/CodeMirror/extensions/markdownHighlightExtension.js
packages/editor/CodeMirror/extensions/markdownMathExtension.test.js
packages/editor/CodeMirror/extensions/markdownMathExtension.js
packages/editor/CodeMirror/extensions/modifierKeyCssExtension.js
packages/editor/CodeMirror/extensions/overwriteModeExtension.test.js
packages/editor/CodeMirror/extensions/overwriteModeExtension.js
packages/editor/CodeMirror/extensions/rendering/addFormattingClasses.js
packages/editor/CodeMirror/extensions/rendering/renderBlockImages.test.js
packages/editor/CodeMirror/extensions/rendering/renderBlockImages.js
packages/editor/CodeMirror/extensions/rendering/renderingExtension.js
packages/editor/CodeMirror/extensions/rendering/replaceBulletLists.js
packages/editor/CodeMirror/extensions/rendering/replaceCheckboxes.js
packages/editor/CodeMirror/extensions/rendering/replaceDividers.js
packages/editor/CodeMirror/extensions/rendering/replaceFormatCharacters.js
packages/editor/CodeMirror/extensions/rendering/types.js
packages/editor/CodeMirror/extensions/rendering/utils/makeBlockReplaceExtension.js
packages/editor/CodeMirror/extensions/rendering/utils/makeInlineReplaceExtension.js
packages/editor/CodeMirror/extensions/rendering/utils/nodeIntersectsSelection.js
packages/editor/CodeMirror/extensions/searchExtension.js
packages/editor/CodeMirror/extensions/selectedNoteIdExtension.js
packages/editor/CodeMirror/getScrollFraction.js
packages/editor/CodeMirror/markdown/MarkdownHighlightExtension.test.js
packages/editor/CodeMirror/markdown/MarkdownHighlightExtension.js
packages/editor/CodeMirror/markdown/MarkdownMathExtension.test.js
packages/editor/CodeMirror/markdown/MarkdownMathExtension.js
packages/editor/CodeMirror/markdown/codeBlockLanguages/allLanguages.js
packages/editor/CodeMirror/markdown/codeBlockLanguages/defaultLanguage.js
packages/editor/CodeMirror/markdown/codeBlockLanguages/lookUpLanguage.js
packages/editor/CodeMirror/markdown/computeSelectionFormatting.test.js
packages/editor/CodeMirror/markdown/computeSelectionFormatting.js
packages/editor/CodeMirror/markdown/decoratorExtension.test.js
packages/editor/CodeMirror/markdown/decoratorExtension.js
packages/editor/CodeMirror/markdown/insertNewlineContinueMarkup.test.js
packages/editor/CodeMirror/markdown/insertNewlineContinueMarkup.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/utils/renumberSelectedLists.test.js
packages/editor/CodeMirror/markdown/utils/renumberSelectedLists.js
packages/editor/CodeMirror/markdown/utils/stripBlockquote.js
packages/editor/CodeMirror/index.js
packages/editor/CodeMirror/pluginApi/PluginLoader.js
packages/editor/CodeMirror/pluginApi/codeMirrorRequire.js
packages/editor/CodeMirror/pluginApi/customEditorCompletion.test.js
packages/editor/CodeMirror/pluginApi/customEditorCompletion.js
packages/editor/CodeMirror/testUtil/createEditorControl.js
packages/editor/CodeMirror/testUtil/createEditorSettings.js
packages/editor/CodeMirror/testUtil/createTestEditor.js
packages/editor/CodeMirror/testUtil/findNodesWithName.js
packages/editor/CodeMirror/testUtil/forceFullParse.js
packages/editor/CodeMirror/testUtil/loadLanguages.js
packages/editor/CodeMirror/testUtil/pressReleaseKey.js
packages/editor/CodeMirror/testUtil/typeText.js
packages/editor/CodeMirror/testing/createEditorControl.js
packages/editor/CodeMirror/testing/createTestEditor.js
packages/editor/CodeMirror/testing/findNodesWithName.js
packages/editor/CodeMirror/testing/forceFullParse.js
packages/editor/CodeMirror/testing/loadLanguages.js
packages/editor/CodeMirror/testing/pressReleaseKey.js
packages/editor/CodeMirror/testing/typeText.js
packages/editor/CodeMirror/theme.js
packages/editor/CodeMirror/utils/biDirectionalTextExtension.js
packages/editor/CodeMirror/utils/formatting/RegionSpec.js
packages/editor/CodeMirror/utils/formatting/computeSelectionFormatting.test.js
packages/editor/CodeMirror/utils/formatting/computeSelectionFormatting.js
packages/editor/CodeMirror/utils/formatting/findInlineMatch.test.js
packages/editor/CodeMirror/utils/formatting/findInlineMatch.js
packages/editor/CodeMirror/utils/formatting/isIndentationEquivalent.js
@@ -1010,15 +1071,56 @@ packages/editor/CodeMirror/utils/handleLinkEditRequests.js
packages/editor/CodeMirror/utils/handlePasteEvent.js
packages/editor/CodeMirror/utils/isCursorAtBeginning.js
packages/editor/CodeMirror/utils/isInSyntaxNode.js
packages/editor/CodeMirror/utils/keyUpHandlerExtension.js
packages/editor/CodeMirror/utils/overwriteModeExtension.test.js
packages/editor/CodeMirror/utils/overwriteModeExtension.js
packages/editor/CodeMirror/utils/searchExtension.js
packages/editor/CodeMirror/utils/selectedNoteIdExtension.js
packages/editor/CodeMirror/utils/markdown/codeBlockLanguages/allLanguages.js
packages/editor/CodeMirror/utils/markdown/codeBlockLanguages/defaultLanguage.js
packages/editor/CodeMirror/utils/markdown/codeBlockLanguages/lookUpLanguage.js
packages/editor/CodeMirror/utils/markdown/renumberSelectedLists.test.js
packages/editor/CodeMirror/utils/markdown/renumberSelectedLists.js
packages/editor/CodeMirror/utils/markdown/stripBlockquote.js
packages/editor/CodeMirror/utils/setupVim.js
packages/editor/ProseMirror/commands.test.js
packages/editor/ProseMirror/commands.js
packages/editor/ProseMirror/createEditor.js
packages/editor/ProseMirror/index.js
packages/editor/ProseMirror/plugins/inputRulesPlugin.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin/createEditorDialog.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.test.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin/postProcessRenderedHtml.js
packages/editor/ProseMirror/plugins/joplinEditorApiPlugin.js
packages/editor/ProseMirror/plugins/keymapPlugin.js
packages/editor/ProseMirror/plugins/linkTooltipPlugin.test.js
packages/editor/ProseMirror/plugins/linkTooltipPlugin.js
packages/editor/ProseMirror/plugins/listPlugin.js
packages/editor/ProseMirror/plugins/originalMarkupPlugin.js
packages/editor/ProseMirror/plugins/resourcePlaceholderPlugin.js
packages/editor/ProseMirror/plugins/searchPlugin.js
packages/editor/ProseMirror/schema.js
packages/editor/ProseMirror/styles.js
packages/editor/ProseMirror/testing/createTestEditor.js
packages/editor/ProseMirror/types.js
packages/editor/ProseMirror/utils/UndoStackSynchronizer.js
packages/editor/ProseMirror/utils/canReplaceSelectionWith.js
packages/editor/ProseMirror/utils/computeSelectionFormatting.js
packages/editor/ProseMirror/utils/dom/createTextArea.js
packages/editor/ProseMirror/utils/dom/createTextNode.js
packages/editor/ProseMirror/utils/dom/createUniqueId.js
packages/editor/ProseMirror/utils/extractSelectedLinesTo.test.js
packages/editor/ProseMirror/utils/extractSelectedLinesTo.js
packages/editor/ProseMirror/utils/jumpToHash.js
packages/editor/ProseMirror/utils/preprocessEditorInput.test.js
packages/editor/ProseMirror/utils/preprocessEditorInput.js
packages/editor/ProseMirror/utils/sanitizeHtml.js
packages/editor/ProseMirror/utils/trimEmptyParagraphs.js
packages/editor/ProseMirror/vendor/changedDescendants.js
packages/editor/ProseMirror/vendor/splitBlockAs.js
packages/editor/SelectionFormatting.js
packages/editor/events.js
packages/editor/polyfills.js
packages/editor/testing/createEditorSettings.js
packages/editor/testing/setUpLogger.js
packages/editor/types.js
packages/editor/utils/getFileFromPasteEvent.js
packages/fork-htmlparser2/src/CollectingHandler.js
packages/fork-htmlparser2/src/FeedHandler.spec.js
packages/fork-htmlparser2/src/FeedHandler.js
@@ -1063,6 +1165,8 @@ packages/lib/JoplinDatabase.js
packages/lib/JoplinError.js
packages/lib/JoplinServerApi.js
packages/lib/ObjectUtils.js
packages/lib/PerformanceLogger.test.js
packages/lib/PerformanceLogger.js
packages/lib/PoorManIntervals.js
packages/lib/RotatingLogs.test.js
packages/lib/RotatingLogs.js
@@ -1080,6 +1184,8 @@ packages/lib/array.js
packages/lib/callbackUrlUtils.test.js
packages/lib/callbackUrlUtils.js
packages/lib/clipperUtils.js
packages/lib/commands/convertHtmlToMarkdown.test.js
packages/lib/commands/convertHtmlToMarkdown.js
packages/lib/commands/deleteNote.js
packages/lib/commands/historyBackward.js
packages/lib/commands/historyForward.js
@@ -1096,6 +1202,8 @@ packages/lib/commands/toggleAllFolders.js
packages/lib/commands/toggleEditorPlugin.js
packages/lib/components/EncryptionConfigScreen/utils.test.js
packages/lib/components/EncryptionConfigScreen/utils.js
packages/lib/components/shared/NoteEditor/WarningBanner/onRichTextDismissLinkClick.js
packages/lib/components/shared/NoteEditor/WarningBanner/onRichTextReadMoreLinkClick.js
packages/lib/components/shared/NoteList/getEmptyFolderMessage.js
packages/lib/components/shared/NoteRevisionViewer/getHelpMessage.js
packages/lib/components/shared/NoteRevisionViewer/useDeleteHistoryClick.js
@@ -1272,6 +1380,7 @@ packages/lib/services/database/migrations/44.js
packages/lib/services/database/migrations/45.js
packages/lib/services/database/migrations/46.js
packages/lib/services/database/migrations/47.js
packages/lib/services/database/migrations/48.js
packages/lib/services/database/migrations/index.js
packages/lib/services/database/sqlStringToLines.js
packages/lib/services/database/types.js
@@ -1340,6 +1449,8 @@ packages/lib/services/ocr/OcrDriverBase.js
packages/lib/services/ocr/OcrService.test.js
packages/lib/services/ocr/OcrService.js
packages/lib/services/ocr/drivers/OcrDriverTesseract.js
packages/lib/services/ocr/drivers/OcrDriverTranscribe.test.js
packages/lib/services/ocr/drivers/OcrDriverTranscribe.js
packages/lib/services/ocr/utils/filterOcrText.test.js
packages/lib/services/ocr/utils/filterOcrText.js
packages/lib/services/ocr/utils/types.js
@@ -1509,6 +1620,7 @@ packages/lib/shim-init-node.js
packages/lib/shim.js
packages/lib/string-utils.test.js
packages/lib/string-utils.js
packages/lib/testing/plugins/createTestPlugin.js
packages/lib/testing/share/makeMockShareInvitation.js
packages/lib/testing/share/mockShareService.js
packages/lib/testing/syncTargetUtils.js
@@ -1659,6 +1771,7 @@ packages/tools/fuzzer/utils/SeededRandom.js
packages/tools/fuzzer/utils/getNumberProperty.js
packages/tools/fuzzer/utils/getProperty.js
packages/tools/fuzzer/utils/getStringProperty.js
packages/tools/fuzzer/utils/openDebugSession.js
packages/tools/fuzzer/utils/retryWithCount.js
packages/tools/generate-database-types.js
packages/tools/generate-images.js
@@ -1690,6 +1803,7 @@ packages/tools/release-electron.js
packages/tools/release-ios.js
packages/tools/release-plugin-repo-cli.js
packages/tools/release-server.js
packages/tools/release-transcribe.js
packages/tools/saveClaConsentRecords.js
packages/tools/setupNewRelease.js
packages/tools/spellcheck.js

View File

@@ -23,6 +23,7 @@ module.exports = {
'FileSystemCreateWritableOptions': 'readonly',
'FileSystemHandle': 'readonly',
'IDBTransactionMode': 'readonly',
'FlatArray': 'readonly',
'BigInt': 'readonly',
'globalThis': 'readonly',

View File

@@ -7,9 +7,13 @@
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
ROOT_DIR="$SCRIPT_DIR/../.."
TRANSCRIBE_TAG_PREFIX=transcribe
TRANSCRIBE_REPOSITORY=joplin/transcribe
IS_PULL_REQUEST=0
IS_DESKTOP_RELEASE=0
IS_SERVER_RELEASE=0
IS_TRANSCRIBE_RELEASE=0
IS_LINUX=0
IS_MACOS=0
@@ -23,6 +27,10 @@ if [[ $GIT_TAG_NAME = $SERVER_TAG_PREFIX-* ]]; then
IS_SERVER_RELEASE=1
fi
if [[ $GIT_TAG_NAME = $TRANSCRIBE_TAG_PREFIX-* ]]; then
IS_TRANSCRIBE_RELEASE=1
fi
if [[ $GIT_TAG_NAME = v* ]]; then
IS_DESKTOP_RELEASE=1
fi
@@ -41,15 +49,17 @@ DOCKER_IMAGE_PLATFORM="linux/amd64"
# a release
RUN_TESTS=0
if [ "$IS_SERVER_RELEASE" = 0 ] && [ "$IS_DESKTOP_RELEASE" = 0 ]; then
if [ "$IS_SERVER_RELEASE" = 0 ] && [ "$IS_DESKTOP_RELEASE" = 0 ] && [ "$IS_TRANSCRIBE_RELEASE" = 0 ]; then
RUN_TESTS=1
fi
if [ "$RUNNER_ARCH" == "ARM64" ] && [ "$IS_SERVER_RELEASE" == "0" ]; then
# We exit now because nothing works properly with the ARM64 architecture.
# We only proceed if building the server image.
echo "Running on ARM64 and not trying to build server image - early exit"
exit 0
if [ "$RUNNER_ARCH" == "ARM64" ]; then
if [ "$IS_SERVER_RELEASE" == "0" ] && [ "$IS_TRANSCRIBE_RELEASE" == "0" ]; then
# We exit now because nothing works properly with the ARM64 architecture.
# We only proceed if building the server image.
echo "Running on ARM64 and not trying to build server image - early exit"
exit 0
fi
fi
if [ "$RUNNER_ARCH" == "ARM64" ]; then
@@ -80,12 +90,14 @@ echo "GIT_TAG_NAME=$GIT_TAG_NAME"
echo "BUILD_SEQUENCIAL=$BUILD_SEQUENCIAL"
echo "SERVER_REPOSITORY=$SERVER_REPOSITORY"
echo "SERVER_TAG_PREFIX=$SERVER_TAG_PREFIX"
echo "TRANSCRIBE_TAG_PREFIX=$TRANSCRIBE_TAG_PREFIX"
echo "DOCKER_IMAGE_PLATFORM=$DOCKER_IMAGE_PLATFORM"
echo "IS_CONTINUOUS_INTEGRATION=$IS_CONTINUOUS_INTEGRATION"
echo "IS_PULL_REQUEST=$IS_PULL_REQUEST"
echo "IS_DESKTOP_RELEASE=$IS_DESKTOP_RELEASE"
echo "IS_SERVER_RELEASE=$IS_SERVER_RELEASE"
echo "IS_TRANSCRIBE_RELEASE=$IS_TRANSCRIBE_RELEASE"
echo "RUN_TESTS=$RUN_TESTS"
echo "IS_LINUX=$IS_LINUX"
echo "IS_MACOS=$IS_MACOS"
@@ -301,9 +313,13 @@ if [ "$IS_DESKTOP_RELEASE" == "1" ]; then
USE_HARD_LINKS=false yarn dist
fi
elif [[ $IS_LINUX = 1 ]] && [ "$IS_SERVER_RELEASE" == "1" ]; then
echo "Step: Building Docker Image..."
echo "Step: Building Joplin Server Docker Image..."
cd "$ROOT_DIR"
yarn buildServerDocker --platform $DOCKER_IMAGE_PLATFORM --tag-name $GIT_TAG_NAME --push-images --repository $SERVER_REPOSITORY
yarn buildServerDocker --docker-file Dockerfile.server --platform $DOCKER_IMAGE_PLATFORM --tag-name $GIT_TAG_NAME --push-images --repository $SERVER_REPOSITORY
elif [[ $IS_LINUX = 1 ]] && [ "$IS_TRANSCRIBE_RELEASE" == "1" ]; then
echo "Step: Building Joplin Transcribe Docker Image..."
cd "$ROOT_DIR"
yarn buildServerDocker --docker-file Dockerfile.transcribe --platform $DOCKER_IMAGE_PLATFORM --tag-name $GIT_TAG_NAME --push-images --repository $TRANSCRIBE_REPOSITORY
else
echo "Step: Building but *not* publishing desktop application..."

View File

@@ -17,7 +17,6 @@ jobs:
uses: ./.github/workflows/shared/setup-build-environment
- name: Install Docker Engine
# if: runner.os == 'Linux' && startsWith(github.ref, 'refs/tags/server-v')
if: runner.os == 'Linux'
run: |
sudo apt-get install -y apt-transport-https
@@ -36,7 +35,7 @@ jobs:
# a pull request it will fail because the PR doesn't have access to
# secrets
- uses: docker/login-action@v3
if: runner.os == 'Linux' && startsWith(github.ref, 'refs/tags/server-v')
if: runner.os == 'Linux' && (startsWith(github.ref, 'refs/tags/server-v') || startsWith(github.ref, 'refs/tags/transcribe-v'))
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -141,7 +140,7 @@ jobs:
echo "DOCKER_IMAGE_PLATFORM=$DOCKER_IMAGE_PLATFORM"
yarn install
yarn buildServerDocker --platform $DOCKER_IMAGE_PLATFORM --tag-name server-v0.0.0 --repository joplin/server
yarn buildServerDocker --docker-file Dockerfile.server --platform $DOCKER_IMAGE_PLATFORM --tag-name server-v0.0.0 --repository joplin/server
# Basic test to ensure that the created build is valid. It should exit with
# code 0 if it works.

222
.gitignore vendored
View File

@@ -71,6 +71,7 @@ packages/app-cli/app/app.js
packages/app-cli/app/base-command.js
packages/app-cli/app/command-apidoc.js
packages/app-cli/app/command-attach.js
packages/app-cli/app/command-batch.js
packages/app-cli/app/command-cat.js
packages/app-cli/app/command-config.js
packages/app-cli/app/command-cp.js
@@ -108,6 +109,7 @@ packages/app-cli/app/gui/StatusBarWidget.js
packages/app-cli/app/services/plugins/PluginRunner.js
packages/app-cli/app/setupCommand.js
packages/app-cli/app/utils/initializeCommandService.js
packages/app-cli/app/utils/iterateStdin.js
packages/app-cli/app/utils/shimInitCli.js
packages/app-cli/app/utils/testUtils.js
packages/app-cli/tests/HtmlToMd.js
@@ -130,6 +132,8 @@ packages/app-desktop/app.reducer.js
packages/app-desktop/app.js
packages/app-desktop/bridge.js
packages/app-desktop/checkForUpdates.js
packages/app-desktop/commands/convertNoteToMarkdown.test.js
packages/app-desktop/commands/convertNoteToMarkdown.js
packages/app-desktop/commands/copyDevCommand.js
packages/app-desktop/commands/copyToClipboard.js
packages/app-desktop/commands/editProfileConfig.js
@@ -170,6 +174,7 @@ packages/app-desktop/gui/ConfigScreen/controls/ToggleAdvancedSettingsButton.js
packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.js
packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.js
packages/app-desktop/gui/ConfigScreen/controls/plugins/SearchPlugins.js
packages/app-desktop/gui/ConversionNotification/ConversionNotification.js
packages/app-desktop/gui/Dialog.js
packages/app-desktop/gui/DialogButtonRow.js
packages/app-desktop/gui/DialogButtonRow/useKeyboardHandler.js
@@ -271,6 +276,7 @@ packages/app-desktop/gui/NoteEditor/utils/clipboardUtils.test.js
packages/app-desktop/gui/NoteEditor/utils/clipboardUtils.js
packages/app-desktop/gui/NoteEditor/utils/contextMenu.js
packages/app-desktop/gui/NoteEditor/utils/contextMenuUtils.js
packages/app-desktop/gui/NoteEditor/utils/getResourceBaseUrl.js
packages/app-desktop/gui/NoteEditor/utils/getWindowCommandPriority.js
packages/app-desktop/gui/NoteEditor/utils/index.js
packages/app-desktop/gui/NoteEditor/utils/markupRenderOptions.js
@@ -528,6 +534,8 @@ packages/app-desktop/integration-tests/util/setSettingValue.js
packages/app-desktop/integration-tests/util/test.js
packages/app-desktop/integration-tests/util/waitForNextOpenPath.js
packages/app-desktop/integration-tests/wcag.spec.js
packages/app-desktop/main-html.js
packages/app-desktop/main.js
packages/app-desktop/playwright.config.js
packages/app-desktop/plugins/GotoAnything.js
packages/app-desktop/services/autoUpdater/AutoUpdaterService.test.js
@@ -600,16 +608,21 @@ packages/app-mobile/components/CameraView/Camera/index.web.js
packages/app-mobile/components/CameraView/Camera/types.js
packages/app-mobile/components/CameraView/CameraView.test.js
packages/app-mobile/components/CameraView/CameraView.js
packages/app-mobile/components/CameraView/CameraView.web.js
packages/app-mobile/components/CameraView/CameraViewMultiPage.test.js
packages/app-mobile/components/CameraView/CameraViewMultiPage.js
packages/app-mobile/components/CameraView/PhotoPreview.js
packages/app-mobile/components/CameraView/ScannedBarcodes.js
packages/app-mobile/components/CameraView/types.js
packages/app-mobile/components/CameraView/utils/fitRectIntoBounds.js
packages/app-mobile/components/CameraView/utils/testing.js
packages/app-mobile/components/CameraView/utils/useBarcodeScanner.js
packages/app-mobile/components/Checkbox.js
packages/app-mobile/components/ComboBox.test.js
packages/app-mobile/components/ComboBox.js
packages/app-mobile/components/DialogManager/PromptButton.js
packages/app-mobile/components/DialogManager/PromptDialog.js
packages/app-mobile/components/DialogManager/TextInputDialog.js
packages/app-mobile/components/DialogManager/hooks/useDialogControl.js
packages/app-mobile/components/DialogManager/index.js
packages/app-mobile/components/DialogManager/types.js
@@ -631,64 +644,56 @@ packages/app-mobile/components/ExtendedWebView/index.jest.js
packages/app-mobile/components/ExtendedWebView/index.js
packages/app-mobile/components/ExtendedWebView/index.web.js
packages/app-mobile/components/ExtendedWebView/types.js
packages/app-mobile/components/ExtendedWebView/utils/useCss.js
packages/app-mobile/components/FolderPicker.js
packages/app-mobile/components/Icon.js
packages/app-mobile/components/IconButton.js
packages/app-mobile/components/Modal.js
packages/app-mobile/components/ModalDialog.js
packages/app-mobile/components/NestableFlatList.js
packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.test.js
packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.js
packages/app-mobile/components/NoteBodyViewer/bundledJs/Renderer.test.js
packages/app-mobile/components/NoteBodyViewer/bundledJs/Renderer.js
packages/app-mobile/components/NoteBodyViewer/bundledJs/noteBodyViewerBundle.js
packages/app-mobile/components/NoteBodyViewer/bundledJs/types.js
packages/app-mobile/components/NoteBodyViewer/bundledJs/utils/addPluginAssets.js
packages/app-mobile/components/NoteBodyViewer/bundledJs/utils/makeResourceModel.js
packages/app-mobile/components/NoteBodyViewer/hooks/useContentScripts.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/useRenderer.js
packages/app-mobile/components/NoteBodyViewer/hooks/useRerenderHandler.js
packages/app-mobile/components/NoteBodyViewer/hooks/useSource.js
packages/app-mobile/components/NoteBodyViewer/types.js
packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.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/polyfills.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/ImageEditor/utils/useEditorMessenger.js
packages/app-mobile/components/NoteEditor/MarkdownEditor.js
packages/app-mobile/components/NoteEditor/NoteEditor.test.js
packages/app-mobile/components/NoteEditor/NoteEditor.js
packages/app-mobile/components/NoteEditor/RichTextEditor.test.js
packages/app-mobile/components/NoteEditor/RichTextEditor.js
packages/app-mobile/components/NoteEditor/SearchPanel.js
packages/app-mobile/components/NoteEditor/WarningBanner.js
packages/app-mobile/components/NoteEditor/commandDeclarations.js
packages/app-mobile/components/NoteEditor/hooks/useCodeMirrorPlugins.js
packages/app-mobile/components/NoteEditor/hooks/useEditorCommandHandler.test.js
packages/app-mobile/components/NoteEditor/hooks/useEditorCommandHandler.js
packages/app-mobile/components/NoteEditor/testing/createTestEditorProps.js
packages/app-mobile/components/NoteEditor/types.js
packages/app-mobile/components/NoteItem.js
packages/app-mobile/components/NoteList.js
packages/app-mobile/components/ProfileSwitcher/ProfileEditor.js
packages/app-mobile/components/ProfileSwitcher/ProfileSwitcher.js
packages/app-mobile/components/ProfileSwitcher/useProfileConfig.js
packages/app-mobile/components/SafeAreaView.js
packages/app-mobile/components/ScreenHeader/Menu.js
packages/app-mobile/components/ScreenHeader/WarningBanner.test.js
packages/app-mobile/components/ScreenHeader/WarningBanner.js
packages/app-mobile/components/ScreenHeader/WarningBox.js
packages/app-mobile/components/ScreenHeader/WebBetaButton.js
packages/app-mobile/components/ScreenHeader/index.js
packages/app-mobile/components/SearchInput.js
packages/app-mobile/components/SelectDateTimeDialog.js
packages/app-mobile/components/SideMenu.js
packages/app-mobile/components/SideMenuContentNote.js
packages/app-mobile/components/TagEditor.test.js
packages/app-mobile/components/TagEditor.js
packages/app-mobile/components/TextInput.js
packages/app-mobile/components/accessibility/AccessibleView.test.js
packages/app-mobile/components/accessibility/AccessibleView.js
@@ -713,6 +718,7 @@ packages/app-mobile/components/getResponsiveValue.js
packages/app-mobile/components/global-style.js
packages/app-mobile/components/plugins/PluginNotification.js
packages/app-mobile/components/plugins/PluginRunner.js
packages/app-mobile/components/plugins/PluginRunnerWebView.test.js
packages/app-mobile/components/plugins/PluginRunnerWebView.js
packages/app-mobile/components/plugins/backgroundPage/initializeDialogWebView.js
packages/app-mobile/components/plugins/backgroundPage/initializePluginBackgroundIframe.js
@@ -784,6 +790,8 @@ packages/app-mobile/components/screens/ConfigScreen/plugins/utils/usePluginItem.
packages/app-mobile/components/screens/ConfigScreen/plugins/utils/useRepoApi.js
packages/app-mobile/components/screens/ConfigScreen/plugins/utils/useUpdateState.js
packages/app-mobile/components/screens/ConfigScreen/types.js
packages/app-mobile/components/screens/DocumentScanner/DocumentScanner.js
packages/app-mobile/components/screens/DocumentScanner/NotePreview.js
packages/app-mobile/components/screens/JoplinCloudLoginScreen.js
packages/app-mobile/components/screens/LogScreen.js
packages/app-mobile/components/screens/Note/Note.test.js
@@ -822,6 +830,37 @@ packages/app-mobile/components/voiceTyping/AudioRecordingBanner.js
packages/app-mobile/components/voiceTyping/RecordingControls.js
packages/app-mobile/components/voiceTyping/SpeechToTextBanner.js
packages/app-mobile/components/voiceTyping/types.js
packages/app-mobile/contentScripts/imageEditorBundle/contentScript/applyTemplateToEditor.js
packages/app-mobile/contentScripts/imageEditorBundle/contentScript/index.test.js
packages/app-mobile/contentScripts/imageEditorBundle/contentScript/index.js
packages/app-mobile/contentScripts/imageEditorBundle/contentScript/startAutosaveLoop.js
packages/app-mobile/contentScripts/imageEditorBundle/contentScript/types.js
packages/app-mobile/contentScripts/imageEditorBundle/contentScript/watchEditorForTemplateChanges.js
packages/app-mobile/contentScripts/imageEditorBundle/useWebViewSetup.js
packages/app-mobile/contentScripts/imageEditorBundle/utils/useEditorMessenger.js
packages/app-mobile/contentScripts/markdownEditorBundle/contentScript.js
packages/app-mobile/contentScripts/markdownEditorBundle/types.js
packages/app-mobile/contentScripts/markdownEditorBundle/useWebViewSetup.js
packages/app-mobile/contentScripts/rendererBundle/contentScript/Renderer.test.js
packages/app-mobile/contentScripts/rendererBundle/contentScript/Renderer.js
packages/app-mobile/contentScripts/rendererBundle/contentScript/index.js
packages/app-mobile/contentScripts/rendererBundle/contentScript/types.js
packages/app-mobile/contentScripts/rendererBundle/contentScript/utils/addPluginAssets.js
packages/app-mobile/contentScripts/rendererBundle/contentScript/utils/afterFullPageRender.js
packages/app-mobile/contentScripts/rendererBundle/contentScript/utils/makeResourceModel.js
packages/app-mobile/contentScripts/rendererBundle/types.js
packages/app-mobile/contentScripts/rendererBundle/useWebViewSetup.js
packages/app-mobile/contentScripts/rendererBundle/utils/useContentScripts.js
packages/app-mobile/contentScripts/rendererBundle/utils/useEditPopup.test.js
packages/app-mobile/contentScripts/rendererBundle/utils/useEditPopup.js
packages/app-mobile/contentScripts/richTextEditorBundle/contentScript/convertHtmlToMarkdown.js
packages/app-mobile/contentScripts/richTextEditorBundle/contentScript/index.js
packages/app-mobile/contentScripts/richTextEditorBundle/types.js
packages/app-mobile/contentScripts/richTextEditorBundle/useWebViewSetup.js
packages/app-mobile/contentScripts/types.js
packages/app-mobile/contentScripts/utils/polyfills.js
packages/app-mobile/contentScripts/utils/readFileToBase64.js
packages/app-mobile/contentScripts/utils/setUpLogger.js
packages/app-mobile/gulpfile.js
packages/app-mobile/index.web.js
packages/app-mobile/root.js
@@ -844,7 +883,7 @@ packages/app-mobile/services/voiceTyping/whisper.js
packages/app-mobile/setupQuickActions.js
packages/app-mobile/tools/buildInjectedJs/BundledFile.js
packages/app-mobile/tools/buildInjectedJs/constants.js
packages/app-mobile/tools/buildInjectedJs/copyJs.js
packages/app-mobile/tools/buildInjectedJs/copyAssets.js
packages/app-mobile/tools/buildInjectedJs/gulpTasks.js
packages/app-mobile/tools/copyAssets.js
packages/app-mobile/utils/ShareExtension.js
@@ -853,6 +892,7 @@ packages/app-mobile/utils/ShareUtils.js
packages/app-mobile/utils/TlsUtils.js
packages/app-mobile/utils/appDefaultState.js
packages/app-mobile/utils/autodetectTheme.js
packages/app-mobile/utils/buildStartupTasks.js
packages/app-mobile/utils/checkPermissions.js
packages/app-mobile/utils/createRootStyle.js
packages/app-mobile/utils/database-driver-react-native.js
@@ -880,7 +920,6 @@ packages/app-mobile/utils/image/fileToImage.web.js
packages/app-mobile/utils/image/getImageDimensions.js
packages/app-mobile/utils/image/resizeImage.js
packages/app-mobile/utils/initializeCommandService.js
packages/app-mobile/utils/injectedJs.js
packages/app-mobile/utils/ipc/RNToWebViewMessenger.js
packages/app-mobile/utils/ipc/WebViewToRNMessenger.js
packages/app-mobile/utils/lockToSingleInstance.js
@@ -926,47 +965,67 @@ packages/editor/CodeMirror/editorCommands/duplicateLine.js
packages/editor/CodeMirror/editorCommands/editorCommands.js
packages/editor/CodeMirror/editorCommands/insertLineAfter.test.js
packages/editor/CodeMirror/editorCommands/insertLineAfter.js
packages/editor/CodeMirror/editorCommands/insertNewlineContinueMarkup.test.js
packages/editor/CodeMirror/editorCommands/insertNewlineContinueMarkup.js
packages/editor/CodeMirror/editorCommands/jumpToHash.test.js
packages/editor/CodeMirror/editorCommands/jumpToHash.js
packages/editor/CodeMirror/editorCommands/markdownCommands.bulletedVsChecklist.test.js
packages/editor/CodeMirror/editorCommands/markdownCommands.test.js
packages/editor/CodeMirror/editorCommands/markdownCommands.toggleList.test.js
packages/editor/CodeMirror/editorCommands/markdownCommands.js
packages/editor/CodeMirror/editorCommands/sortSelectedLines.test.js
packages/editor/CodeMirror/editorCommands/sortSelectedLines.js
packages/editor/CodeMirror/editorCommands/supportsCommand.js
packages/editor/CodeMirror/extensions/biDirectionalTextExtension.js
packages/editor/CodeMirror/extensions/keyUpHandlerExtension.js
packages/editor/CodeMirror/extensions/links/ctrlClickLinksExtension.js
packages/editor/CodeMirror/extensions/links/followLinkTooltipExtension.test.js
packages/editor/CodeMirror/extensions/links/followLinkTooltipExtension.js
packages/editor/CodeMirror/extensions/links/referenceLinksStateField.js
packages/editor/CodeMirror/extensions/links/utils/findLineMatchingLink.test.js
packages/editor/CodeMirror/extensions/links/utils/findLineMatchingLink.js
packages/editor/CodeMirror/extensions/links/utils/getUrlAtPosition.js
packages/editor/CodeMirror/extensions/links/utils/openLink.js
packages/editor/CodeMirror/extensions/markdownDecorationExtension.test.js
packages/editor/CodeMirror/extensions/markdownDecorationExtension.js
packages/editor/CodeMirror/extensions/markdownHighlightExtension.test.js
packages/editor/CodeMirror/extensions/markdownHighlightExtension.js
packages/editor/CodeMirror/extensions/markdownMathExtension.test.js
packages/editor/CodeMirror/extensions/markdownMathExtension.js
packages/editor/CodeMirror/extensions/modifierKeyCssExtension.js
packages/editor/CodeMirror/extensions/overwriteModeExtension.test.js
packages/editor/CodeMirror/extensions/overwriteModeExtension.js
packages/editor/CodeMirror/extensions/rendering/addFormattingClasses.js
packages/editor/CodeMirror/extensions/rendering/renderBlockImages.test.js
packages/editor/CodeMirror/extensions/rendering/renderBlockImages.js
packages/editor/CodeMirror/extensions/rendering/renderingExtension.js
packages/editor/CodeMirror/extensions/rendering/replaceBulletLists.js
packages/editor/CodeMirror/extensions/rendering/replaceCheckboxes.js
packages/editor/CodeMirror/extensions/rendering/replaceDividers.js
packages/editor/CodeMirror/extensions/rendering/replaceFormatCharacters.js
packages/editor/CodeMirror/extensions/rendering/types.js
packages/editor/CodeMirror/extensions/rendering/utils/makeBlockReplaceExtension.js
packages/editor/CodeMirror/extensions/rendering/utils/makeInlineReplaceExtension.js
packages/editor/CodeMirror/extensions/rendering/utils/nodeIntersectsSelection.js
packages/editor/CodeMirror/extensions/searchExtension.js
packages/editor/CodeMirror/extensions/selectedNoteIdExtension.js
packages/editor/CodeMirror/getScrollFraction.js
packages/editor/CodeMirror/markdown/MarkdownHighlightExtension.test.js
packages/editor/CodeMirror/markdown/MarkdownHighlightExtension.js
packages/editor/CodeMirror/markdown/MarkdownMathExtension.test.js
packages/editor/CodeMirror/markdown/MarkdownMathExtension.js
packages/editor/CodeMirror/markdown/codeBlockLanguages/allLanguages.js
packages/editor/CodeMirror/markdown/codeBlockLanguages/defaultLanguage.js
packages/editor/CodeMirror/markdown/codeBlockLanguages/lookUpLanguage.js
packages/editor/CodeMirror/markdown/computeSelectionFormatting.test.js
packages/editor/CodeMirror/markdown/computeSelectionFormatting.js
packages/editor/CodeMirror/markdown/decoratorExtension.test.js
packages/editor/CodeMirror/markdown/decoratorExtension.js
packages/editor/CodeMirror/markdown/insertNewlineContinueMarkup.test.js
packages/editor/CodeMirror/markdown/insertNewlineContinueMarkup.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/utils/renumberSelectedLists.test.js
packages/editor/CodeMirror/markdown/utils/renumberSelectedLists.js
packages/editor/CodeMirror/markdown/utils/stripBlockquote.js
packages/editor/CodeMirror/index.js
packages/editor/CodeMirror/pluginApi/PluginLoader.js
packages/editor/CodeMirror/pluginApi/codeMirrorRequire.js
packages/editor/CodeMirror/pluginApi/customEditorCompletion.test.js
packages/editor/CodeMirror/pluginApi/customEditorCompletion.js
packages/editor/CodeMirror/testUtil/createEditorControl.js
packages/editor/CodeMirror/testUtil/createEditorSettings.js
packages/editor/CodeMirror/testUtil/createTestEditor.js
packages/editor/CodeMirror/testUtil/findNodesWithName.js
packages/editor/CodeMirror/testUtil/forceFullParse.js
packages/editor/CodeMirror/testUtil/loadLanguages.js
packages/editor/CodeMirror/testUtil/pressReleaseKey.js
packages/editor/CodeMirror/testUtil/typeText.js
packages/editor/CodeMirror/testing/createEditorControl.js
packages/editor/CodeMirror/testing/createTestEditor.js
packages/editor/CodeMirror/testing/findNodesWithName.js
packages/editor/CodeMirror/testing/forceFullParse.js
packages/editor/CodeMirror/testing/loadLanguages.js
packages/editor/CodeMirror/testing/pressReleaseKey.js
packages/editor/CodeMirror/testing/typeText.js
packages/editor/CodeMirror/theme.js
packages/editor/CodeMirror/utils/biDirectionalTextExtension.js
packages/editor/CodeMirror/utils/formatting/RegionSpec.js
packages/editor/CodeMirror/utils/formatting/computeSelectionFormatting.test.js
packages/editor/CodeMirror/utils/formatting/computeSelectionFormatting.js
packages/editor/CodeMirror/utils/formatting/findInlineMatch.test.js
packages/editor/CodeMirror/utils/formatting/findInlineMatch.js
packages/editor/CodeMirror/utils/formatting/isIndentationEquivalent.js
@@ -985,15 +1044,56 @@ packages/editor/CodeMirror/utils/handleLinkEditRequests.js
packages/editor/CodeMirror/utils/handlePasteEvent.js
packages/editor/CodeMirror/utils/isCursorAtBeginning.js
packages/editor/CodeMirror/utils/isInSyntaxNode.js
packages/editor/CodeMirror/utils/keyUpHandlerExtension.js
packages/editor/CodeMirror/utils/overwriteModeExtension.test.js
packages/editor/CodeMirror/utils/overwriteModeExtension.js
packages/editor/CodeMirror/utils/searchExtension.js
packages/editor/CodeMirror/utils/selectedNoteIdExtension.js
packages/editor/CodeMirror/utils/markdown/codeBlockLanguages/allLanguages.js
packages/editor/CodeMirror/utils/markdown/codeBlockLanguages/defaultLanguage.js
packages/editor/CodeMirror/utils/markdown/codeBlockLanguages/lookUpLanguage.js
packages/editor/CodeMirror/utils/markdown/renumberSelectedLists.test.js
packages/editor/CodeMirror/utils/markdown/renumberSelectedLists.js
packages/editor/CodeMirror/utils/markdown/stripBlockquote.js
packages/editor/CodeMirror/utils/setupVim.js
packages/editor/ProseMirror/commands.test.js
packages/editor/ProseMirror/commands.js
packages/editor/ProseMirror/createEditor.js
packages/editor/ProseMirror/index.js
packages/editor/ProseMirror/plugins/inputRulesPlugin.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin/createEditorDialog.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.test.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin/postProcessRenderedHtml.js
packages/editor/ProseMirror/plugins/joplinEditorApiPlugin.js
packages/editor/ProseMirror/plugins/keymapPlugin.js
packages/editor/ProseMirror/plugins/linkTooltipPlugin.test.js
packages/editor/ProseMirror/plugins/linkTooltipPlugin.js
packages/editor/ProseMirror/plugins/listPlugin.js
packages/editor/ProseMirror/plugins/originalMarkupPlugin.js
packages/editor/ProseMirror/plugins/resourcePlaceholderPlugin.js
packages/editor/ProseMirror/plugins/searchPlugin.js
packages/editor/ProseMirror/schema.js
packages/editor/ProseMirror/styles.js
packages/editor/ProseMirror/testing/createTestEditor.js
packages/editor/ProseMirror/types.js
packages/editor/ProseMirror/utils/UndoStackSynchronizer.js
packages/editor/ProseMirror/utils/canReplaceSelectionWith.js
packages/editor/ProseMirror/utils/computeSelectionFormatting.js
packages/editor/ProseMirror/utils/dom/createTextArea.js
packages/editor/ProseMirror/utils/dom/createTextNode.js
packages/editor/ProseMirror/utils/dom/createUniqueId.js
packages/editor/ProseMirror/utils/extractSelectedLinesTo.test.js
packages/editor/ProseMirror/utils/extractSelectedLinesTo.js
packages/editor/ProseMirror/utils/jumpToHash.js
packages/editor/ProseMirror/utils/preprocessEditorInput.test.js
packages/editor/ProseMirror/utils/preprocessEditorInput.js
packages/editor/ProseMirror/utils/sanitizeHtml.js
packages/editor/ProseMirror/utils/trimEmptyParagraphs.js
packages/editor/ProseMirror/vendor/changedDescendants.js
packages/editor/ProseMirror/vendor/splitBlockAs.js
packages/editor/SelectionFormatting.js
packages/editor/events.js
packages/editor/polyfills.js
packages/editor/testing/createEditorSettings.js
packages/editor/testing/setUpLogger.js
packages/editor/types.js
packages/editor/utils/getFileFromPasteEvent.js
packages/fork-htmlparser2/src/CollectingHandler.js
packages/fork-htmlparser2/src/FeedHandler.spec.js
packages/fork-htmlparser2/src/FeedHandler.js
@@ -1038,6 +1138,8 @@ packages/lib/JoplinDatabase.js
packages/lib/JoplinError.js
packages/lib/JoplinServerApi.js
packages/lib/ObjectUtils.js
packages/lib/PerformanceLogger.test.js
packages/lib/PerformanceLogger.js
packages/lib/PoorManIntervals.js
packages/lib/RotatingLogs.test.js
packages/lib/RotatingLogs.js
@@ -1055,6 +1157,8 @@ packages/lib/array.js
packages/lib/callbackUrlUtils.test.js
packages/lib/callbackUrlUtils.js
packages/lib/clipperUtils.js
packages/lib/commands/convertHtmlToMarkdown.test.js
packages/lib/commands/convertHtmlToMarkdown.js
packages/lib/commands/deleteNote.js
packages/lib/commands/historyBackward.js
packages/lib/commands/historyForward.js
@@ -1071,6 +1175,8 @@ packages/lib/commands/toggleAllFolders.js
packages/lib/commands/toggleEditorPlugin.js
packages/lib/components/EncryptionConfigScreen/utils.test.js
packages/lib/components/EncryptionConfigScreen/utils.js
packages/lib/components/shared/NoteEditor/WarningBanner/onRichTextDismissLinkClick.js
packages/lib/components/shared/NoteEditor/WarningBanner/onRichTextReadMoreLinkClick.js
packages/lib/components/shared/NoteList/getEmptyFolderMessage.js
packages/lib/components/shared/NoteRevisionViewer/getHelpMessage.js
packages/lib/components/shared/NoteRevisionViewer/useDeleteHistoryClick.js
@@ -1247,6 +1353,7 @@ packages/lib/services/database/migrations/44.js
packages/lib/services/database/migrations/45.js
packages/lib/services/database/migrations/46.js
packages/lib/services/database/migrations/47.js
packages/lib/services/database/migrations/48.js
packages/lib/services/database/migrations/index.js
packages/lib/services/database/sqlStringToLines.js
packages/lib/services/database/types.js
@@ -1315,6 +1422,8 @@ packages/lib/services/ocr/OcrDriverBase.js
packages/lib/services/ocr/OcrService.test.js
packages/lib/services/ocr/OcrService.js
packages/lib/services/ocr/drivers/OcrDriverTesseract.js
packages/lib/services/ocr/drivers/OcrDriverTranscribe.test.js
packages/lib/services/ocr/drivers/OcrDriverTranscribe.js
packages/lib/services/ocr/utils/filterOcrText.test.js
packages/lib/services/ocr/utils/filterOcrText.js
packages/lib/services/ocr/utils/types.js
@@ -1484,6 +1593,7 @@ packages/lib/shim-init-node.js
packages/lib/shim.js
packages/lib/string-utils.test.js
packages/lib/string-utils.js
packages/lib/testing/plugins/createTestPlugin.js
packages/lib/testing/share/makeMockShareInvitation.js
packages/lib/testing/share/mockShareService.js
packages/lib/testing/syncTargetUtils.js
@@ -1634,6 +1744,7 @@ packages/tools/fuzzer/utils/SeededRandom.js
packages/tools/fuzzer/utils/getNumberProperty.js
packages/tools/fuzzer/utils/getProperty.js
packages/tools/fuzzer/utils/getStringProperty.js
packages/tools/fuzzer/utils/openDebugSession.js
packages/tools/fuzzer/utils/retryWithCount.js
packages/tools/generate-database-types.js
packages/tools/generate-images.js
@@ -1665,6 +1776,7 @@ packages/tools/release-electron.js
packages/tools/release-ios.js
packages/tools/release-plugin-repo-cli.js
packages/tools/release-server.js
packages/tools/release-transcribe.js
packages/tools/saveClaConsentRecords.js
packages/tools/setupNewRelease.js
packages/tools/spellcheck.js

View File

@@ -219,10 +219,7 @@
$('.feature-description-' + featureId).toggle(200);
});
});
</script>
<script>
const setHostingType = (type) => {
const other = type === 'managed' ? 'self' : 'managed';
$('.toggle-button-' + type).addClass('active');
@@ -244,6 +241,7 @@
setHostingType('self');
});
setHostingType('managed');
const initialHostingType = urlQuery.get('hosting') ? urlQuery.get('hosting') : 'managed';
setHostingType(initialHostingType);
</script>
</div>

View File

@@ -23,7 +23,6 @@ RUN corepack enable
WORKDIR /app
COPY .yarn/plugins ./.yarn/plugins
COPY .yarn/releases ./.yarn/releases
COPY .yarn/patches ./.yarn/patches
COPY package.json .

View File

@@ -5,11 +5,11 @@
"version": "latest",
"platforms": ["aarch64-darwin", "x86_64-darwin"],
},
"yarn": "latest",
"yarn": "1.22.19",
"vips.dev": {
"platforms": ["aarch64-darwin"],
},
"nodejs": "23.9.0",
"nodejs": "23.10.0",
"pkg-config": "latest",
"darwin.apple_sdk.frameworks.Foundation": { // satisfies missing CoreText/CoreText.h
// https://github.com/NixOS/nixpkgs/blob/master/pkgs/os-specific/darwin/apple-sdk/default.nix

View File

@@ -17,11 +17,21 @@
version: '3'
networks:
app-network:
transcribe-network:
shared-network:
services:
db:
image: postgres:16
profiles:
- full
- server
volumes:
- ./data/postgres:/var/lib/postgresql/data
networks:
- app-network
ports:
- "5432:5432"
restart: unless-stopped
@@ -31,10 +41,17 @@ services:
- POSTGRES_DB=${POSTGRES_DATABASE}
app:
image: joplin/server:latest
profiles:
- full
- server
depends_on:
- db
- transcribe
ports:
- "22300:22300"
networks:
- app-network
- shared-network
restart: unless-stopped
environment:
- APP_PORT=22300
@@ -45,3 +62,48 @@ services:
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PORT=${POSTGRES_PORT}
- POSTGRES_HOST=db
- TRANSCRIBE_API_KEY=${TRANSCRIBE_API_KEY}
- TRANSCRIBE_BASE_URL=http://transcribe:4567
- TRANSCRIBE_ENABLED=${TRANSCRIBE_ENABLED}
transcribe-db:
image: postgres:16
profiles:
- full
volumes:
- ./data/transcribe-postgres:/var/lib/postgresql/data
networks:
- transcribe-network
ports:
- "${QUEUE_DATABASE_PORT}:5432"
restart: unless-stopped
environment:
- POSTGRES_PASSWORD=${QUEUE_DATABASE_PASSWORD}
- POSTGRES_USER=${QUEUE_DATABASE_USER}
- POSTGRES_DB=${QUEUE_DATABASE_NAME}
command: -p ${QUEUE_DATABASE_PORT}
transcribe:
image: joplin/transcribe:latest
profiles:
- full
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ${HTR_CLI_IMAGES_FOLDER}:/app/packages/transcribe/images
depends_on:
- transcribe-db
ports:
- "4567:4567"
networks:
- transcribe-network
- shared-network
restart: unless-stopped
environment:
- APP_PORT=4567
- DB_CLIENT=pg
- QUEUE_DATABASE_NAME=${QUEUE_DATABASE_NAME}
- QUEUE_DATABASE_USER=${QUEUE_DATABASE_USER}
- QUEUE_DATABASE_PASSWORD=${QUEUE_DATABASE_PASSWORD}
- QUEUE_DATABASE_PORT=${QUEUE_DATABASE_PORT}
- QUEUE_DATABASE_HOST=transcribe-db
- API_KEY=${TRANSCRIBE_API_KEY}
- HTR_CLI_IMAGES_FOLDER=${HTR_CLI_IMAGES_FOLDER}

View File

@@ -51,6 +51,8 @@
"releasePluginGenerator": "node packages/tools/release-plugin-generator.js",
"releasePluginRepoCli": "node packages/tools/release-plugin-repo-cli.js",
"releaseServer": "node packages/tools/release-server.js",
"releaseTranscribe": "node packages/tools/release-transcribe.js",
"saveClaConsentRecords": "node packages/tools/saveClaConsentRecords.js",
"setupNewRelease": "node ./packages/tools/setupNewRelease",
"spellcheck": "node packages/tools/spellcheck.js",
"tagServerLatest": "node packages/tools/tagServerLatest.js",
@@ -72,35 +74,35 @@
"@typescript-eslint/eslint-plugin": "6.21.0",
"@typescript-eslint/parser": "6.21.0",
"cspell": "5.21.2",
"eslint": "8.57.0",
"eslint": "8.57.1",
"eslint-interactive": "10.8.0",
"eslint-plugin-import": "2.29.1",
"eslint-plugin-import": "2.31.0",
"eslint-plugin-jest": "27.9.0",
"eslint-plugin-promise": "6.2.0",
"eslint-plugin-react": "7.34.3",
"eslint-plugin-promise": "6.6.0",
"eslint-plugin-react": "7.37.4",
"execa": "5.1.1",
"fs-extra": "11.2.0",
"glob": "11.0.1",
"glob": "11.0.2",
"gulp": "4.0.2",
"husky": "9.1.7",
"lerna": "3.22.1",
"lint-staged": "15.5.1",
"lint-staged": "15.5.2",
"madge": "8.0.0",
"npm-package-json-lint": "8.0.0",
"typescript": "5.4.5"
"typescript": "5.8.2"
},
"dependencies": {
"@types/fs-extra": "11.0.4",
"eslint-plugin-github": "4.10.2",
"http-server": "14.1.1",
"node-gyp": "11.2.0",
"nodemon": "3.1.9"
"nodemon": "3.1.10"
},
"packageManager": "yarn@4.9.2",
"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@8.57.0#./.yarn/patches/eslint-npm-8.39.0-d92bace04d.patch",
"eslint": "patch:eslint@8.57.1#./.yarn/patches/eslint-npm-8.39.0-d92bace04d.patch",
"app-builder-lib@24.4.0": "patch:app-builder-lib@npm%3A24.4.0#./.yarn/patches/app-builder-lib-npm-24.4.0-05322ff057.patch",
"nanoid": "patch:nanoid@npm%3A3.3.7#./.yarn/patches/nanoid-npm-3.3.7-98824ba130.patch",
"pdfjs-dist": "patch:pdfjs-dist@npm%3A3.11.174#./.yarn/patches/pdfjs-dist-npm-3.11.174-67f2fee6d6.patch",

View File

@@ -9,7 +9,6 @@ import Tag from '@joplin/lib/models/Tag';
import Setting, { Env } from '@joplin/lib/models/Setting';
import { reg } from '@joplin/lib/registry.js';
import { dirname, fileExtension } from '@joplin/lib/path-utils';
import { splitCommandString } from '@joplin/utils';
import { _ } from '@joplin/lib/locale';
import { pathExists, readFile, readdirSync } from 'fs-extra';
import RevisionService from '@joplin/lib/services/RevisionService';
@@ -19,7 +18,6 @@ import { FolderEntity, NoteEntity } from '@joplin/lib/services/database/types';
import initializeCommandService from './utils/initializeCommandService';
const { cliUtils } = require('./cli-utils.js');
const Cache = require('@joplin/lib/Cache');
const { splitCommandBatch } = require('@joplin/lib/string-utils');
class Application extends BaseApplication {
@@ -381,22 +379,6 @@ class Application extends BaseApplication {
return output;
}
public async commandList(argv: string[]) {
if (argv.length && argv[0] === 'batch') {
const commands = [];
const commandLines = splitCommandBatch(await readFile(argv[1], 'utf-8'));
for (const commandLine of commandLines) {
if (!commandLine.trim()) continue;
const splitted = splitCommandString(commandLine.trim());
commands.push(splitted);
}
return commands;
} else {
return [argv];
}
}
// We need this special case here because by the time the `version` command
// runs, the keychain has already been setup.
public checkIfKeychainEnabled(argv: string[]) {
@@ -433,15 +415,10 @@ class Application extends BaseApplication {
if (argv.length) {
this.gui_ = this.dummyGui();
this.currentFolder_ = await Folder.load(Setting.value('activeFolderId'));
await this.applySettingsSideEffects();
await this.refreshCurrentFolder();
try {
const commands = await this.commandList(argv);
for (const command of commands) {
await this.execCommand(command);
}
await this.execCommand(argv);
} catch (error) {
if (this.showStackTraces_) {
console.error(error);

View File

@@ -1,19 +0,0 @@
const BaseCommand = require('./base-command').default;
const { _ } = require('@joplin/lib/locale');
class Command extends BaseCommand {
usage() {
return 'batch <file-path>';
}
description() {
return _('Runs the commands contained in the text file. There should be one command per line.');
}
async action() {
// Implementation is in app.js::commandList()
throw new Error('No implemented');
}
}
module.exports = Command;

View File

@@ -0,0 +1,79 @@
import { splitCommandBatch } from '@joplin/lib/string-utils';
import BaseCommand from './base-command';
import { _ } from '@joplin/lib/locale';
import { splitCommandString } from '@joplin/utils';
import iterateStdin from './utils/iterateStdin';
import { readFile } from 'fs-extra';
import app from './app';
interface Options {
'file-path': string;
options: {
'continue-on-failure': boolean;
};
}
class Command extends BaseCommand {
public usage() {
return 'batch <file-path>';
}
public options() {
return [
// These are present mostly for testing purposes
['--continue-on-failure', 'Continue running commands when one command in the batch fails.'],
];
}
public description() {
return _('Runs the commands contained in the text file. There should be one command per line.');
}
private streamCommands_ = async function*(filePath: string) {
const processLines = function*(lines: string) {
const commandLines = splitCommandBatch(lines);
for (const command of commandLines) {
if (!command.trim()) continue;
yield splitCommandString(command.trim());
}
};
if (filePath === '-') { // stdin
// Iterating over standard input conflicts with the CLI app's GUI.
if (app().hasGui()) {
throw new Error(_('Reading commands from standard input is only available in CLI mode.'));
}
for await (const lines of iterateStdin('command> ')) {
yield* processLines(lines);
}
} else {
const data = await readFile(filePath, 'utf-8');
yield* processLines(data);
}
};
public async action(options: Options) {
let lastError;
for await (const command of this.streamCommands_(options['file-path'])) {
try {
await app().refreshCurrentFolder();
await app().execCommand(command);
} catch (error) {
if (options.options['continue-on-failure']) {
app().stdout(error.message);
lastError = error;
} else {
throw error;
}
}
}
if (lastError) {
throw lastError;
}
}
}
module.exports = Command;

View File

@@ -14,17 +14,25 @@ class Command extends BaseCommand {
return `${_('Start, stop or check the API server. To specify on which port it should run, set the api.port config variable. Commands are (%s).', ['start', 'stop', 'status'].join('|'))} This is an experimental feature - use at your own risks! It is recommended that the server runs off its own separate profile so that no two CLI instances access that profile at the same time. Use --profile to specify the profile path.`;
}
options() {
return [
['--exit-early', 'Allow the command to exit while the server is still running. The server will still stop when the app exits. Valid only for the `start` subcommand.'],
['--quiet', 'Log less information to the console. More verbose logs will still be available through log-clipper.txt.'],
];
}
async action(args) {
const command = args.command;
const ClipperServer = require('@joplin/lib/ClipperServer').default;
ClipperServer.instance().initialize();
const stdoutFn = (...s) => this.stdout(s.join(' '));
const ignoreOutputFn = ()=>{};
const clipperLogger = new Logger();
clipperLogger.addTarget('file', { path: `${Setting.value('profileDir')}/log-clipper.txt` });
clipperLogger.addTarget('console', { console: {
info: stdoutFn,
warn: stdoutFn,
info: args.options.quiet ? ignoreOutputFn : stdoutFn,
warn: args.options.quiet ? ignoreOutputFn : stdoutFn,
error: stdoutFn,
} });
ClipperServer.instance().setDispatch(() => {});
@@ -38,7 +46,11 @@ class Command extends BaseCommand {
this.stdout(_('Server is already running on port %d', runningOnPort));
} else {
await shim.fsDriver().writeFile(pidPath, process.pid.toString(), 'utf-8');
await ClipperServer.instance().start(); // Never exit
const promise = ClipperServer.instance().start();
if (!args.options['exit-early']) {
await promise; // Never exit
}
}
} else if (command === 'status') {
this.stdout(runningOnPort ? _('Server is running on port %d', runningOnPort) : _('Server is not running.'));

View File

@@ -0,0 +1,54 @@
import { createInterface } from 'readline/promises';
const iterateStdin = async function*(prompt: string) {
let nextLineListeners: (()=> void)[] = [];
const dispatchAllListeners = () => {
const listeners = nextLineListeners;
nextLineListeners = [];
for (const listener of listeners) {
listener();
}
};
const rl = createInterface({
input: process.stdin,
output: process.stdout,
});
rl.setPrompt(prompt);
let buffer: string[] = [];
rl.on('line', (line) => {
buffer.push(line);
dispatchAllListeners();
});
let done = false;
rl.on('close', () => {
done = true;
dispatchAllListeners();
});
const readNextLines = () => {
return new Promise<string|null>(resolve => {
if (done) {
resolve(null);
} else if (buffer.length > 0) {
resolve(buffer.join('\n'));
buffer = [];
} else {
nextLineListeners.push(() => {
resolve(buffer.join('\n'));
buffer = [];
});
}
});
};
while (!done) {
rl.prompt();
const lines = await readNextLines();
yield lines;
}
};
export default iterateStdin;

View File

@@ -9,7 +9,7 @@ const shimInitCli = (options: ShimInitOptions) => {
shim.showMessageBox = async (message: string, options: ShowMessageBoxOptions) => {
const gui = app()?.gui();
let answers = options.buttons ?? [_('Ok'), _('Cancel')];
let answers = options.buttons ?? [_('OK'), _('Cancel')];
if (options.type === 'error' || options.type === 'info') {
answers = [];

View File

@@ -57,7 +57,7 @@
"proper-lockfile": "4.1.2",
"redux": "4.2.1",
"server-destroy": "1.0.1",
"sharp": "0.33.5",
"sharp": "0.34.1",
"sprintf-js": "1.1.3",
"sqlite3": "5.1.6",
"string-padding": "1.0.2",
@@ -72,12 +72,12 @@
"devDependencies": {
"@joplin/tools": "~3.4",
"@types/fs-extra": "11.0.4",
"@types/jest": "29.5.12",
"@types/node": "18.19.86",
"@types/jest": "29.5.14",
"@types/node": "18.19.100",
"@types/proper-lockfile": "^4.1.2",
"gulp": "4.0.2",
"jest": "29.7.0",
"temp": "0.9.4",
"typescript": "5.4.5"
"typescript": "5.8.2"
}
}

View File

@@ -1,6 +1,6 @@
import MarkupToHtml, { MarkupLanguage } from '@joplin/renderer/MarkupToHtml';
import { RenderResult } from '@joplin/renderer/types';
import MarkupToHtml from '@joplin/renderer/MarkupToHtml';
import { RenderResult, MarkupLanguage } from '@joplin/renderer/types';
describe('MarkupToHtml', () => {

View File

@@ -0,0 +1,13 @@
<p>A task list created by the TipTap editor:</p>
<ul data-type="taskList">
<li><label contenteditable="false"><input type="checkbox"><span></span></label>
<div>
<p>Testing...</p>
</div>
</li>
<li><label contenteditable="false"><input type="checkbox"><span></span></label>
<div>
<p>testing</p>
</div>
</li>
</ul>

View File

@@ -0,0 +1,5 @@
A task list created by the TipTap editor:
- [ ] Testing...
- [ ] testing

View File

@@ -0,0 +1,26 @@
<p>List 1:</p>
<ul>
<li><label><input type="checkbox"/>This</label></li>
<li><label><input type="checkbox" checked/>is a test.</label></li>
</ul>
<p>List 2:</p>
<ul>
<li>
<input type="checkbox" id="checkbox-1"/><label for="checkbox-1">This</label>
</li>
<li>
<input type="checkbox" checked id="checkbox-2"/><label for="checkbox-2">is another test.</label>
</li>
</ul>
<p>List 3:</p>
<ul>
<li>
<input type="checkbox" id="checkbox-a1"/><label for="checkbox-a1">This</label>
</li>
<li>
<input type="checkbox" checked id="checkbox-a2"/><label for="checkbox-a2">is another test.</label>
</li>
<li>
<input type="checkbox" checked id="checkbox-a3"/><label for="checkbox-a3"></label>
</li>
</ul>

View File

@@ -0,0 +1,15 @@
List 1:
- [ ] This
- [x] is a test.
List 2:
- [ ] This
- [x] is another test.
List 3:
- [ ] This
- [x] is another test.
- [x] &nbsp;

View File

@@ -1,7 +1,7 @@
<ul class="joplin-checklist">
<ul class="joplin-checklist" data-is-checklist="1">
<li>Not checked</li>
<li class="checked">Checked!!
<ul class="joplin-checklist">
<ul class="joplin-checklist" data-is-checklist="1">
<li class="checked">Indented, with <strong>bold</strong></li>
<li>Indented, not checked</li>
</ul>

View File

@@ -1,15 +1,15 @@
<div class="not-loaded-resource not-loaded-image-resource resource-status-notDownloaded" data-resource-id="a1test2a1test2a1test2a1test22345" data-original-alt data-original-title="test" contenteditable="false"><img src="data:image/svg+xml;utf8,
<span class="not-loaded-resource not-loaded-image-resource resource-status-notDownloaded" data-resource-id="a1test2a1test2a1test2a1test22345" data-original-alt data-original-title="test" contenteditable="false"><img src="data:image/svg+xml;utf8,
&Tab;&Tab;&lt;svg width=&quot;1700&quot; height=&quot;1536&quot; xmlns=&quot;http://www.w3.org/2000/svg&quot;&gt;
&Tab;&Tab; &lt;path d=&quot;M1280 1344c0-35-29-64-64-64s-64 29-64 64 29 64 64 64 64-29 64-64zm256 0c0-35-29-64-64-64s-64 29-64 64 29 64 64 64 64-29 64-64zm128-224v320c0 53-43 96-96 96H96c-53 0-96-43-96-96v-320c0-53 43-96 96-96h465l135 136c37 36 85 56 136 56s99-20 136-56l136-136h464c53 0 96 43 96 96zm-325-569c10 24 5 52-14 70l-448 448c-12 13-29 19-45 19s-33-6-45-19L339 621c-19-18-24-46-14-70 10-23 33-39 59-39h256V64c0-35 29-64 64-64h256c35 0 64 29 64 64v448h256c26 0 49 16 59 39z&quot;/&gt;
&Tab;&Tab;&lt;/svg&gt;
&Tab;"/></div>
<div class="not-loaded-resource not-loaded-image-resource resource-status-notDownloaded" data-resource-id="a1test2a1test2a1test2a1test22346" data-original-alt="test" data-original-title contenteditable="false"><img src="data:image/svg+xml;utf8,
&Tab;"/></span>
<span class="not-loaded-resource not-loaded-image-resource resource-status-notDownloaded" data-resource-id="a1test2a1test2a1test2a1test22346" data-original-alt="test" data-original-title contenteditable="false"><img src="data:image/svg+xml;utf8,
&Tab;&Tab;&lt;svg width=&quot;1700&quot; height=&quot;1536&quot; xmlns=&quot;http://www.w3.org/2000/svg&quot;&gt;
&Tab;&Tab; &lt;path d=&quot;M1280 1344c0-35-29-64-64-64s-64 29-64 64 29 64 64 64 64-29 64-64zm256 0c0-35-29-64-64-64s-64 29-64 64 29 64 64 64 64-29 64-64zm128-224v320c0 53-43 96-96 96H96c-53 0-96-43-96-96v-320c0-53 43-96 96-96h465l135 136c37 36 85 56 136 56s99-20 136-56l136-136h464c53 0 96 43 96 96zm-325-569c10 24 5 52-14 70l-448 448c-12 13-29 19-45 19s-33-6-45-19L339 621c-19-18-24-46-14-70 10-23 33-39 59-39h256V64c0-35 29-64 64-64h256c35 0 64 29 64 64v448h256c26 0 49 16 59 39z&quot;/&gt;
&Tab;&Tab;&lt;/svg&gt;
&Tab;"/></div>
<div class="not-loaded-resource not-loaded-image-resource resource-status-notDownloaded" data-resource-id="a1test2a1test2a1test2a1test22347" data-original-before=" " data-original-after=" class=&quot;jop-noMdConv&quot;/" contenteditable="false"><img src="data:image/svg+xml;utf8,
&Tab;"/></span>
<span class="not-loaded-resource not-loaded-image-resource resource-status-notDownloaded" data-resource-id="a1test2a1test2a1test2a1test22347" data-original-before=" " data-original-after=" class=&quot;jop-noMdConv&quot;/" contenteditable="false"><img src="data:image/svg+xml;utf8,
&Tab;&Tab;&lt;svg width=&quot;1700&quot; height=&quot;1536&quot; xmlns=&quot;http://www.w3.org/2000/svg&quot;&gt;
&Tab;&Tab; &lt;path d=&quot;M1280 1344c0-35-29-64-64-64s-64 29-64 64 29 64 64 64 64-29 64-64zm256 0c0-35-29-64-64-64s-64 29-64 64 29 64 64 64 64-29 64-64zm128-224v320c0 53-43 96-96 96H96c-53 0-96-43-96-96v-320c0-53 43-96 96-96h465l135 136c37 36 85 56 136 56s99-20 136-56l136-136h464c53 0 96 43 96 96zm-325-569c10 24 5 52-14 70l-448 448c-12 13-29 19-45 19s-33-6-45-19L339 621c-19-18-24-46-14-70 10-23 33-39 59-39h256V64c0-35 29-64 64-64h256c35 0 64 29 64 64v448h256c26 0 49 16 59 39z&quot;/&gt;
&Tab;&Tab;&lt;/svg&gt;
&Tab;"/></div>
&Tab;"/></span>

View File

@@ -8,7 +8,7 @@ import { FileLocker } from '@joplin/utils/fs';
import { IpcMessageHandler, IpcServer, Message, newHttpError, sendMessage, SendMessageOptions, startServer, stopServer } from '@joplin/utils/ipc';
import { BrowserWindow, Tray, WebContents, screen, App, nativeTheme } from 'electron';
import bridge from './bridge';
const url = require('url');
import * as url from 'url';
const path = require('path');
const { dirname } = require('@joplin/lib/path-utils');
const fs = require('fs-extra');

View File

@@ -55,11 +55,16 @@ import userFetcher, { initializeUserFetcher } from '@joplin/lib/utils/userFetche
import { parseNotesParent } from '@joplin/lib/reducer';
import OcrService from '@joplin/lib/services/ocr/OcrService';
import OcrDriverTesseract from '@joplin/lib/services/ocr/drivers/OcrDriverTesseract';
import OcrDriverTranscribe from '@joplin/lib/services/ocr/drivers/OcrDriverTranscribe';
import SearchEngine from '@joplin/lib/services/search/SearchEngine';
import { PackageInfo } from '@joplin/lib/versionInfo';
import { CustomProtocolHandler } from './utils/customProtocols/handleCustomProtocols';
import { refreshFolders } from '@joplin/lib/folders-screen-utils';
import initializeCommandService from './utils/initializeCommandService';
import OcrDriverBase from '@joplin/lib/services/ocr/OcrDriverBase';
import PerformanceLogger from '@joplin/lib/PerformanceLogger';
const perfLogger = PerformanceLogger.create();
const pluginClasses = [
require('./plugins/GotoAnything').default,
@@ -67,6 +72,8 @@ const pluginClasses = [
const appDefaultState = createAppDefaultState(resourceEditWatcherDefaultState);
type StartupTask = { label: string; task: ()=> void|Promise<void> };
class Application extends BaseApplication {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
@@ -348,16 +355,19 @@ class Application extends BaseApplication {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const Tesseract = (window as any).Tesseract;
const driver = new OcrDriverTesseract(
const drivers: OcrDriverBase[] = [];
drivers.push(new OcrDriverTesseract(
{ createWorker: Tesseract.createWorker },
{
workerPath: `${bridge().buildDir()}/tesseract.js/worker.min.js`,
corePath: `${bridge().buildDir()}/tesseract.js-core`,
languageDataPath: Setting.value('ocr.languageDataPath') || null,
},
);
));
this.ocrService_ = new OcrService(driver);
drivers.push(new OcrDriverTranscribe());
this.ocrService_ = new OcrService(drivers);
}
void this.ocrService_.runInBackground();
@@ -411,56 +421,53 @@ class Application extends BaseApplication {
});
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
public async start(argv: string[], startOptions: StartOptions = null): Promise<any> {
// If running inside a package, the command line, instead of being "node.exe <path> <flags>" is "joplin.exe <flags>" so
// insert an extra argument so that they can be processed in a consistent way everywhere.
if (!bridge().electronIsDev()) argv.splice(1, 0, '.');
private buildStartupTasks_() {
const tasks: StartupTask[] = [];
const addTask = (label: string, task: StartupTask['task']) => {
tasks.push({ label, task });
};
argv = await super.start(argv, startOptions);
addTask('app/set up extra debug logging', () => {
reg.logger().info('app.start: doing regular boot');
const dir: string = Setting.value('profileDir');
await this.setupIntegrationTestUtils();
syncDebugLog.enabled = false;
bridge().setLogFilePath(Logger.globalLogger.logFilePath());
if (dir.endsWith('dev-desktop-2')) {
syncDebugLog.addTarget(TargetType.File, {
path: `${homedir()}/synclog.txt`,
});
syncDebugLog.enabled = true;
syncDebugLog.info(`Profile dir: ${dir}`);
}
});
await this.applySettingsSideEffects();
addTask('app/set up registry', () => {
reg.setDispatch(this.dispatch.bind(this));
reg.setShowErrorMessageBoxHandler((message: string) => { bridge().showErrorMessageBox(message); });
});
if (Setting.value('sync.upgradeState') === Setting.SYNC_UPGRADE_STATE_MUST_DO) {
reg.logger().info('app.start: doing upgradeSyncTarget action');
bridge().mainWindow().show();
return { action: 'upgradeSyncTarget' };
}
addTask('app/set up auto updater', () => {
this.setupAutoUpdaterService();
});
reg.logger().info('app.start: doing regular boot');
const dir: string = Setting.value('profileDir');
syncDebugLog.enabled = false;
if (dir.endsWith('dev-desktop-2')) {
syncDebugLog.addTarget(TargetType.File, {
path: `${homedir()}/synclog.txt`,
});
syncDebugLog.enabled = true;
syncDebugLog.info(`Profile dir: ${dir}`);
}
this.setupAutoUpdaterService();
AlarmService.setDriver(new AlarmServiceDriverNode({ appName: packageInfo.build.appId }));
AlarmService.setLogger(reg.logger());
reg.setDispatch(this.dispatch.bind(this));
reg.setShowErrorMessageBoxHandler((message: string) => { bridge().showErrorMessageBox(message); });
addTask('app/set up AlarmService', () => {
AlarmService.setDriver(new AlarmServiceDriverNode({ appName: packageInfo.build.appId }));
AlarmService.setLogger(reg.logger());
});
if (Setting.value('flagOpenDevTools')) {
bridge().openDevTools();
addTask('app/openDevTools', () => {
bridge().openDevTools();
});
}
this.protocolHandler_ = bridge().electronApp().getCustomProtocolHandler();
this.protocolHandler_.allowReadAccessToDirectory(__dirname); // App bundle directory
this.protocolHandler_.allowReadAccessToDirectory(Setting.value('cacheDir'));
this.protocolHandler_.allowReadAccessToDirectory(Setting.value('resourceDir'));
addTask('app/set up custom protocol handler', async () => {
this.protocolHandler_ = bridge().electronApp().getCustomProtocolHandler();
this.protocolHandler_.allowReadAccessToDirectory(__dirname); // App bundle directory
this.protocolHandler_.allowReadAccessToDirectory(Setting.value('cacheDir'));
this.protocolHandler_.allowReadAccessToDirectory(Setting.value('resourceDir'));
});
// this.protocolHandler_.allowReadAccessTo(Setting.value('tempDir'));
// For now, this doesn't seem necessary:
// this.protocolHandler_.allowReadAccessTo(Setting.value('profileDir'));
@@ -468,44 +475,52 @@ class Application extends BaseApplication {
// handler, and, as such, it may make sense to also limit permissions of
// allowed pages with a Content Security Policy.
PluginManager.instance().dispatch_ = this.dispatch.bind(this);
PluginManager.instance().setLogger(reg.logger());
PluginManager.instance().register(pluginClasses);
addTask('app/initialize PluginManager, redux, CommandService, and KeymapService', async () => {
PluginManager.instance().dispatch_ = this.dispatch.bind(this);
PluginManager.instance().setLogger(reg.logger());
PluginManager.instance().register(pluginClasses);
this.initRedux();
this.initRedux();
PerFolderSortOrderService.initialize();
initializeCommandService(this.store(), Setting.value('env') === 'dev');
initializeCommandService(this.store(), Setting.value('env') === 'dev');
const keymapService = KeymapService.instance();
// We only add the commands that appear in the menu because only
// those can have a shortcut associated with them.
keymapService.initialize(menuCommandNames());
const keymapService = KeymapService.instance();
// We only add the commands that appear in the menu because only
// those can have a shortcut associated with them.
keymapService.initialize(menuCommandNames());
try {
await keymapService.loadCustomKeymap(`${dir}/keymap-desktop.json`);
} catch (error) {
reg.logger().error(error);
}
// Since the settings need to be loaded before the store is
// created, it will never receive the SETTING_UPDATE_ALL even,
// which mean state.settings will not be initialised. So we
// manually call dispatchUpdateAll() to force an update.
Setting.dispatchUpdateAll();
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
await refreshFolders((action: any) => this.dispatch(action), '');
const tags = await Tag.allWithNotes();
this.dispatch({
type: 'TAG_UPDATE_ALL',
items: tags,
try {
await keymapService.loadCustomKeymap(`${Setting.value('profileDir')}/keymap-desktop.json`);
} catch (error) {
reg.logger().error(error);
}
});
await this.setupCustomCss();
addTask('app/initialize PerFolderSortOrderService', () => {
PerFolderSortOrderService.initialize();
});
addTask('app/dispatch initial settings', () => {
// Since the settings need to be loaded before the store is
// created, it will never receive the SETTING_UPDATE_ALL even,
// which mean state.settings will not be initialised. So we
// manually call dispatchUpdateAll() to force an update.
Setting.dispatchUpdateAll();
});
addTask('app/update folders and tags', async () => {
await refreshFolders((action) => this.dispatch(action), '');
const tags = await Tag.allWithNotes();
this.dispatch({
type: 'TAG_UPDATE_ALL',
items: tags,
});
});
addTask('app/set up custom CSS', async () => {
await this.setupCustomCss();
});
// const masterKeys = await MasterKey.all();
@@ -514,188 +529,237 @@ class Application extends BaseApplication {
// items: masterKeys,
// });
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'),
};
addTask('app/send initial selection to redux', async () => {
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,
});
}
return notesParent;
};
const notesParent = await getNotesParent();
this.store().dispatch({
type: 'FOLDER_SET_COLLAPSED_ALL',
ids: Setting.value('collapsedFolderIds'),
});
if (notesParent.type === 'SmartFilter') {
this.store().dispatch({
type: 'SMART_FILTER_SELECT',
id: notesParent.selectedItemId,
type: 'NOTE_DEVTOOLS_SET',
value: Setting.value('flagOpenDevTools'),
});
} 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',
ids: Setting.value('collapsedFolderIds'),
});
this.store().dispatch({
type: 'NOTE_DEVTOOLS_SET',
value: Setting.value('flagOpenDevTools'),
addTask('app/initializeUserFetcher', async () => {
initializeUserFetcher();
shim.setInterval(() => { void userFetcher(); }, 1000 * 60 * 60);
});
// Always disable on Mac for now - and disable too for the few apps that may have the flag enabled.
// At present, it only seems to work on Windows.
if (shim.isMac()) {
Setting.setValue('featureFlag.autoUpdaterServiceEnabled', false);
}
addTask('app/updateTray', () => this.updateTray());
// Note: Auto-update is a misnomer in the code.
// The code below only checks, if a new version is available.
// We only allow Windows and macOS users to automatically check for updates
if (!Setting.value('featureFlag.autoUpdaterServiceEnabled')) {
if (shim.isWindows() || shim.isMac()) {
const runAutoUpdateCheck = () => {
if (Setting.value('autoUpdateEnabled')) {
void checkForUpdates(true, bridge().mainWindow(), { includePreReleases: Setting.value('autoUpdate.includePreReleases') });
}
};
// Initial check on startup
shim.setTimeout(() => { runAutoUpdateCheck(); }, 5000);
// Then every x hours
shim.setInterval(() => { runAutoUpdateCheck(); }, 12 * 60 * 60 * 1000);
addTask('app/set main window state', () => {
if (Setting.value('startMinimized') && Setting.value('showTrayIcon')) {
bridge().mainWindow().hide();
} else {
bridge().mainWindow().show();
}
}
});
initializeUserFetcher();
shim.setInterval(() => { void userFetcher(); }, 1000 * 60 * 60);
addTask('app/start maintenance tasks', () => {
// Always disable on Mac for now - and disable too for the few apps that may have the flag enabled.
// At present, it only seems to work on Windows.
if (shim.isMac()) {
Setting.setValue('featureFlag.autoUpdaterServiceEnabled', false);
}
this.updateTray();
// Note: Auto-update is a misnomer in the code.
// The code below only checks, if a new version is available.
// We only allow Windows and macOS users to automatically check for updates
if (!Setting.value('featureFlag.autoUpdaterServiceEnabled')) {
if (shim.isWindows() || shim.isMac()) {
const runAutoUpdateCheck = () => {
if (Setting.value('autoUpdateEnabled')) {
void checkForUpdates(true, bridge().mainWindow(), { includePreReleases: Setting.value('autoUpdate.includePreReleases') });
}
};
shim.setTimeout(() => {
void AlarmService.garbageCollect();
}, 1000 * 60 * 60);
// Initial check on startup
shim.setTimeout(() => { runAutoUpdateCheck(); }, 5000);
// Then every x hours
shim.setInterval(() => { runAutoUpdateCheck(); }, 12 * 60 * 60 * 1000);
}
}
if (Setting.value('startMinimized') && Setting.value('showTrayIcon')) {
bridge().mainWindow().hide();
} else {
bridge().mainWindow().show();
}
shim.setTimeout(() => {
void AlarmService.garbageCollect();
}, 1000 * 60 * 60);
void ShareService.instance().maintenance();
void ShareService.instance().maintenance();
ResourceService.runInBackground();
ResourceService.runInBackground();
if (Setting.value('env') === 'dev') {
void AlarmService.updateAllNotifications();
} else {
// eslint-disable-next-line promise/prefer-await-to-then -- Old code before rule was applied
void reg.scheduleSync(1000).then(() => {
// Wait for the first sync before updating the notifications, since synchronisation
// might change the notifications.
if (Setting.value('env') === 'dev') {
void AlarmService.updateAllNotifications();
} else {
// eslint-disable-next-line promise/prefer-await-to-then -- Old code before rule was applied
void reg.scheduleSync(1000).then(() => {
// Wait for the first sync before updating the notifications, since synchronisation
// might change the notifications.
void AlarmService.updateAllNotifications();
void DecryptionWorker.instance().scheduleStart();
});
}
void DecryptionWorker.instance().scheduleStart();
});
}
const clipperLogger = new Logger();
clipperLogger.addTarget(TargetType.File, { path: `${Setting.value('profileDir')}/log-clipper.txt` });
clipperLogger.addTarget(TargetType.Console);
ClipperServer.instance().initialize(actionApi);
ClipperServer.instance().setEnabled(!Setting.value('altInstanceId'));
ClipperServer.instance().setLogger(clipperLogger);
ClipperServer.instance().setDispatch(this.store().dispatch);
if (ClipperServer.instance().enabled() && Setting.value('clipperServer.autoStart')) {
void ClipperServer.instance().start();
}
ExternalEditWatcher.instance().setLogger(reg.logger());
ExternalEditWatcher.instance().initialize(bridge, this.store().dispatch);
ResourceEditWatcher.instance().initialize(
reg.logger(),
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
(action: any) => { this.store().dispatch(action); },
(path: string) => bridge().openItem(path),
() => this.store().getState().windowId,
);
// Forwards the local event to the global event manager, so that it can
// be picked up by the plugin manager.
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
ResourceEditWatcher.instance().on('resourceChange', (event: any) => {
eventManager.emit(EventName.ResourceChange, event);
RevisionService.instance().runInBackground();
this.startRotatingLogMaintenance(Setting.value('profileDir'));
});
RevisionService.instance().runInBackground();
addTask('app/set up ClipperServer', () => {
const clipperLogger = new Logger();
clipperLogger.addTarget(TargetType.File, { path: `${Setting.value('profileDir')}/log-clipper.txt` });
clipperLogger.addTarget(TargetType.Console);
ClipperServer.instance().initialize(actionApi);
ClipperServer.instance().setEnabled(!Setting.value('altInstanceId'));
ClipperServer.instance().setLogger(clipperLogger);
ClipperServer.instance().setDispatch(this.store().dispatch);
if (ClipperServer.instance().enabled() && Setting.value('clipperServer.autoStart')) {
void ClipperServer.instance().start();
}
});
addTask('app/set up external edit watchers', () => {
ExternalEditWatcher.instance().setLogger(reg.logger());
ExternalEditWatcher.instance().initialize(bridge, this.store().dispatch);
ResourceEditWatcher.instance().initialize(
reg.logger(),
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
(action: any) => { this.store().dispatch(action); },
(path: string) => bridge().openItem(path),
() => this.store().getState().windowId,
);
// Forwards the local event to the global event manager, so that it can
// be picked up by the plugin manager.
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
ResourceEditWatcher.instance().on('resourceChange', (event: any) => {
eventManager.emit(EventName.ResourceChange, event);
});
});
// Make it available to the console window - useful to call revisionService.collectRevisions()
if (Setting.value('env') === 'dev') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
(window as any).joplin = {
revisionService: RevisionService.instance(),
migrationService: MigrationService.instance(),
decryptionWorker: DecryptionWorker.instance(),
commandService: CommandService.instance(),
pluginService: PluginService.instance(),
bridge: bridge(),
debug: new DebugService(reg.db()),
resourceService: ResourceService.instance(),
searchEngine: SearchEngine.instance(),
ocrService: () => this.ocrService_,
};
addTask('app/add debug variables', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
(window as any).joplin = {
revisionService: RevisionService.instance(),
migrationService: MigrationService.instance(),
decryptionWorker: DecryptionWorker.instance(),
commandService: CommandService.instance(),
pluginService: PluginService.instance(),
bridge: bridge(),
debug: new DebugService(reg.db()),
resourceService: ResourceService.instance(),
searchEngine: SearchEngine.instance(),
ocrService: () => this.ocrService_,
};
});
}
bridge().addEventListener('nativeThemeUpdated', this.bridge_nativeThemeUpdated);
bridge().setOnAllowedExtensionsChangeListener((newExtensions) => {
Setting.setValue('linking.extraAllowedExtensions', newExtensions);
addTask('app/listen for main process events', () => {
bridge().addEventListener('nativeThemeUpdated', this.bridge_nativeThemeUpdated);
bridge().setOnAllowedExtensionsChangeListener((newExtensions) => {
Setting.setValue('linking.extraAllowedExtensions', newExtensions);
});
ipcRenderer.on('window-focused', (_event, newWindowId) => {
const currentWindowId = this.store().getState().windowId;
if (newWindowId !== currentWindowId) {
this.dispatch({
type: 'WINDOW_FOCUS',
windowId: newWindowId,
lastWindowId: currentWindowId,
});
}
});
});
ipcRenderer.on('window-focused', (_event, newWindowId) => {
const currentWindowId = this.store().getState().windowId;
if (newWindowId !== currentWindowId) {
this.dispatch({
type: 'WINDOW_FOCUS',
windowId: newWindowId,
lastWindowId: currentWindowId,
});
}
addTask('app/initPluginService', () => this.initPluginService());
addTask('app/setupContextMenu', () => {
this.setupContextMenu();
});
await this.initPluginService();
this.setupContextMenu();
await SpellCheckerService.instance().initialize(new SpellCheckerServiceDriverNative());
this.startRotatingLogMaintenance(Setting.value('profileDir'));
await this.setupOcrService();
eventManager.on(EventName.OcrServiceResourcesProcessed, async () => {
await ResourceService.instance().indexNoteResources();
addTask('app/set up SpellCheckerService', async () => {
await SpellCheckerService.instance().initialize(new SpellCheckerServiceDriverNative());
});
eventManager.on(EventName.NoteResourceIndexed, async () => {
SearchEngine.instance().scheduleSyncTables();
addTask('app/listen for resource events', () => {
eventManager.on(EventName.OcrServiceResourcesProcessed, async () => {
await ResourceService.instance().indexNoteResources();
});
eventManager.on(EventName.NoteResourceIndexed, async () => {
SearchEngine.instance().scheduleSyncTables();
});
});
// Used by tests
ipcRenderer.send('startup-finished');
addTask('app/setupOcrService', () => this.setupOcrService());
return tasks;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
public async start(argv: string[], startOptions: StartOptions = null): Promise<any> {
const startupTask = perfLogger.taskStart('app/start');
// If running inside a package, the command line, instead of being "node.exe <path> <flags>" is "joplin.exe <flags>" so
// insert an extra argument so that they can be processed in a consistent way everywhere.
if (!bridge().electronIsDev()) argv.splice(1, 0, '.');
argv = await super.start(argv, startOptions);
await this.setupIntegrationTestUtils();
bridge().setLogFilePath(Logger.globalLogger.logFilePath());
await this.applySettingsSideEffects();
if (Setting.value('sync.upgradeState') === Setting.SYNC_UPGRADE_STATE_MUST_DO) {
reg.logger().info('app.start: doing upgradeSyncTarget action');
bridge().mainWindow().show();
startupTask.onEnd();
return { action: 'upgradeSyncTarget' };
}
const startupTasks = this.buildStartupTasks_();
for (const task of startupTasks) {
await perfLogger.track(task.label, async () => task.task());
}
// setTimeout(() => {
// void populateDatabase(reg.db(), {
@@ -749,6 +813,10 @@ class Application extends BaseApplication {
// await runIntegrationTests();
// Used by tests
ipcRenderer.send('startup-finished');
startupTask.onEnd();
return null;
}

View File

@@ -8,8 +8,8 @@ import { urlDecode } from '@joplin/lib/string-utils';
import * as Sentry from '@sentry/electron/main';
import { homedir } from 'os';
import { msleep } from '@joplin/utils/time';
import { pathExists, pathExistsSync, writeFileSync } from 'fs-extra';
import { extname, normalize } from 'path';
import { pathExists, pathExistsSync, writeFileSync, ensureDirSync } from 'fs-extra';
import { extname, normalize, join } from 'path';
import isSafeToOpen from './utils/isSafeToOpen';
import { closeSync, openSync, readSync, statSync } from 'fs';
import { KB } from '@joplin/utils/bytes';
@@ -67,6 +67,30 @@ export class Bridge {
this.logFilePath_ = v;
}
private getCrashDumpDirectory(): string {
try {
const platformName = shim.platformName();
switch (platformName) {
case 'win32':
// Windows: Use %LOCALAPPDATA%\CrashDumps
return join(process.env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local'), 'CrashDumps');
case 'darwin':
// macOS: Use ~/Library/Logs/DiagnosticReports
return join(homedir(), 'Library', 'Logs', 'DiagnosticReports');
case 'linux':
// Linux: Use XDG_STATE_HOME (for logs) or fallback to ~/.local/state
return join(process.env.XDG_STATE_HOME || join(homedir(), '.local', 'state'), 'joplin');
default:
// For unknown platforms, default to the home directory
return homedir();
}
} catch (error) {
// If we can't get the platform name, fallback to the home directory
return homedir();
}
}
private sentryInit() {
const getLogLines = () => {
try {
@@ -109,7 +133,10 @@ export class Bridge {
log: logAttachment ? logAttachment.data.trim().split('\n') : [],
};
writeFileSync(`${homedir()}/joplin_crash_dump_${date}.json`, JSON.stringify(errorEventWithLog, null, '\t'), 'utf-8');
const crashDumpDir = this.getCrashDumpDirectory();
ensureDirSync(crashDumpDir);
const crashDumpPath = join(crashDumpDir, `joplin_crash_dump_${date}.json`);
writeFileSync(crashDumpPath, JSON.stringify(errorEventWithLog, null, '\t'), 'utf-8');
} catch (error) {
// Ignore the error since we can't handle it here
}

View File

@@ -0,0 +1,96 @@
import * as convertHtmlToMarkdown from './convertNoteToMarkdown';
import { AppState, createAppDefaultState } from '../app.reducer';
import Note from '@joplin/lib/models/Note';
import { MarkupLanguage } from '@joplin/renderer';
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
import Folder from '@joplin/lib/models/Folder';
import { NoteEntity } from '@joplin/lib/services/database/types';
describe('convertNoteToMarkdown', () => {
let state: AppState = undefined;
beforeEach(async () => {
state = createAppDefaultState({});
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
});
it('should set the original note to be trashed', async () => {
const folder = await Folder.save({ title: 'test_folder' });
const htmlNote = await Note.save({ title: 'test', body: '<p>Hello</p>', parent_id: folder.id, markup_language: MarkupLanguage.Html });
state.selectedNoteIds = [htmlNote.id];
await convertHtmlToMarkdown.runtime().execute({ state, dispatch: () => {} });
const refreshedNote = await Note.load(htmlNote.id);
expect(htmlNote.deleted_time).toBe(0);
expect(refreshedNote.deleted_time).not.toBe(0);
});
it('should recreate a new note that is a clone of the original', async () => {
let noteConvertedToMarkdownId = '';
const dispatchFn = jest.fn()
.mockImplementationOnce(() => {})
.mockImplementationOnce(action => {
noteConvertedToMarkdownId = action.id;
});
const folder = await Folder.save({ title: 'test_folder' });
const htmlNoteProperties = {
title: 'test',
body: '<p>Hello</p>',
parent_id: folder.id,
markup_language: MarkupLanguage.Html,
author: 'test-author',
is_todo: 1,
todo_completed: 1,
};
const htmlNote = await Note.save(htmlNoteProperties);
state.selectedNoteIds = [htmlNote.id];
await convertHtmlToMarkdown.runtime().execute({ state, dispatch: dispatchFn });
expect(dispatchFn).toHaveBeenCalledTimes(2);
expect(noteConvertedToMarkdownId).not.toBe('');
const markdownNote = await Note.load(noteConvertedToMarkdownId);
const fields: (keyof NoteEntity)[] = ['parent_id', 'title', 'author', 'is_todo', 'todo_completed'];
for (const field of fields) {
expect(htmlNote[field]).toEqual(markdownNote[field]);
}
});
it('should generate action to trigger notification', async () => {
let originalHtmlNoteId = '';
let actionType = '';
const dispatchFn = jest.fn()
.mockImplementationOnce(action => {
originalHtmlNoteId = action.value;
actionType = action.type;
})
.mockImplementationOnce(() => {});
const folder = await Folder.save({ title: 'test_folder' });
const htmlNoteProperties = {
title: 'test',
body: '<p>Hello</p>',
parent_id: folder.id,
markup_language: MarkupLanguage.Html,
author: 'test-author',
is_todo: 1,
todo_completed: 1,
};
const htmlNote = await Note.save(htmlNoteProperties);
state.selectedNoteIds = [htmlNote.id];
await convertHtmlToMarkdown.runtime().execute({ state, dispatch: dispatchFn });
expect(dispatchFn).toHaveBeenCalledTimes(2);
expect(originalHtmlNoteId).toBe(htmlNote.id);
expect(actionType).toBe('NOTE_HTML_TO_MARKDOWN_DONE');
});
});

View File

@@ -0,0 +1,52 @@
import { _ } from '@joplin/lib/locale';
import Note from '@joplin/lib/models/Note';
import { stateUtils } from '@joplin/lib/reducer';
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
import { MarkupLanguage } from '@joplin/renderer';
import { runtime as convertHtmlToMarkdown } from '@joplin/lib/commands/convertHtmlToMarkdown';
import bridge from '../services/bridge';
export const declaration: CommandDeclaration = {
name: 'convertNoteToMarkdown',
label: () => _('Convert note to Markdown'),
};
export const runtime = (): CommandRuntime => {
return {
execute: async (context: CommandContext, noteId: string = null) => {
noteId = noteId || stateUtils.selectedNoteId(context.state);
const note = await Note.load(noteId);
if (!note) return;
try {
const markdownBody = await convertHtmlToMarkdown().execute(context, note.body);
const newNote = await Note.duplicate(note.id);
newNote.body = markdownBody;
newNote.markup_language = MarkupLanguage.Markdown;
await Note.save(newNote);
await Note.delete(note.id, { toTrash: true });
context.dispatch({
type: 'NOTE_HTML_TO_MARKDOWN_DONE',
value: note.id,
});
context.dispatch({
type: 'NOTE_SELECT',
id: newNote.id,
});
} catch (error) {
bridge().showErrorMessageBox(_('Could not convert note to Markdown: %s', error.message));
}
},
enabledCondition: 'oneNoteSelected && noteIsHtml && !noteIsReadOnly',
};
};

View File

@@ -1,4 +1,5 @@
// AUTO-GENERATED using `gulp buildScriptIndexes`
import * as convertNoteToMarkdown from './convertNoteToMarkdown';
import * as copyDevCommand from './copyDevCommand';
import * as copyToClipboard from './copyToClipboard';
import * as editProfileConfig from './editProfileConfig';
@@ -24,6 +25,7 @@ import * as toggleSafeMode from './toggleSafeMode';
import * as toggleTabMovesFocus from './toggleTabMovesFocus';
const index: any[] = [
convertNoteToMarkdown,
copyDevCommand,
copyToClipboard,
editProfileConfig,

View File

@@ -0,0 +1,28 @@
import * as React from 'react';
import { useContext, useEffect } from 'react';
import { _ } from '@joplin/lib/locale';
import { Dispatch } from 'redux';
import { PopupNotificationContext } from '../PopupNotification/PopupNotificationProvider';
import { NotificationType } from '../PopupNotification/types';
interface Props {
noteId: string;
dispatch: Dispatch;
}
export default (props: Props) => {
const popupManager = useContext(PopupNotificationContext);
useEffect(() => {
if (!props.noteId || props.noteId === '') return;
props.dispatch({ type: 'NOTE_HTML_TO_MARKDOWN_DONE', value: '' });
const notification = popupManager.createPopup(() => (
<div>{_('The note has been converted to Markdown and the original note has been moved to the trash')}</div>
), { type: NotificationType.Success });
notification.scheduleDismiss();
}, [props.dispatch, popupManager, props.noteId]);
return <div style={{ display: 'none' }}/>;
};

View File

@@ -1,4 +1,4 @@
import { useMemo, useRef, useState } from 'react';
import { useMemo, useRef, useState, useCallback } from 'react';
interface Props {
width: number;
@@ -9,40 +9,62 @@ interface Props {
const fontSizeCache_: Record<string, number> = {};
export default (props: Props) => {
const containerRef = useRef(null);
const containerRef = useRef<HTMLDivElement>(null);
const [containerReady, setContainerReady] = useState(false);
const refCallback = useCallback((el: HTMLDivElement | null) => {
if (el && !containerRef.current) {
containerRef.current = el;
requestAnimationFrame(() => {
setContainerReady(true);
});
}
}, []);
const fontSize = useMemo(() => {
if (!containerReady) return props.height;
if (!containerReady || !containerRef.current) {
return Math.min(props.height * 0.7, 14);
}
const cacheKey = [props.width, props.height, props.emoji].join('-');
if (fontSizeCache_[cacheKey]) {
return fontSizeCache_[cacheKey];
}
// Set the emoji font size so that it fits within the specified width
// and height. In fact, currently it only looks at the height.
let spanFontSize = props.height;
const span = document.createElement('span');
span.innerText = props.emoji;
span.style.fontSize = `${spanFontSize}px`;
span.style.visibility = 'hidden';
span.style.position = 'absolute';
span.style.whiteSpace = 'nowrap';
containerRef.current.appendChild(span);
let rect = span.getBoundingClientRect();
while (rect.height > props.height) {
spanFontSize -= .5;
while ((rect.height > props.height || rect.width > props.width) && spanFontSize > 1) {
spanFontSize -= 0.5;
span.style.fontSize = `${spanFontSize}px`;
rect = span.getBoundingClientRect();
}
span.remove();
fontSizeCache_[cacheKey] = spanFontSize;
return spanFontSize;
}, [props.width, props.height, props.emoji, containerReady, containerRef]);
}, [props.width, props.height, props.emoji, containerReady]);
return <div className="emoji-box" ref={el => { containerRef.current = el; setContainerReady(true); }} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: props.width, height: props.height, fontSize }}>{props.emoji}</div>;
return <div
ref={refCallback}
style={{
width: props.width,
height: props.height,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
fontSize,
}}
>
{props.emoji}
</div>;
};

View File

@@ -38,12 +38,14 @@ import restart from '../services/restart';
import { connect } from 'react-redux';
import { NoteListColumns } from '@joplin/lib/services/plugins/api/noteListType';
import validateColumns from './NoteListHeader/utils/validateColumns';
import ConversionNotification from './ConversionNotification/ConversionNotification';
import TrashNotification from './TrashNotification/TrashNotification';
import UpdateNotification from './UpdateNotification/UpdateNotification';
import NoteEditor from './NoteEditor/NoteEditor';
import PluginNotification from './PluginNotification/PluginNotification';
import { Toast } from '@joplin/lib/services/plugins/api/types';
import PluginService from '@joplin/lib/services/plugins/PluginService';
import { Dispatch } from 'redux';
const ipcRenderer = require('electron').ipcRenderer;
@@ -84,6 +86,7 @@ interface Props {
showInvalidJoplinCloudCredential: boolean;
toast: Toast;
shouldSwitchToAppleSiliconVersion: boolean;
noteHtmlToMarkdownDone: string;
}
interface ShareFolderDialogOptions {
@@ -797,6 +800,10 @@ class MainScreenComponent extends React.Component<Props, State> {
return (
<div style={style}>
<ConversionNotification
noteId={this.props.noteHtmlToMarkdownDone}
dispatch={this.props.dispatch as Dispatch}
/>
<TrashNotification
lastDeletion={this.props.lastDeletion}
lastDeletionNotificationTime={this.props.lastDeletionNotificationTime}
@@ -853,6 +860,7 @@ const mapStateToProps = (state: AppState) => {
showInvalidJoplinCloudCredential: state.settings['sync.target'] === 10 && state.mustAuthenticate,
toast: state.toast,
shouldSwitchToAppleSiliconVersion: shim.isAppleSilicon() && process.arch !== 'arm64',
noteHtmlToMarkdownDone: state.noteHtmlToMarkdownDone,
};
};

View File

@@ -803,6 +803,7 @@ function useMenu(props: Props) {
menuItemDic.toggleNoteList,
menuItemDic.toggleVisiblePanes,
menuItemDic.toggleEditorPlugin,
menuItemDic.toggleEditors,
{
label: _('Layout button sequence'),
submenu: layoutButtonSequenceMenuItems,
@@ -906,6 +907,7 @@ function useMenu(props: Props) {
separator(),
menuItemDic.setTags,
menuItemDic.showShareNoteDialog,
menuItemDic.convertNoteToMarkdown,
separator(),
menuItemDic.showNoteProperties,
menuItemDic.showNoteContentProperties,

View File

@@ -340,6 +340,8 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
props.setShowLocalSearch(event.searchState.dialogVisible);
}
lastSearchState.current = event.searchState;
} else if (event.kind === EditorEventType.FollowLink) {
void CommandService.instance().execute('openItem', event.link);
}
}, [editor_scroll, codeMirror_change, props.setLocalSearch, props.setShowLocalSearch]);
@@ -362,6 +364,8 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
readOnly: props.disabled,
markdownMarkEnabled: Setting.value('markdown.plugin.mark'),
katexEnabled: Setting.value('markdown.plugin.katex'),
inlineRenderingEnabled: Setting.value('editor.inlineRendering'),
imageRenderingEnabled: Setting.value('editor.imageRendering'),
themeData: {
...styles.globalTheme,
marginLeft: 0,
@@ -410,6 +414,7 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
onSelectPastBeginning={onSelectPastBeginning}
externalSearch={props.searchMarkers}
useLocalSearch={props.useLocalSearch}
onLocalize={_}
/>
</div>
);

View File

@@ -15,6 +15,10 @@ import useEditorSearch from '../utils/useEditorSearchExtension';
import CommandService from '@joplin/lib/services/CommandService';
import { SearchMarkers } from '../../../utils/useSearchMarkers';
import localisation from './utils/localisation';
import Resource from '@joplin/lib/models/Resource';
import { parseResourceUrl } from '@joplin/lib/urlUtils';
import { resourceFilename } from '@joplin/lib/models/utils/resourceUtils';
import getResourceBaseUrl from '../../../utils/getResourceBaseUrl';
interface Props extends EditorProps {
style: React.CSSProperties;
@@ -104,7 +108,15 @@ const Editor = (props: Props, ref: ForwardedRef<CodeMirrorControl>) => {
onLogMessage: message => onLogMessageRef.current(message),
};
const editor = createEditor(editorContainerRef.current, editorProps);
const editor = createEditor(editorContainerRef.current, {
...editorProps,
resolveImageSrc: async src => {
const url = parseResourceUrl(src);
if (!url.itemId) return null;
const item = await Resource.load(url.itemId);
return `${getResourceBaseUrl()}/${resourceFilename(item)}`;
},
});
editor.addStyles({
'.cm-scroller': { overflow: 'auto' },
'&.CodeMirror': {

View File

@@ -18,11 +18,12 @@ const logger = Logger.create('shouldPasteResources');
// instead the clipboard resources, which will contain the actual image.
//
// We have a lot of log statements so that if someone reports a bug we can ask
// them to check the console and give us the messages they have.
// them to check the console and give us the messages they have. However, to avoid
// including sensitive information in the logs, users will need to check the console,
// not the log file.
export default (pastedText: string, pastedHtml: string, resourceMds: string[]) => {
logger.info('Pasted text:', pastedText);
logger.info('Pasted HTML:', pastedHtml);
logger.info('Resources:', resourceMds);
const debugInformation = JSON.stringify({ pastedText, pastedHtml, resourceMds }, undefined, '\t');
logger.debug('Input data:', debugInformation);
if (pastedText) {
logger.info('Not pasting resources only because the clipboard contains plain text');

View File

@@ -5,6 +5,7 @@ import shim from '@joplin/lib/shim';
const useLinkTooltips = (editor: Editor|null) => {
const resetModifiedTitles = useCallback(() => {
if (!editor) return;
for (const element of editor.getDoc().querySelectorAll('a[data-joplin-original-title]')) {
element.setAttribute('title', element.getAttribute('data-joplin-original-title') ?? '');
element.removeAttribute('data-joplin-original-title');

View File

@@ -56,6 +56,7 @@ import useResourceUnwatcher from './utils/useResourceUnwatcher';
import StatusBar from './StatusBar';
import useVisiblePluginEditorViewIds from '@joplin/lib/hooks/plugins/useVisiblePluginEditorViewIds';
import useConnectToEditorPlugin from './utils/useConnectToEditorPlugin';
import getResourceBaseUrl from './utils/getResourceBaseUrl';
const debounce = require('debounce');
@@ -169,7 +170,7 @@ function NoteEditorContent(props: NoteEditorProps) {
const theme = themeStyle(options.themeId ? options.themeId : props.themeId);
const markupToHtml = markupLanguageUtils.newMarkupToHtml(props.plugins, {
resourceBaseUrl: `joplin-content://note-viewer/${Setting.value('resourceDir')}/`,
resourceBaseUrl: getResourceBaseUrl(),
customCss: props.customCss,
});
@@ -466,6 +467,7 @@ function NoteEditorContent(props: NoteEditorProps) {
// It is currently used to remember pdf scroll position for each attachments of each note uniquely.
noteId: props.noteId,
watchedNoteFiles: props.watchedNoteFiles,
enableHtmlToMarkdownBanner: props.enableHtmlToMarkdownBanner,
};
let editor = null;
@@ -488,6 +490,17 @@ function NoteEditorContent(props: NoteEditorProps) {
setShowRevisions(false);
}, []);
const onBannerConvertItToMarkdown = useCallback(async (event: React.MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
if (!props.selectedNoteIds || props.selectedNoteIds.length === 0) return;
await CommandService.instance().execute('convertNoteToMarkdown', props.selectedNoteIds[0]);
}, [props.selectedNoteIds]);
const onHideBannerConvertItToMarkdown = async (event: React.MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
Setting.setValue('editor.enableHtmlToMarkdownBanner', false);
};
const onBannerResourceClick = useCallback(async (event: React.MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
const resourceId = event.currentTarget.getAttribute('data-resource-id');
@@ -632,9 +645,30 @@ function NoteEditorContent(props: NoteEditorProps) {
const theme = themeStyle(props.themeId);
function renderConvertHtmlToMarkdown(): React.ReactNode {
if (!props.enableHtmlToMarkdownBanner) return null;
const note = props.notes.find(n => n.id === props.selectedNoteIds[0]);
if (!note) return null;
if (note.markup_language !== MarkupLanguage.Html) return null;
return (
<div style={styles.resourceWatchBanner}>
<p style={styles.resourceWatchBannerLine}>
{_('This note is in HTML format. Convert it to Markdown to edit it more easily.')}
&nbsp;
<a href="#" style={styles.resourceWatchBannerAction} onClick={onBannerConvertItToMarkdown}>{`${_('Convert it')}`}</a>
{' / '}
<a href="#" style={styles.resourceWatchBannerAction} onClick={onHideBannerConvertItToMarkdown}>{_('Don\'t show this message again')}</a>
</p>
</div>
);
}
return (
<div style={styles.root} onDragOver={onDragOver} onDrop={onDrop} ref={containerRef}>
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{renderConvertHtmlToMarkdown()}
{renderResourceWatchingNotification()}
{renderResourceInSearchResultsNotification()}
<NoteTitleBar
@@ -722,6 +756,7 @@ const mapStateToProps = (state: AppState, ownProps: ConnectProps) => {
syncUserId: state.settings['sync.userId'],
shareCacheSetting: state.settings['sync.shareCache'],
searchResults: state.searchResults,
enableHtmlToMarkdownBanner: state.settings['editor.enableHtmlToMarkdownBanner'],
};
};

View File

@@ -4,7 +4,8 @@ import { AppState } from '../../../app.reducer';
import Setting from '@joplin/lib/models/Setting';
import BannerContent from './BannerContent';
import { _ } from '@joplin/lib/locale';
import bridge from '../../../services/bridge';
import onRichTextReadMoreLinkClick from '@joplin/lib/components/shared/NoteEditor/WarningBanner/onRichTextReadMoreLinkClick';
import onRichTextDismissLinkClick from '@joplin/lib/components/shared/NoteEditor/WarningBanner/onRichTextDismissLinkClick';
import { useMemo } from 'react';
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
import PluginService from '@joplin/lib/services/plugins/PluginService';
@@ -16,14 +17,6 @@ interface Props {
plugins: PluginStates;
}
const onRichTextDismissLinkClick = () => {
Setting.setValue('richTextBannerDismissed', true);
};
const onRichTextReadMoreLinkClick = () => {
void bridge().openExternal('https://joplinapp.org/help/apps/rich_text_editor');
};
const onSwitchToLegacyEditor = () => {
Setting.setValue('editor.legacyMarkdown', true);
};

View File

@@ -69,6 +69,10 @@ export default function styles(props: NoteEditorProps) {
marginTop: 0,
marginBottom: 10,
},
resourceWatchBannerAction: {
textDecoration: 'underline',
color: theme.colorWarnUrl,
},
};
});
}

View File

@@ -8,14 +8,15 @@ const MenuItem = bridge().MenuItem;
import Resource, { resourceOcrStatusToString } from '@joplin/lib/models/Resource';
import BaseItem from '@joplin/lib/models/BaseItem';
import BaseModel, { ModelType } from '@joplin/lib/BaseModel';
import { NoteEntity, ResourceEntity, ResourceOcrStatus } from '@joplin/lib/services/database/types';
import { NoteEntity, ResourceEntity, ResourceOcrDriverId, ResourceOcrStatus } from '@joplin/lib/services/database/types';
import { TinyMceEditorEvents } from '../NoteBody/TinyMCE/utils/types';
import { itemIsReadOnlySync, ItemSlice } from '@joplin/lib/models/utils/readOnly';
import Setting from '@joplin/lib/models/Setting';
import ItemChange from '@joplin/lib/models/ItemChange';
import shim from '@joplin/lib/shim';
import shim, { MessageBoxType } from '@joplin/lib/shim';
import { openFileWithExternalEditor } from '@joplin/lib/services/ExternalEditWatcher/utils';
import CommandService from '@joplin/lib/services/CommandService';
import SyncTargetRegistry from '@joplin/lib/SyncTargetRegistry';
const fs = require('fs-extra');
const { writeFile } = require('fs-extra');
const { clipboard } = require('electron');
@@ -137,6 +138,40 @@ export function menuItems(dispatch: Function): ContextMenuItems {
},
isActive: (itemType: ContextMenuItemType, options: ContextMenuOptions) => !!options.textToCopy && itemType === ContextMenuItemType.Image && options.mime?.startsWith('image/svg'),
},
recognizeHandwrittenImage: {
label: _('Recognize handwritten image'),
onAction: async (options: ContextMenuOptions) => {
const syncTargetId = Setting.value('sync.target');
if (!SyncTargetRegistry.isJoplinServerOrCloud(syncTargetId)) {
await shim.showMessageBox(_('This feature is only available on Joplin Cloud and Joplin Server.'), { type: MessageBoxType.Error });
return;
}
if (!Setting.value('ocr.handwrittenTextDriverEnabled')) {
await shim.showMessageBox(_('This feature is disabled by default, you need to manually enable it by turning on the option to \'Enable handwritten transcription\'.'), { type: MessageBoxType.Error });
return;
}
const { resource } = await resourceInfo(options);
if (!['image/png', 'image/jpg', 'image/jpeg', 'image/bmp'].includes(resource.mime)) {
await shim.showMessageBox(_('This image type is not supported by the recognition system.'), { type: MessageBoxType.Error });
return;
}
await Resource.save({
id: resource.id,
ocr_status: ResourceOcrStatus.Todo,
ocr_driver_id: ResourceOcrDriverId.HandwrittenText,
ocr_details: '',
ocr_error: '',
ocr_text: '',
});
},
isActive: (itemType: ContextMenuItemType, options: ContextMenuOptions) => {
return itemType === ContextMenuItemType.Resource || (itemType === ContextMenuItemType.Image && options.resourceId);
},
},
revealInFolder: {
label: _('Reveal file in folder'),
onAction: async (options: ContextMenuOptions) => {

View File

@@ -0,0 +1,4 @@
import Setting from '@joplin/lib/models/Setting';
const getResourceBaseUrl = () => `joplin-content://note-viewer/${Setting.value('resourceDir')}/`;
export default getResourceBaseUrl;

View File

@@ -67,6 +67,7 @@ export interface NoteEditorProps {
onTitleChange?: (title: string)=> void;
bodyEditor: string;
startupPluginsLoaded: boolean;
enableHtmlToMarkdownBanner: boolean;
}
export interface NoteBodyEditorRef {
@@ -138,6 +139,7 @@ export interface NoteBodyEditorProps {
noteId: string;
useCustomPdfViewer: boolean;
watchedNoteFiles: string[];
enableHtmlToMarkdownBanner: boolean;
}
export interface NoteBodyEditorPropsAndRef extends NoteBodyEditorProps {

View File

@@ -49,7 +49,7 @@ const useScheduleSaveCallbacks = (props: Props) => {
}, [props.dispatch, props.editorId, props.setFormNote]);
const saveNoteIfWillChange = useCallback(async (formNote: FormNote) => {
if (!formNote.id || !formNote.bodyWillChangeId) return;
if (!formNote.id || !formNote.bodyWillChangeId || !props.editorRef.current) return;
const body = await props.editorRef.current.content();

View File

@@ -411,7 +411,14 @@ class NotePropertiesDialog extends React.Component<Props, State> {
const ll = this.latLongFromLocation(value);
url = Note.geoLocationUrlFromLatLong(ll.latitude, ll.longitude);
}
const urlStyle: React.CSSProperties = { ...theme.urlStyle, maxWidth: '180px', overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis' };
const urlStyle: React.CSSProperties = {
...theme.urlStyle,
maxWidth: '180px',
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
display: 'inline-block',
};
controlComp = (
<a href="#" onClick={() => bridge().openExternal(url)} style={urlStyle}>
{displayedValue}

View File

@@ -8,6 +8,7 @@ import { focus } from '@joplin/lib/utils/focusHandler';
import Dialog from './Dialog';
import { ChangeEvent } from 'react';
import { formatDateTimeLocalToMs, isValidDate } from '@joplin/utils/time';
import lightTheme from '@joplin/lib/themes/light';
interface Props {
themeId: number;
@@ -117,6 +118,15 @@ export default class PromptDialog extends React.Component<Props, any> {
borderColor: theme.dividerColor,
};
// The button to change the date/time cannot be customized easily so we need to use the
// light theme for that particular component.
this.styles_.dateTimeInput = {
...this.styles_.input,
color: lightTheme.color,
backgroundColor: lightTheme.backgroundColor,
borderColor: lightTheme.dividerColor,
};
this.styles_.select = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
control: (provided: any) => {
@@ -256,7 +266,7 @@ export default class PromptDialog extends React.Component<Props, any> {
onChange={onChange}
type="datetime-local"
className='datetime-picker'
style={styles.input}
style={styles.dateTimeInput}
/>;
} else if (this.props.inputType === 'tags') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied

View File

@@ -27,7 +27,7 @@ const CollapseExpandAllButton = (props: CollapseExpandAllButtonProps) => {
const icon = props.allFoldersCollapsed ? 'far fa-caret-square-right' : 'far fa-caret-square-down';
const label = props.allFoldersCollapsed ? _('Expand all notebooks') : _('Collapse all notebooks');
return <button onClick={() => onToggleAllFolders(props.allFoldersCollapsed)} className='sidebar-header-button -collapseall'>
return <button onClick={() => onToggleAllFolders(props.allFoldersCollapsed)} className='sidebar-header-button -collapseall' title={label}>
<i
aria-label={label}
role='img'
@@ -39,9 +39,11 @@ const CollapseExpandAllButton = (props: CollapseExpandAllButtonProps) => {
const NewFolderButton = () => {
// To allow it to be accessed by accessibility tools, the new folder button
// is not included in the portion of the list with role='tree'.
return <button onClick={onAddFolderButtonClick} className='sidebar-header-button -newfolder'>
const label = _('New notebook');
return <button onClick={onAddFolderButtonClick} className='sidebar-header-button -newfolder' title={label}>
<i
aria-label={_('New notebook')}
aria-label={label}
role='img'
className='fas fa-plus'
/>

View File

@@ -79,5 +79,7 @@ export default function() {
'switchProfile3',
'pasteAsText',
'showNoteProperties',
'convertNoteToMarkdown',
'toggleEditors',
];
}

View File

@@ -2,35 +2,36 @@
// Disable React message in console "Download the React DevTools for a better development experience"
// https://stackoverflow.com/questions/42196819/disable-hide-download-the-react-devtools#42196820
// eslint-disable-next-line no-undef
window.__REACT_DEVTOOLS_GLOBAL_HOOK__ = {
// eslint-disable-next-line no-undef, @typescript-eslint/no-explicit-any
(window as any).__REACT_DEVTOOLS_GLOBAL_HOOK__ = {
supportsFiber: true,
inject: function() {},
onCommitFiberRoot: function() {},
onCommitFiberUnmount: function() {},
};
require('./utils/sourceMapSetup');
const app = require('./app').default;
const Folder = require('@joplin/lib/models/Folder').default;
const Resource = require('@joplin/lib/models/Resource').default;
const BaseItem = require('@joplin/lib/models/BaseItem').default;
const Note = require('@joplin/lib/models/Note').default;
const Tag = require('@joplin/lib/models/Tag').default;
const NoteTag = require('@joplin/lib/models/NoteTag').default;
const MasterKey = require('@joplin/lib/models/MasterKey').default;
const Setting = require('@joplin/lib/models/Setting').default;
const Revision = require('@joplin/lib/models/Revision').default;
const Logger = require('@joplin/utils/Logger').default;
const FsDriverNode = require('@joplin/lib/fs-driver-node').default;
const bridge = require('./services/bridge').default;
const shim = require('@joplin/lib/shim').default;
import './utils/sourceMapSetup';
import app from './app';
import Folder from '@joplin/lib/models/Folder';
import Resource from '@joplin/lib/models/Resource';
import BaseItem from '@joplin/lib/models/BaseItem';
import Note from '@joplin/lib/models/Note';
import Tag from '@joplin/lib/models/Tag';
import NoteTag from '@joplin/lib/models/NoteTag';
import MasterKey from '@joplin/lib/models/MasterKey';
import Setting, { AppType } from '@joplin/lib/models/Setting';
import Revision from '@joplin/lib/models/Revision';
import Logger from '@joplin/utils/Logger';
import FsDriverNode from '@joplin/lib/fs-driver-node';
import bridge from './services/bridge';
import shim from '@joplin/lib/shim';
const { shimInit } = require('@joplin/lib/shim-init-node.js');
const EncryptionService = require('@joplin/lib/services/e2ee/EncryptionService').default;
const FileApiDriverLocal = require('@joplin/lib/file-api-driver-local').default;
const React = require('react');
const nodeSqlite = require('sqlite3');
const initLib = require('@joplin/lib/initLib').default;
import EncryptionService from '@joplin/lib/services/e2ee/EncryptionService';
import FileApiDriverLocal from '@joplin/lib/file-api-driver-local';
import * as React from 'react';
import nodeSqlite = require('sqlite3');
import initLib from '@joplin/lib/initLib';
import PerformanceLogger from '@joplin/lib/PerformanceLogger';
const pdfJs = require('pdfjs-dist');
const { isAppleSilicon } = require('is-apple-silicon');
require('@sentry/electron/renderer');
@@ -38,6 +39,8 @@ require('@sentry/electron/renderer');
// Allows components to use React as a global
window.React = React;
const perfLogger = PerformanceLogger.create();
const main = async () => {
// eslint-disable-next-line no-console
@@ -60,7 +63,7 @@ const main = async () => {
BaseItem.loadClass('Revision', Revision);
Setting.setConstant('appId', bridge().appId());
Setting.setConstant('appType', 'desktop');
Setting.setConstant('appType', AppType.Desktop);
Setting.setConstant('pluginAssetDir', `${__dirname}/pluginAssets`);
// eslint-disable-next-line no-console
@@ -106,7 +109,7 @@ const main = async () => {
}
};
main().catch((error) => {
perfLogger.track('main', main).catch((error) => {
const env = bridge().env();
console.error(error);
@@ -127,6 +130,6 @@ main().catch((error) => {
// In dev, we give the option to leave the app open as debug statements in the
// console can be useful
const canIgnore = env === 'dev';
bridge().electronApp().handleAppFailure(errorMessage, canIgnore);
void bridge().electronApp().handleAppFailure(errorMessage, canIgnore);
});

View File

@@ -1,18 +1,18 @@
// This is the basic initialization for the Electron MAIN process
require('./utils/sourceMapSetup');
const electronApp = require('electron').app;
import './utils/sourceMapSetup';
import { app as electronApp } from 'electron';
require('@electron/remote/main').initialize();
const ElectronAppWrapper = require('./ElectronAppWrapper').default;
const { pathExistsSync, readFileSync, mkdirpSync } = require('fs-extra');
const { initBridge } = require('./bridge');
const Logger = require('@joplin/utils/Logger').default;
const FsDriverNode = require('@joplin/lib/fs-driver-node').default;
import ElectronAppWrapper from './ElectronAppWrapper';
import { pathExistsSync, readFileSync, mkdirpSync } from 'fs-extra';
import { initBridge } from './bridge';
import Logger from '@joplin/utils/Logger';
import FsDriverNode from '@joplin/lib/fs-driver-node';
const envFromArgs = require('@joplin/lib/envFromArgs');
const packageInfo = require('./packageInfo.js');
const { isCallbackUrl } = require('@joplin/lib/callbackUrlUtils');
const determineBaseAppDirs = require('@joplin/lib/determineBaseAppDirs').default;
const registerCustomProtocols = require('./utils/customProtocols/registerCustomProtocols').default;
import { isCallbackUrl } from '@joplin/lib/callbackUrlUtils';
import determineBaseAppDirs from '@joplin/lib/determineBaseAppDirs';
import registerCustomProtocols from './utils/customProtocols/registerCustomProtocols';
// Electron takes the application name from package.json `name` and
// displays this in the tray icon toolip and message box titles, however in
@@ -26,7 +26,7 @@ process.on('unhandledRejection', (reason, p) => {
process.exit(1);
});
const getFlagValueFromArgs = (args, flag, defaultValue) => {
const getFlagValueFromArgs = (args: string[], flag: string, defaultValue: string|null) => {
if (!args) return null;
const index = args.indexOf(flag);
if (index <= 0 || index >= args.length - 1) return defaultValue;
@@ -75,7 +75,13 @@ const wrapper = new ElectronAppWrapper(electronApp, {
env, profilePath: rootProfileDir, isDebugMode, initialCallbackUrl, isEndToEndTesting,
});
globalThis.joplinBridge = initBridge(wrapper, appId, appName, rootProfileDir, autoUploadCrashDumps, altInstanceId);
type ExtendedGlobal = {
joplinBridge: unknown;
};
(globalThis as unknown as ExtendedGlobal).joplinBridge = (
initBridge(wrapper, appId, appName, rootProfileDir, autoUploadCrashDumps, altInstanceId)
);
wrapper.start().catch((error) => {
console.error('Electron App fatal error:');

View File

@@ -1,6 +1,6 @@
{
"name": "@joplin/app-desktop",
"version": "3.4.1",
"version": "3.4.5",
"description": "Joplin for Desktop",
"main": "main.bundle.js",
"private": true,
@@ -133,7 +133,7 @@
"7zip-bin": "5.2.0",
"@axe-core/playwright": "4.10.1",
"@electron/notarize": "2.5.0",
"@electron/rebuild": "3.7.1",
"@electron/rebuild": "3.7.2",
"@fortawesome/fontawesome-free": "5.15.4",
"@joeattardi/emoji-button": "4.6.4",
"@joplin/default-plugins": "~3.4",
@@ -145,11 +145,11 @@
"@playwright/test": "1.51.1",
"@sentry/electron": "4.24.0",
"@testing-library/react-hooks": "8.0.1",
"@types/jest": "29.5.12",
"@types/mustache": "4.2.5",
"@types/node": "18.19.86",
"@types/react": "18.3.20",
"@types/react-dom": "18.3.6",
"@types/jest": "29.5.14",
"@types/mustache": "4.2.6",
"@types/node": "18.19.100",
"@types/react": "18.3.21",
"@types/react-dom": "18.3.7",
"@types/react-redux": "7.1.33",
"@types/styled-components": "5.1.32",
"@types/tesseract.js": "2.0.0",
@@ -162,11 +162,11 @@
"debounce": "1.2.1",
"electron": "35.5.1",
"electron-builder": "24.13.3",
"electron-updater": "6.6.0",
"electron-updater": "6.6.2",
"electron-window-state": "5.0.3",
"esbuild": "^0.25.3",
"formatcoords": "1.1.3",
"glob": "11.0.1",
"glob": "11.0.2",
"gulp": "4.0.2",
"highlight.js": "11.11.1",
"immer": "9.0.21",
@@ -202,9 +202,9 @@
"taboverride": "4.0.3",
"tesseract.js": "5.1.1",
"tinymce": "6.8.5",
"ts-jest": "29.1.5",
"ts-jest": "29.3.1",
"ts-node": "10.9.2",
"typescript": "5.4.5"
"typescript": "5.8.2"
},
"dependencies": {
"@electron/remote": "2.1.2",

View File

@@ -345,8 +345,8 @@ class DialogComponent extends React.PureComponent<Props, State> {
return {
id: result.commandName,
title: result.title,
parent_id: null,
fields: [],
parent_id: null as string,
fields: [] as string[],
type: BaseModel.TYPE_COMMAND,
};
});

View File

@@ -51,10 +51,8 @@
const modulePath = args && args.length ? args[0] : null;
if (!modulePath) throw new Error('No module path specified on `require` call');
// The sqlite3 is actually part of the lib package so we need to do
// something convoluted to get it working.
if (modulePath === 'sqlite3') {
return require('../../node_modules/@joplin/lib/node_modules/sqlite3/lib/sqlite3.js');
return require('sqlite3');
}
if (modulePath === 'fs-extra') {

View File

@@ -120,8 +120,8 @@ const makeBuildContext = (entryPoint: string, renderer: boolean, computeFileSize
const bundleJs = async (writeStats: boolean) => {
const entryPoints = [
{ fileName: 'main.js', renderer: false },
{ fileName: 'main-html.js', renderer: true },
{ fileName: 'main.ts', renderer: false },
{ fileName: 'main-html.ts', renderer: true },
];
for (const { fileName, renderer } of entryPoints) {
const compiler = await makeBuildContext(fileName, renderer, writeStats);

View File

@@ -67,7 +67,8 @@ yarn-error.log
lib/csstojs/
lib/rnInjectedJs/
dist/
components/**/*.bundle.js
/**/*.bundle.js
/**/*.bundle.css
components/**/*.bundle.js.LICENSE.txt
components/**/*.bundle.js.md5
components/**/*.bundle.min.js

View File

@@ -89,8 +89,8 @@ android {
applicationId "net.cozic.joplin"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 2097773
versionName "3.4.0"
versionCode 2097777
versionName "3.4.4"
ndk {
abiFilters "armeabi-v7a", "x86", "arm64-v8a", "x86_64"
}

View File

@@ -6,6 +6,12 @@ import com.facebook.react.ReactActivityDelegate
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
import com.facebook.react.defaults.DefaultReactActivityDelegate
import android.os.Build
import android.os.Bundle
import android.view.View
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
class MainActivity : ReactActivity() {
/**
@@ -20,4 +26,25 @@ class MainActivity : ReactActivity() {
*/
override fun createReactActivityDelegate(): ReactActivityDelegate =
ReactActivityDelegateWrapper(this, BuildConfig.IS_NEW_ARCHITECTURE_ENABLED, DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled))
/**
* This is a workaround to fix the upstream issue https://github.com/facebook/react-native/issues/49759#issuecomment-2918934967
*/
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (Build.VERSION.SDK_INT >= 35) {
val rootView = findViewById<View>(android.R.id.content)
ViewCompat.setOnApplyWindowInsetsListener(rootView) { _, insets ->
val innerPadding = insets.getInsets(WindowInsetsCompat.Type.ime())
rootView.setPadding(
innerPadding.left,
innerPadding.top,
innerPadding.right,
innerPadding.bottom
)
insets
}
}
}
}

View File

@@ -15,6 +15,7 @@ pluginManagement {
"../android/expo-gradle-plugin"
).absolutePath
includeBuild(expoPluginsPath)
includeBuild("../node_modules/@react-native/gradle-plugin")
}
plugins {

View File

@@ -15,6 +15,7 @@ const Camera = (props: Props, ref: ForwardedRef<CameraRef>) => {
await shim.fsDriver().writeFile(
path,
`<svg viewBox="0 -70 232 78" width="232" height="78" version="1.1" baseProfile="full" xmlns="http://www.w3.org/2000/svg">
<rect width="232" height="78" y="-70" rx="32" style="fill: #ccc;"/>
<text style="font-family: serif; font-size: 104px; fill: rgb(128, 51, 128);">Test!</text>
</svg>`,
'utf8',

View File

@@ -16,24 +16,12 @@ import useBarcodeScanner from './utils/useBarcodeScanner';
import ScannedBarcodes from './ScannedBarcodes';
import { CameraRef } from './Camera/types';
import Camera from './Camera/index';
import { CameraResult, OnInsertBarcode } from './types';
import { CameraViewProps } from './types';
import Logger from '@joplin/utils/Logger';
import useBackHandler from '../../utils/hooks/useBackHandler';
const logger = Logger.create('CameraView');
interface Props {
themeId: number;
style: ViewStyle;
cameraType: CameraDirection;
cameraRatio: string;
onPhoto: (data: CameraResult)=> void;
// If null, cancelling should be handled by the parent
// component
onCancel: (()=> void)|null;
onInsertBarcode: OnInsertBarcode;
}
interface UseStyleProps {
themeId: number;
style: ViewStyle;
@@ -104,7 +92,7 @@ const useAvailableRatios = (): string[] => {
};
const CameraViewComponent: React.FC<Props> = props => {
const CameraViewComponent: React.FC<CameraViewProps> = props => {
const styles = useStyles(props);
const cameraRef = useRef<CameraRef|null>(null);
const [cameraReady, setCameraReady] = useState(false);

View File

@@ -0,0 +1,58 @@
import * as React from 'react';
import { useCallback, useMemo } from 'react';
import { connect } from 'react-redux';
import { StyleSheet, View } from 'react-native';
import { _ } from '@joplin/lib/locale';
import { AppState } from '../../utils/types';
import { PrimaryButton } from '../buttons';
import { themeStyle } from '../global-style';
import { CameraViewProps } from './types';
import pickDocument from '../../utils/pickDocument';
const useStyles = (themeId: number) => {
return useMemo(() => {
const theme = themeStyle(themeId);
return StyleSheet.create({
container: {
backgroundColor: theme.backgroundColor,
flexGrow: 1,
justifyContent: 'center',
alignItems: 'center',
},
});
}, [themeId]);
};
const CameraViewComponent: React.FC<CameraViewProps> = props => {
const styles = useStyles(props.themeId);
const onUploadPress = useCallback(async () => {
const response = await pickDocument({ preferCamera: true });
for (const asset of response) {
props.onPhoto({
uri: asset.uri,
type: asset.type,
});
}
}, [props.onPhoto]);
return (
<View style={styles.container}>
<PrimaryButton
icon='file-upload'
onPress={onUploadPress}
>{_('Upload photo')}</PrimaryButton>
</View>
);
};
const mapStateToProps = (state: AppState) => {
return {
themeId: state.settings.theme,
cameraRatio: state.settings['camera.ratio'],
cameraType: state.settings['camera.type'],
};
};
export default connect(mapStateToProps)(CameraViewComponent);

View File

@@ -1,6 +1,6 @@
import * as React from 'react';
import Setting from '@joplin/lib/models/Setting';
import CameraViewMultiPage, { OnComplete } from './CameraViewMultiPage';
import CameraViewMultiPage from './CameraViewMultiPage';
import { CameraResult, OnInsertBarcode } from './types';
import { Store } from 'redux';
import { AppState } from '../../utils/types';
@@ -8,24 +8,29 @@ import TestProviderStack from '../testing/TestProviderStack';
import createMockReduxStore from '../../utils/testing/createMockReduxStore';
import { fireEvent, render, screen, waitFor } from '@testing-library/react-native';
import { startCamera, takePhoto } from './utils/testing';
import { useState } from 'react';
interface WrapperProps {
onComplete?: (finalPhotos: CameraResult[])=> void;
onCancel?: ()=> void;
onInsertBarcode?: OnInsertBarcode;
onComplete?: OnComplete;
}
let store: Store<AppState>;
const WrappedCamera: React.FC<WrapperProps> = ({
onCancel = jest.fn(),
onComplete = jest.fn(),
onInsertBarcode = jest.fn(),
onCancel = jest.fn(),
}) => {
const [photos, setPhotos] = useState<CameraResult[]>([]);
return <TestProviderStack store={store}>
<CameraViewMultiPage
themeId={Setting.THEME_LIGHT}
photos={photos}
onSetPhotos={setPhotos}
onCancel={onCancel}
onComplete={onComplete}
onComplete={() => onComplete(photos)}
onInsertBarcode={onInsertBarcode}
/>
</TestProviderStack>;

View File

@@ -1,20 +1,22 @@
import * as React from 'react';
import { CameraResult } from './types';
import { View, StyleSheet, Platform, ImageBackground, ViewStyle, TextStyle } from 'react-native';
import { View, StyleSheet } from 'react-native';
import CameraView from './CameraView';
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useMemo } from 'react';
import { themeStyle } from '../global-style';
import { Button, Text } from 'react-native-paper';
import { Button } from 'react-native-paper';
import { _ } from '@joplin/lib/locale';
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
import shim from '@joplin/lib/shim';
import PhotoPreview from './PhotoPreview';
export type OnComplete = (photos: CameraResult[])=> void;
export type OnPhotosChange = (photos: CameraResult[])=> void;
export type OnComplete = ()=> void;
interface Props {
themeId: number;
onCancel: ()=> void;
onComplete: OnComplete;
photos: CameraResult[];
onSetPhotos: OnPhotosChange;
onInsertBarcode: (barcodeText: string)=> void;
}
@@ -42,17 +44,8 @@ const useStyle = (themeId: number) => {
imagePreview: {
maxWidth: 70,
flexShrink: 1,
flexGrow: 1,
alignContent: 'center',
justifyContent: 'center',
},
imageCountText: {
marginLeft: 'auto',
marginRight: 'auto',
marginTop: 'auto',
padding: 2,
borderRadius: 4,
backgroundColor: theme.backgroundColor2,
color: theme.color2,
},
@@ -60,55 +53,13 @@ const useStyle = (themeId: number) => {
}, [themeId]);
};
interface PhotoProps {
source: CameraResult;
backgroundStyle: ViewStyle;
textStyle: TextStyle;
label: number;
}
const PhotoPreview: React.FC<PhotoProps> = ({ source, label, backgroundStyle, textStyle }) => {
const [uri, setUri] = useState('');
useAsyncEffect(async (event) => {
if (Platform.OS === 'web') {
const file = await shim.fsDriver().fileAtPath(source.uri);
if (event.cancelled) return;
const uri = URL.createObjectURL(file);
setUri(uri);
event.onCleanup(() => {
URL.revokeObjectURL(uri);
});
} else {
setUri(source.uri);
}
}, [source]);
return <ImageBackground
style={backgroundStyle}
resizeMode='contain'
source={{ uri }}
accessibilityLabel={_('%d photo(s) taken', label)}
>
<Text
style={textStyle}
testID='photo-count'
>{label}</Text>
</ImageBackground>;
};
const CameraViewMultiPage: React.FC<Props> = ({
onInsertBarcode, onCancel, onComplete, themeId,
onInsertBarcode, onCancel, onComplete, themeId, photos, onSetPhotos,
}) => {
const [photos, setPhotos] = useState<CameraResult[]>([]);
const onPhoto = useCallback((data: CameraResult) => {
setPhotos(photos => [...photos, data]);
}, []);
const onDonePressed = useCallback(() => {
onComplete(photos);
}, [photos, onComplete]);
onSetPhotos([...photos, data]);
}, [photos, onSetPhotos]);
const styles = useStyle(themeId);
const renderLastPhoto = () => {
@@ -137,7 +88,7 @@ const CameraViewMultiPage: React.FC<Props> = ({
<Button
icon='arrow-right'
disabled={photos.length === 0}
onPress={onDonePressed}
onPress={onComplete}
>{_('Next')}</Button>
</View>
</View>;

View File

@@ -0,0 +1,69 @@
import * as React from 'react';
import { ViewStyle, TextStyle, Platform, ImageBackground, Text, StyleSheet } from 'react-native';
import { useState } from 'react';
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
import { CameraResult } from './types';
import shim from '@joplin/lib/shim';
import { _ } from '@joplin/lib/locale';
interface PhotoProps {
source: CameraResult;
backgroundStyle: ViewStyle;
textStyle: TextStyle;
label: number;
}
const styles = StyleSheet.create({
background: {
maxWidth: 70,
flexShrink: 1,
flexGrow: 1,
alignContent: 'center',
justifyContent: 'center',
},
text: {
marginLeft: 'auto',
marginRight: 'auto',
marginTop: 'auto',
padding: 4,
borderRadius: 32,
color: 'white',
backgroundColor: '#11c',
},
});
const PhotoPreview: React.FC<PhotoProps> = ({ source, label, backgroundStyle, textStyle }) => {
const [uri, setUri] = useState('');
useAsyncEffect(async (event) => {
if (!source) {
setUri('');
} else if (Platform.OS === 'web') {
const file = await shim.fsDriver().fileAtPath(source.uri);
if (event.cancelled) return;
const uri = URL.createObjectURL(file);
setUri(uri);
event.onCleanup(() => {
URL.revokeObjectURL(uri);
});
} else {
setUri(source.uri);
}
}, [source]);
return <ImageBackground
style={[styles.background, backgroundStyle]}
resizeMode='contain'
source={{ uri }}
accessibilityLabel={_('%d photo(s) taken', label)}
>
<Text
style={[styles.text, textStyle]}
testID='photo-count'
>{label}</Text>
</ImageBackground>;
};
export default PhotoPreview;

View File

@@ -1,3 +1,5 @@
import type { CameraDirection } from '@joplin/lib/models/settings/builtInMetadata';
import type { ViewStyle } from 'react-native';
export type OnInsertBarcode = (barcodeText: string)=> void;
@@ -5,3 +7,15 @@ export interface CameraResult {
uri: string;
type: string;
}
export interface CameraViewProps {
themeId: number;
style: ViewStyle;
cameraType: CameraDirection;
cameraRatio: string;
onPhoto: (data: CameraResult)=> void;
// If null, cancelling should be handled by the parent
// component
onCancel: (()=> void)|null;
onInsertBarcode: OnInsertBarcode;
}

View File

@@ -0,0 +1,90 @@
import * as React from 'react';
import { fireEvent, render, screen } from '@testing-library/react-native';
import createMockReduxStore from '../utils/testing/createMockReduxStore';
import TestProviderStack from './testing/TestProviderStack';
import ComboBox, { OnItemSelected, Option } from './ComboBox';
import { useMemo } from 'react';
interface Item {
title: string;
}
interface WrapperProps {
items: Item[];
onItemSelected?: OnItemSelected;
}
const store = createMockReduxStore();
const WrappedComboBox: React.FC<WrapperProps> = ({
items,
onItemSelected = jest.fn(),
}: WrapperProps) => {
const mappedItems = useMemo(() => {
return items.map((item): Option => ({
title: item.title,
icon: undefined,
accessibilityHint: undefined,
willRemoveOnPress: false,
}));
}, [items]);
return <TestProviderStack store={store}>
<ComboBox
items={mappedItems}
alwaysExpand={true}
style={{}}
onItemSelected={onItemSelected}
placeholder={'Test combobox'}
/>
</TestProviderStack>;
};
const getSearchInput = () => {
return screen.getByPlaceholderText('Test combobox');
};
const getSearchResults = () => {
return screen.getAllByTestId(/^search-result-/);
};
describe('ComboBox', () => {
test('should list all items when the search query is empty', () => {
const testItems = [
{ title: 'test 1' },
{ title: 'test 2' },
{ title: 'test 3' },
];
const { unmount } = render(
<WrappedComboBox
items={testItems}
/>,
);
expect(getSearchInput()).toHaveTextContent('');
expect(getSearchResults()).toHaveLength(3);
// Manually unmounting prevents a warning
unmount();
});
test('changing the search query should limit which items are visible', () => {
const testItems = [
{ title: 'a' },
{ title: 'b' },
{ title: 'c' },
{ title: 'aa' },
];
const { unmount } = render(
<WrappedComboBox items={testItems}/>,
);
expect(getSearchResults()).toHaveLength(4);
fireEvent.changeText(getSearchInput(), 'a');
const updatedResults = getSearchResults();
expect(updatedResults[0]).toHaveTextContent('a');
expect(updatedResults[1]).toHaveTextContent('aa');
expect(updatedResults).toHaveLength(2);
unmount();
});
});

View File

@@ -0,0 +1,601 @@
import * as React from 'react';
import { AccessibilityInfo, NativeSyntheticEvent, Platform, Role, ScrollViewProps, StyleSheet, TextInput, TextInputProps, useWindowDimensions, View, ViewProps, ViewStyle } from 'react-native';
import { TouchableRipple, Text } from 'react-native-paper';
import { connect } from 'react-redux';
import { AppState } from '../utils/types';
import { themeStyle } from './global-style';
import Icon from './Icon';
import { RefObject, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
import { _ } from '@joplin/lib/locale';
import SearchInput from './SearchInput';
import focusView from '../utils/focusView';
import AsyncActionQueue from '@joplin/lib/AsyncActionQueue';
import NestableFlatList, { NestableFlatListControl } from './NestableFlatList';
const naturalCompare = require('string-natural-compare');
export interface Option {
title: string;
icon: string|undefined;
accessibilityHint: string|undefined;
onPress?: ()=> void;
// True if pressing this option removes it. Used for working around
// focus issues.
willRemoveOnPress: boolean;
}
export type OnItemSelected = (item: Option, index: number)=> void;
interface BaseProps {
themeId: number;
items: Option[];
alwaysExpand: boolean;
placeholder: string;
onItemSelected: OnItemSelected;
style: ViewStyle;
searchInputProps?: TextInputProps;
searchResultProps?: ScrollViewProps;
}
type OnAddItem = (content: string)=> void;
type OnCanAddItem = (item: string)=> boolean;
type Props = BaseProps & ({
onAddItem: OnAddItem|null;
canAddItem: OnCanAddItem;
}|{
onAddItem?: undefined;
canAddItem?: undefined;
});
const optionKeyExtractor = (option: Option) => option.title;
interface UseSearchResultsOptions {
search: string;
setSearch: (search: string)=> void;
options: Option[];
onAddItem: null|OnAddItem;
canAddItem: OnCanAddItem;
}
const useSearchResults = ({
search, setSearch, options, onAddItem, canAddItem,
}: UseSearchResultsOptions) => {
const results = useMemo(() => {
return options
.filter(option => option.title.startsWith(search))
.sort((a, b) => {
if (a.title === b.title) return 0;
// Full matches should go first
if (a.title === search) return -1;
if (b.title === search) return 1;
return naturalCompare(a.title, b.title);
});
}, [search, options]);
const canAdd = (
!!onAddItem
&& search.trim()
&& results[0]?.title !== search
&& canAddItem(search)
);
// Use a ref to prevent unnecessary rerenders if onAddItem changes
const addCurrentSearch = useRef(()=>{});
addCurrentSearch.current = () => {
onAddItem(search);
AccessibilityInfo.announceForAccessibility(_('Added new: %s', search));
setSearch('');
};
return useMemo(() => {
if (!canAdd) return results;
return [
...results,
{
title: _('Add new'),
icon: 'fas fa-plus',
accessibilityHint: undefined,
willRemoveOnPress: true,
onPress: () => {
addCurrentSearch.current?.();
},
},
];
}, [canAdd, results]);
};
interface SelectedIndexControl {
onNextResult: ()=> void;
onPreviousResult: ()=> void;
onFirstResult: ()=> void;
onLastResult: ()=> void;
}
const useSelectedIndex = (search: string, searchResults: Option[]) => {
const [selectedIndex, setSelectedIndex] = useState(0);
useEffect(() => {
if (search) {
setSelectedIndex(0);
} else {
const hasResults = !!searchResults.length;
setSelectedIndex(hasResults ? 0 : -1);
}
}, [searchResults, search]);
const resultCount = searchResults.length;
const selectedIndexControl: SelectedIndexControl = useMemo(() => ({
onNextResult: () => {
setSelectedIndex(index => {
return Math.min(index + 1, resultCount - 1);
});
},
onPreviousResult: () => {
setSelectedIndex(index => {
return Math.max(index - 1, 0);
});
},
onFirstResult: () => {
setSelectedIndex(0);
},
onLastResult: () => {
setSelectedIndex(resultCount - 1);
},
}), [resultCount]);
return { selectedIndex, selectedIndexControl };
};
const useStyles = (themeId: number, showSearchResults: boolean) => {
const { fontScale } = useWindowDimensions();
const menuItemHeight = 40 * fontScale;
const theme = themeStyle(themeId);
const styles = useMemo(() => {
const borderRadius = 4;
const itemMarginVertical = 8;
return StyleSheet.create({
root: {
flexDirection: 'column',
overflow: 'hidden',
borderRadius,
backgroundColor: theme.backgroundColor,
borderColor: theme.dividerColor,
borderWidth: showSearchResults ? 1 : 0,
},
searchInputContainer: {
borderRadius,
backgroundColor: theme.backgroundColor,
borderColor: theme.dividerColor,
borderWidth: 1,
...(showSearchResults ? {
borderTopWidth: 0,
borderLeftWidth: 0,
borderRightWidth: 0,
} : {}),
},
tagSearchHelp: {
color: theme.colorFaded,
marginTop: 6,
},
searchInput: {
minHeight: 32,
},
searchResults: {
height: 200,
flexGrow: 1,
flexShrink: 1,
...(showSearchResults ? {} : {
display: 'none',
}),
},
optionIcon: {
color: theme.color,
fontSize: theme.fontSizeSmaller,
textAlign: 'center',
paddingLeft: 4,
paddingRight: 4,
},
optionLabel: {
fontSize: theme.fontSize,
color: theme.color,
paddingInlineStart: 3,
},
optionContent: {
flexDirection: 'row',
alignItems: 'center',
borderRadius,
height: menuItemHeight - itemMarginVertical,
marginTop: itemMarginVertical / 2,
marginBottom: itemMarginVertical / 2,
paddingHorizontal: 3,
},
optionContentSelected: {
backgroundColor: theme.selectedColor,
},
});
}, [theme, menuItemHeight, showSearchResults]);
return { menuItemHeight, styles };
};
type Styles = ReturnType<typeof useStyles>['styles'];
interface SearchResultProps {
text: string;
icon: string;
selected: boolean;
styles: Styles;
}
const SearchResult: React.FC<SearchResultProps> = ({
text, styles, selected, icon: iconName,
}) => {
const icon = iconName ? <Icon
style={styles.optionIcon}
name={iconName}
// Description is provided by adjacent text
accessibilityLabel={null}
/> : null;
return (
<View style={[styles.optionContent, selected && styles.optionContentSelected]}>
{icon}
<Text
style={styles.optionLabel}
>{text}</Text>
</View>
);
};
interface ResultWrapperProps extends ViewProps {
index: number;
item: Option;
}
interface SearchResultContainerProps {
onItemSelected: OnItemSelected;
selectedIndex: number;
baseId: string;
resultCount: number;
searchInputRef: RefObject<TextInput>;
// Used to determine focus
resultsHideOnPress: boolean;
}
const useSearchResultContainerComponent = ({
onItemSelected, selectedIndex, baseId, resultCount, searchInputRef, resultsHideOnPress,
}: SearchResultContainerProps): React.FC<ResultWrapperProps> => {
const listItemsRef = useRef<Record<number, View>>({});
const eventQueue = useMemo(() => {
const queue = new AsyncActionQueue(100);
// Don't allow skipping any onItemSelected calls:
queue.setCanSkipTaskHandler(() => false);
return queue;
}, []);
const onItemPressRef = useRef(onItemSelected);
onItemPressRef.current = (item, index) => {
let focusTarget = null;
if (resultsHideOnPress) {
focusTarget = searchInputRef.current;
} else if (Platform.OS === 'android' && item.willRemoveOnPress) {
// Workaround for an accessibility bug on Android: By default, when an item is removed
// from the list of results, focus can occasionally jump to the start of the document.
// To prevent this, manually move focus to the next item before the results list changes:
const adjacentView = listItemsRef.current[index + 1] ?? listItemsRef.current[index - 1];
focusTarget = adjacentView ?? searchInputRef.current;
}
if (focusTarget) {
focusView('ComboBox::focusAfterPress', focusTarget);
eventQueue.push(() => {
onItemSelected(item, index);
});
} else {
onItemSelected(item, index);
}
};
// For the correct accessibility structure, the `TouchableRipple`s need to be siblings.
return useMemo(() => ({ index, item, children, ...rest }) => (
<TouchableRipple
{...rest}
ref={(item) => {
listItemsRef.current[index] = item;
}}
onPress={() => { onItemPressRef.current(item, index); }}
// On web, focus is controlled using the arrow keys. On other
// platforms, arrow key navigation is not available and each item
// needs to be focusable
tabIndex={Platform.OS === 'web' ? -1 : undefined}
role={Platform.OS === 'web' ? 'option' : 'button'}
accessibilityHint={item.accessibilityHint}
aria-selected={index === selectedIndex}
nativeID={`${baseId}-${index}`}
testID={`search-result-${index}`}
aria-setsize={resultCount}
aria-posinset={index + 1}
><View>{children}</View></TouchableRipple>
), [selectedIndex, baseId, resultCount]);
};
const useShowSearchResults = (alwaysExpand: boolean, search: string) => {
const [showSearchResults, setShowSearchResults] = useState(alwaysExpand);
const showResultsRef = useRef(showSearchResults);
showResultsRef.current = showSearchResults;
useEffect(() => {
if (alwaysExpand) {
setShowSearchResults(true);
}
}, [alwaysExpand]);
useEffect(() => {
if (search.length > 0 && !showResultsRef.current) {
setShowSearchResults(true);
}
}, [search]);
return { showSearchResults, setShowSearchResults };
};
interface AnnounceSelectionOptions {
enabled: boolean;
selectedResultTitle: string|undefined;
resultCount: number;
searchQuery: string;
}
const useAnnounceSelection = ({ selectedResultTitle, resultCount, enabled, searchQuery }: AnnounceSelectionOptions) => {
const enabledRef = useRef(enabled);
enabledRef.current = enabled;
const announcement = (() => {
if (!searchQuery) return '';
if (resultCount === 0) return _('No results');
if (selectedResultTitle) return _('Selected: %s', selectedResultTitle);
return '';
})();
useEffect(() => {
if (enabledRef.current && announcement) {
AccessibilityInfo.announceForAccessibility(announcement);
}
}, [announcement]);
};
const useSelectionAutoScroll = (
listRef: RefObject<NestableFlatListControl|null>, results: Option[], selectedIndex: number,
) => {
const resultsRef = useRef(results);
resultsRef.current = results;
useEffect(() => {
if (resultsRef.current?.length && selectedIndex >= 0) {
listRef.current?.scrollToIndex({ index: selectedIndex, animated: false, viewPosition: 0.4 });
}
}, [selectedIndex, listRef]);
};
interface UseInputEventHandlersProps {
selectedIndexControl: SelectedIndexControl;
onItemSelected: OnItemSelected;
selectedIndex: number;
selectedResult: Option|null;
alwaysExpand: boolean;
showSearchResults: boolean;
setShowSearchResults: (show: boolean)=> void;
setSearch: (search: string)=> void;
}
const useInputEventHandlers = ({
selectedIndexControl,
onItemSelected: propsOnItemSelected, setShowSearchResults, alwaysExpand,
setSearch, selectedResult, selectedIndex, showSearchResults,
}: UseInputEventHandlersProps) => {
const propsOnItemSelectedRef = useRef(propsOnItemSelected);
propsOnItemSelectedRef.current = propsOnItemSelected;
const onItemSelected = useCallback((item: Option, index: number) => {
let result;
if (item.onPress) {
result = item.onPress();
} else {
result = propsOnItemSelectedRef.current(item, index);
}
if (!alwaysExpand) {
setSearch('');
setShowSearchResults(false);
}
return result;
}, [setShowSearchResults, alwaysExpand, setSearch]);
const onSubmit = useCallback(() => {
if (selectedResult) {
onItemSelected(selectedResult, selectedIndex);
setSearch('');
}
}, [onItemSelected, selectedResult, selectedIndex, setSearch]);
// For now, onKeyPress only works on web.
// See https://github.com/react-native-community/discussions-and-proposals/issues/249
type KeyPressEvent = { key: string };
const onKeyPress = useCallback((event: NativeSyntheticEvent<KeyPressEvent>) => {
const key = event.nativeEvent.key;
const isDownArrow = key === 'ArrowDown';
const isUpArrow = key === 'ArrowUp';
if (!showSearchResults && (isDownArrow || isUpArrow)) {
setShowSearchResults(true);
if (isUpArrow) {
selectedIndexControl.onLastResult();
} else {
selectedIndexControl.onFirstResult();
}
event.preventDefault();
} else if (key === 'ArrowDown') {
selectedIndexControl.onNextResult();
event.preventDefault();
} else if (key === 'ArrowUp') {
selectedIndexControl.onPreviousResult();
event.preventDefault();
} else if (key === 'Enter') {
// This case is necessary on web to prevent the
// search input from becoming defocused after
// pressing "enter".
event.preventDefault();
onSubmit();
setSearch('');
} else if (key === 'Escape' && !alwaysExpand) {
setShowSearchResults(false);
event.preventDefault();
}
}, [onSubmit, setSearch, selectedIndexControl, setShowSearchResults, showSearchResults, alwaysExpand]);
return { onKeyPress, onItemSelected, onSubmit };
};
const ComboBox: React.FC<Props> = ({
themeId,
items,
onItemSelected: propsOnItemSelected,
placeholder,
onAddItem,
canAddItem,
style: rootStyle,
alwaysExpand,
searchInputProps,
searchResultProps,
}) => {
const [search, setSearch] = useState('');
const { showSearchResults, setShowSearchResults } = useShowSearchResults(alwaysExpand, search);
const { styles, menuItemHeight } = useStyles(themeId, showSearchResults);
const results = useSearchResults({
search,
setSearch,
options: items,
onAddItem,
canAddItem,
});
const { selectedIndex, selectedIndexControl } = useSelectedIndex(search, results);
const searchInputRef = useRef<TextInput|null>(null);
const listRef = useRef<NestableFlatListControl|null>(null);
useSelectionAutoScroll(listRef, results, selectedIndex);
useAnnounceSelection({
// On web, announcements are handled natively based on accessibility roles.
// Manual announcements are only needed on iOS and Android:
enabled: Platform.OS !== 'web',
selectedResultTitle: results[selectedIndex]?.title,
searchQuery: search,
resultCount: results.length,
});
const { onItemSelected, onKeyPress, onSubmit } = useInputEventHandlers({
selectedIndexControl,
onItemSelected: propsOnItemSelected,
selectedIndex,
selectedResult: results[selectedIndex],
alwaysExpand,
showSearchResults,
setShowSearchResults,
setSearch,
});
const baseId = useId();
const SearchResultWrapper = useSearchResultContainerComponent({
onItemSelected, selectedIndex, baseId, searchInputRef, resultCount: results.length,
resultsHideOnPress: !alwaysExpand,
});
type RenderEvent = { item: Option; index: number };
const renderItem = useCallback(({ item, index }: RenderEvent) => {
return <SearchResult
text={item.title}
styles={styles}
selected={index === selectedIndex}
icon={item.icon ?? ''}
/>;
}, [selectedIndex, styles]);
const webProps = {
onKeyDown: onKeyPress,
};
const activeId = `${baseId}-${selectedIndex}`;
const searchResults = <NestableFlatList
ref={listRef}
data={results}
{...searchResultProps}
CellRendererComponent={SearchResultWrapper}
itemHeight={menuItemHeight}
contentWrapperProps={{
// A better role would be 'listbox', but that isn't supported by RN.
role: Platform.OS === 'web' ? 'listbox' as Role : undefined,
'aria-activedescendant': activeId,
nativeID: `menuBox-${baseId}`,
onKeyPress,
// Allow focusing the results list directly on web. It has been observed
// that certain screen readers on web sometimes fail to read changes to the results list.
// Being able to navigate directly to the results list may help users in this case.
tabIndex: Platform.OS === 'web' ? 0 : undefined,
} as ViewProps}
style={styles.searchResults}
keyExtractor={optionKeyExtractor}
extraData={renderItem}
renderItem={renderItem}
/>;
const helpComponent = <Text style={styles.tagSearchHelp}>{_('To create a new tag, type the name and press enter.')}</Text>;
return <View style={[styles.root, rootStyle]} {...webProps}>
<SearchInput
inputRef={searchInputRef}
themeId={themeId}
containerStyle={styles.searchInputContainer}
style={styles.searchInput}
value={search}
onChangeText={setSearch}
onKeyPress={onKeyPress}
onSubmitEditing={onSubmit}
placeholder={placeholder}
aria-activedescendant={showSearchResults ? activeId : undefined}
aria-controls={`menuBox-${baseId}`}
// Certain accessibility properties only work well on web:
{...(Platform.OS === 'web' ? {
role: 'combobox',
'aria-autocomplete': 'list',
'aria-expanded': showSearchResults,
'aria-label': placeholder,
} : {})}
{...searchInputProps}
/>
{searchResults}
{!showSearchResults && helpComponent}
</View>;
};
export default connect((state: AppState) => ({
themeId: state.settings.theme,
}))(ComboBox);

View File

@@ -1,28 +1,19 @@
import * as React from 'react';
import { Dialog, Divider, Surface, Text } from 'react-native-paper';
import { DialogType, PromptDialogData } from './types';
import { StyleSheet } from 'react-native';
import { DialogType, ButtonDialogData } from './types';
import { StyleSheet, ViewStyle } from 'react-native';
import { useMemo } from 'react';
import { themeStyle } from '../global-style';
import PromptButton from './PromptButton';
interface Props {
dialog: PromptDialogData;
dialog: ButtonDialogData;
containerStyle: ViewStyle;
themeId: number;
}
const useStyles = (themeId: number, isMenu: boolean) => {
const useStyles = (isMenu: boolean) => {
return useMemo(() => {
const theme = themeStyle(themeId);
return StyleSheet.create({
dialogContainer: {
backgroundColor: theme.backgroundColor,
borderRadius: theme.borderRadius,
paddingTop: theme.borderRadius,
marginLeft: 4,
marginRight: 4,
},
dialogContent: {
paddingBottom: 14,
@@ -40,12 +31,12 @@ const useStyles = (themeId: number, isMenu: boolean) => {
textAlign: isMenu ? 'center' : undefined,
},
});
}, [themeId, isMenu]);
}, [isMenu]);
};
const PromptDialog: React.FC<Props> = ({ dialog, themeId }) => {
const PromptDialog: React.FC<Props> = ({ dialog, containerStyle, themeId }) => {
const isMenu = dialog.type === DialogType.Menu;
const styles = useStyles(themeId, isMenu);
const styles = useStyles(isMenu);
const buttons = dialog.buttons.map((button, index) => {
return <PromptButton
@@ -63,7 +54,7 @@ const PromptDialog: React.FC<Props> = ({ dialog, themeId }) => {
return (
<Surface
testID={'prompt-dialog'}
style={styles.dialogContainer}
style={containerStyle}
key={dialog.key}
elevation={1}
>

View File

@@ -0,0 +1,76 @@
import * as React from 'react';
import { Dialog, Surface, Text } from 'react-native-paper';
import { TextInputDialogData } from './types';
import { StyleSheet, ViewStyle } from 'react-native';
import { useId, useMemo, useState } from 'react';
import PromptButton from './PromptButton';
import { _ } from '@joplin/lib/locale';
import TextInput from '../TextInput';
interface Props {
dialog: TextInputDialogData;
containerStyle: ViewStyle;
themeId: number;
}
const useStyles = () => {
return useMemo(() => {
return StyleSheet.create({
dialogContent: {
paddingBottom: 14,
},
dialogActions: {
paddingBottom: 14,
paddingTop: 4,
},
});
}, []);
};
const TextInputDialog: React.FC<Props> = ({ dialog, containerStyle, themeId }) => {
const styles = useStyles();
const [text, setText] = useState('');
const labelId = useId();
return (
<Surface
testID={'prompt-dialog'}
style={containerStyle}
key={dialog.key}
elevation={1}
>
<Dialog.Content style={styles.dialogContent}>
<Text
variant='bodyMedium'
nativeID={labelId}
>{dialog.message}</Text>
<TextInput
aria-labelledby={labelId}
themeId={themeId}
value={text}
onChangeText={setText}
/>
</Dialog.Content>
<Dialog.Actions
style={styles.dialogActions}
>
<PromptButton
buttonSpec={{
text: _('Cancel'),
onPress: dialog.onDismiss,
}}
themeId={themeId}
/>
<PromptButton
buttonSpec={{
text: _('OK'),
onPress: () => dialog.onSubmit(text),
}}
themeId={themeId}
/>
</Dialog.Actions>
</Surface>
);
};
export default TextInputDialog;

View File

@@ -1,16 +1,16 @@
import * as React from 'react';
import { Alert, Platform } from 'react-native';
import { DialogControl, DialogType, MenuChoice, PromptButtonSpec, PromptDialogData, PromptOptions } from '../types';
import { DialogControl, DialogType, MenuChoice, PromptButtonSpec, DialogData, PromptOptions } from '../types';
import { _ } from '@joplin/lib/locale';
import { useMemo, useRef } from 'react';
type SetPromptDialogs = React.Dispatch<React.SetStateAction<PromptDialogData[]>>;
type SetPromptDialogs = React.Dispatch<React.SetStateAction<DialogData[]>>;
const useDialogControl = (setPromptDialogs: SetPromptDialogs) => {
const nextDialogIdRef = useRef(0);
const dialogControl: DialogControl = useMemo(() => {
const onDismiss = (dialog: PromptDialogData) => {
const onDismiss = (dialog: DialogData) => {
setPromptDialogs(dialogs => dialogs.filter(d => d !== dialog));
};
@@ -39,8 +39,8 @@ const useDialogControl = (setPromptDialogs: SetPromptDialogs) => {
Alert.alert(title, message, buttons, options);
} else {
const cancelable = options?.cancelable ?? true;
const dialog: PromptDialogData = {
type: DialogType.Prompt,
const dialog: DialogData = {
type: DialogType.ButtonPrompt,
key: `dialog-${nextDialogIdRef.current++}`,
title,
message,
@@ -69,7 +69,7 @@ const useDialogControl = (setPromptDialogs: SetPromptDialogs) => {
return new Promise<T>((resolve) => {
const dismiss = () => onDismiss(dialog);
const dialog: PromptDialogData = {
const dialog: DialogData = {
type: DialogType.Menu,
key: `menu-dialog-${nextDialogIdRef.current++}`,
title: '',
@@ -91,6 +91,33 @@ const useDialogControl = (setPromptDialogs: SetPromptDialogs) => {
});
});
},
promptForText: (message: string) => {
return new Promise<string|null>((resolve) => {
const dismiss = () => {
onDismiss(dialog);
};
const dialog: DialogData = {
type: DialogType.TextInput,
key: `prompt-dialog-${nextDialogIdRef.current++}`,
message,
onSubmit: (text) => {
resolve(text);
dismiss();
},
onDismiss: () => {
resolve(null);
dismiss();
},
};
setPromptDialogs(dialogs => {
return [
...dialogs,
dialog,
];
});
});
},
};
return control;

View File

@@ -5,10 +5,11 @@ import { Portal } from 'react-native-paper';
import Modal from '../Modal';
import shim from '@joplin/lib/shim';
import makeShowMessageBox from '../../utils/makeShowMessageBox';
import { DialogControl, PromptDialogData } from './types';
import { DialogControl, DialogData, DialogType } from './types';
import useDialogControl from './hooks/useDialogControl';
import PromptDialog from './PromptDialog';
import { themeStyle } from '../global-style';
import TextInputDialog from './TextInputDialog';
export type { DialogControl } from './types';
export const DialogContext = createContext<DialogControl>(null);
@@ -18,10 +19,11 @@ interface Props {
children: React.ReactNode;
}
const useStyles = () => {
const useStyles = (themeId: number) => {
const windowSize = useWindowDimensions();
return useMemo(() => {
const theme = themeStyle(themeId);
return StyleSheet.create({
modalContainer: {
marginLeft: 'auto',
@@ -31,12 +33,20 @@ const useStyles = () => {
width: Math.max(windowSize.width / 2, 400),
maxWidth: '100%',
},
dialogContainer: {
backgroundColor: theme.backgroundColor,
borderRadius: theme.borderRadius,
paddingTop: theme.borderRadius,
marginLeft: 4,
marginRight: 4,
},
});
}, [windowSize.width]);
}, [windowSize.width, themeId]);
};
const DialogManager: React.FC<Props> = props => {
const [dialogModels, setPromptDialogs] = useState<PromptDialogData[]>([]);
const [dialogModels, setPromptDialogs] = useState<DialogData[]>([]);
const dialogControl = useDialogControl(setPromptDialogs);
const dialogControlRef = useRef(dialogControl);
@@ -51,17 +61,34 @@ const DialogManager: React.FC<Props> = props => {
}, []);
const theme = themeStyle(props.themeId);
const styles = useStyles();
const styles = useStyles(props.themeId);
const dialogComponents: React.ReactNode[] = [];
for (const dialog of dialogModels) {
dialogComponents.push(
<PromptDialog
key={dialog.key}
dialog={dialog}
themeId={props.themeId}
/>,
);
const dialogProps = {
containerStyle: styles.dialogContainer,
themeId: props.themeId,
};
if (dialog.type === DialogType.Menu || dialog.type === DialogType.ButtonPrompt) {
dialogComponents.push(
<PromptDialog
dialog={dialog}
{...dialogProps}
key={dialog.key}
/>,
);
} else if (dialog.type === DialogType.TextInput) {
dialogComponents.push(
<TextInputDialog
dialog={dialog}
{...dialogProps}
key={dialog.key}
/>,
);
} else {
const exhaustivenessCheck: never = dialog.type;
throw new Error(`Unexpected dialog type ${exhaustivenessCheck}`);
}
}
// Web: Use a <Modal> wrapper for better keyboard focus handling.

View File

@@ -24,16 +24,18 @@ export interface DialogControl {
info(message: string): Promise<void>;
error(message: string): Promise<void>;
prompt(title: string, message: string, buttons?: PromptButtonSpec[], options?: PromptOptions): void;
promptForText(message: string): Promise<string>;
showMenu<IdType>(title: string, choices: MenuChoice<IdType>[]): Promise<IdType>;
}
export enum DialogType {
Prompt,
ButtonPrompt,
Menu,
TextInput,
}
export interface PromptDialogData {
type: DialogType;
export interface ButtonDialogData {
type: DialogType.ButtonPrompt|DialogType.Menu;
key: string;
title: string;
message: string;
@@ -41,3 +43,13 @@ export interface PromptDialogData {
onDismiss: (()=> void)|null;
}
export interface TextInputDialogData {
type: DialogType.TextInput;
key: string;
message: string;
onSubmit: (text: string)=> void;
onDismiss: ()=> void;
}
export type DialogData = ButtonDialogData | TextInputDialogData;

View File

@@ -1,28 +1,31 @@
import * as React from 'react';
import {
forwardRef, Ref, useEffect, useImperativeHandle, useMemo, useRef,
forwardRef, Ref, useCallback, useEffect, useImperativeHandle, useMemo, useRef,
} from 'react';
import { View } from 'react-native';
import Logger from '@joplin/utils/Logger';
import { Props, WebViewControl } from './types';
import { JSDOM } from 'jsdom';
import useCss from './utils/useCss';
const logger = Logger.create('ExtendedWebView');
const ExtendedWebView = (props: Props, ref: Ref<WebViewControl>) => {
const dom = useMemo(() => {
// Note: Adding `runScripts: 'dangerously'` to allow running inline <script></script>s.
// Use with caution.
// Use with caution -- don't load untrusted WebView HTML while testing.
return new JSDOM(props.html, { runScripts: 'dangerously', pretendToBeVisual: true });
}, [props.html]);
const injectJs = useCallback((js: string) => {
return dom.window.eval(js);
}, [dom]);
useImperativeHandle(ref, (): WebViewControl => {
const result = {
injectJS(js: string) {
return dom.window.eval(js);
},
injectJS: injectJs,
postMessage(message: unknown) {
const messageEventContent = {
data: message,
@@ -36,33 +39,61 @@ const ExtendedWebView = (props: Props, ref: Ref<WebViewControl>) => {
},
};
return result;
}, [dom]);
}, [dom, injectJs]);
const onMessageRef = useRef(props.onMessage);
onMessageRef.current = props.onMessage;
const { injectedJs: cssInjectedJavaScript } = useCss(
injectJs,
props.css,
);
// Don't re-load when injected JS changes. This should match the behavior of the native webview.
const injectedJavaScriptRef = useRef(props.injectedJavaScript);
injectedJavaScriptRef.current = props.injectedJavaScript;
injectedJavaScriptRef.current = props.injectedJavaScript + cssInjectedJavaScript;
useEffect(() => {
// JSDOM polyfills
dom.window.eval(`
// 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 = () => {};
range.getClientRects = () => {
return {
length: 0,
item: () => null,
[Symbol.iterator]: () => {},
};
};
window.scrollBy = (_amount) => { };
return range;
// JSDOM iframes are missing certain functionality required by Joplin,
// including:
// - MessageEvent.source: Should point to the window that created a message.
// Joplin uses this to determine the source of messages in iframe-related IPC.
// - iframe.srcdoc: Used by Joplin to create plugin windows.
const polyfillIframeContentWindow = (contentWindow) => {
contentWindow.addEventListener('message', event => {
// Work around a missing ".source" property on events.
// See https://github.com/jsdom/jsdom/issues/2745#issuecomment-1207414024
if (!event.source) {
contentWindow.dispatchEvent(new MessageEvent('message', {
source: window,
data: event.data,
}));
event.stopImmediatePropagation();
}
});
contentWindow.parent.postMessage = (message) => {
window.dispatchEvent(new MessageEvent('message', {
data: message,
source: contentWindow,
}));
};
};
Object.defineProperty(HTMLIFrameElement.prototype, 'srcdoc', {
set(value) {
this.src = 'about:blank';
setTimeout(() => {
this.contentDocument.write(value);
polyfillIframeContentWindow(this.contentWindow);
}, 0);
},
});
`);
dom.window.eval(`
@@ -77,10 +108,14 @@ const ExtendedWebView = (props: Props, ref: Ref<WebViewControl>) => {
},
});
dom.window.eval(injectedJavaScriptRef.current);
// Wrap the injected JavaScript in (() => {...})() to more closely
// match the behavior of injectedJavaScript on Android -- variables
// declared with "var" or "const" should not become global variables.
dom.window.eval(`(() => {
${injectedJavaScriptRef.current}
})()`);
}, [dom]);
const onLoadEndRef = useRef(props.onLoadEnd);
onLoadEndRef.current = props.onLoadEnd;
const onLoadStartRef = useRef(props.onLoadStart);

View File

@@ -12,6 +12,7 @@ import Setting from '@joplin/lib/models/Setting';
import shim from '@joplin/lib/shim';
import Logger from '@joplin/utils/Logger';
import { Props, WebViewControl } from './types';
import useCss from './utils/useCss';
const logger = Logger.create('ExtendedWebView');
@@ -98,6 +99,9 @@ const ExtendedWebView = (props: Props, ref: Ref<WebViewControl>) => {
}, 250);
}, []);
const { injectedJs: cssInjectedJs } = useCss(webviewRef.current?.injectJavaScript, props.css);
const injectedJavaScript = props.injectedJavaScript + cssInjectedJs;
// - `setSupportMultipleWindows` must be `true` for security reasons:
// https://github.com/react-native-webview/react-native-webview/releases/tag/v11.0.0
@@ -131,7 +135,7 @@ const ExtendedWebView = (props: Props, ref: Ref<WebViewControl>) => {
allowFileAccess={true}
allowFileAccessFromFileURLs={props.allowFileAccessFromJs}
webviewDebuggingEnabled={allowWebviewDebugging}
injectedJavaScript={props.injectedJavaScript}
injectedJavaScript={injectedJavaScript}
onMessage={props.onMessage}
onError={props.onError ?? onError}
onLoadEnd={props.onLoadEnd}

View File

@@ -1,13 +1,14 @@
import * as React from 'react';
import {
forwardRef, Ref, useEffect, useImperativeHandle, useRef, useState,
forwardRef, Ref, useCallback, useEffect, useImperativeHandle, useRef, useState,
} from 'react';
import { Props, WebViewControl } from './types';
import { View, ViewStyle } from 'react-native';
import makeSandboxedIframe from '@joplin/lib/utils/dom/makeSandboxedIframe';
import Logger from '@joplin/utils/Logger';
import useCss from './utils/useCss';
const logger = Logger.create('ExtendedWebView');
@@ -20,24 +21,26 @@ const wrapperStyle: ViewStyle = { height: '100%', width: '100%', flex: 1 };
const ExtendedWebView = (props: Props, ref: Ref<WebViewControl>) => {
const iframeRef = useRef<HTMLIFrameElement|null>(null);
const injectJs = useCallback((js: string) => {
if (!iframeRef.current) {
logger.warn(`WebView(${props.webviewInstanceId}): Tried to inject JavaScript after the iframe has unloaded.`);
return;
}
// react-native-webview doesn't seem to show a warning in the case where JavaScript
// is injected before the first page loads.
if (!iframeRef.current.contentWindow) {
return;
}
iframeRef.current.contentWindow.postMessage({
injectJs: js,
}, '*');
}, [props.webviewInstanceId]);
useImperativeHandle(ref, (): WebViewControl => {
return {
injectJS(js: string) {
if (!iframeRef.current) {
logger.warn(`WebView(${props.webviewInstanceId}): Tried to inject JavaScript after the iframe has unloaded.`);
return;
}
// react-native-webview doesn't seem to show a warning in the case where JavaScript
// is injected before the first page loads.
if (!iframeRef.current.contentWindow) {
return;
}
iframeRef.current.contentWindow.postMessage({
injectJs: js,
}, '*');
},
injectJS: injectJs,
postMessage(message: unknown) {
if (!iframeRef.current || !iframeRef.current.contentWindow) {
logger.warn(`WebView(${props.webviewInstanceId}): Tried to post a message to an unloaded iframe.`);
@@ -49,7 +52,7 @@ const ExtendedWebView = (props: Props, ref: Ref<WebViewControl>) => {
}, '*');
},
};
}, [props.webviewInstanceId]);
}, [props.webviewInstanceId, injectJs]);
const [containerElement, setContainerElement] = useState<HTMLDivElement>();
const containerRef = useRef(containerElement);
@@ -62,9 +65,15 @@ const ExtendedWebView = (props: Props, ref: Ref<WebViewControl>) => {
const onLoadStartRef = useRef(props.onLoadStart);
onLoadStartRef.current = props.onLoadStart;
const { injectedJs: cssInjectedJs } = useCss(
iframeRef.current ? injectJs : null,
props.css,
);
const injectedJavaScript = props.injectedJavaScript + cssInjectedJs;
// Don't re-load when injected JS changes. This should match the behavior of the native webview.
const injectedJavaScriptRef = useRef(props.injectedJavaScript);
injectedJavaScriptRef.current = props.injectedJavaScript;
const injectedJavaScriptRef = useRef(injectedJavaScript);
injectedJavaScriptRef.current = injectedJavaScript;
useEffect(() => {
const headHtml = `

View File

@@ -31,6 +31,7 @@ export interface Props {
// If HTML is still being loaded, [html] should be an empty string.
html: string;
css?: string;
// Initial javascript. Must evaluate to true.
injectedJavaScript: string;

View File

@@ -0,0 +1,38 @@
import { useEffect } from 'react';
type OnInjectJs = (js: string)=> void;
const webViewCssClassName = 'extended-webview-css';
const applyCssJs = (css: string) => `
(function() {
const styleId = ${JSON.stringify(webViewCssClassName)};
const oldStyle = document.getElementById(styleId);
if (oldStyle) {
oldStyle.remove();
}
const style = document.createElement('style');
style.setAttribute('id', styleId);
style.appendChild(document.createTextNode(${JSON.stringify(css)}));
document.head.appendChild(style);
})();
true;
`;
const useCss = (injectJs: OnInjectJs|null, css: string) => {
useEffect(() => {
if (injectJs && css) {
injectJs(applyCssJs(css));
}
}, [injectJs, css]);
return {
injectedJs: css ? applyCssJs(css) : '',
};
};
export default useCss;

View File

@@ -1,10 +1,13 @@
import * as React from 'react';
import { FunctionComponent, ReactElement } from 'react';
import { FunctionComponent, ReactElement, useCallback, useContext } from 'react';
import { _ } from '@joplin/lib/locale';
import Folder, { FolderEntityWithChildren } from '@joplin/lib/models/Folder';
import { themeStyle } from './global-style';
import Dropdown, { DropdownListItem, OnValueChangedListener } from './Dropdown';
import { FolderEntity } from '@joplin/lib/services/database/types';
import { View } from 'react-native';
import { Button } from 'react-native-paper';
import { DialogContext } from './DialogManager';
interface FolderPickerProps {
disabled?: boolean;
@@ -16,6 +19,7 @@ interface FolderPickerProps {
darkText?: boolean;
themeId?: number;
coverableChildrenRight?: ReactElement|ReactElement[];
onNewFolder?: (title: string)=> void;
}
@@ -28,6 +32,7 @@ const FolderPicker: FunctionComponent<FolderPickerProps> = ({
placeholder,
darkText,
coverableChildrenRight,
onNewFolder,
themeId,
}) => {
const theme = themeStyle(themeId);
@@ -61,7 +66,15 @@ const FolderPicker: FunctionComponent<FolderPickerProps> = ({
return output;
};
return (
const dialogs = useContext(DialogContext);
const onNewFolderPress = useCallback(async () => {
const title = await dialogs.promptForText(_('New notebook title'));
if (title !== null) {
onNewFolder(title);
}
}, [dialogs, onNewFolder]);
const dropdown = (
<Dropdown
items={titlePickerItems(!!mustSelect)}
accessibilityHint={_('Selects a notebook')}
@@ -88,6 +101,19 @@ const FolderPicker: FunctionComponent<FolderPickerProps> = ({
}}
/>
);
if (onNewFolder) {
return <View style={{ flexDirection: 'column', flex: 1 }}>
{dropdown}
<Button
style={{ alignSelf: 'flex-end' }}
icon='plus'
onPress={onNewFolderPress}
>{_('Create new notebook')}</Button>
</View>;
} else {
return dropdown;
}
};
export default FolderPicker;

View File

@@ -1,6 +1,6 @@
import * as React from 'react';
import { TextStyle, Text } from 'react-native';
import { TextStyle, Text, StyleProp } from 'react-native';
const FontAwesomeIcon = require('react-native-vector-icons/FontAwesome5').default;
const AntIcon = require('react-native-vector-icons/AntDesign').default;
@@ -9,7 +9,7 @@ const Ionicon = require('react-native-vector-icons/Ionicons').default;
interface Props {
name: string;
style: TextStyle;
style: StyleProp<TextStyle>;
// If `null` is given, the content must be labeled elsewhere.
accessibilityLabel: string|null;

View File

@@ -5,8 +5,8 @@
import * as React from 'react';
import { themeStyle } from '@joplin/lib/theme';
import { Theme } from '@joplin/lib/themes/type';
import { useState, useMemo, useCallback, useRef } from 'react';
import { Text, Pressable, ViewStyle, StyleSheet, LayoutChangeEvent, LayoutRectangle, Animated, AccessibilityState, AccessibilityRole, TextStyle, GestureResponderEvent, Platform, Role } from 'react-native';
import { useState, useMemo, useCallback, useRef, Ref } from 'react';
import { Text, Pressable, ViewStyle, StyleSheet, LayoutChangeEvent, LayoutRectangle, Animated, AccessibilityState, AccessibilityRole, TextStyle, GestureResponderEvent, Platform, Role, StyleProp, View } from 'react-native';
import { Menu, MenuOptions, MenuTrigger, renderers } from 'react-native-popup-menu';
import Icon from './Icon';
import AccessibleView from './accessibility/AccessibleView';
@@ -15,11 +15,13 @@ type ButtonClickListener = ()=> void;
interface ButtonProps {
onPress: ButtonClickListener;
pressableRef?: Ref<View>;
// Accessibility label and text shown in a tooltip
description: string;
iconName: string;
iconStyle: TextStyle;
iconStyle: StyleProp<TextStyle>;
themeId: number;
@@ -87,6 +89,7 @@ const IconButton = (props: ButtonProps) => {
const button = (
<Pressable
ref={props.pressableRef}
onPress={props.onPress}
onLongPress={onLongPress}
onPressIn={onPressIn}

View File

@@ -1,91 +1,88 @@
import * as React from 'react';
import { ReactNode } from 'react';
import { Text, View, StyleSheet, Button, TextStyle, ViewStyle } from 'react-native';
import { useMemo } from 'react';
import { View, StyleSheet } from 'react-native';
import { themeStyle } from './global-style';
import { _ } from '@joplin/lib/locale';
import Modal from './Modal';
import { PrimaryButton } from './buttons';
import { _ } from '@joplin/lib/locale';
import { Button } from 'react-native-paper';
interface Props {
themeId: number;
ContentComponent: ReactNode;
children: React.ReactNode;
buttonBarEnabled: boolean;
title: string;
okTitle: string;
cancelTitle: string;
onOkPress: ()=> void;
onCancelPress: ()=> void;
}
interface State {
}
class ModalDialog extends React.Component<Props, State> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
private styles_: any;
public constructor(props: Props) {
super(props);
this.styles_ = {};
}
private styles() {
const themeId = this.props.themeId;
const useStyles = (themeId: number) => {
return useMemo(() => {
const theme = themeStyle(themeId);
if (this.styles_[themeId]) return this.styles_[themeId];
this.styles_ = {};
const styles: Record<string, ViewStyle|TextStyle> = {
modalContentWrapper: {
flex: 1,
flexDirection: 'column',
return StyleSheet.create({
container: {
borderRadius: 4,
backgroundColor: theme.backgroundColor,
borderWidth: 1,
borderColor: theme.dividerColor,
margin: 20,
padding: 10,
borderRadius: 5,
elevation: 10,
maxWidth: 600,
maxHeight: 500,
width: '100%',
height: '100%',
alignSelf: 'center',
marginVertical: 'auto',
flexGrow: 1,
flexShrink: 1,
padding: theme.margin,
},
modalContentWrapper2: {
flex: 1,
title: theme.headerStyle,
contentWrapper: {
flexGrow: 1,
flexShrink: 1,
},
title: { ...theme.normalText, borderBottomWidth: 1,
borderBottomColor: theme.dividerColor,
paddingBottom: 10,
fontWeight: 'bold' },
buttonRow: {
flexDirection: 'row',
borderTopWidth: 1,
borderTopColor: theme.dividerColor,
paddingTop: 10,
justifyContent: 'flex-end',
gap: theme.margin,
marginTop: theme.marginTop,
},
};
// Ensures that screen-reader-only headings have size (necessary for focusing/reading them).
invisibleHeading: {
flexGrow: 1,
},
});
}, [themeId]);
};
this.styles_[themeId] = StyleSheet.create(styles);
return this.styles_[themeId];
}
const ModalDialog: React.FC<Props> = props => {
const styles = useStyles(props.themeId);
const theme = themeStyle(props.themeId);
public override render() {
const ContentComponent = this.props.ContentComponent;
const buttonBarEnabled = this.props.buttonBarEnabled !== false;
return (
<Modal transparent={true} visible={true} onRequestClose={() => {}} containerStyle={this.styles().modalContentWrapper}>
<Text style={this.styles().title}>{this.props.title}</Text>
<View style={this.styles().modalContentWrapper2}>{ContentComponent}</View>
<View style={this.styles().buttonRow}>
<View style={{ flex: 1 }}>
<Button disabled={!buttonBarEnabled} title={_('OK')} onPress={this.props.onOkPress}></Button>
</View>
<View style={{ flex: 1, marginLeft: 5 }}>
<Button disabled={!buttonBarEnabled} title={_('Cancel')} onPress={this.props.onCancelPress}></Button>
</View>
</View>
</Modal>
);
}
}
return (
<Modal
transparent={true}
visible={true}
onRequestClose={null}
containerStyle={styles.container}
backgroundColor={theme.backgroundColorTransparent2}
>
<View style={styles.contentWrapper}>{props.children}</View>
<View style={styles.buttonRow}>
<View
// This heading makes it easier for screen readers to jump to the
// actions list. Without a heading here, it can be difficult to locate the "ok" and "cancel"
// buttons.
role='heading'
aria-label={_('Actions')}
accessible={true}
style={styles.invisibleHeading}
/>
<Button disabled={!props.buttonBarEnabled} onPress={props.onCancelPress}>{props.cancelTitle}</Button>
<PrimaryButton disabled={!props.buttonBarEnabled} onPress={props.onOkPress}>{props.okTitle}</PrimaryButton>
</View>
</Modal>
);
};
export default ModalDialog;

View File

@@ -0,0 +1,127 @@
import * as React from 'react';
import { Ref, useRef, useImperativeHandle, useState, useCallback } from 'react';
import { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, ScrollView, ScrollViewProps, View, ViewProps } from 'react-native';
interface RenderEvent<T> {
item: T;
index: number;
}
interface ScrollToOptions {
index: number;
viewPosition: number;
animated: boolean;
}
export interface NestableFlatListControl {
scrollToIndex(options: ScrollToOptions): void;
}
interface CellRendererProps<T> {
index: number;
item: T;
children: React.ReactNode;
}
// For compatibility, these props should be mostly compatible with the
// native FlatList props:
interface Props<T> extends ScrollViewProps {
ref: Ref<NestableFlatListControl>;
data: T[];
itemHeight: number;
CellRendererComponent?: React.ComponentType<CellRendererProps<T>>;
renderItem: (event: RenderEvent<T>)=> React.ReactNode;
keyExtractor: (item: T)=> string;
extraData: unknown;
// Additional props.
// The contentWrapperProps can be used to improve accessibility by
// applying certain content roles to the <View> that directly contains
// the list's content. At least on web, applying these props directly to the ScrollView may
// not work due to additional <View>s added by React Native.
contentWrapperProps?: ViewProps;
}
// This component allows working around restrictions on nesting React Native's built-in
// <FlatList> within <ScrollView>s. For the most part, this component's interface should
// be compatible with the <FlatList> API.
//
// See https://github.com/facebook/react-native/issues/31697.
const NestableFlatList = function<T>({
ref,
itemHeight,
renderItem,
keyExtractor,
data,
CellRendererComponent = React.Fragment,
contentWrapperProps,
...rest
}: Props<T>) {
const scrollViewRef = useRef<ScrollView|null>(null);
const [scroll, setScroll] = useState(0);
const [listHeight, setListHeight] = useState(0);
useImperativeHandle(ref, () => {
return {
scrollToIndex: ({ index, animated, viewPosition }) => {
const offset = Math.max(0, index * itemHeight - viewPosition * listHeight);
scrollViewRef.current.scrollTo({
y: offset,
animated,
});
// onScroll events don't seem to be sent when scrolling with .scrollTo.
// The scroll offset needs to be updated manually:
setScroll(offset);
},
};
}, [itemHeight, listHeight]);
const onScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
setScroll(event.nativeEvent.contentOffset.y);
}, []);
const onLayout = useCallback((event: LayoutChangeEvent) => {
setListHeight(event.nativeEvent.layout.height);
}, []);
const bufferSize = 10;
const visibleStartIndex = Math.floor(scroll / itemHeight);
const visibleEndIndex = Math.ceil((scroll + listHeight) / itemHeight);
const startIndex = Math.max(0, visibleStartIndex - bufferSize);
const maximumIndex = data.length - 1;
const endIndex = Math.min(visibleEndIndex + bufferSize, maximumIndex);
const paddingTop = startIndex * itemHeight;
const paddingBottom = (maximumIndex - endIndex) * itemHeight;
const renderVisibleItems = () => {
const result: React.ReactNode[] = [];
for (let i = startIndex; i <= endIndex; i++) {
result.push(
<CellRendererComponent
index={i}
item={data[i]}
key={keyExtractor(data[i])}
>
{renderItem({ item: data[i], index: i })}
</CellRendererComponent>,
);
}
return result;
};
return <ScrollView
ref={scrollViewRef}
onScroll={onScroll}
onLayout={onLayout}
{...rest}
>
<View {...contentWrapperProps}>
<View style={{ height: paddingTop }}/>
{renderVisibleItems()}
<View style={{ height: paddingBottom }}/>
</View>
</ScrollView>;
};
export default NestableFlatList;

View File

@@ -1,24 +1,20 @@
import * as React from 'react';
import useOnMessage, { HandleMessageCallback, OnMarkForDownloadCallback } from './hooks/useOnMessage';
import { useRef, useCallback, useState, useMemo } from 'react';
import { useRef, useCallback } from 'react';
import { View, ViewStyle } from 'react-native';
import ExtendedWebView from '../ExtendedWebView';
import { WebViewControl } from '../ExtendedWebView/types';
import useOnResourceLongPress from './hooks/useOnResourceLongPress';
import useRenderer from './hooks/useRenderer';
import { OnWebViewMessageHandler } from './types';
import useRerenderHandler, { ResourceInfo } from './hooks/useRerenderHandler';
import useSource from './hooks/useSource';
import Setting from '@joplin/lib/models/Setting';
import uuid from '@joplin/lib/uuid';
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
import useContentScripts from './hooks/useContentScripts';
import { MarkupLanguage } from '@joplin/renderer';
import shim from '@joplin/lib/shim';
import CommandService from '@joplin/lib/services/CommandService';
import { AppState } from '../../utils/types';
import { connect } from 'react-redux';
import useWebViewSetup from '../../contentScripts/rendererBundle/useWebViewSetup';
interface Props {
themeId: number;
@@ -69,27 +65,14 @@ function NoteBodyViewer(props: Props) {
onResourceLongPress,
});
const [webViewLoaded, setWebViewLoaded] = useState(false);
const [onWebViewMessage, setOnWebViewMessage] = useState<OnWebViewMessageHandler>(()=>()=>{});
// The renderer can write to whichever temporary directory we choose. As such,
// we use a subdirectory of the main temporary directory for security reasons.
const tempDir = useMemo(() => {
return `${Setting.value('tempDir')}/${uuid.createNano()}`;
}, []);
const renderer = useRenderer({
webViewLoaded,
onScroll,
const { api: renderer, pageSetup, webViewEventHandlers } = useWebViewSetup({
webviewRef,
onBodyScroll: onScroll,
onPostMessage,
setOnWebViewMessage,
tempDir,
pluginStates: props.pluginStates,
themeId: props.themeId,
});
const contentScripts = useContentScripts(props.pluginStates);
useRerenderHandler({
renderer,
fontSize: props.fontSize,
@@ -102,16 +85,14 @@ function NoteBodyViewer(props: Props) {
initialScroll: props.initialScroll,
paddingBottom: props.paddingBottom,
contentScripts,
});
const onLoadEnd = useCallback(() => {
setWebViewLoaded(true);
webViewEventHandlers.onLoadEnd();
if (props.onLoadEnd) props.onLoadEnd();
}, [props.onLoadEnd]);
}, [props.onLoadEnd, webViewEventHandlers]);
const { html, injectedJs } = useSource(tempDir, props.themeId);
const { html, js } = useSource(pageSetup, props.themeId);
return (
<View style={props.style}>
@@ -121,10 +102,10 @@ function NoteBodyViewer(props: Props) {
testID='NoteBodyViewer'
html={html}
allowFileAccessFromJs={true}
injectedJavaScript={injectedJs}
injectedJavaScript={js}
mixedContentMode="always"
onLoadEnd={onLoadEnd}
onMessage={onWebViewMessage}
onMessage={webViewEventHandlers.onMessage}
/>
</View>
);

View File

@@ -1,239 +0,0 @@
import { MarkupLanguage, MarkupToHtml } from '@joplin/renderer';
import type { MarkupToHtmlConverter, RenderOptions, RenderResultPluginAsset, FsDriver as RendererFsDriver } from '@joplin/renderer/types';
import makeResourceModel from './utils/makeResourceModel';
import addPluginAssets from './utils/addPluginAssets';
import { ExtraContentScriptSource } from './types';
import { ExtraContentScript } from '@joplin/lib/services/plugins/utils/loadContentScripts';
export interface RendererSetupOptions {
settings: {
safeMode: boolean;
tempDir: string;
resourceDir: string;
resourceDownloadMode: string;
};
// True if asset and resource files should be transferred to the WebView before rendering.
// This must be true on web, where asset and resource files are virtual and can't be accessed
// without transferring.
useTransferredFiles: boolean;
fsDriver: RendererFsDriver;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
pluginOptions: Record<string, any>;
}
export interface RendererSettings {
theme: string;
onResourceLoaded: ()=> void;
highlightedKeywords: string[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
resources: Record<string, any>;
codeTheme: string;
noteHash: string;
initialScroll: number;
createEditPopupSyntax: string;
destroyEditPopupSyntax: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
pluginSettings: Record<string, any>;
requestPluginSetting: (pluginId: string, settingKey: string)=> void;
readAssetBlob: (assetPath: string)=> Promise<Blob>;
}
export interface MarkupRecord {
language: MarkupLanguage;
markup: string;
}
export default class Renderer {
private markupToHtml: MarkupToHtmlConverter;
private lastSettings: RendererSettings|null = null;
private extraContentScripts: ExtraContentScript[] = [];
private lastRenderMarkup: MarkupRecord|null = null;
private resourcePathOverrides: Record<string, string> = Object.create(null);
public constructor(private setupOptions: RendererSetupOptions) {
this.recreateMarkupToHtml();
}
private recreateMarkupToHtml() {
this.markupToHtml = new MarkupToHtml({
extraRendererRules: this.extraContentScripts,
fsDriver: this.setupOptions.fsDriver,
isSafeMode: this.setupOptions.settings.safeMode,
tempDir: this.setupOptions.settings.tempDir,
ResourceModel: makeResourceModel(this.setupOptions.settings.resourceDir),
pluginOptions: this.setupOptions.pluginOptions,
});
}
// Intended for web, where resources can't be linked to normally.
public async setResourceFile(id: string, file: Blob) {
this.resourcePathOverrides[id] = URL.createObjectURL(file);
}
public getResourcePathOverride(resourceId: string) {
if (Object.prototype.hasOwnProperty.call(this.resourcePathOverrides, resourceId)) {
return this.resourcePathOverrides[resourceId];
}
return null;
}
public async setExtraContentScriptsAndRerender(
extraContentScripts: ExtraContentScriptSource[],
) {
this.extraContentScripts = extraContentScripts.map(script => {
const scriptModule = (eval(script.js))({
pluginId: script.pluginId,
contentScriptId: script.id,
});
if (!scriptModule.plugin) {
throw new Error(`
Expected content script ${script.id} to export a function that returns an object with a "plugin" property.
Found: ${scriptModule}, which has keys ${Object.keys(scriptModule)}.
`);
}
return {
...script,
module: scriptModule,
};
});
this.recreateMarkupToHtml();
// If possible, rerenders with the last rendering settings. The goal
// of this is to reduce the number of IPC calls between the viewer and
// React Native. We want the first render to be as fast as possible.
if (this.lastRenderMarkup) {
await this.rerender(this.lastRenderMarkup, this.lastSettings);
}
}
public async rerender(markup: MarkupRecord, settings: RendererSettings) {
this.lastSettings = settings;
this.lastRenderMarkup = markup;
const options: RenderOptions = {
onResourceLoaded: settings.onResourceLoaded,
highlightedKeywords: settings.highlightedKeywords,
resources: settings.resources,
codeTheme: settings.codeTheme,
postMessageSyntax: 'window.joplinPostMessage_',
enableLongPress: true,
// Show an 'edit' popup over SVG images
editPopupFiletypes: ['image/svg+xml'],
createEditPopupSyntax: settings.createEditPopupSyntax,
destroyEditPopupSyntax: settings.destroyEditPopupSyntax,
itemIdToUrl: this.setupOptions.useTransferredFiles ? (id: string) => this.getResourcePathOverride(id) : undefined,
settingValue: (pluginId: string, settingName: string) => {
const settingKey = `${pluginId}.${settingName}`;
if (!(settingKey in settings.pluginSettings)) {
// This should make the setting available on future renders.
settings.requestPluginSetting(pluginId, settingName);
return undefined;
}
return settings.pluginSettings[settingKey];
},
whiteBackgroundNoteRendering: markup.language === MarkupLanguage.Html,
};
this.markupToHtml.clearCache(markup.language);
const contentContainer = document.getElementById('joplin-container-content');
let html = '';
let pluginAssets: RenderResultPluginAsset[] = [];
try {
const result = await this.markupToHtml.render(
markup.language,
markup.markup,
JSON.parse(settings.theme),
options,
);
html = result.html;
pluginAssets = result.pluginAssets;
} catch (error) {
if (!contentContainer) {
alert(`Renderer error: ${error}`);
} else {
contentContainer.innerText = `
Error: ${error}
${error.stack ?? ''}
`;
}
throw error;
}
contentContainer.innerHTML = html;
// Adding plugin assets can be slow -- run it asynchronously.
void (async () => {
await addPluginAssets(pluginAssets, {
inlineAssets: this.setupOptions.useTransferredFiles,
readAssetBlob: settings.readAssetBlob,
});
// Some plugins require this event to be dispatched just after being added.
document.dispatchEvent(new Event('joplin-noteDidUpdate'));
})();
this.afterRender(settings);
}
private afterRender(renderSettings: RendererSettings) {
const readyStateCheckInterval = setInterval(() => {
if (document.readyState === 'complete') {
clearInterval(readyStateCheckInterval);
if (this.setupOptions.settings.resourceDownloadMode === 'manual') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
(window as any).webviewLib.setupResourceManualDownload();
}
const hash = renderSettings.noteHash;
const initialScroll = renderSettings.initialScroll;
// Don't scroll to a hash if we're given initial scroll (initial scroll
// overrides scrolling to a hash).
if ((initialScroll ?? null) !== null) {
const scrollingElement = document.scrollingElement ?? document.documentElement;
scrollingElement.scrollTop = initialScroll;
} else if (hash) {
// Gives it a bit of time before scrolling to the anchor
// so that images are loaded.
setTimeout(() => {
const e = document.getElementById(hash);
if (!e) {
console.warn('Cannot find hash', hash);
return;
}
e.scrollIntoView();
}, 500);
}
}
}, 10);
}
public clearCache(markupLanguage: MarkupLanguage) {
this.markupToHtml.clearCache(markupLanguage);
}
private extraCssElements: Record<string, HTMLStyleElement> = {};
public setExtraCss(key: string, css: string) {
if (this.extraCssElements.hasOwnProperty(key)) {
this.extraCssElements[key].remove();
}
const extraCssElement = document.createElement('style');
extraCssElement.appendChild(document.createTextNode(css));
document.head.appendChild(extraCssElement);
this.extraCssElements[key] = extraCssElement;
}
}

View File

@@ -1,67 +0,0 @@
import WebViewToRNMessenger from '../../../utils/ipc/WebViewToRNMessenger';
import { NoteViewerLocalApi, NoteViewerRemoteApi, RendererWebViewOptions, WebViewLib } from './types';
import Renderer from './Renderer';
declare global {
interface Window {
rendererWebViewOptions: RendererWebViewOptions;
webviewLib: WebViewLib;
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
declare const webviewLib: WebViewLib;
const messenger = new WebViewToRNMessenger<NoteViewerLocalApi, NoteViewerRemoteApi>(
'note-viewer',
null,
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
(window as any).joplinPostMessage_ = (message: string, _args: any) => {
return messenger.remoteApi.onPostMessage(message);
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
(window as any).webviewApi = {
postMessage: messenger.remoteApi.onPostPluginMessage,
};
webviewLib.initialize({
postMessage: (message: string) => {
messenger.remoteApi.onPostMessage(message);
},
});
// Share the webview library globally so that the renderer can access it.
window.webviewLib = webviewLib;
window.webviewLib = webviewLib;
const renderer = new Renderer({
...window.rendererWebViewOptions,
fsDriver: messenger.remoteApi.fsDriver,
});
messenger.setLocalInterface({
renderer,
jumpToHash: (hash: string) => {
location.hash = `#${hash}`;
},
});
const lastScrollTop: number|null = null;
const onMainContentScroll = () => {
const newScrollTop = document.scrollingElement.scrollTop;
if (lastScrollTop !== newScrollTop) {
messenger.remoteApi.onScroll(newScrollTop);
}
};
// Listen for events on both scrollingElement and window
// - On Android, scrollingElement.addEventListener('scroll', callback) doesn't call callback on
// scroll. However, window.addEventListener('scroll', callback) does.
// - iOS needs a listener to be added to scrollingElement -- events aren't received when
// the listener is added to window with window.addEventListener('scroll', ...).
document.scrollingElement?.addEventListener('scroll', onMainContentScroll);
window.addEventListener('scroll', onMainContentScroll);

View File

@@ -1,39 +0,0 @@
import type { FsDriver as RendererFsDriver } from '@joplin/renderer/types';
import Renderer from './Renderer';
export interface RendererWebViewOptions {
settings: {
safeMode: boolean;
tempDir: string;
resourceDir: string;
resourceDownloadMode: string;
};
useTransferredFiles: boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
pluginOptions: Record<string, any>;
}
export interface ExtraContentScriptSource {
id: string;
js: string;
assetPath: string;
pluginId: string;
}
export interface NoteViewerLocalApi {
renderer: Renderer;
jumpToHash: (hash: string)=> void;
}
export interface NoteViewerRemoteApi {
onScroll(scrollTop: number): void;
onPostMessage(message: string): void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
onPostPluginMessage(contentScriptId: string, message: any): Promise<any>;
fsDriver: RendererFsDriver;
}
export interface WebViewLib {
initialize(config: unknown): void;
}

View File

@@ -1,86 +0,0 @@
import { Dispatch, RefObject, SetStateAction, useEffect, useMemo, useRef } from 'react';
import { WebViewControl } from '../../ExtendedWebView/types';
import { OnScrollCallback, OnWebViewMessageHandler } from '../types';
import RNToWebViewMessenger from '../../../utils/ipc/RNToWebViewMessenger';
import { NoteViewerLocalApi, NoteViewerRemoteApi } from '../bundledJs/types';
import shim from '@joplin/lib/shim';
import { WebViewMessageEvent } from 'react-native-webview';
import PluginService from '@joplin/lib/services/plugins/PluginService';
import Logger from '@joplin/utils/Logger';
const logger = Logger.create('useRenderer');
interface Props {
webviewRef: RefObject<WebViewControl>;
onScroll: OnScrollCallback;
onPostMessage: (message: string)=> void;
setOnWebViewMessage: Dispatch<SetStateAction<OnWebViewMessageHandler>>;
webViewLoaded: boolean;
tempDir: string;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const onPostPluginMessage = async (contentScriptId: string, message: any) => {
logger.debug(`Handling message from content script: ${contentScriptId}:`, message);
const pluginService = PluginService.instance();
const pluginId = pluginService.pluginIdByContentScriptId(contentScriptId);
if (!pluginId) {
throw new Error(`Plugin not found for content script with ID ${contentScriptId}`);
}
const plugin = pluginService.pluginById(pluginId);
return plugin.emitContentScriptMessage(contentScriptId, message);
};
const useRenderer = (props: Props) => {
const onScrollRef = useRef(props.onScroll);
onScrollRef.current = props.onScroll;
const onPostMessageRef = useRef(props.onPostMessage);
onPostMessageRef.current = props.onPostMessage;
const messenger = useMemo(() => {
const fsDriver = shim.fsDriver();
const localApi = {
onScroll: (fraction: number) => onScrollRef.current?.(fraction),
onPostMessage: (message: string) => onPostMessageRef.current?.(message),
onPostPluginMessage,
fsDriver: {
writeFile: async (path: string, content: string, encoding?: string) => {
if (!await fsDriver.exists(props.tempDir)) {
await fsDriver.mkdir(props.tempDir);
}
// To avoid giving the WebView access to the entire main tempDir,
// we use props.tempDir (which should be different).
path = fsDriver.resolveRelativePathWithinDir(props.tempDir, path);
return await fsDriver.writeFile(path, content, encoding);
},
exists: fsDriver.exists,
cacheCssToFile: fsDriver.cacheCssToFile,
},
};
return new RNToWebViewMessenger<NoteViewerRemoteApi, NoteViewerLocalApi>(
'note-viewer', props.webviewRef, localApi,
);
}, [props.webviewRef, props.tempDir]);
useEffect(() => {
props.setOnWebViewMessage(() => (event: WebViewMessageEvent) => {
messenger.onWebViewMessage(event);
});
}, [messenger, props.setOnWebViewMessage]);
useEffect(() => {
if (props.webViewLoaded) {
messenger.onWebViewLoaded();
}
}, [messenger, props.webViewLoaded]);
return useMemo(() => {
return messenger.remoteApi.renderer;
}, [messenger]);
};
export default useRenderer;

View File

@@ -1,26 +1,20 @@
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
import usePrevious from '@joplin/lib/hooks/usePrevious';
import { themeStyle } from '@joplin/lib/theme';
import { MarkupLanguage } from '@joplin/renderer';
import useEditPopup from './useEditPopup';
import Renderer from '../bundledJs/Renderer';
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import Logger from '@joplin/utils/Logger';
import { ExtraContentScriptSource } from '../bundledJs/types';
import Setting from '@joplin/lib/models/Setting';
import shim from '@joplin/lib/shim';
import { ResourceEntity, ResourceLocalStateEntity } from '@joplin/lib/services/database/types';
import { RendererControl, RenderOptions } from '../../../contentScripts/rendererBundle/types';
import Resource from '@joplin/lib/models/Resource';
import { ResourceEntity } from '@joplin/lib/services/database/types';
import resolvePathWithinDir from '@joplin/lib/utils/resolvePathWithinDir';
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
export interface ResourceInfo {
localState: unknown;
localState: ResourceLocalStateEntity;
item: ResourceEntity;
}
interface Props {
renderer: Renderer;
renderer: RendererControl;
noteBody: string;
noteMarkupLanguage: MarkupLanguage;
@@ -33,8 +27,6 @@ interface Props {
initialScroll: number|undefined;
paddingBottom: number;
contentScripts: ExtraContentScriptSource[];
}
const onlyCheckboxHasChangedHack = (previousBody: string, newBody: string) => {
@@ -56,10 +48,35 @@ const onlyCheckboxHasChangedHack = (previousBody: string, newBody: string) => {
const logger = Logger.create('useRerenderHandler');
const useRerenderHandler = (props: Props) => {
const { createEditPopupSyntax, destroyEditPopupSyntax, editPopupCss } = useEditPopup(props.themeId);
const useResourceLoadCounter = (noteResources: Record<string, ResourceInfo>) => {
const [lastResourceLoadCounter, setLastResourceLoadCounter] = useState(0);
const [pluginSettingKeys, setPluginSettingKeys] = useState<Record<string, boolean>>({});
const lastDownloadCount = useRef(-1);
useEffect(() => {
let downloadedCount = 0;
for (const resource of Object.values(noteResources)) {
if (resource.localState.fetch_status === Resource.FETCH_STATUS_DONE) {
downloadedCount ++;
}
}
if (lastDownloadCount.current !== -1 && lastDownloadCount.current < downloadedCount) {
setLastResourceLoadCounter(counter => counter + 1);
}
lastDownloadCount.current = downloadedCount;
}, [noteResources]);
return lastResourceLoadCounter;
};
const useRerenderHandler = (props: Props) => {
const resourceDownloadRerenderCounter = useResourceLoadCounter(props.noteResources);
useEffect(() => {
// Whenever a resource state changes, for example when it goes from "not downloaded" to "downloaded", the "noteResources"
// props changes, thus triggering a render. The **content** of this noteResources array however is not changed because
// it doesn't contain info about the resource download state. Because of that, if we were to use the markupToHtml() cache
// it wouldn't re-render at all.
props.renderer.clearCache(props.noteMarkupLanguage);
}, [resourceDownloadRerenderCounter, props.renderer, props.noteMarkupLanguage]);
// To address https://github.com/laurent22/joplin/issues/433
//
@@ -82,8 +99,8 @@ const useRerenderHandler = (props: Props) => {
// below logic rely on this.
const effectDependencies = [
props.noteBody, props.noteMarkupLanguage, props.renderer, props.highlightedKeywords,
props.noteHash, props.noteResources, props.themeId, props.paddingBottom, lastResourceLoadCounter,
createEditPopupSyntax, destroyEditPopupSyntax, pluginSettingKeys, props.fontSize,
props.noteHash, props.noteResources, props.themeId, props.paddingBottom, resourceDownloadRerenderCounter,
props.fontSize,
];
const previousDeps = usePrevious(effectDependencies, []);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
@@ -99,125 +116,43 @@ const useRerenderHandler = (props: Props) => {
const previousHash = usePrevious(props.noteHash, '');
const hashChanged = previousHash !== props.noteHash;
useEffect(() => {
// Whenever a resource state changes, for example when it goes from "not downloaded" to "downloaded", the "noteResources"
// props changes, thus triggering a render. The **content** of this noteResources array however is not changed because
// it doesn't contain info about the resource download state. Because of that, if we were to use the markupToHtml() cache
// it wouldn't re-render at all.
props.renderer.clearCache(props.noteMarkupLanguage);
}, [lastResourceLoadCounter, props.renderer, props.noteMarkupLanguage]);
useEffect(() => {
void props.renderer.setExtraContentScriptsAndRerender(props.contentScripts);
}, [props.contentScripts, props.renderer]);
useAsyncEffect(async event => {
useAsyncEffect(async (event) => {
if (onlyNoteBodyHasChanged && onlyCheckboxesHaveChanged) {
logger.info('Only a checkbox has changed - not updating HTML');
return;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const pluginSettings: Record<string, any> = { };
for (const key in pluginSettingKeys) {
pluginSettings[key] = Setting.value(`plugin-${key}`);
}
let newPluginSettingKeys = pluginSettingKeys;
// On web, resources are virtual files and thus need to be transferred to the WebView.
if (shim.mobilePlatform() === 'web') {
for (const [resourceId, resource] of Object.entries(props.noteResources)) {
try {
await props.renderer.setResourceFile(
resourceId,
await shim.fsDriver().fileAtPath(Resource.fullPath(resource.item)),
);
} catch (error) {
if (error.code !== 'ENOENT') {
throw error;
}
// This can happen if a resource hasn't been downloaded yet
logger.warn('Error: Resource file not found (ENOENT)', Resource.fullPath(resource.item), 'for ID', resource.item.id);
}
}
}
const theme = themeStyle(props.themeId);
const config = {
// We .stringify the theme to avoid a JSON serialization error involving
// the color package.
theme: JSON.stringify({
const config: RenderOptions = {
themeId: props.themeId,
themeOverrides: {
bodyPaddingTop: '0.8em',
bodyPaddingBottom: props.paddingBottom,
...theme,
noteViewerFontSize: props.fontSize,
}),
codeTheme: theme.codeThemeCss,
onResourceLoaded: () => {
// Force a rerender when a resource loads
setLastResourceLoadCounter(lastResourceLoadCounter + 1);
},
highlightedKeywords: props.highlightedKeywords,
resources: props.noteResources,
pluginAssetContainerSelector: '#joplin-container-pluginAssetsContainer',
removeUnusedPluginAssets: true,
// If the hash changed, we don't set initial scroll -- we want to scroll to the hash
// instead.
initialScroll: (previousHash && hashChanged) ? undefined : props.initialScroll,
noteHash: props.noteHash,
pluginSettings,
requestPluginSetting: (pluginId: string, settingKey: string) => {
// Don't trigger additional renders
if (event.cancelled) return;
const key = `${pluginId}.${settingKey}`;
logger.debug(`Request plugin setting: plugin-${key}`);
if (!(key in newPluginSettingKeys)) {
newPluginSettingKeys = { ...newPluginSettingKeys, [`${pluginId}.${settingKey}`]: true };
setPluginSettingKeys(newPluginSettingKeys);
}
},
readAssetBlob: (assetPath: string) => {
// Built-in assets are in resourceDir, external plugin assets are in cacheDir.
const assetsDirs = [Setting.value('resourceDir'), Setting.value('cacheDir')];
let resolvedPath = null;
for (const assetDir of assetsDirs) {
resolvedPath ??= resolvePathWithinDir(assetDir, assetPath);
if (resolvedPath) break;
}
if (!resolvedPath) {
throw new Error(`Failed to load asset at ${assetPath} -- not in any of the allowed asset directories: ${assetsDirs.join(',')}.`);
}
return shim.fsDriver().fileAtPath(resolvedPath);
},
createEditPopupSyntax,
destroyEditPopupSyntax,
};
try {
logger.debug('Starting render...');
await props.renderer.rerender({
await props.renderer.rerenderToBody({
language: props.noteMarkupLanguage,
markup: props.noteBody,
}, config);
}, config, event);
logger.debug('Render complete.');
} catch (error) {
logger.error('Render failed:', error);
}
}, effectDependencies);
useEffect(() => {
props.renderer.setExtraCss('edit-popup', editPopupCss);
}, [editPopupCss, props.renderer]);
};
export default useRerenderHandler;

View File

@@ -1,49 +1,15 @@
import { useMemo } from 'react';
import shim from '@joplin/lib/shim';
import Setting from '@joplin/lib/models/Setting';
import { RendererWebViewOptions } from '../bundledJs/types';
import { themeStyle } from '../../global-style';
import { Platform } from 'react-native';
const useSource = (tempDirPath: string, themeId: number) => {
const injectedJs = useMemo(() => {
const subValues = Setting.subValues('markdown.plugin', Setting.toPlainObject());
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const pluginOptions: any = {};
for (const n in subValues) {
pluginOptions[n] = { enabled: subValues[n] };
}
const rendererWebViewOptions: RendererWebViewOptions = {
settings: {
safeMode: Setting.value('isSafeMode'),
tempDir: tempDirPath,
resourceDir: Setting.value('resourceDir'),
resourceDownloadMode: Setting.value('sync.resourceDownloadMode'),
},
// Web needs files to be transferred manually, since image SRCs can't reference
// the Origin Private File System.
useTransferredFiles: Platform.OS === 'web',
pluginOptions,
};
return `
window.rendererWebViewOptions = ${JSON.stringify(rendererWebViewOptions)};
if (!window.injectedJsLoaded) {
window.injectedJsLoaded = true;
${shim.injectedJs('webviewLib')}
${shim.injectedJs('noteBodyViewerBundle')}
}
`;
}, [tempDirPath]);
import { PageSetupSources } from '../../../contentScripts/types';
const useSource = (rendererSource: PageSetupSources, themeId: number) => {
const [paddingLeft, paddingRight] = useMemo(() => {
const theme = themeStyle(themeId);
return [theme.marginLeft, theme.marginRight];
}, [themeId]);
const rendererBaseCss = rendererSource.css;
const html = useMemo(() => {
// iOS doesn't automatically adjust the WebView's font size to match users'
// accessibility settings. To do this, we need to tell it to match the system font.
@@ -75,6 +41,7 @@ const useSource = (tempDirPath: string, themeId: number) => {
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
${defaultCss}
${rendererBaseCss}
${shim.mobilePlatform() === 'ios' ? iOSSpecificCss : ''}
</style>
</head>
@@ -84,9 +51,9 @@ const useSource = (tempDirPath: string, themeId: number) => {
</body>
</html>
`;
}, [paddingLeft, paddingRight]);
}, [paddingLeft, paddingRight, rendererBaseCss]);
return { html, injectedJs };
return { html, js: rendererSource.js };
};
export default useSource;

View File

@@ -1,91 +0,0 @@
/* eslint-disable import/prefer-default-export */
// This contains the CodeMirror instance, which needs to be built into a bundle
// using `yarn buildInjectedJs`. This bundle is then loaded from
// NoteEditor.tsx into the webview.
//
// In general, since this file is harder to debug due to the intermediate built
// step, it's better to keep it as light as possible - it should just be a light
// wrapper to access CodeMirror functionalities. Anything else should be done
// from NoteEditor.tsx.
import { EditorSettings } from '@joplin/editor/types';
import createEditor from '@joplin/editor/CodeMirror/createEditor';
import CodeMirrorControl from '@joplin/editor/CodeMirror/CodeMirrorControl';
import WebViewToRNMessenger from '../../../utils/ipc/WebViewToRNMessenger';
import { WebViewToEditorApi } from '../types';
import { focus } from '@joplin/lib/utils/focusHandler';
import Logger, { TargetType } from '@joplin/utils/Logger';
let loggerCreated = false;
export const setUpLogger = () => {
if (!loggerCreated) {
const logger = new Logger();
logger.addTarget(TargetType.Console);
logger.setLevel(Logger.LEVEL_WARN);
Logger.initializeGlobalLogger(logger);
loggerCreated = true;
}
};
export const initCodeMirror = (
parentElement: HTMLElement,
initialText: string,
initialNoteId: string,
settings: EditorSettings,
): CodeMirrorControl => {
const messenger = new WebViewToRNMessenger<CodeMirrorControl, WebViewToEditorApi>('editor', null);
const control = createEditor(parentElement, {
initialText,
initialNoteId,
settings,
onPasteFile: async (data) => {
const reader = new FileReader();
return new Promise<void>((resolve, reject) => {
reader.onload = async () => {
const dataUrl = reader.result as string;
const base64 = dataUrl.replace(/^data:.*;base64,/, '');
await messenger.remoteApi.onPasteFile(data.type, base64);
resolve();
};
reader.onerror = () => reject(new Error('Failed to load file.'));
reader.readAsDataURL(data);
});
},
onLogMessage: message => {
void messenger.remoteApi.logMessage(message);
},
onEvent: (event): void => {
void messenger.remoteApi.onEditorEvent(event);
},
});
// Works around https://github.com/laurent22/joplin/issues/10047 by handling
// the text/uri-list MIME type when pasting, rather than sending the paste event
// to CodeMirror.
//
// TODO: Remove this workaround when the issue has been fixed upstream.
control.on('paste', (_editor, event: ClipboardEvent) => {
const clipboardData = event.clipboardData;
if (clipboardData.types.length === 1 && clipboardData.types[0] === 'text/uri-list') {
event.preventDefault();
control.insertText(clipboardData.getData('text/uri-list'));
}
});
// Note: Just adding an onclick listener seems sufficient to focus the editor when its background
// is tapped.
parentElement.addEventListener('click', (event) => {
const activeElement = document.querySelector(':focus');
if (!parentElement.contains(activeElement) && event.target === parentElement) {
focus('initial editor focus', control);
}
});
messenger.setLocalInterface(control);
return control;
};

View File

@@ -1,60 +0,0 @@
<!--
Open this file in a web browser to more easily debug the CodeMirror editor.
Messages will show up in the console when posted.
-->
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>
<meta charset="utf-8"/>
<title>CodeMirror test</title>
</head>
<body>
<div class="CodeMirror"></div>
<script>
// Override the default postMessage — codeMirrorBundle expects
// this to be present.
window.ReactNativeWebView = {
postMessage: message => {
console.log('postMessage:', message);
},
};
</script>
<script src="./CodeMirror.bundle.js"></script>
<script>
const parent = document.querySelector('.CodeMirror');
const initialText = 'Testing...';
const settings = {
themeData: {
fontSize: 12, // px
fontFamily: 'serif',
backgroundColor: 'black',
color: 'white',
backgroundColor2: '#330',
color2: '#ff0',
backgroundColor3: '#404',
color3: '#f0f',
backgroundColor4: '#555',
color4: '#0ff',
appearance: 'dark',
},
themeId: 0,
spellcheckEnabled: true,
language: 'markdown',
katexEnabled: true,
useExternalSearch: false,
readOnly: false,
keymap: 'default',
automatchBraces: false,
ignoreModifiers: false,
indentWithTabs: false,
};
window.cm = codeMirrorBundle.initCodeMirror(parent, initialText, settings);
</script>
</body>
</html>

View File

@@ -1,20 +1,13 @@
import * as React from '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 { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { Platform } from 'react-native';
import { useCallback, useContext, useEffect, useRef, useState } from 'react';
import ExtendedWebView from '../../ExtendedWebView';
import { OnMessageEvent, WebViewControl } from '../../ExtendedWebView/types';
import { clearAutosave } from './autosave';
import { LocalizedStrings } from './js-draw/types';
import { clearAutosave, writeAutosave } from './autosave';
import { DialogContext } from '../../DialogManager';
import useEditorMessenger from './utils/useEditorMessenger';
import BackButtonService from '../../../services/BackButtonService';
import useWebViewSetup, { ImageEditorControl } from '../../../contentScripts/imageEditorBundle/useWebViewSetup';
const logger = Logger.create('ImageEditor');
@@ -28,69 +21,15 @@ interface Props {
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;
overflow: hidden;
}
/* 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 because 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 = useRef<WebViewControl|null>(null);
const webViewRef = useRef<WebViewControl|null>(null);
const [imageChanged, setImageChanged] = useState(false);
const editorControlRef = useRef<ImageEditorControl|null>(null);
const dialogs = useContext(DialogContext);
const onRequestCloseEditor = useCallback((promptIfUnsaved: boolean) => {
const onRequestCloseEditor = useCallback((promptIfUnsaved = true) => {
const discardChangesAndClose = async () => {
await clearAutosave();
props.onExit();
@@ -98,7 +37,7 @@ const ImageEditor = (props: Props) => {
if (!imageChanged || !promptIfUnsaved) {
void discardChangesAndClose();
return true;
return;
}
dialogs.prompt(
@@ -113,13 +52,12 @@ const ImageEditor = (props: Props) => {
onPress: () => {
// saveDrawing calls props.onSave(...) which may close the
// editor.
webviewRef.current.injectJS('window.editorControl.saveThenExit()');
void editorControlRef.current.saveThenExit();
},
},
],
);
return true;
}, [webviewRef, dialogs, props.onExit, imageChanged]);
}, [dialogs, props.onExit, imageChanged]);
useEffect(() => {
const hardwareBackPressListener = () => {
@@ -133,9 +71,18 @@ const ImageEditor = (props: Props) => {
};
}, [onRequestCloseEditor]);
const css = useCss(editorTheme);
const [html, setHtml] = useState('');
const { pageSetup, api: editorControl, webViewEventHandlers } = useWebViewSetup({
webViewRef,
themeId: props.themeId,
onSetImageChanged: setImageChanged,
onAutoSave: writeAutosave,
onSave: props.onSave,
onRequestCloseEditor,
resourceFilename: props.resourceFilename,
});
editorControlRef.current = editorControl;
const [html, setHtml] = useState('');
useEffect(() => {
setHtml(`
<!DOCTYPE html>
@@ -144,8 +91,8 @@ const ImageEditor = (props: Props) => {
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no"/>
<style id='main-style'>
${css}
<style>
${pageSetup.css}
</style>
</head>
<body></body>
@@ -160,112 +107,12 @@ const ImageEditor = (props: Props) => {
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps
}, []);
// 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 appInfo = useMemo(() => {
return {
name: 'Joplin',
description: `v${shim.appVersion()}`,
};
}, []);
const injectedJavaScript = useMemo(() => `
window.onerror = (message, source, lineno) => {
window.ReactNativeWebView.postMessage(
"error: " + message + " in file://" + source + ", line " + lineno,
);
};
window.onunhandledrejection = (error) => {
window.ReactNativeWebView.postMessage(
"error: " + error.reason,
);
};
try {
if (window.editorControl === undefined) {
${shim.injectedJs('svgEditorBundle')}
window.editorControl = svgEditorBundle.createJsDrawEditor(
svgEditorBundle.createMessenger().remoteApi,
${JSON.stringify(Setting.value('imageeditor.jsdrawToolbar'))},
${JSON.stringify(Setting.value('locale'))},
${JSON.stringify(localizedStrings)},
${JSON.stringify({
appInfo,
...(shim.mobilePlatform() === 'web' ? {
// Use the browser-default clipboard API on web.
clipboardApi: null,
} : {}),
})},
);
}
} catch(e) {
window.ReactNativeWebView.postMessage(
'error: ' + e.message + ': ' + JSON.stringify(e)
);
}
true;
`, [localizedStrings, appInfo]);
useEffect(() => {
webviewRef.current?.injectJS(`
document.querySelector('#main-style').textContent = ${JSON.stringify(css)};
if (window.editorControl) {
window.editorControl.onThemeUpdate();
}
`);
}, [css]);
const onReadyToLoadData = useCallback(async () => {
const getInitialInjectedData = async () => {
// On mobile, it's faster to load the image within the WebView with an XMLHttpRequest.
// In this case, the image is loaded elsewhere.
if (Platform.OS !== 'web') {
return undefined;
}
// On web, however, this doesn't work, so the image needs to be loaded here.
if (!props.resourceFilename) {
return '';
}
return await shim.fsDriver().readFile(props.resourceFilename, 'utf-8');
};
// 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 initialSVGPath = ${JSON.stringify(props.resourceFilename)};
const initialTemplateData = ${JSON.stringify(Setting.value('imageeditor.imageTemplate'))};
const initialData = ${JSON.stringify(await getInitialInjectedData())};
editorControl.loadImageOrTemplate(initialSVGPath, initialTemplateData, initialData);
}
})();`);
}, [webviewRef, props.resourceFilename]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const onError = useCallback((event: any) => {
logger.error('ImageEditor: WebView error: ', event);
}, []);
const messenger = useEditorMessenger({
webviewRef,
setImageChanged,
onReadyToLoadData,
onSave: props.onSave,
onRequestCloseEditor,
});
const onWebViewMessage = webViewEventHandlers.onMessage;
const onMessage = useCallback((event: OnMessageEvent) => {
const data = event.nativeEvent.data;
if (typeof data === 'string' && data.startsWith('error:')) {
@@ -273,18 +120,18 @@ const ImageEditor = (props: Props) => {
return;
}
messenger.onWebViewMessage(event);
}, [messenger]);
onWebViewMessage(event);
}, [onWebViewMessage]);
return (
<ExtendedWebView
html={html}
injectedJavaScript={injectedJavaScript}
injectedJavaScript={pageSetup.js}
allowFileAccessFromJs={true}
onMessage={onMessage}
onLoadEnd={messenger.onWebViewLoaded}
onLoadEnd={webViewEventHandlers.onLoadEnd}
onError={onError}
ref={webviewRef}
ref={webViewRef}
webviewInstanceId={'image-editor-js-draw'}
/>
);

View File

@@ -1,11 +0,0 @@
// .replaceChildren is not supported in Chromium 83, which is the default for Android 11
// (unless auto-updated from the Google Play store).
HTMLElement.prototype.replaceChildren ??= function(this: HTMLElement, ...nodes: Node[]) {
while (this.children.length) {
this.children[0].remove();
}
for (const node of nodes) {
this.appendChild(node);
}
};

View File

@@ -0,0 +1,194 @@
import { themeStyle } from '@joplin/lib/theme';
import themeToCss from '@joplin/lib/services/style/themeToCss';
import ExtendedWebView from '../ExtendedWebView';
import * as React from 'react';
import { useEffect } from 'react';
import { useMemo, useCallback } from 'react';
import { NativeSyntheticEvent } from 'react-native';
import { EditorProps } from './types';
import { _ } from '@joplin/lib/locale';
import useCodeMirrorPlugins from './hooks/useCodeMirrorPlugins';
import { WebViewErrorEvent } from 'react-native-webview/lib/RNCWebViewNativeComponent';
import Logger from '@joplin/utils/Logger';
import { OnMessageEvent } from '../ExtendedWebView/types';
import useWebViewSetup from '../../contentScripts/markdownEditorBundle/useWebViewSetup';
const logger = Logger.create('MarkdownEditor');
function useCss(themeId: number): string {
return useMemo(() => {
const theme = themeStyle(themeId);
const themeVariableCss = themeToCss(theme);
return `
${themeVariableCss}
:root {
background-color: ${theme.backgroundColor};
}
body {
margin: 0;
height: 100vh;
/* Prefer 100% -- 100vw shows an unnecessary horizontal scrollbar in Google Chrome (desktop). */
width: 100%;
box-sizing: border-box;
padding-left: 1px;
padding-right: 1px;
padding-bottom: 1px;
padding-top: 10px;
font-size: 13pt;
}
* {
scrollbar-width: thin;
scrollbar-color: rgba(100, 100, 100, 0.7) rgba(0, 0, 0, 0.1);
}
@supports selector(::-webkit-scrollbar) {
*::-webkit-scrollbar {
width: 7px;
height: 7px;
}
*::-webkit-scrollbar-corner {
background: none;
}
*::-webkit-scrollbar-track {
border: none;
}
*::-webkit-scrollbar-thumb {
background: rgba(100, 100, 100, 0.3);
border-radius: 5px;
}
*::-webkit-scrollbar-track:hover {
background: rgba(0, 0, 0, 0.1);
}
*::-webkit-scrollbar-thumb:hover {
background: rgba(100, 100, 100, 0.7);
}
* {
scrollbar-width: unset;
scrollbar-color: unset;
}
}
`;
}, [themeId]);
}
function useHtml(): string {
return useMemo(() => `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>${_('Note editor')}</title>
<style>
/* For better scrolling on iOS (working scrollbar) we use external, rather than internal,
scrolling. */
.cm-scroller {
overflow: none;
}
</style>
</head>
<body>
<div class="CodeMirror" style="height:100%;" autocapitalize="on"></div>
</body>
</html>
`, []);
}
const MarkdownEditor: React.FC<EditorProps> = props => {
const webviewRef = props.webviewRef;
const editorWebViewSetup = useWebViewSetup({
initialSelection: props.initialSelection,
noteHash: props.noteHash,
globalSearch: props.globalSearch,
onEditorEvent: props.onEditorEvent,
onAttachFile: props.onAttach,
editorOptions: {
parentElementClassName: 'CodeMirror',
initialText: props.initialText,
initialNoteId: props.noteId,
settings: props.editorSettings,
onLocalize: _,
},
webviewRef,
});
props.editorRef.current = editorWebViewSetup.api.editor;
const injectedJavaScript = `
window.onerror = (message, source, lineno) => {
console.error(message);
window.ReactNativeWebView.postMessage(
"error: " + message + " in file://" + source + ", line " + lineno
);
};
window.onunhandledrejection = (event) => {
window.ReactNativeWebView.postMessage(
"error: Unhandled promise rejection: " + event
);
};
try {
${editorWebViewSetup.pageSetup.js}
} catch (e) {
console.error('Setup error: ', e);
window.ReactNativeWebView.postMessage("error:" + e.message + ": " + JSON.stringify(e))
}
true;
`;
const css = useCss(props.themeId);
const html = useHtml();
const codeMirrorPlugins = useCodeMirrorPlugins(props.plugins);
useEffect(() => {
void editorWebViewSetup.api.editor.setContentScripts(codeMirrorPlugins);
}, [codeMirrorPlugins, editorWebViewSetup]);
const onMessage = useCallback((event: OnMessageEvent) => {
const data = event.nativeEvent.data;
if (typeof data === 'string' && data.indexOf('error:') === 0) {
logger.error('CodeMirror error', data);
return;
}
editorWebViewSetup.webViewEventHandlers.onMessage(event);
}, [editorWebViewSetup]);
const onError = useCallback((event: NativeSyntheticEvent<WebViewErrorEvent>) => {
logger.error(`Load error: Code ${event.nativeEvent.code}: ${event.nativeEvent.description}`);
}, []);
return (
<ExtendedWebView
ref={webviewRef}
webviewInstanceId='MarkdownEditor'
testID='MarkdownEditor'
scrollEnabled={true}
html={html}
injectedJavaScript={injectedJavaScript}
css={css}
hasPluginScripts={codeMirrorPlugins.length > 0}
onMessage={onMessage}
onLoadEnd={editorWebViewSetup.webViewEventHandlers.onLoadEnd}
onError={onError}
/>
);
};
export default MarkdownEditor;

View File

@@ -15,10 +15,31 @@ import mockCommandRuntimes from '../EditorToolbar/testing/mockCommandRuntimes';
import setupGlobalStore from '../../utils/testing/setupGlobalStore';
import { Store } from 'redux';
import { AppState } from '../../utils/types';
import { MarkupLanguage } from '@joplin/renderer';
import { EditorType } from './types';
let store: Store<AppState>;
let registeredRuntime: RegisteredRuntime;
const defaultEditorProps = {
themeId: Setting.THEME_ARITIM_DARK,
markupLanguage: MarkupLanguage.Markdown,
initialText: 'Testing...',
globalSearch: '',
noteId: '',
noteHash: '',
style: {},
toolbarEnabled: true,
readOnly: false,
onChange: ()=>{},
onSelectionChange: ()=>{},
onUndoRedoDepthChange: ()=>{},
onAttach: async ()=>{},
noteResources: {},
plugins: {},
mode: EditorType.Markdown,
};
describe('NoteEditor', () => {
beforeAll(() => {
// This allows the NoteEditor test to register editor commands without errors.
@@ -45,19 +66,8 @@ describe('NoteEditor', () => {
const wrappedNoteEditor = render(
<TestProviderStack store={store}>
<NoteEditor
themeId={Setting.THEME_ARITIM_DARK}
initialText='Testing...'
globalSearch=''
noteId=''
noteHash=''
style={{}}
toolbarEnabled={true}
readOnly={false}
onChange={()=>{}}
onSelectionChange={()=>{}}
onUndoRedoDepthChange={()=>{}}
onAttach={async ()=>{}}
plugins={{}}
ref={undefined}
{...defaultEditorProps}
/>
</TestProviderStack>,
);
@@ -99,4 +109,27 @@ describe('NoteEditor', () => {
wrappedNoteEditor.unmount();
});
it('should show a warning banner the first time the Rich Text Editor is used', () => {
const wrappedNoteEditor = render(
<TestProviderStack store={store}>
<NoteEditor
ref={undefined}
{...defaultEditorProps}
mode={EditorType.RichText}
/>
</TestProviderStack>,
);
const warningBannerQuery = /This Rich Text editor has a number of limitations.*/;
const warning = screen.getByText(warningBannerQuery);
expect(warning).toBeVisible();
// Pressing dismiss should dismiss the warning
const dismissButton = screen.getByHintText('Hides warning');
fireEvent.press(dismissButton);
expect(screen.queryByText(warningBannerQuery)).toBeNull();
wrappedNoteEditor.unmount();
});
});

View File

@@ -1,46 +1,49 @@
import Setting from '@joplin/lib/models/Setting';
import shim from '@joplin/lib/shim';
import { themeStyle } from '@joplin/lib/theme';
import themeToCss from '@joplin/lib/services/style/themeToCss';
import EditLinkDialog from './EditLinkDialog';
import { defaultSearchState, SearchPanel } from './SearchPanel';
import ExtendedWebView from '../ExtendedWebView';
import { WebViewControl } from '../ExtendedWebView/types';
import * as React from 'react';
import { forwardRef, RefObject, useEffect, useImperativeHandle } from 'react';
import { Ref, RefObject, useEffect, useImperativeHandle } from 'react';
import { useMemo, useState, useCallback, useRef } from 'react';
import { LayoutChangeEvent, NativeSyntheticEvent, View, ViewStyle } from 'react-native';
import { LayoutChangeEvent, View, ViewStyle } from 'react-native';
import { editorFont } from '../global-style';
import { EditorControl as EditorBodyControl, ContentScriptData } from '@joplin/editor/types';
import { EditorControl, EditorSettings, SelectionRange, WebViewToEditorApi } from './types';
import { EditorControl, EditorSettings, EditorType } from './types';
import { _ } from '@joplin/lib/locale';
import { ChangeEvent, EditorEvent, EditorEventType, SelectionRangeChangeEvent, UndoRedoDepthChangeEvent } from '@joplin/editor/events';
import { EditorCommandType, EditorKeymap, EditorLanguageType, SearchState } from '@joplin/editor/types';
import SelectionFormatting, { defaultSelectionFormatting } from '@joplin/editor/SelectionFormatting';
import useCodeMirrorPlugins from './hooks/useCodeMirrorPlugins';
import RNToWebViewMessenger from '../../utils/ipc/RNToWebViewMessenger';
import { WebViewErrorEvent } from 'react-native-webview/lib/RNCWebViewNativeComponent';
import Logger from '@joplin/utils/Logger';
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
import useEditorCommandHandler from './hooks/useEditorCommandHandler';
import { OnMessageEvent } from '../ExtendedWebView/types';
import { join, dirname } from 'path';
import * as mimeUtils from '@joplin/lib/mime-utils';
import uuid from '@joplin/lib/uuid';
import EditorToolbar from '../EditorToolbar/EditorToolbar';
import { SelectionRange } from '../../contentScripts/markdownEditorBundle/types';
import MarkdownEditor from './MarkdownEditor';
import RichTextEditor from './RichTextEditor';
import { ResourceInfos } from '@joplin/renderer/types';
import CommandService from '@joplin/lib/services/CommandService';
import Resource from '@joplin/lib/models/Resource';
import { join } from 'path';
import uuid from '@joplin/lib/uuid';
import shim from '@joplin/lib/shim';
import { dirname } from '@joplin/utils/path';
import { toFileExtension } from '@joplin/lib/mime-utils';
import { MarkupLanguage } from '@joplin/renderer';
import WarningBanner from './WarningBanner';
type ChangeEventHandler = (event: ChangeEvent)=> void;
type UndoRedoDepthChangeHandler = (event: UndoRedoDepthChangeEvent)=> void;
type SelectionChangeEventHandler = (event: SelectionRangeChangeEvent)=> void;
type OnAttachCallback = (filePath?: string)=> Promise<void>;
const logger = Logger.create('NoteEditor');
interface Props {
ref: Ref<EditorControl>;
themeId: number;
initialText: string;
mode: EditorType;
markupLanguage: MarkupLanguage;
noteId: string;
noteHash: string;
globalSearch: string;
@@ -49,6 +52,7 @@ interface Props {
toolbarEnabled: boolean;
readOnly: boolean;
plugins: PluginStates;
noteResources: ResourceInfos;
onChange: ChangeEventHandler;
onSelectionChange: SelectionChangeEventHandler;
@@ -61,103 +65,6 @@ function fontFamilyFromSettings() {
return font ? `${font}, sans-serif` : 'sans-serif';
}
function useCss(themeId: number): string {
return useMemo(() => {
const theme = themeStyle(themeId);
const themeVariableCss = themeToCss(theme);
return `
${themeVariableCss}
:root {
background-color: ${theme.backgroundColor};
}
body {
margin: 0;
height: 100vh;
/* Prefer 100% -- 100vw shows an unnecessary horizontal scrollbar in Google Chrome (desktop). */
width: 100%;
box-sizing: border-box;
padding-left: 1px;
padding-right: 1px;
padding-bottom: 1px;
padding-top: 10px;
font-size: 13pt;
}
* {
scrollbar-width: thin;
scrollbar-color: rgba(100, 100, 100, 0.7) rgba(0, 0, 0, 0.1);
}
@supports selector(::-webkit-scrollbar) {
*::-webkit-scrollbar {
width: 7px;
height: 7px;
}
*::-webkit-scrollbar-corner {
background: none;
}
*::-webkit-scrollbar-track {
border: none;
}
*::-webkit-scrollbar-thumb {
background: rgba(100, 100, 100, 0.3);
border-radius: 5px;
}
*::-webkit-scrollbar-track:hover {
background: rgba(0, 0, 0, 0.1);
}
*::-webkit-scrollbar-thumb:hover {
background: rgba(100, 100, 100, 0.7);
}
* {
scrollbar-width: unset;
scrollbar-color: unset;
}
}
`;
}, [themeId]);
}
const themeStyleSheetClassName = 'note-editor-styles';
function useHtml(initialCss: string): string {
const cssRef = useRef(initialCss);
cssRef.current = initialCss;
return useMemo(() => `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>${_('Note editor')}</title>
<style>
/* For better scrolling on iOS (working scrollbar) we use external, rather than internal,
scrolling. */
.cm-scroller {
overflow: none;
}
</style>
<style class=${JSON.stringify(themeStyleSheetClassName)}>
${cssRef.current}
</style>
</head>
<body>
<div class="CodeMirror" style="height:100%;" autocapitalize="on"></div>
</body>
</html>
`, []);
}
function editorTheme(themeId: number) {
const fontSizeInPx = Setting.value('style.editor.fontSize');
@@ -167,6 +74,7 @@ function editorTheme(themeId: number) {
const estimatedFontSizeInEm = fontSizeInPx / 16;
return {
themeId,
...themeStyle(themeId),
// To allow accessibility font scaling, we also need to set the
@@ -181,54 +89,54 @@ function editorTheme(themeId: number) {
type OnSetVisibleCallback = (visible: boolean)=> void;
type OnSearchStateChangeCallback = (state: SearchState)=> void;
const useEditorControl = (
bodyControl: EditorBodyControl,
editorRef: RefObject<EditorBodyControl>,
webviewRef: RefObject<WebViewControl>,
setLinkDialogVisible: OnSetVisibleCallback,
setSearchState: OnSearchStateChangeCallback,
): EditorControl => {
return useMemo(() => {
const execEditorCommand = (command: EditorCommandType) => {
void bodyControl.execCommand(command);
void editorRef.current.execCommand(command);
};
const setSearchStateCallback = (state: SearchState) => {
bodyControl.setSearchState(state);
editorRef.current.setSearchState(state);
setSearchState(state);
};
const control: EditorControl = {
supportsCommand(command: EditorCommandType) {
return bodyControl.supportsCommand(command);
return editorRef.current.supportsCommand(command);
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
execCommand(command, ...args: any[]) {
return bodyControl.execCommand(command, ...args);
return editorRef.current.execCommand(command, ...args);
},
focus() {
void bodyControl.execCommand(EditorCommandType.Focus);
void editorRef.current.execCommand(EditorCommandType.Focus);
},
undo() {
bodyControl.undo();
editorRef.current.undo();
},
redo() {
bodyControl.redo();
editorRef.current.redo();
},
select(anchor: number, head: number) {
bodyControl.select(anchor, head);
editorRef.current.select(anchor, head);
},
setScrollPercent(fraction: number) {
bodyControl.setScrollPercent(fraction);
editorRef.current.setScrollPercent(fraction);
},
insertText(text: string) {
bodyControl.insertText(text);
editorRef.current.insertText(text);
},
updateBody(newBody: string) {
bodyControl.updateBody(newBody);
editorRef.current.updateBody(newBody);
},
updateSettings(newSettings: EditorSettings) {
bodyControl.updateSettings(newSettings);
editorRef.current.updateSettings(newSettings);
},
toggleBolded() {
@@ -276,7 +184,7 @@ const useEditorControl = (
execEditorCommand(EditorCommandType.IndentLess);
},
updateLink(label: string, url: string) {
bodyControl.updateLink(label, url);
editorRef.current.updateLink(label, url);
},
scrollSelectionIntoView() {
execEditorCommand(EditorCommandType.ScrollSelectionIntoView);
@@ -292,7 +200,7 @@ const useEditorControl = (
},
setContentScripts: async (plugins: ContentScriptData[]) => {
return bodyControl.setContentScripts(plugins);
return editorRef.current.setContentScripts(plugins);
},
setSearchState: setSearchStateCallback,
@@ -320,37 +228,27 @@ const useEditorControl = (
setSearchState: setSearchStateCallback,
},
onResourceDownloaded: (id: string) => {
editorRef.current.onResourceDownloaded(id);
},
};
return control;
}, [webviewRef, bodyControl, setLinkDialogVisible, setSearchState]);
}, [webviewRef, editorRef, setLinkDialogVisible, setSearchState]);
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
function NoteEditor(props: Props, ref: any) {
function NoteEditor(props: Props) {
const webviewRef = useRef<WebViewControl>(null);
const setInitialSelectionJs = props.initialSelection ? `
cm.select(${props.initialSelection.start}, ${props.initialSelection.end});
cm.execCommand('scrollSelectionIntoView');
` : '';
const jumpToHashJs = props.noteHash ? `
cm.jumpToHash(${JSON.stringify(props.noteHash)});
` : '';
const setInitialSearchJs = props.globalSearch ? `
cm.setSearchState(${JSON.stringify({
...defaultSearchState,
searchText: props.globalSearch,
})})
` : '';
const editorSettings: EditorSettings = useMemo(() => ({
themeId: props.themeId,
themeData: editorTheme(props.themeId),
markdownMarkEnabled: Setting.value('markdown.plugin.mark'),
katexEnabled: Setting.value('markdown.plugin.katex'),
spellcheckEnabled: Setting.value('editor.mobile.spellcheckEnabled'),
language: EditorLanguageType.Markdown,
inlineRenderingEnabled: Setting.value('editor.inlineRendering'),
imageRenderingEnabled: Setting.value('editor.imageRendering'),
language: props.markupLanguage === MarkupLanguage.Html ? EditorLanguageType.Html : EditorLanguageType.Markdown,
useExternalSearch: true,
readOnly: props.readOnly,
@@ -365,208 +263,83 @@ function NoteEditor(props: Props, ref: any) {
indentWithTabs: true,
editorLabel: _('Markdown editor'),
}), [props.themeId, props.readOnly]);
}), [props.themeId, props.readOnly, props.markupLanguage]);
const injectedJavaScript = `
window.onerror = (message, source, lineno) => {
window.ReactNativeWebView.postMessage(
"error: " + message + " in file://" + source + ", line " + lineno
);
};
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
// the wrapper component.
window.cm = null;
try {
${shim.injectedJs('codeMirrorBundle')};
codeMirrorBundle.setUpLogger();
const parentElement = document.getElementsByClassName('CodeMirror')[0];
// On Android, injectJavaScript is run twice -- once before the parent element exists.
// To avoid logging unnecessary errors to the console, skip setup in this case:
if (parentElement) {
const initialText = ${JSON.stringify(props.initialText)};
const settings = ${JSON.stringify(editorSettings)};
window.cm = codeMirrorBundle.initCodeMirror(
parentElement,
initialText,
${JSON.stringify(props.noteId)},
settings
);
${jumpToHashJs}
// Set the initial selection after jumping to the header -- the initial selection,
// if specified, should take precedence.
${setInitialSelectionJs}
${setInitialSearchJs}
window.onresize = () => {
cm.execCommand('scrollSelectionIntoView');
};
} else {
console.warn('No parent element for the editor found. This may mean that the editor HTML is still loading.');
}
} catch (e) {
window.ReactNativeWebView.postMessage("error:" + e.message + ": " + JSON.stringify(e))
}
}
true;
`;
const css = useCss(props.themeId);
useEffect(() => {
if (webviewRef.current) {
webviewRef.current.injectJS(`
const styleClass = ${JSON.stringify(themeStyleSheetClassName)};
for (const oldStyle of [...document.getElementsByClassName(styleClass)]) {
oldStyle.remove();
}
const style = document.createElement('style');
style.classList.add(styleClass);
style.appendChild(document.createTextNode(${JSON.stringify(css)}));
document.head.appendChild(style);
`);
}
}, [css]);
// Scroll to the new hash, if it changes.
const isFirstScrollRef = useRef(true);
useEffect(() => {
// The first "jump to header" is handled during editor setup and shouldn't
// be handled a second time:
if (isFirstScrollRef.current) {
isFirstScrollRef.current = false;
return;
}
if (jumpToHashJs && webviewRef.current) {
webviewRef.current.injectJS(jumpToHashJs);
}
}, [jumpToHashJs]);
const html = useHtml(css);
const [selectionState, setSelectionState] = useState<SelectionFormatting>(defaultSelectionFormatting);
const [linkDialogVisible, setLinkDialogVisible] = useState(false);
const [searchState, setSearchState] = useState(defaultSearchState);
const onEditorEvent = useRef((_event: EditorEvent) => {});
const editorControlRef = useRef<EditorControl|null>(null);
const 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.FollowLink:
void CommandService.instance().execute('openItem', event.link);
break;
case EditorEventType.UpdateSearchDialog:
setSearchState(event.searchState);
const onAttachRef = useRef(props.onAttach);
onAttachRef.current = props.onAttach;
const editorMessenger = useMemo(() => {
const localApi: WebViewToEditorApi = {
async onEditorEvent(event) {
onEditorEvent.current(event);
},
async logMessage(message) {
logger.debug('CodeMirror:', message);
},
async onPasteFile(type, data) {
const tempFilePath = join(Setting.value('tempDir'), `paste.${uuid.createNano()}.${mimeUtils.toFileExtension(type)}`);
await shim.fsDriver().mkdir(dirname(tempFilePath));
try {
await shim.fsDriver().writeFile(tempFilePath, data, 'base64');
await onAttachRef.current(tempFilePath);
} finally {
await shim.fsDriver().remove(tempFilePath);
}
},
};
const messenger = new RNToWebViewMessenger<WebViewToEditorApi, EditorBodyControl>(
'editor', webviewRef, localApi,
);
return messenger;
}, []);
if (event.searchState.dialogVisible) {
editorControl.searchControl.showSearch();
} else {
editorControl.searchControl.hideSearch();
}
break;
case EditorEventType.Scroll:
// Not handled
break;
default:
exhaustivenessCheck = event;
return exhaustivenessCheck;
}
return;
};
const editorRef = useRef<EditorBodyControl|null>(null);
const editorControl = useEditorControl(
editorMessenger.remoteApi, webviewRef, setLinkDialogVisible, setSearchState,
editorRef, webviewRef, setLinkDialogVisible, setSearchState,
);
editorControlRef.current = editorControl;
useEffect(() => {
editorControl.updateSettings(editorSettings);
}, [editorSettings, editorControl]);
const lastNoteResources = useRef<ResourceInfos>(props.noteResources);
useEffect(() => {
const isDownloaded = (resourceInfos: ResourceInfos, resourceId: string) => {
return resourceInfos[resourceId]?.localState?.fetch_status === Resource.FETCH_STATUS_DONE;
};
for (const key in props.noteResources) {
const wasDownloaded = isDownloaded(lastNoteResources.current, key);
if (!wasDownloaded && isDownloaded(props.noteResources, key)) {
editorControl.onResourceDownloaded(key);
}
}
}, [props.noteResources, editorControl]);
useEditorCommandHandler(editorControl);
useImperativeHandle(ref, () => {
useImperativeHandle(props.ref, () => {
return editorControl;
});
useEffect(() => {
onEditorEvent.current = (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);
if (event.searchState.dialogVisible) {
editorControl.searchControl.showSearch();
} else {
editorControl.searchControl.hideSearch();
}
break;
case EditorEventType.Scroll:
// Not handled
break;
default:
exhaustivenessCheck = event;
return exhaustivenessCheck;
}
return;
};
}, [props.onChange, props.onUndoRedoDepthChange, props.onSelectionChange, editorControl]);
const codeMirrorPlugins = useCodeMirrorPlugins(props.plugins);
useEffect(() => {
void editorControl.setContentScripts(codeMirrorPlugins);
}, [codeMirrorPlugins, editorControl]);
const onLoadEnd = useCallback(() => {
editorMessenger.onWebViewLoaded();
}, [editorMessenger]);
const onMessage = useCallback((event: OnMessageEvent) => {
const data = event.nativeEvent.data;
if (typeof data === 'string' && data.indexOf('error:') === 0) {
logger.error('CodeMirror error', data);
return;
}
editorMessenger.onWebViewMessage(event);
}, [editorMessenger]);
const onError = useCallback((event: NativeSyntheticEvent<WebViewErrorEvent>) => {
logger.error(`Load error: Code ${event.nativeEvent.code}: ${event.nativeEvent.description}`);
}, []);
const [hasSpaceForToolbar, setHasSpaceForToolbar] = useState(true);
const toolbarEnabled = props.toolbarEnabled && hasSpaceForToolbar;
@@ -580,12 +353,24 @@ function NoteEditor(props: Props, ref: any) {
}
}, []);
const onAttach = useCallback(async (type: string, base64: string) => {
const tempFilePath = join(Setting.value('tempDir'), `paste.${uuid.createNano()}.${toFileExtension(type)}`);
await shim.fsDriver().mkdir(dirname(tempFilePath));
try {
await shim.fsDriver().writeFile(tempFilePath, base64, 'base64');
await props.onAttach(tempFilePath);
} finally {
await shim.fsDriver().remove(tempFilePath);
}
}, [props.onAttach]);
const toolbarEditorState = useMemo(() => ({
selectionState,
searchVisible: searchState.dialogVisible,
}), [selectionState, searchState.dialogVisible]);
const toolbar = <EditorToolbar editorState={toolbarEditorState} />;
const EditorComponent = props.mode === EditorType.Markdown ? MarkdownEditor : RichTextEditor;
return (
<View
@@ -607,20 +392,25 @@ function NoteEditor(props: Props, ref: any) {
flexShrink: 0,
minHeight: '30%',
}}>
<ExtendedWebView
webviewInstanceId='NoteEditor'
testID='NoteEditor'
scrollEnabled={true}
ref={webviewRef}
html={html}
injectedJavaScript={injectedJavaScript}
hasPluginScripts={codeMirrorPlugins.length > 0}
onMessage={onMessage}
onLoadEnd={onLoadEnd}
onError={onError}
<EditorComponent
editorRef={editorRef}
webviewRef={webviewRef}
themeId={props.themeId}
noteId={props.noteId}
noteHash={props.noteHash}
initialText={props.initialText}
initialSelection={props.initialSelection}
editorSettings={editorSettings}
globalSearch={props.globalSearch}
onEditorEvent={onEditorEvent}
noteResources={props.noteResources}
plugins={props.plugins}
onAttach={onAttach}
/>
</View>
<WarningBanner editorType={props.mode}/>
<SearchPanel
editorSettings={editorSettings}
searchControl={editorControl.searchControl}
@@ -632,4 +422,4 @@ function NoteEditor(props: Props, ref: any) {
);
}
export default forwardRef(NoteEditor);
export default NoteEditor;

View File

@@ -0,0 +1,372 @@
import * as React from 'react';
import { describe, it, beforeEach } from '@jest/globals';
import { render, waitFor } from '../../utils/testing/testingLibrary';
import Setting from '@joplin/lib/models/Setting';
import { createNoteAndResource, resourceFetcher, setupDatabaseAndSynchronizer, supportDir, switchClient, synchronizerStart } from '@joplin/lib/testing/test-utils';
import getWebViewWindowById from '../../utils/testing/getWebViewWindowById';
import TestProviderStack from '../testing/TestProviderStack';
import createMockReduxStore from '../../utils/testing/createMockReduxStore';
import RichTextEditor from './RichTextEditor';
import createTestEditorProps from './testing/createTestEditorProps';
import { EditorEvent, EditorEventType } from '@joplin/editor/events';
import { RefObject, useCallback, useMemo } from 'react';
import Note from '@joplin/lib/models/Note';
import shim from '@joplin/lib/shim';
import Resource from '@joplin/lib/models/Resource';
import { ResourceInfos } from '@joplin/renderer/types';
import { EditorControl, EditorLanguageType } from '@joplin/editor/types';
import attachedResources from '@joplin/lib/utils/attachedResources';
import { MarkupLanguage } from '@joplin/renderer';
import { NoteEntity } from '@joplin/lib/services/database/types';
import { EditorSettings } from './types';
import { pregQuote } from '@joplin/lib/string-utils';
interface WrapperProps {
ref?: RefObject<EditorControl>;
noteResources?: ResourceInfos;
onBodyChange: (newBody: string)=> void;
onLinkClick?: (link: string)=> void;
note?: NoteEntity;
noteBody: string;
}
const defaultEditorProps = createTestEditorProps();
const testStore = createMockReduxStore();
const WrappedEditor: React.FC<WrapperProps> = (
{
noteBody,
note,
onBodyChange,
onLinkClick,
noteResources,
ref,
}: WrapperProps,
) => {
const onEvent = useCallback((event: EditorEvent) => {
if (event.kind === EditorEventType.Change) {
onBodyChange(event.value);
} else if (event.kind === EditorEventType.FollowLink) {
if (!onLinkClick) {
throw new Error('No mock function for onLinkClick registered.');
}
onLinkClick(event.link);
}
}, [onBodyChange, onLinkClick]);
const editorSettings = useMemo((): EditorSettings => {
const isHtml = note?.markup_language === MarkupLanguage.Html;
return {
...defaultEditorProps.editorSettings,
language: isHtml ? EditorLanguageType.Html : EditorLanguageType.Markdown,
};
}, [note]);
return <TestProviderStack store={testStore}>
<RichTextEditor
{...defaultEditorProps}
editorSettings={editorSettings}
onEditorEvent={onEvent}
initialText={noteBody}
noteId={note?.id ?? defaultEditorProps.noteId}
noteResources={noteResources ?? defaultEditorProps.noteResources}
editorRef={ref ?? defaultEditorProps.editorRef}
/>
</TestProviderStack>;
};
const getEditorWindow = async () => {
return await getWebViewWindowById('RichTextEditor');
};
type EditorWindow = Window&typeof globalThis;
const getEditorControl = (window: EditorWindow) => {
if ('joplinRichTextEditor_' in window) {
return window.joplinRichTextEditor_ as EditorControl;
}
throw new Error('No editor control found. Is the editor loaded?');
};
const mockTyping = (window: EditorWindow, text: string) => {
const document = window.document;
const editor = document.querySelector('div[contenteditable]');
for (const character of text.split('')) {
editor.dispatchEvent(new window.KeyboardEvent('keydown', { key: character }));
const paragraphs = editor.querySelectorAll('p');
(paragraphs[paragraphs.length - 1] ?? editor).appendChild(document.createTextNode(character));
editor.dispatchEvent(new window.KeyboardEvent('keyup', { key: character }));
}
};
const mockSelectionMovement = (window: EditorWindow, position: number) => {
getEditorControl(window).select(position, position);
};
const findElement = async function<ElementType extends Element = Element>(selector: string) {
const window = await getEditorWindow();
return await waitFor(() => {
const element = window.document.querySelector<ElementType>(selector);
expect(element).toBeTruthy();
return element;
}, {
onTimeout: (error) => {
return new Error(`Failed to find element from selector ${selector}. DOM: ${window?.document?.body?.innerHTML}. \n\nFull error: ${error}`);
},
});
};
const createRemoteResourceAndNote = async (remoteClientId: number) => {
await setupDatabaseAndSynchronizer(remoteClientId);
await switchClient(remoteClientId);
let note = await Note.save({ title: 'Note 1', parent_id: '' });
note = await shim.attachFileToNote(note, `${supportDir}/photo.jpg`);
const allResources = await Resource.all();
expect(allResources.length).toBe(1);
const resourceId = allResources[0].id;
await synchronizerStart();
await switchClient(0);
await synchronizerStart();
return { noteId: note.id, resourceId };
};
describe('RichTextEditor', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(0);
await switchClient(0);
Setting.setValue('editor.codeView', false);
});
it('should render basic markdown', async () => {
render(<WrappedEditor
noteBody={'### Test\n\nParagraph `test`'}
onBodyChange={jest.fn()}
/>);
const dom = (await getEditorWindow()).document;
expect((await findElement('h3')).textContent).toBe('Test');
expect(dom.querySelector('p').textContent).toBe('Paragraph test');
expect(dom.querySelector('p code').textContent).toBe('test');
});
it('should dispatch events when the editor content changes', async () => {
let body = '**bold** normal';
render(<WrappedEditor
noteBody={body}
onBodyChange={newBody => { body = newBody; }}
/>);
const window = await getEditorWindow();
mockTyping(window, ' test');
await waitFor(async () => {
expect(body.trim()).toBe('**bold** normal test');
});
});
it('should save repeated spaces using nonbreaking spaces', async () => {
let body = 'Test';
render(<WrappedEditor
noteBody={body}
onBodyChange={newBody => { body = newBody; }}
/>);
const window = await getEditorWindow();
mockTyping(window, ' test');
await waitFor(async () => {
expect(body.trim()).toBe('Test \u00A0test');
});
});
it('should render clickable checkboxes', async () => {
let body = '- [ ] Test\n- [x] Another test';
render(<WrappedEditor
noteBody={body}
onBodyChange={newBody => { body = newBody; }}
/>);
const firstCheckbox = await findElement<HTMLInputElement>('input[type=checkbox]');
const dom = (await getEditorWindow()).document;
const getCheckboxLabel = (checkbox: HTMLElement) => {
const labelledByAttr = checkbox.getAttribute('aria-labelledby');
const label = dom.getElementById(labelledByAttr);
return label;
};
// Should have the correct labels
expect(firstCheckbox.getAttribute('aria-labelledby')).toBeTruthy();
expect(getCheckboxLabel(firstCheckbox).textContent).toBe('Test');
// Should be correctly checked/unchecked
expect(firstCheckbox.checked).toBe(false);
// Clicking a checkbox should toggle it
firstCheckbox.click();
await waitFor(async () => {
// At present, lists are saved as non-tight lists:
expect(body.trim()).toBe('- [x] Test\n \n- [x] Another test');
});
});
it('should reload resource placeholders when the corresponding item downloads', async () => {
Setting.setValue('sync.resourceDownloadMode', 'manual');
const { noteId, resourceId } = await createRemoteResourceAndNote(1);
const note = await Note.load(noteId);
const localResource = await Resource.load(resourceId);
let localState = await Resource.localState(localResource);
expect(localState.fetch_status).toBe(Resource.FETCH_STATUS_IDLE);
const editorRef = React.createRef<EditorControl>();
const component = render(
<WrappedEditor
noteBody={note.body}
noteResources={{ [localResource.id]: { localState, item: localResource } }}
onBodyChange={jest.fn()}
ref={editorRef}
/>,
);
// The resource placeholder should have rendered
const placeholder = await findElement(`span[data-resource-id=${JSON.stringify(localResource.id)}]`);
expect([...placeholder.classList]).toContain('not-loaded-resource');
await resourceFetcher().markForDownload([localResource.id]);
await waitFor(async () => {
localState = await Resource.localState(localResource.id);
expect(localState).toMatchObject({ fetch_status: Resource.FETCH_STATUS_DONE });
});
component.rerender(
<WrappedEditor
noteBody={note.body}
noteResources={{ [localResource.id]: { localState, item: localResource } }}
onBodyChange={jest.fn()}
ref={editorRef}
/>,
);
editorRef.current.onResourceDownloaded(localResource.id);
expect(
await findElement(`img[data-resource-id=${JSON.stringify(localResource.id)}]`),
).toBeTruthy();
});
it('should render clickable internal note links', async () => {
const linkTarget = await Note.save({ title: 'test' });
const body = `[link](:/${linkTarget.id})`;
const onLinkClick = jest.fn();
render(<WrappedEditor
noteBody={body}
onBodyChange={jest.fn()}
onLinkClick={onLinkClick}
/>);
const window = await getEditorWindow();
const link = await findElement<HTMLAnchorElement>('a[href]');
expect(link.href).toBe(`:/${linkTarget.id}`);
mockSelectionMovement(window, 2);
const tooltipButton = await findElement<HTMLButtonElement>('.link-tooltip:not(.-hidden) > button');
tooltipButton.click();
await waitFor(() => {
expect(onLinkClick).toHaveBeenCalledWith(`:/${linkTarget.id}`);
});
});
it.each([
MarkupLanguage.Markdown, MarkupLanguage.Html,
])('should preserve image attachments on edit (case %#)', async (markupLanguage) => {
const { note, resource } = await createNoteAndResource({ markupLanguage });
let body = note.body;
const resources = await attachedResources(body);
render(<WrappedEditor
noteBody={note.body}
note={note}
onBodyChange={newBody => { body = newBody; }}
noteResources={resources}
/>);
const renderedImage = await findElement<HTMLImageElement>(`img[data-resource-id=${JSON.stringify(resource.id)}]`);
expect(renderedImage).toBeTruthy();
const window = await getEditorWindow();
mockTyping(window, ' test');
// The rendered image should still have the correct ALT and source
await waitFor(async () => {
const editorContent = body.trim();
if (markupLanguage === MarkupLanguage.Html) {
expect(editorContent).toMatch(
new RegExp(`^<p><img src=":/${pregQuote(resource.id)}" alt="${pregQuote(renderedImage.alt)}"[^>]*> test</p>$`),
);
} else {
expect(editorContent).toBe(`![${renderedImage.alt}](:/${resource.id}) test`);
}
});
});
it.each([
{ useValidSyntax: false },
{ useValidSyntax: true },
])('should preserve inline math on edit (%j)', async ({ useValidSyntax }) => {
const macros = '\\def\\<{\\langle} \\def\\>{\\rangle}';
let inlineMath = '| \\< u, v \\> |^2 \\leq \\< u, u \\>\\< v, v \\>';
// The \\< escapes are invalid without the above custom macro definitions.
// It should be possible for the editor to preserve invalid math syntax.
if (useValidSyntax) {
inlineMath = macros + inlineMath;
}
let body = `Inline math: $${inlineMath}$...`;
render(<WrappedEditor
noteBody={body}
onBodyChange={newBody => { body = newBody; }}
/>);
const renderedInlineMath = await findElement<HTMLElement>('span.joplin-editable');
expect(renderedInlineMath).toBeTruthy();
const window = await getEditorWindow();
mockTyping(window, ' testing');
await waitFor(async () => {
expect(body.trim()).toBe(`Inline math: $${inlineMath}$... testing`);
});
});
it('should preserve block math on edit', async () => {
let body = 'Test:\n\n$$3^2 + 4^2 = \\sqrt{625}$$\n\nTest.';
render(<WrappedEditor
noteBody={body}
onBodyChange={newBody => { body = newBody; }}
/>);
const renderedInlineMath = await findElement<HTMLElement>('div.joplin-editable');
expect(renderedInlineMath).toBeTruthy();
const window = await getEditorWindow();
mockTyping(window, ' testing');
await waitFor(async () => {
expect(body.trim()).toBe('Test:\n\n$$\n3^2 + 4^2 = \\sqrt{625}\n$$\n\nTest. testing');
});
});
});

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