1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-08-27 20:29:45 +02:00

Compare commits

...

147 Commits

Author SHA1 Message Date
Laurent Cozic
116e2fc92e CLI v2.9.1 2022-10-12 15:50:17 +01:00
Laurent Cozic
713c00053e Releasing sub-packages 2022-10-12 15:45:50 +01:00
Laurent Cozic
6a0700e335 Chore: Fix pdf-viewer build 2022-10-12 15:43:56 +01:00
Laurent Cozic
7961acd06f Doc: News about Joplin company 2022-10-12 15:18:16 +01:00
Laurent Cozic
e9f7f106f1 Doc: Fixed changelog 2022-10-11 17:20:06 +01:00
Laurent Cozic
5e944df595 Android 2.9.5 2022-10-11 17:19:37 +01:00
Laurent Cozic
6f6f427356 Tools: Build mobile app before release 2022-10-11 14:43:39 +01:00
Laurent Cozic
cac10c4e29 Android 2.9.4 2022-10-11 14:28:43 +01:00
Laurent Cozic
9b348fdc29 Mobile: Disable multi-highlighting to fix context menu 2022-10-11 14:18:09 +01:00
Laurent Cozic
ec97dd8c60 Mobile: Display icon for all notebooks if at least one notebook has an icon 2022-10-11 12:46:40 +01:00
Laurent Cozic
f28c1bc6ba Chore: Refactor side-menu-content to TS and React Hooks 2022-10-11 12:31:09 +01:00
Laurent Cozic
e660fafb7a Server v2.9.5 2022-10-11 11:44:12 +01:00
Laurent Cozic
2c49270f38 Tools: Trying to fix encodeAssets EEXIST error 2022-10-11 11:43:22 +01:00
Laurent Cozic
13c1ae3d39 Desktop: Add some extra space between icon and notebook name 2022-10-11 11:20:47 +01:00
Laurent Cozic
29550ade49 Server v2.9.4 2022-10-11 11:08:03 +01:00
Laurent Cozic
1b9f74f674 Chore: Trying to fix CI for Joplin Server build 2022-10-11 11:07:40 +01:00
Laurent Cozic
0b69ae371c Server v2.9.3 2022-10-11 10:42:53 +01:00
Laurent Cozic
37ebd21cb3 Chore: Trying to fix CI for Joplin Server build 2022-10-11 10:42:13 +01:00
Laurent Cozic
c996ddaf9d Server v2.9.2 2022-10-10 11:59:58 +01:00
Laurent Cozic
cea1aeac4b Android 2.9.3 2022-10-07 12:13:34 +01:00
mrkaato0
13ee1c89ea Update fi_FI.po (#6922) 2022-10-07 11:50:07 +01:00
Laurent Cozic
f01ec941b7 Server v2.9.1 2022-10-07 11:48:00 +01:00
Laurent Cozic
0853521bc9 Server: Update email templates 2022-10-06 11:40:11 +01:00
Joplin Bot
e484671a08 Doc: Auto-update documentation
Auto-updated using release-website.sh
2022-10-04 12:27:50 +00:00
Joplin Bot
50253d00e7 Doc: Auto-update documentation
Auto-updated using release-website.sh
2022-10-04 06:31:07 +00:00
Self Not Found
5364965a69 Desktop: Fixes #6257: Fixed the missing format when pasting text by Ctrl+V in Rich Text editor (#6901) 2022-10-01 15:35:54 +01:00
Self Not Found
50baad3c04 Mobile: Show client ID in log (#6897) 2022-09-30 17:38:22 +01:00
ScriptInfra
cf219762c9 Doc: Update faq.md (#6879) 2022-09-30 17:32:24 +01:00
Laurent Cozic
9e27b0881f Doc: Info about eslint 2022-09-30 17:32:01 +01:00
Laurent Cozic
44a96f347a Tools: Add eslint rule prefer-await-to-then 2022-09-30 17:32:00 +01:00
Self Not Found
cc6620a7e1 Desktop: Fixes #6630: Made autoMatchBraces work on CJK characters (#6858) 2022-09-30 17:03:45 +01:00
asrient
29f1abb666 Desktop: Remove page number box from new PDF Viewer (#6846) 2022-09-30 17:01:55 +01:00
Laurent Cozic
9781a33419 Update CONTRIBUTING.md 2022-09-30 16:19:09 +01:00
Laurent Cozic
0954794195 Chore: Removed build file 2022-09-30 15:22:51 +01:00
Laurent Cozic
a996375b88 Mobile: Fixes #6898: Fixed crash when trying to move note to notebook 2022-09-30 12:13:29 +01:00
Laurent Cozic
129ac1829d Chore: Restore accidentally deleted files 2022-09-30 12:07:26 +01:00
Laurent Cozic
44e60bdda9 Revert: Mobile: Add note bar (#6772)
Revert commit dfd95f8385
Due to UX issues.
Ref https://discourse.joplinapp.org/t/25775/30
2022-09-30 11:46:26 +01:00
Joplin Bot
afc34b44c8 Doc: Auto-update documentation
Auto-updated using release-website.sh
2022-09-20 18:24:07 +00:00
Joplin Bot
e08c74ae08 Doc: Auto-update documentation
Auto-updated using release-website.sh
2022-09-20 12:27:32 +00:00
Laurent Cozic
e5c669dc7a Doc: Mention that we do not offer bounties 2022-09-20 12:15:13 +03:00
Helmut K. C. Tessarek
f4a7f5914e All: Update Mermaid 8.13.9 to 9.1.7 (#6849) 2022-09-18 21:22:41 +01:00
Self Not Found
62eee4df56 Desktop: Fixes #6860: Made "Open profile directory" work on Windows (#6861) 2022-09-17 20:19:12 +01:00
Joplin Bot
c16445bc2f Doc: Auto-update documentation
Auto-updated using release-website.sh
2022-09-16 06:49:50 +00:00
Self Not Found
e05c5598a0 Mobile: Increase the attachment size limit to 200MB (#6848) 2022-09-14 12:21:21 +01:00
Mayank Bondre
66c9ee0a1a Desktop: Fix missing plugin file error and missing setting key error in dev mode (#6827) 2022-09-12 16:08:06 +01:00
asrient
d07788607c Desktop: Fix pdf text blurry (#6843) 2022-09-12 16:07:39 +01:00
Laurent Cozic
907dc7601b Desktop release v2.9.8 2022-09-12 14:12:39 +01:00
Laurent Cozic
4b9adcde04 Tools: Restore Windows build on CI 2022-09-12 14:12:07 +01:00
Henry Heino
9f3a4e0d99 Mobile: Fix multiple webview instances (#6841) 2022-09-12 10:46:12 +01:00
Henry Heino
ea14488dc3 Tools: Update Joplin plugin generator to Webpack 5, TypeScript 4.8 (#6826) 2022-09-12 10:44:40 +01:00
Laurent Cozic
f59d29f1c5 Desktop release v2.9.7 2022-09-11 20:07:47 +01:00
Laurent Cozic
0a9e919ac7 Merge branch 'release-2.9' into dev 2022-09-11 20:07:21 +01:00
Laurent Cozic
f11b6e8fa9 Tools: Remove desktop Windows build for now (broken due to invalid cert) 2022-09-11 20:06:49 +01:00
Laurent Cozic
167560ff6f Desktop release v2.9.6 2022-09-11 18:53:38 +01:00
Laurent Cozic
4b4e316bf0 Chore: Remove broken default plugin bundler for now 2022-09-11 18:53:05 +01:00
Self Not Found
7809228bd3 Mobile: Supports attaching multiple files to a note at once (#6831) 2022-09-11 16:58:36 +01:00
Laurent Cozic
540fbbc22c Desktop release v2.9.5 2022-09-11 15:04:00 +01:00
Laurent Cozic
2983d4f1a3 Merge branch 'dev' into release-2.9 2022-09-11 15:03:34 +01:00
asrient
f6a8bf9ea2 Desktop: Add PDF full screen viewer (#6821) 2022-09-11 14:58:32 +01:00
BeeverTeeth
e3ba02281b Doc: Update markdown.md (#6834) 2022-09-10 09:35:46 +01:00
Joplin Bot
295b310079 Doc: Auto-update documentation
Auto-updated using release-website.sh
2022-09-09 18:23:10 +00:00
Henry Heino
62346575f8 iOS: Fixes #6805: Add button to reduce space below markdown toolbar (#6823) 2022-09-09 15:11:58 +01:00
chelstad
0a590b7de9 Doc: Update README to work well with Linode (#6830) 2022-09-09 15:11:03 +01:00
Tolulope Malomo
dfd95f8385 Mobile: Add note bar (#6772) 2022-09-09 15:06:03 +01:00
Retrove
6efe8c171a Chore: Seperate allPossibleCategories to @joplin/lib (#6754) 2022-09-09 15:05:08 +01:00
Philipp Tschannen
a7cdcaf25f Doc: Update e2ee.md (#6833)
Fix typo
2022-09-09 12:40:23 +01:00
Joplin Bot
6277958d6a Doc: Auto-update documentation
Auto-updated using release-website.sh
2022-09-06 18:21:44 +00:00
Laurent Cozic
25bd91bed1 Doc: Added news with link to interview 2022-09-06 16:23:33 +01:00
Laurent Cozic
7974df98ff Desktop: Display default notebook icons when at least one notebook has an icon 2022-09-05 17:26:22 +01:00
Laurent Cozic
e37d980453 Chore: Converted desktop Sidebar to React Hooks 2022-09-05 16:21:26 +01:00
Mayank Bondre
597569745c Doc: Add spec for default plugins (#6811) 2022-09-05 12:51:22 +01:00
Henry Heino
6e6275b1b7 Mobile: Resolves #6808: Convert empty bolded regions to bold-italic regions in beta editor (#6807) 2022-09-05 12:50:32 +01:00
Henry Heino
cfba73e938 Android: Fixes #6802: Double/triple-tap selection doesn't show context menu (#6803) 2022-09-05 12:47:25 +01:00
Henry Heino
7e1c34b769 Chore: Factor duplicate WebView code into ExtendedWebView.tsx (#6771) 2022-09-05 12:46:13 +01:00
Andrej Lifinzew
b5b281c276 CLI: Resolves #1728: Toggle short ids and mv notebooks (#6671) 2022-09-05 12:37:51 +01:00
Mayank Bondre
80906cbdb3 Desktop: Remove demo plugins folder and update pinned version of backup plugin (#6801) 2022-09-05 12:36:21 +01:00
asrient
1504cb71ae Desktop: Added PDF viewer options (#6800) 2022-09-05 12:35:38 +01:00
Self Not Found
eb7083d788 All: Fixes #6688: Fix resources sync when proxy is set (#6817) 2022-09-05 10:42:22 +01:00
Laurent Cozic
e40d733176 Android 2.9.2 2022-09-01 16:19:45 +01:00
Laurent Cozic
170c669e37 Desktop release v2.9.4 2022-09-01 16:19:44 +01:00
Laurent Cozic
24b4b879f2 Android 2.9.2 2022-09-01 16:19:03 +01:00
Mayank Bondre
3942029c90 Desktop: Bundle default plugins with desktop application (#6679) 2022-09-01 11:53:58 +01:00
Mayank Bondre
01f4bb0591 Desktop: Install default plugins on first app start (#6585) 2022-09-01 11:44:33 +01:00
Laurent Cozic
86fbf82d36 Merge branch 'dev' into release-2.9 2022-09-01 11:05:10 +01:00
Henry Heino
1069d7d6fb Chore: Update ESLint and TypeScript (#6774) 2022-08-31 12:57:28 +01:00
asrient
8d67aefcd5 Chore: Fix yarn install (#6790) 2022-08-31 12:56:58 +01:00
javad mnjd
ff90166b6e Android: Fixes #6791: Fixed handling of normal paths in filesystem sync (#6792) 2022-08-31 10:44:32 +01:00
Laurent Cozic
6beaaf75bb Chore: Fixed bug 2022-08-29 16:27:26 +01:00
Laurent Cozic
ebf9a9375c Desktop, Cli: Fixes #6704: Fixed names of imported duplicate notebooks 2022-08-29 16:22:13 +01:00
javad mnjd
de94c35c0b Android: Fixes #6779: Fixed android filesystem sync (resources) (#6789) 2022-08-29 15:29:28 +01:00
Laurent Cozic
6a4eb33093 Desktop: Fixes #6692: Fixed file and directory paths in plugin setting dialogs 2022-08-29 15:27:19 +01:00
Laurent Cozic
8b91427056 Chore: Added more messages for external editing 2022-08-29 15:09:30 +01:00
Henry Heino
b174fcf17b Mobile: Add Markdown toolbar (#6753) 2022-08-29 14:19:04 +01:00
Henry Heino
c6b91cdc5d Chore: Force syntax tree generation after creating editor in test units (#6783) 2022-08-29 12:40:19 +01:00
Ivan Piskun
e784e8c947 Update uk_UA.po (#6786) 2022-08-28 15:41:04 +01:00
asrient
6498f94c36 Desktop: Add zoom feature on PDF viewer (#6748) 2022-08-28 12:18:51 +01:00
Henry Heino
ae300de42f Mobile: Setting to disable spellcheck in beta editor (#6780) 2022-08-27 13:53:46 +01:00
Henry Heino
40e682faae Chore: Fix #6764: Switch from @babel/register to ts-node for mobile TypeScript build scripts (#6765) 2022-08-27 13:41:49 +01:00
Henry Heino
92c24c2129 Chore: Migrate mobile Dropdown, ScreenHeader to TypeScript (#6763) 2022-08-27 13:36:59 +01:00
asrient
3ec3a37603 Desktop: PDF scroll persistence (#6747) 2022-08-27 13:32:20 +01:00
Abu Sahal
ed2a328616 All: Translation: Update id_ID.po (#6782) 2022-08-27 08:12:16 -04:00
Retrove
58dc4feee7 Plugins: Add support for media links in plugin manifest.json (#6672) 2022-08-27 12:11:56 +01:00
Anton Tuchkov
0356cbbfab Desktop: Add support for multi-language spell check (#6617) 2022-08-27 12:05:44 +01:00
Tolulope Malomo
8b06cbf04e Mobile: Fix side menu width on wide screen devices (#6662) 2022-08-25 16:59:38 +01:00
Henry Heino
fd82758e74 Android: Enable spellcheck by default on beta editor (#6778) 2022-08-24 23:52:34 +01:00
Laurent Cozic
c705ec682c Website: Default to yearly subscription 2022-08-24 11:30:39 +01:00
Henry Heino
a5e6491cda Mobile: Add long-press tooltips (#6758) 2022-08-21 22:03:41 +01:00
Henry Heino
8ef9804cab iOS: Enable long-press menu (#6738) 2022-08-21 21:58:15 +01:00
Henry Heino
09ec77f904 Android: Fixes #6732: Don't reload the application on screen rotation (#6737) 2022-08-21 21:57:25 +01:00
SeptemberHX
36871d9cb0 Desktop: Fixes #6719: Avoid reloading loaded plugin scripts (#6742) 2022-08-21 21:53:36 +01:00
asrient
b4ece67092 Tools: Fix desktop build performance issue (#6762) 2022-08-19 19:10:39 +01:00
Laurent Cozic
7e8a6dfb54 Tools: Add react-hooks/exhaustive-deps eslint rule 2022-08-19 12:10:04 +01:00
Laurent Cozic
549095f0e5 Chore: clean up ignore files 2022-08-19 11:39:15 +01:00
Laurent Cozic
313c05732b Tools: Add eslint-interactive to more easily apply new eslint rules 2022-08-19 11:39:14 +01:00
Joplin Bot
641b0fa9a2 Doc: Auto-update documentation
Auto-updated using release-website.sh
2022-08-18 18:19:20 +00:00
Laurent Cozic
96982849ce Desktop release v2.9.4 2022-08-18 16:29:00 +01:00
Laurent Cozic
4b8745c875 Tools: Prevent CI from creating a release if "yarn install" failed 2022-08-18 16:28:17 +01:00
Henry Heino
78f72f33e6 Mobile: Fixes #6759: Fix default font in beta editor (#6760) 2022-08-18 11:43:54 +01:00
Laurent Cozic
b4aa418276 Chore: No need to warn about falling back to the default font 2022-08-18 11:42:48 +01:00
Laurent Cozic
8d66322c94 Desktop: Disable publishing recursive notes on Jpolin Cloud (not fully working yet) 2022-08-18 11:39:39 +01:00
Laurent Cozic
6969341745 Desktop release v2.9.3 2022-08-18 11:03:50 +01:00
Laurent Cozic
488f19e3c4 Merge branch 'dev' into release-2.9 2022-08-18 11:03:36 +01:00
Joplin Bot
79889facea Doc: Auto-update documentation
Auto-updated using release-website.sh
2022-08-17 06:20:19 +00:00
Ji-Hyeon Gim
74f513b082 All: Translation: Update ko.po (#6751)
It updates Korean translation.

Signed-off-by: Ji-Hyeon Gim <potatogim@potatogim.net>

Signed-off-by: Ji-Hyeon Gim <potatogim@potatogim.net>
2022-08-14 19:24:31 -04:00
Kevin Hsu
ab540edacc All: Translation: Update zh_TW (#6727) 2022-08-14 13:51:23 -04:00
Ji-Hyeon Gim
9dedd88989 All: Translation: Update ko.po (#6734)
It updates Korean translation.

Signed-off-by: Ji-Hyeon Gim <potatogim@potatogim.net>

Signed-off-by: Ji-Hyeon Gim <potatogim@potatogim.net>
2022-08-14 13:50:17 -04:00
Joplin Bot
be8ebd9fc5 Doc: Auto-update documentation
Auto-updated using release-website.sh
2022-08-12 18:20:29 +00:00
Laurent Cozic
6d41814455 Android 2.9.1 2022-08-12 19:15:40 +02:00
Laurent Cozic
2807a32e64 Desktop release v2.9.2 2022-08-12 19:07:31 +02:00
Laurent Cozic
e6b0e20f08 Doc: Add sponsor 2022-08-12 18:37:59 +02:00
Laurent Cozic
0dc92c17f5 Doc: Mention Meetup on readme file 2022-08-11 19:34:46 +02:00
Joplin Bot
308c7b11c2 Doc: Auto-update documentation
Auto-updated using release-website.sh
2022-08-08 16:46:15 +00:00
Laurent Cozic
75be518d8a Doc: Fixed coding style doc 2022-08-08 18:32:09 +02:00
Laurent Cozic
9f97a2e910 Doc: Upload Meetup news 2022-08-08 18:32:08 +02:00
Henry Heino
03c3188a4a Mobile: Add keyboard-activatable markdown commands (e.g. bold, italicize) (#6707) 2022-08-08 16:00:14 +01:00
José Rebelo
bd5ce114a1 Desktop: Allow electron flag to disable smooth scrolling (#6712) 2022-08-06 11:01:59 +01:00
Joplin Bot
d326700d32 Doc: Auto-update documentation
Auto-updated using release-website.sh
2022-08-06 00:45:43 +00:00
Joplin Bot
5bf949fcb3 Doc: Auto-update documentation
Auto-updated using release-website.sh
2022-08-05 18:21:00 +00:00
Joplin Bot
9d6d2f770a Doc: Auto-update documentation
Auto-updated using release-website.sh
2022-08-05 00:52:30 +00:00
Joplin Bot
3e12313f85 Doc: Auto-update documentation
Auto-updated using release-website.sh
2022-08-04 18:18:43 +00:00
Joplin Bot
358178f83d Doc: Auto-update documentation
Auto-updated using release-website.sh
2022-08-04 12:24:45 +00:00
asrient
6ea40c9895 Desktop: New Embedded Pdf Viewer (#6681) 2022-08-04 10:12:22 +01:00
Laurent Cozic
0191de8bb4 Doc: update sponsors 2022-08-04 10:50:23 +02:00
Joplin Bot
a114e1b5f7 Doc: Auto-update documentation
Auto-updated using release-website.sh
2022-08-03 00:51:59 +00:00
Joplin Bot
cf22ec0c8b Doc: Auto-update documentation
Auto-updated using release-website.sh
2022-08-02 18:17:50 +00:00
Joplin Bot
e074f099c4 Doc: Auto-update documentation
Auto-updated using release-website.sh
2022-08-01 18:17:40 +00:00
Joplin Bot
c5ad2975d6 Doc: Auto-update documentation
Auto-updated using release-website.sh
2022-08-01 12:23:51 +00:00
267 changed files with 13414 additions and 2741 deletions

View File

@@ -6,6 +6,7 @@ _releases/
*.min.js
**/commands/index.ts
**/node_modules/
packages/generator-joplin/generators/app/templates/api/
Assets/
docs/
highlight.pack.js
@@ -69,6 +70,7 @@ packages/tools/node_modules
packages/tools/PortableAppsLauncher
packages/turndown-plugin-gfm/
packages/turndown/
packages/pdf-viewer/dist
plugin_types/
readme/
@@ -115,6 +117,9 @@ packages/app-cli/tests/services/plugins/api/JoplinViewMenuItem.js.map
packages/app-cli/tests/services/plugins/api/JoplinWorkspace.d.ts
packages/app-cli/tests/services/plugins/api/JoplinWorkspace.js
packages/app-cli/tests/services/plugins/api/JoplinWorkspace.js.map
packages/app-cli/tests/services/plugins/defaultPluginsUtils.d.ts
packages/app-cli/tests/services/plugins/defaultPluginsUtils.js
packages/app-cli/tests/services/plugins/defaultPluginsUtils.js.map
packages/app-cli/tests/services/plugins/sandboxProxy.d.ts
packages/app-cli/tests/services/plugins/sandboxProxy.js
packages/app-cli/tests/services/plugins/sandboxProxy.js.map
@@ -328,6 +333,9 @@ packages/app-desktop/gui/MainScreen/commands/openItem.js.map
packages/app-desktop/gui/MainScreen/commands/openNote.d.ts
packages/app-desktop/gui/MainScreen/commands/openNote.js
packages/app-desktop/gui/MainScreen/commands/openNote.js.map
packages/app-desktop/gui/MainScreen/commands/openPdfViewer.d.ts
packages/app-desktop/gui/MainScreen/commands/openPdfViewer.js
packages/app-desktop/gui/MainScreen/commands/openPdfViewer.js.map
packages/app-desktop/gui/MainScreen/commands/openTag.d.ts
packages/app-desktop/gui/MainScreen/commands/openTag.js
packages/app-desktop/gui/MainScreen/commands/openTag.js.map
@@ -592,6 +600,9 @@ packages/app-desktop/gui/OneDriveLoginScreen.js.map
packages/app-desktop/gui/PasswordInput/PasswordInput.d.ts
packages/app-desktop/gui/PasswordInput/PasswordInput.js
packages/app-desktop/gui/PasswordInput/PasswordInput.js.map
packages/app-desktop/gui/PdfViewer.d.ts
packages/app-desktop/gui/PdfViewer.js
packages/app-desktop/gui/PdfViewer.js.map
packages/app-desktop/gui/ResizableLayout/MoveButtons.d.ts
packages/app-desktop/gui/ResizableLayout/MoveButtons.js
packages/app-desktop/gui/ResizableLayout/MoveButtons.js.map
@@ -841,6 +852,15 @@ packages/app-mobile/components/BackButtonDialogBox.js.map
packages/app-mobile/components/CameraView.d.ts
packages/app-mobile/components/CameraView.js
packages/app-mobile/components/CameraView.js.map
packages/app-mobile/components/CustomButton.d.ts
packages/app-mobile/components/CustomButton.js
packages/app-mobile/components/CustomButton.js.map
packages/app-mobile/components/Dropdown.d.ts
packages/app-mobile/components/Dropdown.js
packages/app-mobile/components/Dropdown.js.map
packages/app-mobile/components/ExtendedWebView.d.ts
packages/app-mobile/components/ExtendedWebView.js
packages/app-mobile/components/ExtendedWebView.js.map
packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.d.ts
packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.js
packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.js.map
@@ -856,27 +876,99 @@ packages/app-mobile/components/NoteBodyViewer/hooks/useSource.js.map
packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.d.ts
packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.js
packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.js.map
packages/app-mobile/components/NoteEditor/CodeMirror/createEditor.d.ts
packages/app-mobile/components/NoteEditor/CodeMirror/createEditor.js
packages/app-mobile/components/NoteEditor/CodeMirror/createEditor.js.map
packages/app-mobile/components/NoteEditor/CodeMirror/decoratorExtension.d.ts
packages/app-mobile/components/NoteEditor/CodeMirror/decoratorExtension.js
packages/app-mobile/components/NoteEditor/CodeMirror/decoratorExtension.js.map
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.bulletedVsChecklist.test.d.ts
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.bulletedVsChecklist.test.js
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.bulletedVsChecklist.test.js.map
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.d.ts
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.js
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.js.map
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.test.d.ts
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.test.js
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.test.js.map
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.toggleList.test.d.ts
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.toggleList.test.js
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.toggleList.test.js.map
packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.d.ts
packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.js
packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.js.map
packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.test.d.ts
packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.test.js
packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.test.js.map
packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.d.ts
packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.js
packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.js.map
packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.test.d.ts
packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.test.js
packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.test.js.map
packages/app-mobile/components/NoteEditor/CodeMirror/syntaxHighlightingLanguages.d.ts
packages/app-mobile/components/NoteEditor/CodeMirror/syntaxHighlightingLanguages.js
packages/app-mobile/components/NoteEditor/CodeMirror/syntaxHighlightingLanguages.js.map
packages/app-mobile/components/NoteEditor/CodeMirror/theme.d.ts
packages/app-mobile/components/NoteEditor/CodeMirror/theme.js
packages/app-mobile/components/NoteEditor/CodeMirror/theme.js.map
packages/app-mobile/components/NoteEditor/CodeMirror/types.d.ts
packages/app-mobile/components/NoteEditor/CodeMirror/types.js
packages/app-mobile/components/NoteEditor/CodeMirror/types.js.map
packages/app-mobile/components/NoteEditor/CodeMirror/webviewLogger.d.ts
packages/app-mobile/components/NoteEditor/CodeMirror/webviewLogger.js
packages/app-mobile/components/NoteEditor/CodeMirror/webviewLogger.js.map
packages/app-mobile/components/NoteEditor/EditLinkDialog.d.ts
packages/app-mobile/components/NoteEditor/EditLinkDialog.js
packages/app-mobile/components/NoteEditor/EditLinkDialog.js.map
packages/app-mobile/components/NoteEditor/MarkdownToolbar/MarkdownToolbar.d.ts
packages/app-mobile/components/NoteEditor/MarkdownToolbar/MarkdownToolbar.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/MarkdownToolbar.js.map
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToggleOverflowButton.d.ts
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToggleOverflowButton.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToggleOverflowButton.js.map
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToggleSpaceButton.d.ts
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToggleSpaceButton.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToggleSpaceButton.js.map
packages/app-mobile/components/NoteEditor/MarkdownToolbar/Toolbar.d.ts
packages/app-mobile/components/NoteEditor/MarkdownToolbar/Toolbar.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/Toolbar.js.map
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarButton.d.ts
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarButton.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarButton.js.map
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarOverflowRows.d.ts
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarOverflowRows.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarOverflowRows.js.map
packages/app-mobile/components/NoteEditor/MarkdownToolbar/types.d.ts
packages/app-mobile/components/NoteEditor/MarkdownToolbar/types.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/types.js.map
packages/app-mobile/components/NoteEditor/NoteEditor.d.ts
packages/app-mobile/components/NoteEditor/NoteEditor.js
packages/app-mobile/components/NoteEditor/NoteEditor.js.map
packages/app-mobile/components/NoteEditor/SearchPanel.d.ts
packages/app-mobile/components/NoteEditor/SearchPanel.js
packages/app-mobile/components/NoteEditor/SearchPanel.js.map
packages/app-mobile/components/NoteEditor/SelectionFormatting.d.ts
packages/app-mobile/components/NoteEditor/SelectionFormatting.js
packages/app-mobile/components/NoteEditor/SelectionFormatting.js.map
packages/app-mobile/components/NoteEditor/types.d.ts
packages/app-mobile/components/NoteEditor/types.js
packages/app-mobile/components/NoteEditor/types.js.map
packages/app-mobile/components/ScreenHeader.d.ts
packages/app-mobile/components/ScreenHeader.js
packages/app-mobile/components/ScreenHeader.js.map
packages/app-mobile/components/SelectDateTimeDialog.d.ts
packages/app-mobile/components/SelectDateTimeDialog.js
packages/app-mobile/components/SelectDateTimeDialog.js.map
packages/app-mobile/components/SideMenu.d.ts
packages/app-mobile/components/SideMenu.js
packages/app-mobile/components/SideMenu.js.map
packages/app-mobile/components/getResponsiveValue.d.ts
packages/app-mobile/components/getResponsiveValue.js
packages/app-mobile/components/getResponsiveValue.js.map
packages/app-mobile/components/getResponsiveValue.test.d.ts
packages/app-mobile/components/getResponsiveValue.test.js
packages/app-mobile/components/getResponsiveValue.test.js.map
packages/app-mobile/components/screens/ConfigScreen.d.ts
packages/app-mobile/components/screens/ConfigScreen.js
packages/app-mobile/components/screens/ConfigScreen.js.map
@@ -889,6 +981,9 @@ packages/app-mobile/components/screens/UpgradeSyncTargetScreen.js.map
packages/app-mobile/components/screens/encryption-config.d.ts
packages/app-mobile/components/screens/encryption-config.js
packages/app-mobile/components/screens/encryption-config.js.map
packages/app-mobile/components/side-menu-content.d.ts
packages/app-mobile/components/side-menu-content.js
packages/app-mobile/components/side-menu-content.js.map
packages/app-mobile/gulpfile.d.ts
packages/app-mobile/gulpfile.js
packages/app-mobile/gulpfile.js.map
@@ -931,6 +1026,9 @@ packages/app-mobile/utils/setupNotifications.js.map
packages/app-mobile/utils/shareHandler.d.ts
packages/app-mobile/utils/shareHandler.js
packages/app-mobile/utils/shareHandler.js.map
packages/app-mobile/utils/types.d.ts
packages/app-mobile/utils/types.js
packages/app-mobile/utils/types.js.map
packages/fork-htmlparser2/src/CollectingHandler.d.ts
packages/fork-htmlparser2/src/CollectingHandler.js
packages/fork-htmlparser2/src/CollectingHandler.js.map
@@ -1495,6 +1593,9 @@ packages/lib/services/interop/InteropService_Importer_Md_frontmatter.test.js.map
packages/lib/services/interop/InteropService_Importer_Raw.d.ts
packages/lib/services/interop/InteropService_Importer_Raw.js
packages/lib/services/interop/InteropService_Importer_Raw.js.map
packages/lib/services/interop/InteropService_Importer_Raw.test.d.ts
packages/lib/services/interop/InteropService_Importer_Raw.test.js
packages/lib/services/interop/InteropService_Importer_Raw.test.js.map
packages/lib/services/interop/types.d.ts
packages/lib/services/interop/types.js
packages/lib/services/interop/types.js.map
@@ -1603,6 +1704,12 @@ packages/lib/services/plugins/api/JoplinWorkspace.js.map
packages/lib/services/plugins/api/types.d.ts
packages/lib/services/plugins/api/types.js
packages/lib/services/plugins/api/types.js.map
packages/lib/services/plugins/defaultPlugins/defaultPluginsUtils.d.ts
packages/lib/services/plugins/defaultPlugins/defaultPluginsUtils.js
packages/lib/services/plugins/defaultPlugins/defaultPluginsUtils.js.map
packages/lib/services/plugins/defaultPlugins/desktopDefaultPluginsInfo.d.ts
packages/lib/services/plugins/defaultPlugins/desktopDefaultPluginsInfo.js
packages/lib/services/plugins/defaultPlugins/desktopDefaultPluginsInfo.js.map
packages/lib/services/plugins/reducer.d.ts
packages/lib/services/plugins/reducer.js
packages/lib/services/plugins/reducer.js.map
@@ -1909,6 +2016,60 @@ packages/lib/uuid.js.map
packages/lib/versionInfo.d.ts
packages/lib/versionInfo.js
packages/lib/versionInfo.js.map
packages/pdf-viewer/FullViewer.d.ts
packages/pdf-viewer/FullViewer.js
packages/pdf-viewer/FullViewer.js.map
packages/pdf-viewer/Page.d.ts
packages/pdf-viewer/Page.js
packages/pdf-viewer/Page.js.map
packages/pdf-viewer/PdfDocument.d.ts
packages/pdf-viewer/PdfDocument.js
packages/pdf-viewer/PdfDocument.js.map
packages/pdf-viewer/VerticalPages.d.ts
packages/pdf-viewer/VerticalPages.js
packages/pdf-viewer/VerticalPages.js.map
packages/pdf-viewer/hooks/useIsFocused.d.ts
packages/pdf-viewer/hooks/useIsFocused.js
packages/pdf-viewer/hooks/useIsFocused.js.map
packages/pdf-viewer/hooks/useIsVisible.d.ts
packages/pdf-viewer/hooks/useIsVisible.js
packages/pdf-viewer/hooks/useIsVisible.js.map
packages/pdf-viewer/hooks/usePdfDocument.d.ts
packages/pdf-viewer/hooks/usePdfDocument.js
packages/pdf-viewer/hooks/usePdfDocument.js.map
packages/pdf-viewer/hooks/useScaledSize.d.ts
packages/pdf-viewer/hooks/useScaledSize.js
packages/pdf-viewer/hooks/useScaledSize.js.map
packages/pdf-viewer/hooks/useScrollSaver.d.ts
packages/pdf-viewer/hooks/useScrollSaver.js
packages/pdf-viewer/hooks/useScrollSaver.js.map
packages/pdf-viewer/hooks/useVisibleOnSelect.d.ts
packages/pdf-viewer/hooks/useVisibleOnSelect.js
packages/pdf-viewer/hooks/useVisibleOnSelect.js.map
packages/pdf-viewer/main.d.ts
packages/pdf-viewer/main.js
packages/pdf-viewer/main.js.map
packages/pdf-viewer/messageService.d.ts
packages/pdf-viewer/messageService.js
packages/pdf-viewer/messageService.js.map
packages/pdf-viewer/miniViewer.d.ts
packages/pdf-viewer/miniViewer.js
packages/pdf-viewer/miniViewer.js.map
packages/pdf-viewer/pdfSource.test.d.ts
packages/pdf-viewer/pdfSource.test.js
packages/pdf-viewer/pdfSource.test.js.map
packages/pdf-viewer/types.d.ts
packages/pdf-viewer/types.js
packages/pdf-viewer/types.js.map
packages/pdf-viewer/ui/GotoPage.d.ts
packages/pdf-viewer/ui/GotoPage.js
packages/pdf-viewer/ui/GotoPage.js.map
packages/pdf-viewer/ui/IconButtons.d.ts
packages/pdf-viewer/ui/IconButtons.js
packages/pdf-viewer/ui/IconButtons.js.map
packages/pdf-viewer/ui/ZoomControls.d.ts
packages/pdf-viewer/ui/ZoomControls.js
packages/pdf-viewer/ui/ZoomControls.js.map
packages/plugin-repo-cli/commands/updateRelease.d.ts
packages/plugin-repo-cli/commands/updateRelease.js
packages/plugin-repo-cli/commands/updateRelease.js.map
@@ -2059,6 +2220,12 @@ packages/tools/buildServerDocker.js.map
packages/tools/buildServerDocker.test.d.ts
packages/tools/buildServerDocker.test.js
packages/tools/buildServerDocker.test.js.map
packages/tools/bundleDefaultPlugins.d.ts
packages/tools/bundleDefaultPlugins.js
packages/tools/bundleDefaultPlugins.js.map
packages/tools/bundleDefaultPlugins.test.d.ts
packages/tools/bundleDefaultPlugins.test.js
packages/tools/bundleDefaultPlugins.test.js.map
packages/tools/checkLibPaths.d.ts
packages/tools/checkLibPaths.js
packages/tools/checkLibPaths.js.map

View File

@@ -83,11 +83,15 @@ module.exports = {
// 'complexity': ['warn', { max: 10 }],
// Checks rules of Hooks
'react-hooks/rules-of-hooks': 'error',
'@seiyab/react-hooks/rules-of-hooks': 'error',
'@seiyab/react-hooks/exhaustive-deps': ['error', { 'ignoreThisDependency': 'props' }],
// Checks effect dependencies
// Disable because of this: https://github.com/facebook/react/issues/16265
// "react-hooks/exhaustive-deps": "warn",
'promise/prefer-await-to-then': 'error',
// -------------------------------
// Formatting
// -------------------------------
@@ -134,8 +138,12 @@ module.exports = {
'plugins': [
'react',
'@typescript-eslint',
'react-hooks',
// Need to use a fork of the official rules of hooks because of this bug:
// https://github.com/facebook/react/issues/16265
'@seiyab/eslint-plugin-react-hooks',
// 'react-hooks',
'import',
'promise',
],
'overrides': [
{

View File

@@ -57,6 +57,11 @@ echo "Yarn $( yarn -v )"
cd "$ROOT_DIR"
yarn install
testResult=$?
if [ $testResult -ne 0 ]; then
echo "Yarn installation failed. Search for 'exit code 1' in the log for more information."
exit $testResult
fi
# =============================================================================
# Run test units. Only do it for pull requests and dev branch because we don't
@@ -170,6 +175,9 @@ cd "$ROOT_DIR/packages/app-desktop"
if [[ $GIT_TAG_NAME = v* ]]; then
echo "Step: Building and publishing desktop application..."
# cd "$ROOT_DIR/packages/tools"
# node bundleDefaultPlugins.js
cd "$ROOT_DIR/packages/app-desktop"
USE_HARD_LINKS=false yarn run dist
elif [[ $IS_LINUX = 1 ]] && [[ $GIT_TAG_NAME = $SERVER_TAG_PREFIX-* ]]; then
echo "Step: Building Docker Image..."

View File

@@ -5,7 +5,6 @@ jobs:
runs-on: ${{ matrix.os }}
strategy:
matrix:
# Removed windows-2016 for now - discontinued by GitHub
os: [macos-latest, ubuntu-latest, windows-2019]
steps:
@@ -76,6 +75,8 @@ jobs:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
IS_CONTINUOUS_INTEGRATION: 1
BUILD_SEQUENCIAL: 1
SERVER_REPOSITORY: joplin/server
SERVER_TAG_PREFIX: server
run: |
"${GITHUB_WORKSPACE}/.github/scripts/run_ci.sh"

168
.gitignore vendored
View File

@@ -105,6 +105,9 @@ packages/app-cli/tests/services/plugins/api/JoplinViewMenuItem.js.map
packages/app-cli/tests/services/plugins/api/JoplinWorkspace.d.ts
packages/app-cli/tests/services/plugins/api/JoplinWorkspace.js
packages/app-cli/tests/services/plugins/api/JoplinWorkspace.js.map
packages/app-cli/tests/services/plugins/defaultPluginsUtils.d.ts
packages/app-cli/tests/services/plugins/defaultPluginsUtils.js
packages/app-cli/tests/services/plugins/defaultPluginsUtils.js.map
packages/app-cli/tests/services/plugins/sandboxProxy.d.ts
packages/app-cli/tests/services/plugins/sandboxProxy.js
packages/app-cli/tests/services/plugins/sandboxProxy.js.map
@@ -318,6 +321,9 @@ packages/app-desktop/gui/MainScreen/commands/openItem.js.map
packages/app-desktop/gui/MainScreen/commands/openNote.d.ts
packages/app-desktop/gui/MainScreen/commands/openNote.js
packages/app-desktop/gui/MainScreen/commands/openNote.js.map
packages/app-desktop/gui/MainScreen/commands/openPdfViewer.d.ts
packages/app-desktop/gui/MainScreen/commands/openPdfViewer.js
packages/app-desktop/gui/MainScreen/commands/openPdfViewer.js.map
packages/app-desktop/gui/MainScreen/commands/openTag.d.ts
packages/app-desktop/gui/MainScreen/commands/openTag.js
packages/app-desktop/gui/MainScreen/commands/openTag.js.map
@@ -582,6 +588,9 @@ packages/app-desktop/gui/OneDriveLoginScreen.js.map
packages/app-desktop/gui/PasswordInput/PasswordInput.d.ts
packages/app-desktop/gui/PasswordInput/PasswordInput.js
packages/app-desktop/gui/PasswordInput/PasswordInput.js.map
packages/app-desktop/gui/PdfViewer.d.ts
packages/app-desktop/gui/PdfViewer.js
packages/app-desktop/gui/PdfViewer.js.map
packages/app-desktop/gui/ResizableLayout/MoveButtons.d.ts
packages/app-desktop/gui/ResizableLayout/MoveButtons.js
packages/app-desktop/gui/ResizableLayout/MoveButtons.js.map
@@ -831,6 +840,15 @@ packages/app-mobile/components/BackButtonDialogBox.js.map
packages/app-mobile/components/CameraView.d.ts
packages/app-mobile/components/CameraView.js
packages/app-mobile/components/CameraView.js.map
packages/app-mobile/components/CustomButton.d.ts
packages/app-mobile/components/CustomButton.js
packages/app-mobile/components/CustomButton.js.map
packages/app-mobile/components/Dropdown.d.ts
packages/app-mobile/components/Dropdown.js
packages/app-mobile/components/Dropdown.js.map
packages/app-mobile/components/ExtendedWebView.d.ts
packages/app-mobile/components/ExtendedWebView.js
packages/app-mobile/components/ExtendedWebView.js.map
packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.d.ts
packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.js
packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.js.map
@@ -846,27 +864,99 @@ packages/app-mobile/components/NoteBodyViewer/hooks/useSource.js.map
packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.d.ts
packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.js
packages/app-mobile/components/NoteEditor/CodeMirror/CodeMirror.js.map
packages/app-mobile/components/NoteEditor/CodeMirror/createEditor.d.ts
packages/app-mobile/components/NoteEditor/CodeMirror/createEditor.js
packages/app-mobile/components/NoteEditor/CodeMirror/createEditor.js.map
packages/app-mobile/components/NoteEditor/CodeMirror/decoratorExtension.d.ts
packages/app-mobile/components/NoteEditor/CodeMirror/decoratorExtension.js
packages/app-mobile/components/NoteEditor/CodeMirror/decoratorExtension.js.map
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.bulletedVsChecklist.test.d.ts
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.bulletedVsChecklist.test.js
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.bulletedVsChecklist.test.js.map
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.d.ts
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.js
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.js.map
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.test.d.ts
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.test.js
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.test.js.map
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.toggleList.test.d.ts
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.toggleList.test.js
packages/app-mobile/components/NoteEditor/CodeMirror/markdownCommands.toggleList.test.js.map
packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.d.ts
packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.js
packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.js.map
packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.test.d.ts
packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.test.js
packages/app-mobile/components/NoteEditor/CodeMirror/markdownMathParser.test.js.map
packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.d.ts
packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.js
packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.js.map
packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.test.d.ts
packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.test.js
packages/app-mobile/components/NoteEditor/CodeMirror/markdownReformatter.test.js.map
packages/app-mobile/components/NoteEditor/CodeMirror/syntaxHighlightingLanguages.d.ts
packages/app-mobile/components/NoteEditor/CodeMirror/syntaxHighlightingLanguages.js
packages/app-mobile/components/NoteEditor/CodeMirror/syntaxHighlightingLanguages.js.map
packages/app-mobile/components/NoteEditor/CodeMirror/theme.d.ts
packages/app-mobile/components/NoteEditor/CodeMirror/theme.js
packages/app-mobile/components/NoteEditor/CodeMirror/theme.js.map
packages/app-mobile/components/NoteEditor/CodeMirror/types.d.ts
packages/app-mobile/components/NoteEditor/CodeMirror/types.js
packages/app-mobile/components/NoteEditor/CodeMirror/types.js.map
packages/app-mobile/components/NoteEditor/CodeMirror/webviewLogger.d.ts
packages/app-mobile/components/NoteEditor/CodeMirror/webviewLogger.js
packages/app-mobile/components/NoteEditor/CodeMirror/webviewLogger.js.map
packages/app-mobile/components/NoteEditor/EditLinkDialog.d.ts
packages/app-mobile/components/NoteEditor/EditLinkDialog.js
packages/app-mobile/components/NoteEditor/EditLinkDialog.js.map
packages/app-mobile/components/NoteEditor/MarkdownToolbar/MarkdownToolbar.d.ts
packages/app-mobile/components/NoteEditor/MarkdownToolbar/MarkdownToolbar.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/MarkdownToolbar.js.map
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToggleOverflowButton.d.ts
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToggleOverflowButton.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToggleOverflowButton.js.map
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToggleSpaceButton.d.ts
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToggleSpaceButton.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToggleSpaceButton.js.map
packages/app-mobile/components/NoteEditor/MarkdownToolbar/Toolbar.d.ts
packages/app-mobile/components/NoteEditor/MarkdownToolbar/Toolbar.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/Toolbar.js.map
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarButton.d.ts
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarButton.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarButton.js.map
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarOverflowRows.d.ts
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarOverflowRows.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarOverflowRows.js.map
packages/app-mobile/components/NoteEditor/MarkdownToolbar/types.d.ts
packages/app-mobile/components/NoteEditor/MarkdownToolbar/types.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/types.js.map
packages/app-mobile/components/NoteEditor/NoteEditor.d.ts
packages/app-mobile/components/NoteEditor/NoteEditor.js
packages/app-mobile/components/NoteEditor/NoteEditor.js.map
packages/app-mobile/components/NoteEditor/SearchPanel.d.ts
packages/app-mobile/components/NoteEditor/SearchPanel.js
packages/app-mobile/components/NoteEditor/SearchPanel.js.map
packages/app-mobile/components/NoteEditor/SelectionFormatting.d.ts
packages/app-mobile/components/NoteEditor/SelectionFormatting.js
packages/app-mobile/components/NoteEditor/SelectionFormatting.js.map
packages/app-mobile/components/NoteEditor/types.d.ts
packages/app-mobile/components/NoteEditor/types.js
packages/app-mobile/components/NoteEditor/types.js.map
packages/app-mobile/components/ScreenHeader.d.ts
packages/app-mobile/components/ScreenHeader.js
packages/app-mobile/components/ScreenHeader.js.map
packages/app-mobile/components/SelectDateTimeDialog.d.ts
packages/app-mobile/components/SelectDateTimeDialog.js
packages/app-mobile/components/SelectDateTimeDialog.js.map
packages/app-mobile/components/SideMenu.d.ts
packages/app-mobile/components/SideMenu.js
packages/app-mobile/components/SideMenu.js.map
packages/app-mobile/components/getResponsiveValue.d.ts
packages/app-mobile/components/getResponsiveValue.js
packages/app-mobile/components/getResponsiveValue.js.map
packages/app-mobile/components/getResponsiveValue.test.d.ts
packages/app-mobile/components/getResponsiveValue.test.js
packages/app-mobile/components/getResponsiveValue.test.js.map
packages/app-mobile/components/screens/ConfigScreen.d.ts
packages/app-mobile/components/screens/ConfigScreen.js
packages/app-mobile/components/screens/ConfigScreen.js.map
@@ -879,6 +969,9 @@ packages/app-mobile/components/screens/UpgradeSyncTargetScreen.js.map
packages/app-mobile/components/screens/encryption-config.d.ts
packages/app-mobile/components/screens/encryption-config.js
packages/app-mobile/components/screens/encryption-config.js.map
packages/app-mobile/components/side-menu-content.d.ts
packages/app-mobile/components/side-menu-content.js
packages/app-mobile/components/side-menu-content.js.map
packages/app-mobile/gulpfile.d.ts
packages/app-mobile/gulpfile.js
packages/app-mobile/gulpfile.js.map
@@ -921,6 +1014,9 @@ packages/app-mobile/utils/setupNotifications.js.map
packages/app-mobile/utils/shareHandler.d.ts
packages/app-mobile/utils/shareHandler.js
packages/app-mobile/utils/shareHandler.js.map
packages/app-mobile/utils/types.d.ts
packages/app-mobile/utils/types.js
packages/app-mobile/utils/types.js.map
packages/fork-htmlparser2/src/CollectingHandler.d.ts
packages/fork-htmlparser2/src/CollectingHandler.js
packages/fork-htmlparser2/src/CollectingHandler.js.map
@@ -1485,6 +1581,9 @@ packages/lib/services/interop/InteropService_Importer_Md_frontmatter.test.js.map
packages/lib/services/interop/InteropService_Importer_Raw.d.ts
packages/lib/services/interop/InteropService_Importer_Raw.js
packages/lib/services/interop/InteropService_Importer_Raw.js.map
packages/lib/services/interop/InteropService_Importer_Raw.test.d.ts
packages/lib/services/interop/InteropService_Importer_Raw.test.js
packages/lib/services/interop/InteropService_Importer_Raw.test.js.map
packages/lib/services/interop/types.d.ts
packages/lib/services/interop/types.js
packages/lib/services/interop/types.js.map
@@ -1593,6 +1692,12 @@ packages/lib/services/plugins/api/JoplinWorkspace.js.map
packages/lib/services/plugins/api/types.d.ts
packages/lib/services/plugins/api/types.js
packages/lib/services/plugins/api/types.js.map
packages/lib/services/plugins/defaultPlugins/defaultPluginsUtils.d.ts
packages/lib/services/plugins/defaultPlugins/defaultPluginsUtils.js
packages/lib/services/plugins/defaultPlugins/defaultPluginsUtils.js.map
packages/lib/services/plugins/defaultPlugins/desktopDefaultPluginsInfo.d.ts
packages/lib/services/plugins/defaultPlugins/desktopDefaultPluginsInfo.js
packages/lib/services/plugins/defaultPlugins/desktopDefaultPluginsInfo.js.map
packages/lib/services/plugins/reducer.d.ts
packages/lib/services/plugins/reducer.js
packages/lib/services/plugins/reducer.js.map
@@ -1899,6 +2004,60 @@ packages/lib/uuid.js.map
packages/lib/versionInfo.d.ts
packages/lib/versionInfo.js
packages/lib/versionInfo.js.map
packages/pdf-viewer/FullViewer.d.ts
packages/pdf-viewer/FullViewer.js
packages/pdf-viewer/FullViewer.js.map
packages/pdf-viewer/Page.d.ts
packages/pdf-viewer/Page.js
packages/pdf-viewer/Page.js.map
packages/pdf-viewer/PdfDocument.d.ts
packages/pdf-viewer/PdfDocument.js
packages/pdf-viewer/PdfDocument.js.map
packages/pdf-viewer/VerticalPages.d.ts
packages/pdf-viewer/VerticalPages.js
packages/pdf-viewer/VerticalPages.js.map
packages/pdf-viewer/hooks/useIsFocused.d.ts
packages/pdf-viewer/hooks/useIsFocused.js
packages/pdf-viewer/hooks/useIsFocused.js.map
packages/pdf-viewer/hooks/useIsVisible.d.ts
packages/pdf-viewer/hooks/useIsVisible.js
packages/pdf-viewer/hooks/useIsVisible.js.map
packages/pdf-viewer/hooks/usePdfDocument.d.ts
packages/pdf-viewer/hooks/usePdfDocument.js
packages/pdf-viewer/hooks/usePdfDocument.js.map
packages/pdf-viewer/hooks/useScaledSize.d.ts
packages/pdf-viewer/hooks/useScaledSize.js
packages/pdf-viewer/hooks/useScaledSize.js.map
packages/pdf-viewer/hooks/useScrollSaver.d.ts
packages/pdf-viewer/hooks/useScrollSaver.js
packages/pdf-viewer/hooks/useScrollSaver.js.map
packages/pdf-viewer/hooks/useVisibleOnSelect.d.ts
packages/pdf-viewer/hooks/useVisibleOnSelect.js
packages/pdf-viewer/hooks/useVisibleOnSelect.js.map
packages/pdf-viewer/main.d.ts
packages/pdf-viewer/main.js
packages/pdf-viewer/main.js.map
packages/pdf-viewer/messageService.d.ts
packages/pdf-viewer/messageService.js
packages/pdf-viewer/messageService.js.map
packages/pdf-viewer/miniViewer.d.ts
packages/pdf-viewer/miniViewer.js
packages/pdf-viewer/miniViewer.js.map
packages/pdf-viewer/pdfSource.test.d.ts
packages/pdf-viewer/pdfSource.test.js
packages/pdf-viewer/pdfSource.test.js.map
packages/pdf-viewer/types.d.ts
packages/pdf-viewer/types.js
packages/pdf-viewer/types.js.map
packages/pdf-viewer/ui/GotoPage.d.ts
packages/pdf-viewer/ui/GotoPage.js
packages/pdf-viewer/ui/GotoPage.js.map
packages/pdf-viewer/ui/IconButtons.d.ts
packages/pdf-viewer/ui/IconButtons.js
packages/pdf-viewer/ui/IconButtons.js.map
packages/pdf-viewer/ui/ZoomControls.d.ts
packages/pdf-viewer/ui/ZoomControls.js
packages/pdf-viewer/ui/ZoomControls.js.map
packages/plugin-repo-cli/commands/updateRelease.d.ts
packages/plugin-repo-cli/commands/updateRelease.js
packages/plugin-repo-cli/commands/updateRelease.js.map
@@ -2049,6 +2208,12 @@ packages/tools/buildServerDocker.js.map
packages/tools/buildServerDocker.test.d.ts
packages/tools/buildServerDocker.test.js
packages/tools/buildServerDocker.test.js.map
packages/tools/bundleDefaultPlugins.d.ts
packages/tools/bundleDefaultPlugins.js
packages/tools/bundleDefaultPlugins.js.map
packages/tools/bundleDefaultPlugins.test.d.ts
packages/tools/bundleDefaultPlugins.test.js
packages/tools/bundleDefaultPlugins.test.js.map
packages/tools/checkLibPaths.d.ts
packages/tools/checkLibPaths.js
packages/tools/checkLibPaths.js.map
@@ -2143,3 +2308,6 @@ packages/tools/website/utils/types.d.ts
packages/tools/website/utils/types.js
packages/tools/website/utils/types.js.map
# AUTO-GENERATED - EXCLUDED TYPESCRIPT BUILD
packages/app-mobile/components/get-responsive-value.test.js
packages/app-mobile/components/get-responsive-value.test.js
packages/app-mobile/components/get-responsive-value.test.js

View File

@@ -1,4 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Joplin]]></title><description><![CDATA[Joplin, the open source note-taking application]]></description><link>https://joplinapp.org</link><generator>RSS for Node</generator><lastBuildDate>Mon, 06 Jun 2022 00:00:00 GMT</lastBuildDate><atom:link href="https://joplinapp.org/rss.xml" rel="self" type="application/rss+xml"/><pubDate>Mon, 06 Jun 2022 00:00:00 GMT</pubDate><item><title><![CDATA[Joplin 2.8 is available!]]></title><description><![CDATA[<p>As always a lot of changes and new features in this new version available on both desktop and mobile.</p>
<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Joplin]]></title><description><![CDATA[Joplin, the open source note-taking application]]></description><link>https://joplinapp.org</link><generator>RSS for Node</generator><lastBuildDate>Tue, 06 Sep 2022 00:00:00 GMT</lastBuildDate><atom:link href="https://joplinapp.org/rss.xml" rel="self" type="application/rss+xml"/><pubDate>Tue, 06 Sep 2022 00:00:00 GMT</pubDate><item><title><![CDATA[Joplin interview on Website Planet]]></title><description><![CDATA[<p>Website Planet has recently conducted an interview about Joplin - it may give you some insight on the current status of the project, our priorities, and future plans! More on the article page - <a href="https://www.websiteplanet.com/blog/interview-joplin/">Organise Your Thoughts with Open Source Note-Taking App, Joplin</a></p>
]]></description><link>https://joplinapp.org/news/20220906-interview-websiteplanet/</link><guid isPermaLink="false">20220906-interview-websiteplanet</guid><pubDate>Tue, 06 Sep 2022 00:00:00 GMT</pubDate><twitter-text></twitter-text></item><item><title><![CDATA[Joplin first meetup on 30 August!]]></title><description><![CDATA[<p>We are glad to announce <a href="https://www.meetup.com/joplin/events/287611873/">the first Joplin Meetup</a> that will take place on 30 August 2022 in London!</p>
<p>This is an opportunity to meet other Joplin users as well as some of the main contributors, to discuss the apps, or to ask questions and exchange tips and tricks on how to use the app, develop plugins or contribute to the application. Everybody, technical or not, is welcome!</p>
<p>We will meet at the Old Thameside Inn next to London Bridge. If the weather allows we will be on the terrace outside, if not inside.</p>
<p>More information on the official Meetup page:</p>
<p><a href="https://www.meetup.com/joplin/events/287611873/">https://www.meetup.com/joplin/events/287611873/</a></p>
]]></description><link>https://joplinapp.org/news/20220808-first-meetup/</link><guid isPermaLink="false">20220808-first-meetup</guid><pubDate>Mon, 08 Aug 2022 00:00:00 GMT</pubDate><twitter-text>Joplin will have its first Meetup on 30 August! Come and join us at the Old Thameside Inn next to London Bridge! https://www.meetup.com/joplin/events/287611873/</twitter-text></item><item><title><![CDATA[Joplin 2.8 is available!]]></title><description><![CDATA[<p>As always a lot of changes and new features in this new version available on both desktop and mobile.</p>
<h1>Multiple profile support<a name="multiple-profile-support" href="#multiple-profile-support" class="heading-anchor">🔗</a></h1>
<p>Perhaps the most visible change in this version is the support for multiple profiles. You can now create as many application profile as you wish, each with their own settings, and easily switch from one to another. The main use case is to support for example a &quot;work&quot; profile and a &quot;personal&quot; profile, to allow you to keep things independent, and each profile can sync with a different sync target.</p>
<p>To create a new profile, open <strong>File &gt; Switch profile</strong> and select <strong>Create new profile</strong>, enter the profile name and press OK. The app will automatically switch to this new profile, which you can now configure.</p>
@@ -253,9 +259,4 @@
<p>Also many thanks to everyone who voted and contributed to the tagline discussion! It helped narrow down what the tagline should be, along with the equally important description below. If you have any question or notice any issue with the website let me know!</p>
]]></description><link>https://joplinapp.org/news/20210711-095626/</link><guid isPermaLink="false">20210711-095626</guid><pubDate>Sun, 11 Jul 2021 09:56:26 GMT</pubDate><twitter-text></twitter-text></item><item><title><![CDATA[Poll: What should Joplin tagline be?]]></title><description><![CDATA[<p>Thanks everyone for your tagline suggestions - there were lots of good ideas in there. I've compiled a few of them and create a poll in the forum, so please cast your vote! And if you have any other suggestions on what would make a good tagline, feel free to post over there or here.</p>
<p><a href="https://discourse.joplinapp.org/t/poll-what-should-joplin-tagline-be/18487">https://discourse.joplinapp.org/t/poll-what-should-joplin-tagline-be/18487</a></p>
]]></description><link>https://joplinapp.org/news/20210706-140228/</link><guid isPermaLink="false">20210706-140228</guid><pubDate>Tue, 06 Jul 2021 14:02:28 GMT</pubDate><twitter-text></twitter-text></item><item><title><![CDATA[Any ideas for a Joplin tagline?]]></title><description><![CDATA[<p>I'm going to update the website front page to better showcase the application. I have most of the sections right, but the part I'm still not sure about is the top tagline, so I'm wondering if anyone had any suggestion about it?</p>
<p>From what I can see on Google Keep or Evernote for example it should be something like &quot;Use our app to get X or Y benefit&quot;, it should be a sentence that directly speaks to the user essentially.</p>
<p>So far I have &quot;Your notes, anywhere you are&quot; but I'm not certain that's particularly inspiring. Any other idea about what tagline could be used?</p>
]]></description><link>https://joplinapp.org/news/20210705-094247/</link><guid isPermaLink="false">20210705-094247</guid><pubDate>Mon, 05 Jul 2021 09:42:47 GMT</pubDate><twitter-text></twitter-text></item><item><title><![CDATA[Poll: What's the size of your note collection?]]></title><description><![CDATA[<p>Poll is on the forum:</p>
<p><a href="https://discourse.joplinapp.org/t/poll-whats-the-size-of-your-note-collection/18191">https://discourse.joplinapp.org/t/poll-whats-the-size-of-your-note-collection/18191</a></p>
]]></description><link>https://joplinapp.org/news/20210624-171844/</link><guid isPermaLink="false">20210624-171844</guid><pubDate>Thu, 24 Jun 2021 17:18:44 GMT</pubDate><twitter-text></twitter-text></item></channel></rss>
]]></description><link>https://joplinapp.org/news/20210706-140228/</link><guid isPermaLink="false">20210706-140228</guid><pubDate>Tue, 06 Jul 2021 14:02:28 GMT</pubDate><twitter-text></twitter-text></item></channel></rss>

View File

@@ -116,16 +116,21 @@
});
};
const applyPeriod = (period) => {
subscriptionPeriod = period;
$('.plan-group').removeClass(period === 'monthly' ? 'plan-prices-yearly' : 'plan-prices-monthly');
$('.plan-group').addClass('plan-prices-' + period);
$("#pay-" + period + '-radio').prop('checked', true);
}
$(() => {
$("input[name='pay-radio']").change(function() {
const period = $("input[type='radio'][name='pay-radio']:checked").val();
subscriptionPeriod = period;
$('.plan-group').removeClass(period === 'monthly' ? 'plan-prices-yearly' : 'plan-prices-monthly');
$('.plan-group').addClass('plan-prices-' + period);
applyPeriod(period);
});
setupBetaHandling(urlQuery);
applyPeriod('yearly');
});
</script>
</div>

View File

@@ -31,7 +31,7 @@ Joplin is available in multiple languages thanks to the help of its users. You c
If you want to start contributing to the project's code, please follow these guidelines before creating a pull request:
- Explain WHY you want to add this change. Explain it inside the pull request and you may link to an issue for additional information, but the PR should give a clear overview of why you want to add this.
- The top post of the pull request should contain a full, self-contained explanation of the feature: what it does, how it does it, with examples of usage and screenshots. Also explain why you want to add this - what problem does it solve. Do not simply add a text `Implement feature #4345` or link to forum posts, because the information there will most likely be outdated or confusing (multiple discussions and opinions). The pull request needs to be self-contained.
- Bug fixes are always welcome. Start by reviewing the [list of bugs](https://github.com/laurent22/joplin/issues?q=is%3Aissue+is%3Aopen+label%3Abug)
- A good way to easily start contributing is to pick and work on a [good first issue](https://github.com/laurent22/joplin/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22). We try to make these issues as clear as possible and provide basic info on how the code should be changed, and if something is unclear feel free to ask for more information on the issue.
- Before adding a new feature, ask about it in the [Github Issue Tracker](https://github.com/laurent22/joplin/issues?utf8=%E2%9C%93&q=is%3Aissue) or the [Joplin Forum](https://discourse.joplinapp.org/), or check if existing discussions exist to make sure the new functionality is desired.

View File

@@ -4,6 +4,10 @@
* * *
Joplin will have [its first Meetup on 30 August 2022](https://discourse.joplinapp.org/t/joplin-first-meetup-on-30-august/26808)! Come and join us at the Old Thameside Inn next to London Bridge!
* * *
🌞 Joplin participates in **Google Summer of Code 2022**! More info on [the announcement post](https://github.com/laurent22/joplin/blob/dev/readme/news/20220308-gsoc2022-start.md). 🌞
* * *
@@ -83,8 +87,9 @@ A community maintained list of these distributions can be found here: [Unofficia
| <img width="50" src="https://avatars2.githubusercontent.com/u/1439535?s=96&v=4"/></br>[fbloise](https://github.com/fbloise) | <img width="50" src="https://avatars2.githubusercontent.com/u/49439044?s=96&v=4"/></br>[fourstepper](https://github.com/fourstepper) | <img width="50" src="https://avatars2.githubusercontent.com/u/38898566?s=96&v=4"/></br>[h4sh5](https://github.com/h4sh5) | <img width="50" src="https://avatars2.githubusercontent.com/u/3266447?s=96&v=4"/></br>[iamwillbar](https://github.com/iamwillbar) |
| <img width="50" src="https://avatars2.githubusercontent.com/u/37297218?s=96&v=4"/></br>[Jesssullivan](https://github.com/Jesssullivan) | <img width="50" src="https://avatars2.githubusercontent.com/u/1248504?s=96&v=4"/></br>[joesfer](https://github.com/joesfer) | <img width="50" src="https://avatars2.githubusercontent.com/u/5588131?s=96&v=4"/></br>[kianenigma](https://github.com/kianenigma) | <img width="50" src="https://avatars2.githubusercontent.com/u/24908652?s=96&v=4"/></br>[konishi-t](https://github.com/konishi-t) |
| <img width="50" src="https://avatars2.githubusercontent.com/u/42319182?s=96&v=4"/></br>[marcdw1289](https://github.com/marcdw1289) | <img width="50" src="https://avatars2.githubusercontent.com/u/1788010?s=96&v=4"/></br>[maxtruxa](https://github.com/maxtruxa) | <img width="50" src="https://avatars2.githubusercontent.com/u/29300939?s=96&v=4"/></br>[mcejp](https://github.com/mcejp) | <img width="50" src="https://avatars2.githubusercontent.com/u/1168659?s=96&v=4"/></br>[nicholashead](https://github.com/nicholashead) |
| <img width="50" src="https://avatars2.githubusercontent.com/u/5782817?s=96&v=4"/></br>[piccobit](https://github.com/piccobit) | <img width="50" src="https://avatars2.githubusercontent.com/u/77214738?s=96&v=4"/></br>[Polymathic-Company](https://github.com/Polymathic-Company) | <img width="50" src="https://avatars2.githubusercontent.com/u/47742?s=96&v=4"/></br>[ravenscroftj](https://github.com/ravenscroftj) | <img width="50" src="https://avatars2.githubusercontent.com/u/765564?s=96&v=4"/></br>[taskcruncher](https://github.com/taskcruncher) |
| <img width="50" src="https://avatars2.githubusercontent.com/u/73081837?s=96&v=4"/></br>[thismarty](https://github.com/thismarty) | <img width="50" src="https://avatars2.githubusercontent.com/u/15859362?s=96&v=4"/></br>[thomasbroussard](https://github.com/thomasbroussard) | | |
| <img width="50" src="https://avatars2.githubusercontent.com/u/5782817?s=96&v=4"/></br>[piccobit](https://github.com/piccobit) | <img width="50" src="https://avatars2.githubusercontent.com/u/77214738?s=96&v=4"/></br>[Polymathic-Company](https://github.com/Polymathic-Company) | <img width="50" src="https://avatars2.githubusercontent.com/u/47742?s=96&v=4"/></br>[ravenscroftj](https://github.com/ravenscroftj) | <img width="50" src="https://avatars2.githubusercontent.com/u/327998?s=96&v=4"/></br>[sif](https://github.com/sif) |
| <img width="50" src="https://avatars2.githubusercontent.com/u/54626606?s=96&v=4"/></br>[skyrunner15](https://github.com/skyrunner15) | <img width="50" src="https://avatars2.githubusercontent.com/u/765564?s=96&v=4"/></br>[taskcruncher](https://github.com/taskcruncher) | <img width="50" src="https://avatars2.githubusercontent.com/u/73081837?s=96&v=4"/></br>[thismarty](https://github.com/thismarty) | <img width="50" src="https://avatars2.githubusercontent.com/u/15859362?s=96&v=4"/></br>[thomasbroussard](https://github.com/thomasbroussard) |
| | | | |
<!-- SPONSORS-GITHUB -->
<!-- TOC -->
@@ -341,8 +346,8 @@ If you provide a configuration and you receive "success!" on the "check config"
- Force Path Style: unchecked
### Linode
- URL: https://<region>.linodeobjects.com
- Region: empty
- URL: https://regionName.linodeobjects.com (regionName is the region on the URL provided by Linode; this URL is also the same as the URL provided by Linode with the bucket name removed)
- Region: Anything you want to type, can't be left empty
- Force Path Style: unchecked
### UpCloud

View File

@@ -9,3 +9,7 @@ Only the latest version is supported with security updates.
Please [contact support](https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/AdresseSupport.png) **with a proof of concept** that shows the security vulnerability. Please do not contact us without this proof of concept, as we cannot fix anything without this.
For general opinions on what makes an app more or less secure, please use the forum.
## Bounty
We **do not** offer a bounty for discovering vulnerabilities, please do not ask. We can however credit you and link to your website in the changelog and release announcement.

View File

@@ -12,8 +12,8 @@
"node": ">=16"
},
"scripts": {
"buildParallel": "yarn workspaces foreach --verbose --interlaced --parallel --jobs 2 run build && yarn run tsc",
"buildSequential": "yarn workspaces foreach --verbose --interlaced run build && yarn run tsc",
"buildParallel": "yarn workspaces foreach --verbose --interlaced --parallel --jobs 2 --topological run build && yarn run tsc",
"buildSequential": "yarn workspaces foreach --verbose --interlaced --topological run build && yarn run tsc",
"buildApiDoc": "yarn workspace joplin start apidoc ../../readme/api/references/rest_api.md",
"buildCommandIndex": "gulp buildCommandIndex",
"buildPluginDoc": "typedoc --name 'Joplin Plugin API Documentation' --mode file -theme './Assets/PluginDocTheme/' --readme './Assets/PluginDocTheme/index.md' --excludeNotExported --excludeExternals --excludePrivate --excludeProtected --out ../joplin-website/docs/api/references/plugin_api packages/lib/services/plugins/api/",
@@ -31,6 +31,7 @@
"linter-ci": "eslint --resolve-plugins-relative-to . --quiet --ext .js --ext .jsx --ext .ts --ext .tsx",
"linter-precommit": "eslint --resolve-plugins-relative-to . --fix --ext .js --ext .jsx --ext .ts --ext .tsx",
"linter": "eslint --resolve-plugins-relative-to . --fix --quiet --ext .js --ext .jsx --ext .ts --ext .tsx",
"linter-interactive": "eslint-interactive --resolve-plugins-relative-to . --fix --quiet --ext .js --ext .jsx --ext .ts --ext .tsx",
"postinstall": "gulp build",
"publishAll": "git pull && yarn run buildParallel && lerna version --yes --no-private --no-git-tag-version && gulp completePublishAll",
"releaseAndroid": "PATH=\"/usr/local/opt/openjdk@11/bin:$PATH\" node packages/tools/release-android.js",
@@ -61,13 +62,15 @@
}
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^4.6.0",
"@typescript-eslint/parser": "^4.6.0",
"@seiyab/eslint-plugin-react-hooks": "^4.5.1-alpha.5",
"@typescript-eslint/eslint-plugin": "^5.33.1",
"@typescript-eslint/parser": "^5.33.1",
"cspell": "^5.20.0",
"eslint": "^7.6.0",
"eslint-plugin-import": "^2.20.2",
"eslint-plugin-react": "^7.18.0",
"eslint-plugin-react-hooks": "^2.4.0",
"eslint": "^8.22.0",
"eslint-interactive": "^10.0.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-promise": "^6.0.1",
"eslint-plugin-react": "^7.30.1",
"fs-extra": "^8.1.0",
"glob": "^7.1.6",
"gulp": "^4.0.2",
@@ -76,9 +79,10 @@
"lint-staged": "^9.2.1",
"madge": "^4.0.2",
"typedoc": "^0.17.8",
"typescript": "4.0.5"
"typescript": "4.7.4"
},
"dependencies": {
"@types/fs-extra": "^9.0.13",
"http-server": "^0.12.3",
"node-gyp": "^8.4.1",
"nodemon": "^2.0.9"

View File

@@ -375,6 +375,11 @@ class AppGui {
this.showNoteMetadata(!this.widget('noteMetadata').shown);
}
toggleFolderIds() {
this.widget('folderList').toggleShowIds();
this.widget('noteList').toggleShowIds();
}
widget(name) {
if (name === 'root') return this.rootWidget_;
return this.rootWidget_.childByName(name);
@@ -498,6 +503,8 @@ class AppGui {
}
} else if (cmd === 'toggle_metadata') {
this.toggleNoteMetadata();
} else if (cmd === 'toggle_ids') {
this.toggleFolderIds();
} else if (cmd === 'enter_command_line_mode') {
const cmd = await this.widget('statusBar').prompt();
if (!cmd) return;

View File

@@ -332,6 +332,7 @@ class Application extends BaseApplication {
{ keys: [' '], command: 'todo toggle $n' },
{ keys: ['tc'], type: 'function', command: 'toggle_console' },
{ keys: ['tm'], type: 'function', command: 'toggle_metadata' },
{ keys: ['ti'], type: 'function', command: 'toggle_ids' },
{ keys: ['/'], type: 'prompt', command: 'search ""', cursorPosition: -2 },
{ keys: ['mn'], type: 'prompt', command: 'mknote ""', cursorPosition: -2 },
{ keys: ['mt'], type: 'prompt', command: 'mktodo ""', cursorPosition: -2 },

View File

@@ -124,6 +124,7 @@ async function handleAutocompletionPromise(line) {
return line;
}
function handleAutocompletion(str, callback) {
// eslint-disable-next-line promise/prefer-await-to-then -- Old code before rule was applied
handleAutocompletionPromise(str).then(function(res) {
callback(undefined, res);
});

View File

@@ -7,25 +7,45 @@ const Note = require('@joplin/lib/models/Note').default;
class Command extends BaseCommand {
usage() {
return 'mv <note> [notebook]';
return 'mv <item> [notebook]';
}
description() {
return _('Moves the notes matching <note> to [notebook].');
return _('Moves the given <item> (notes matching pattern in current notebook or one notebook) to [notebook]. If <item> is subnotebook and [notebook] is "root", will make <item> parent notebook');
}
async action(args) {
const pattern = args['note'];
const pattern = args['item'];
const destination = args['notebook'];
let folder = null;
const folder = await Folder.loadByField('title', destination);
if (!folder) throw new Error(_('Cannot find "%s".', destination));
if (destination !== 'root') {
folder = await app().loadItem(BaseModel.TYPE_FOLDER, destination);
if (!folder) throw new Error(_('Cannot find "%s".', destination));
}
const notes = await app().loadItems(BaseModel.TYPE_NOTE, pattern);
if (!notes.length) throw new Error(_('Cannot find "%s".', pattern));
const destinationDuplicates = await Folder.search({ titlePattern: destination, limit: 2 });
if (destinationDuplicates.length > 1) {
throw new Error(_('Ambiguous notebook "%s". Please use short notebook id instead - press "ti" to see the short notebook id' , destination));
}
for (let i = 0; i < notes.length; i++) {
await Note.moveToFolder(notes[i].id, folder.id);
const itemFolder = await app().loadItem(BaseModel.TYPE_FOLDER, pattern);
if (itemFolder) {
const sourceDuplicates = await Folder.search({ titlePattern: pattern, limit: 2 });
if (sourceDuplicates.length > 1) {
throw new Error(_('Ambiguous notebook "%s". Please use notebook id instead - press "ti" to see the short notebook id or use $b for current selected notebook', pattern));
}
if (destination === 'root') {
await Folder.moveToFolder(itemFolder.id, '');
} else {
await Folder.moveToFolder(itemFolder.id, folder.id);
}
} else {
const notes = await app().loadItems(BaseModel.TYPE_NOTE, pattern);
if (notes.length === 0) throw new Error(_('Cannot find "%s".', pattern));
for (let i = 0; i < notes.length; i++) {
await Note.moveToFolder(notes[i].id, folder.id);
}
}
}
}

View File

@@ -36,6 +36,7 @@ async function createClients() {
const client = createClient(clientId);
promises.push(fs.remove(client.profileDir));
promises.push(
// eslint-disable-next-line promise/prefer-await-to-then -- Old code before rule was applied
execCommand(client, 'config sync.target 2').then(() => {
return execCommand(client, `config sync.2.path ${syncDir}`);
})
@@ -2324,10 +2325,12 @@ async function main() {
clients[clientId].activeCommandCount++;
execRandomCommand(clients[clientId])
// eslint-disable-next-line promise/prefer-await-to-then -- Old code before rule was applied
.catch(error => {
logger.info(`Client ${clientId}:`);
logger.error(error);
})
// eslint-disable-next-line promise/prefer-await-to-then -- Old code before rule was applied
.then(r => {
if (r) {
logger.info(`Client ${clientId}:\n${r.trim()}`);

View File

@@ -19,13 +19,20 @@ class FolderListWidget extends ListWidget {
this.updateIndexFromSelectedFolderId_ = false;
this.updateItems_ = false;
this.trimItemTitle = false;
this.showIds = false;
this.itemRenderer = item => {
const output = [];
if (item === '-') {
output.push('-'.repeat(this.innerWidth));
} else if (item.type_ === Folder.modelType()) {
output.push(' '.repeat(this.folderDepth(this.folders, item.id)) + Folder.displayTitle(item));
output.push(' '.repeat(this.folderDepth(this.folders, item.id)));
if (this.showIds) {
output.push(Folder.shortId(item.id));
}
output.push(Folder.displayTitle(item));
if (Setting.value('showNoteCounts')) {
let noteCount = item.note_count;
// Subtract children note_count from parent folder.
@@ -132,6 +139,11 @@ class FolderListWidget extends ListWidget {
this.invalidate();
}
toggleShowIds() {
this.showIds = !this.showIds;
this.invalidate();
}
folderHasChildren_(folders, folderId) {
for (let i = 0; i < folders.length; i++) {
const folder = folders[i];

View File

@@ -5,11 +5,15 @@ class NoteListWidget extends ListWidget {
constructor() {
super();
this.selectedNoteId_ = 0;
this.showIds = false;
this.updateIndexFromSelectedNoteId_ = false;
this.itemRenderer = note => {
let label = Note.displayTitle(note); // + ' ' + note.id;
let label = Note.displayTitle(note);
if (this.showIds) {
label = `${Note.shortId(note.id)} ${Note.displayTitle(note)}`;
}
if (note.is_todo) {
label = `[${note.todo_completed ? 'X' : ' '}] ${label}`;
}
@@ -22,6 +26,11 @@ class NoteListWidget extends ListWidget {
this.selectedNoteId_ = v;
}
toggleShowIds() {
this.showIds = !this.showIds;
this.invalidate();
}
render() {
if (this.updateIndexFromSelectedNoteId_) {
const index = this.itemIndexByKey('id', this.selectedNoteId_);

View File

@@ -50,6 +50,7 @@ export default class PluginRunner extends BasePluginRunner {
const callId = `${pluginId}::${path}::${uuid.createNano()}`;
this.activeSandboxCalls_[callId] = true;
const promise = executeSandboxCall(pluginId, sandbox, `joplin.${path}`, mapEventHandlersToIds(args, this.eventHandlers_), this.eventHandler);
// eslint-disable-next-line promise/prefer-await-to-then -- Old code before rule was applied
promise.finally(() => {
delete this.activeSandboxCalls_[callId];
});

View File

@@ -33,7 +33,7 @@
],
"owner": "Laurent Cozic"
},
"version": "2.9.0",
"version": "2.9.1",
"bin": "./main.js",
"engines": {
"node": ">=10.0.0"

View File

@@ -0,0 +1,269 @@
import { installDefaultPlugins, getDefaultPluginsInstallState, setSettingsForDefaultPlugins, checkPreInstalledDefaultPlugins } from '@joplin/lib/services/plugins/defaultPlugins/defaultPluginsUtils';
import PluginRunner from '../../../app/services/plugins/PluginRunner';
import { pathExists } from 'fs-extra';
import { checkThrow, setupDatabaseAndSynchronizer, supportDir, switchClient } from '@joplin/lib/testing/test-utils';
import PluginService, { defaultPluginSetting, DefaultPluginsInfo, PluginSettings } from '@joplin/lib/services/plugins/PluginService';
import Setting from '@joplin/lib/models/Setting';
const testPluginDir = `${supportDir}/plugins`;
function newPluginService(appVersion: string = '1.4') {
const runner = new PluginRunner();
const service = new PluginService();
service.initialize(
appVersion,
{
joplin: {},
},
runner,
{
dispatch: () => {},
getState: () => {},
}
);
return service;
}
describe('defaultPluginsUtils', function() {
const pluginsId = ['joplin.plugin.ambrt.backlinksToNote', 'org.joplinapp.plugins.ToggleSidebars'];
beforeEach(async (done) => {
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
done();
});
it('should install default plugins with no previous default plugins installed', (async () => {
const testPluginDir = `${supportDir}/pluginRepo/plugins`;
Setting.setValue('installedDefaultPlugins', []);
const service = newPluginService('2.1');
const pluginSettings = service.unserializePluginSettings(Setting.value('plugins.states'));
const newPluginsSettings = await installDefaultPlugins(service, testPluginDir, pluginsId, pluginSettings);
const installedPluginPath1 = `${Setting.value('pluginDir')}/${pluginsId[0]}.jpl`;
const installedPluginPath2 = `${Setting.value('pluginDir')}/${pluginsId[1]}.jpl`;
expect(await pathExists(installedPluginPath1)).toBe(true);
expect(await pathExists(installedPluginPath2)).toBe(true);
expect(newPluginsSettings[pluginsId[0]]).toMatchObject(defaultPluginSetting());
expect(newPluginsSettings[pluginsId[1]]).toMatchObject(defaultPluginSetting());
}));
it('should install default plugins with previous default plugins installed', (async () => {
const testPluginDir = `${supportDir}/pluginRepo/plugins`;
Setting.setValue('installedDefaultPlugins', ['org.joplinapp.plugins.ToggleSidebars']);
const service = newPluginService('2.1');
const pluginSettings = service.unserializePluginSettings(Setting.value('plugins.states'));
const newPluginsSettings = await installDefaultPlugins(service, testPluginDir, pluginsId, pluginSettings);
const installedPluginPath1 = `${Setting.value('pluginDir')}/${pluginsId[0]}.jpl`;
const installedPluginPath2 = `${Setting.value('pluginDir')}/${pluginsId[1]}.jpl`;
expect(await pathExists(installedPluginPath1)).toBe(true);
expect(await pathExists(installedPluginPath2)).toBe(false);
expect(newPluginsSettings[pluginsId[0]]).toMatchObject(defaultPluginSetting());
expect(newPluginsSettings[pluginsId[1]]).toBeUndefined();
}));
it('should get default plugins install state', (async () => {
const testCases = [
{
'installedDefaultPlugins': [''],
'loadingPlugins': [`${testPluginDir}/simple`, `${testPluginDir}/jpl_test/org.joplinapp.FirstJplPlugin.jpl`],
'plugin1DefaultState': defaultPluginSetting(),
'plugin2DefaultState': defaultPluginSetting(),
'installedDefaultPlugins1': true,
'installedDefaultPlugins2': true,
},
{
'installedDefaultPlugins': [''],
'loadingPlugins': [`${testPluginDir}/simple`],
'plugin1DefaultState': defaultPluginSetting(),
'plugin2DefaultState': undefined,
'installedDefaultPlugins1': true,
'installedDefaultPlugins2': false,
},
{
'installedDefaultPlugins': ['org.joplinapp.plugins.Simple'],
'loadingPlugins': [`${testPluginDir}/simple`, `${testPluginDir}/jpl_test/org.joplinapp.FirstJplPlugin.jpl`],
'plugin1DefaultState': undefined,
'plugin2DefaultState': defaultPluginSetting(),
'installedDefaultPlugins1': true,
'installedDefaultPlugins2': true,
},
{
'installedDefaultPlugins': ['org.joplinapp.plugins.Simple'],
'loadingPlugins': [`${testPluginDir}/simple`],
'plugin1DefaultState': undefined,
'plugin2DefaultState': undefined,
'installedDefaultPlugins1': true,
'installedDefaultPlugins2': false,
},
];
for (const testCase of testCases) {
const service = newPluginService();
const pluginsId = ['org.joplinapp.plugins.Simple', 'org.joplinapp.FirstJplPlugin'];
Setting.setValue('installedDefaultPlugins', testCase.installedDefaultPlugins);
await service.loadAndRunPlugins(testCase.loadingPlugins, {});
// setting installedDefaultPlugins state
const defaultInstallStates: PluginSettings = getDefaultPluginsInstallState(service, pluginsId);
expect(defaultInstallStates[pluginsId[0]]).toStrictEqual(testCase.plugin1DefaultState);
expect(defaultInstallStates[pluginsId[1]]).toStrictEqual(testCase.plugin2DefaultState);
const installedDefaultPlugins = Setting.value('installedDefaultPlugins');
expect(installedDefaultPlugins.includes(pluginsId[0])).toBe(testCase.installedDefaultPlugins1);
expect(installedDefaultPlugins.includes(pluginsId[1])).toBe(testCase.installedDefaultPlugins2);
}
}));
it('should check pre-installed default plugins', (async () => {
// with previous pre-installed default plugins
Setting.setValue('installedDefaultPlugins', ['']);
let pluginSettings, installedDefaultPlugins;
pluginSettings = { [pluginsId[0]]: defaultPluginSetting() };
checkPreInstalledDefaultPlugins(pluginsId, pluginSettings);
installedDefaultPlugins = Setting.value('installedDefaultPlugins');
expect(installedDefaultPlugins.includes(pluginsId[0])).toBe(true);
expect(installedDefaultPlugins.includes(pluginsId[1])).toBe(false);
// with no previous pre-installed default plugins
Setting.setValue('installedDefaultPlugins', ['not-a-default-plugin']);
pluginSettings = {};
checkPreInstalledDefaultPlugins(pluginsId, pluginSettings);
installedDefaultPlugins = Setting.value('installedDefaultPlugins');
expect(installedDefaultPlugins.includes(pluginsId[0])).toBe(false);
expect(installedDefaultPlugins.includes(pluginsId[1])).toBe(false);
}));
it('should set initial settings for default plugins', async () => {
const service = newPluginService();
const pluginScript = `
/* joplin-manifest:
{
"id": "io.github.jackgruber.backup",
"manifest_version": 1,
"app_min_version": "1.4",
"name": "JS Bundle test",
"version": "1.0.0"
}
*/
joplin.plugins.register({
onStart: async function() {
await joplin.settings.registerSettings({
path: {
value: "initial-path",
type: 2,
section: "backupSection",
public: true,
label: "Backup path",
},
})
},
});`;
const plugin = await service.loadPluginFromJsBundle('', pluginScript);
await service.runPlugin(plugin);
const defaultPluginsInfo: DefaultPluginsInfo = {
'io.github.jackgruber.backup': {
version: '1.0.2',
settings: {
'path': `${Setting.value('profileDir')}`,
},
},
'plugin.calebjohn.rich-markdown': {
version: '0.8.3',
},
};
// with pre-installed default plugin
Setting.setValue('installedDefaultPlugins', ['io.github.jackgruber.backup']);
setSettingsForDefaultPlugins(defaultPluginsInfo);
expect(Setting.value('plugin-io.github.jackgruber.backup.path')).toBe('initial-path');
await service.destroy();
// with no pre-installed default plugin
Setting.setValue('installedDefaultPlugins', ['']);
setSettingsForDefaultPlugins(defaultPluginsInfo);
expect(Setting.value('plugin-io.github.jackgruber.backup.path')).toBe(`${Setting.value('profileDir')}`);
await service.destroy();
});
it('should not throw error on missing setting key', async () => {
const service = newPluginService();
const pluginScript = `
/* joplin-manifest:
{
"id": "io.github.jackgruber.backup",
"manifest_version": 1,
"app_min_version": "1.4",
"name": "JS Bundle test",
"version": "1.0.0"
}
*/
joplin.plugins.register({
onStart: async function() {
await joplin.settings.registerSettings({
path: {
value: "initial-path",
type: 2,
section: "backupSection",
public: true,
label: "Backup path",
},
})
},
});`;
const plugin = await service.loadPluginFromJsBundle('', pluginScript);
await service.runPlugin(plugin);
const defaultPluginsInfo: DefaultPluginsInfo = {
'io.github.jackgruber.backup': {
version: '1.0.2',
settings: {
'path': `${Setting.value('profileDir')}`,
'missing-key1': 'someValue',
},
},
'plugin.calebjohn.rich-markdown': {
version: '0.8.3',
settings: {
'missing-key2': 'someValue',
},
},
};
Setting.setValue('installedDefaultPlugins', ['']);
expect(checkThrow(() => setSettingsForDefaultPlugins(defaultPluginsInfo))).toBe(false);
expect(Setting.value('plugin-io.github.jackgruber.backup.path')).toBe(`${Setting.value('profileDir')}`);
await service.destroy();
});
});

View File

@@ -43,6 +43,7 @@ import sidebarCommands from './gui/Sidebar/commands/index';
import appCommands from './commands/index';
import libCommands from '@joplin/lib/commands/index';
import { homedir } from 'os';
import getDefaultPluginsInfo from '@joplin/lib/services/plugins/defaultPlugins/desktopDefaultPluginsInfo';
const electronContextMenu = require('./services/electron-context-menu');
// import populateDatabase from '@joplin/lib/services/debug/populateDatabase';
@@ -63,6 +64,8 @@ import checkForUpdates from './checkForUpdates';
import { AppState } from './app.reducer';
import syncDebugLog from '@joplin/lib/services/synchronizer/syncDebugLog';
import eventManager from '@joplin/lib/eventManager';
import path = require('path');
import { checkPreInstalledDefaultPlugins, installDefaultPlugins, setSettingsForDefaultPlugins } from '@joplin/lib/services/plugins/defaultPlugins/defaultPluginsUtils';
// import { runIntegrationTests } from '@joplin/lib/services/e2ee/ppkTestUtils';
const pluginClasses = [
@@ -260,9 +263,9 @@ class Application extends BaseApplication {
const pluginRunner = new PluginRunner();
service.initialize(packageInfo.version, PlatformImplementation.instance(), pluginRunner, this.store());
service.isSafeMode = Setting.value('isSafeMode');
const defaultPluginsId = Object.keys(getDefaultPluginsInfo());
const pluginSettings = service.unserializePluginSettings(Setting.value('plugins.states'));
let pluginSettings = service.unserializePluginSettings(Setting.value('plugins.states'));
{
// Users can add and remove plugins from the config screen at any
// time, however we only effectively uninstall the plugin the next
@@ -272,7 +275,11 @@ class Application extends BaseApplication {
Setting.setValue('plugins.states', newSettings);
}
checkPreInstalledDefaultPlugins(defaultPluginsId, pluginSettings);
try {
const defaultPluginsDir = path.join(bridge().buildDir(), 'defaultPlugins');
pluginSettings = await installDefaultPlugins(service, defaultPluginsDir, defaultPluginsId, pluginSettings);
if (await shim.fsDriver().exists(Setting.value('pluginDir'))) {
await service.loadAndRunPlugins(Setting.value('pluginDir'), pluginSettings);
}
@@ -320,6 +327,7 @@ class Application extends BaseApplication {
type: 'STARTUP_PLUGINS_LOADED',
value: true,
});
setSettingsForDefaultPlugins(getDefaultPluginsInfo());
}
}, 500);
}
@@ -494,6 +502,7 @@ class Application extends BaseApplication {
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.

View File

@@ -246,7 +246,7 @@ export class Bridge {
}
async openItem(fullPath: string) {
return require('electron').shell.openPath(fullPath);
return require('electron').shell.openPath(toSystemSlashes(fullPath));
}
screen() {

View File

@@ -45,6 +45,7 @@ class ClipperConfigScreenComponent extends React.Component {
if (confirm(_('Are you sure you want to renew the authorisation token?'))) {
void EncryptionService.instance()
.generateApiToken()
// eslint-disable-next-line promise/prefer-await-to-then -- Old code before rule was applied
.then((token) => {
Setting.setValue('api.token', token);
});

View File

@@ -15,6 +15,9 @@ import SyncTargetRegistry from '@joplin/lib/SyncTargetRegistry';
const shared = require('@joplin/lib/components/shared/config-shared.js');
import ClipperConfigScreen from '../ClipperConfigScreen';
import restart from '../../services/restart';
import PluginService from '@joplin/lib/services/plugins/PluginService';
import { getDefaultPluginsInstallState, updateDefaultPluginsInstallState } from '@joplin/lib/services/plugins/defaultPlugins/defaultPluginsUtils';
import getDefaultPluginsInfo from '@joplin/lib/services/plugins/defaultPlugins/desktopDefaultPluginsInfo';
const { KeymapConfigScreen } = require('../KeymapConfig/KeymapConfigScreen');
const settingKeyToControl: any = {
@@ -66,6 +69,7 @@ class ConfigScreenComponent extends React.Component<any, any> {
this.switchSection(this.props.defaultSection);
});
}
updateDefaultPluginsInstallState(getDefaultPluginsInstallState(PluginService.instance(), Object.keys(getDefaultPluginsInfo())), this);
}
private async handleSettingButton(key: string) {
@@ -484,13 +488,19 @@ class ConfigScreenComponent extends React.Component<any, any> {
} else {
const paths = await bridge().showOpenDialog();
if (!paths || !paths.length) return;
const cmd = splitCmd(this.state.settings[key]);
cmd[0] = paths[0];
updateSettingValue(key, joinCmd(cmd));
if (md.subType === 'file_path') {
updateSettingValue(key, paths[0]);
} else {
const cmd = splitCmd(this.state.settings[key]);
cmd[0] = paths[0];
updateSettingValue(key, joinCmd(cmd));
}
}
};
const cmd = splitCmd(this.state.settings[key]);
const path = md.subType === 'file_path_and_args' ? cmd[0] : this.state.settings[key];
const argComp = md.subType !== 'file_path_and_args' ? null : (
<div style={{ ...rowStyle, marginBottom: 5 }}>
@@ -526,7 +536,7 @@ class ConfigScreenComponent extends React.Component<any, any> {
onChange={(event: any) => {
onPathChange(event);
}}
value={cmd[0]}
value={path}
spellCheck={false}
/>
<Button

View File

@@ -101,6 +101,7 @@ export default function(props: Props) {
const pluginSettings = useMemo(() => {
return pluginService.unserializePluginSettings(props.value);
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [props.value]);
const pluginItems = usePluginItems(pluginService.plugins, pluginSettings);
@@ -167,6 +168,7 @@ export default function(props: Props) {
});
props.onChange({ value: pluginService.serializePluginSettings(newSettings) });
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [pluginSettings, props.onChange]);
const onToggle = useCallback((event: ItemEvent) => {
@@ -178,6 +180,7 @@ export default function(props: Props) {
});
props.onChange({ value: pluginService.serializePluginSettings(newSettings) });
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [pluginSettings, props.onChange]);
const onInstall = useCallback(async () => {
@@ -195,6 +198,7 @@ export default function(props: Props) {
});
props.onChange({ value: pluginService.serializePluginSettings(newSettings) });
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [pluginSettings, props.onChange]);
const onBrowsePlugins = useCallback(() => {
@@ -203,6 +207,7 @@ export default function(props: Props) {
const onPluginSettingsChange = useCallback((event: OnPluginSettingChangeEvent) => {
props.onChange({ value: pluginService.serializePluginSettings(event.value) });
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, []);
const onUpdate = useOnInstallHandler(setUpdatingPluginIds, pluginSettings, repoApi, onPluginSettingsChange, true);
@@ -229,6 +234,7 @@ export default function(props: Props) {
const onSearchPluginSettingsChange = useCallback((event: any) => {
props.onChange({ value: pluginService.serializePluginSettings(event.value) });
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [props.onChange]);
function renderCells(items: PluginItem[]) {

View File

@@ -60,6 +60,7 @@ export default function(props: Props) {
setSearchResultCount(r.length);
}
});
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [props.searchQuery]);
const onChange = useCallback((event: OnChangeEvent) => {
@@ -70,6 +71,7 @@ export default function(props: Props) {
const onSearchButtonClick = useCallback(() => {
setSearchStarted(false);
props.onSearchQueryChange({ value: '' });
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, []);
function installState(pluginId: string): InstallState {

View File

@@ -57,5 +57,6 @@ export default function(setInstallingPluginIds: Function, pluginSettings: Plugin
});
if (installError) alert(_('Could not install plugin: %s', installError.message));
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [pluginSettings, onPluginSettingsChange]);
}

View File

@@ -16,8 +16,10 @@ export default (props: Props) => {
globalKeydownHandlersRef.current.push(elementId);
return () => {
const idx = globalKeydownHandlersRef.current.findIndex(e => e === elementId);
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
globalKeydownHandlersRef.current.splice(idx, 1);
};
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, []);
const isTopDialog = () => {
@@ -49,6 +51,7 @@ export default (props: Props) => {
} else if (event.keyCode === 27) {
props.onCancelButtonClick();
}
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [props.onOkButtonClick, props.onCancelButtonClick]);
useEffect(() => {

View File

@@ -81,6 +81,7 @@ export default function(props: Props) {
return;
}
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [onClose, folderTitle, folderIcon, props.folderId, props.parentId]);
const onFolderTitleChange = useCallback((event: any) => {

View File

@@ -2,15 +2,19 @@ import { FolderIcon, FolderIconType } from '@joplin/lib/services/database/types'
interface Props {
folderIcon: FolderIcon;
opacity?: number;
}
export default function(props: Props) {
const folderIcon = props.folderIcon;
const opacity = 'opacity' in props ? props.opacity : 1;
if (folderIcon.type === FolderIconType.Emoji) {
return <span style={{ fontSize: 20 }}>{folderIcon.emoji}</span>;
return <span style={{ fontSize: 20, opacity }}>{folderIcon.emoji}</span>;
} else if (folderIcon.type === FolderIconType.DataUrl) {
return <img style={{ width: 20, height: 20 }} src={folderIcon.dataUrl} />;
return <img style={{ width: 20, height: 20, opacity }} src={folderIcon.dataUrl} />;
} else if (folderIcon.type === FolderIconType.FontAwesome) {
return <i style={{ fontSize: 18, width: 20, opacity }} className={folderIcon.name}></i>;
} else {
throw new Error(`Unsupported folder icon type: ${folderIcon.type}`);
}

View File

@@ -39,6 +39,7 @@ export const ShortcutRecorder = ({ onSave, onReset, onCancel, onError, initialAc
onError({ recorderError });
setSaveAllowed(false);
}
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [accelerator]);
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {

View File

@@ -15,6 +15,7 @@ import * as openFolder from './openFolder';
import * as openFolderDialog from './openFolderDialog';
import * as openItem from './openItem';
import * as openNote from './openNote';
import * as openPdfViewer from './openPdfViewer';
import * as openTag from './openTag';
import * as print from './print';
import * as renameFolder from './renameFolder';
@@ -55,6 +56,7 @@ const index:any[] = [
openFolderDialog,
openItem,
openNote,
openPdfViewer,
openTag,
print,
renameFolder,

View File

@@ -0,0 +1,28 @@
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
import { _ } from '@joplin/lib/locale';
import Resource from '@joplin/lib/models/Resource';
export const declaration: CommandDeclaration = {
name: 'openPdfViewer',
label: () => _('Open PDF viewer'),
};
export const runtime = (): CommandRuntime => {
return {
execute: async (context: CommandContext, resourceId: string, pageNo: number) => {
const resource = await Resource.load(resourceId);
if (!resource) throw new Error(`No such resource: ${resourceId}`);
if (resource.mime !== 'application/pdf') throw new Error(`Not a PDF: ${resource.mime}`);
console.log('Opening PDF', resource);
context.dispatch({
type: 'DIALOG_OPEN',
name: 'pdfViewer',
props: {
resource,
pageNo: pageNo,
},
});
},
};
};

View File

@@ -14,21 +14,24 @@ export const declaration: CommandDeclaration = {
export const runtime = (): CommandRuntime => {
return {
execute: async (context: CommandContext, selectedLanguage: string = null, useSpellChecker: boolean = null) => {
selectedLanguage = selectedLanguage === null ? context.state.settings['spellChecker.language'] : selectedLanguage;
execute: async (context: CommandContext, selectedLanguages: string[] = null, useSpellChecker: boolean = null) => {
selectedLanguages = selectedLanguages === null ? context.state.settings['spellChecker.languages'] : selectedLanguages;
useSpellChecker = useSpellChecker === null ? context.state.settings['spellChecker.enabled'] : useSpellChecker;
const menuItems = SpellCheckerService.instance().spellCheckerConfigMenuItems(selectedLanguage, useSpellChecker);
const menuItems = SpellCheckerService.instance().spellCheckerConfigMenuItems(selectedLanguages, useSpellChecker);
const menu = Menu.buildFromTemplate(menuItems as any);
menu.popup(bridge().window());
},
mapStateToTitle(state: AppState): string {
if (!state.settings['spellChecker.enabled']) return null;
const language = state.settings['spellChecker.language'];
if (!language) return null;
const s = language.split('-');
return s[0];
const languages = state.settings['spellChecker.languages'];
if (languages.length === 0) return null;
const s: string[] = [];
languages.forEach((language: string) => {
s.push(language.split('-')[0]);
});
return s.join(', ');
},
};
};

View File

@@ -38,6 +38,7 @@ export default function(props: Props) {
if ([MasterPasswordStatus.NotSet, MasterPasswordStatus.Invalid].includes(status)) return false;
if (mode === Mode.Reset) return false;
return true;
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [status]);
const onClose = useCallback(() => {
@@ -84,6 +85,7 @@ export default function(props: Props) {
}
return;
}
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [currentPassword, password1, onClose, mode]);
const needToRepeatPassword = useMemo(() => {

View File

@@ -122,7 +122,7 @@ interface Props {
pluginMenuItems: any[];
pluginMenus: any[];
['spellChecker.enabled']: boolean;
['spellChecker.language']: string;
['spellChecker.languages']: string[];
plugins: PluginStates;
customCss: string;
locale: string;
@@ -192,12 +192,17 @@ function useMenuStates(menu: any, props: Props) {
clearTimeout(timeoutId);
timeoutId = null;
};
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [
props.menuItemProps,
props.layoutButtonSequence,
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
props['notes.sortOrder.field'],
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
props['folders.sortOrder.field'],
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
props['notes.sortOrder.reverse'],
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
props['folders.sortOrder.reverse'],
props.showNoteCounts,
props.uncompletedTodosOnTop,
@@ -276,6 +281,7 @@ function useMenu(props: Props) {
}
void CommandService.instance().execute('hideModalMessage');
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [props.selectedFolderId]);
const onMenuItemClickRef = useRef(null);
@@ -292,6 +298,7 @@ function useMenu(props: Props) {
(commandName: string) => onMenuItemClickRef.current(commandName),
props.locale
);
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [commandNames, pluginCommandNames, props.locale]);
const switchProfileMenuItems: any[] = useSwitchProfileMenuItems(props.profileConfig, menuItemDic);
@@ -471,7 +478,7 @@ function useMenu(props: Props) {
}
toolsItems = toolsItems.concat(toolsItemsAll);
toolsItems.push(SpellCheckerService.instance().spellCheckerConfigMenuItem(props['spellChecker.language'], props['spellChecker.enabled']));
toolsItems.push(SpellCheckerService.instance().spellCheckerConfigMenuItem(props['spellChecker.languages'], props['spellChecker.enabled']));
function _checkForUpdates() {
void checkForUpdates(false, bridge().window(), { includePreReleases: Setting.value('autoUpdate.includePreReleases') });
@@ -905,13 +912,16 @@ function useMenu(props: Props) {
clearTimeout(timeoutId);
timeoutId = null;
};
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [
props.routeName,
props.pluginMenuItems,
props.pluginMenus,
keymapLastChangeTime,
modulesLastChangeTime,
props['spellChecker.language'],
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
props['spellChecker.languages'],
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
props['spellChecker.enabled'],
props.customCss,
props.locale,
@@ -973,7 +983,7 @@ const mapStateToProps = (state: AppState) => {
showCompletedTodos: state.settings.showCompletedTodos,
pluginMenuItems: stateUtils.selectArrayShallow({ array: pluginUtils.viewsByType(state.pluginService.plugins, 'menuItem') }, 'menuBar.pluginMenuItems'),
pluginMenus: stateUtils.selectArrayShallow({ array: pluginUtils.viewsByType(state.pluginService.plugins, 'menu') }, 'menuBar.pluginMenus'),
['spellChecker.language']: state.settings['spellChecker.language'],
['spellChecker.languages']: state.settings['spellChecker.languages'],
['spellChecker.enabled']: state.settings['spellChecker.enabled'],
plugins: state.pluginService.plugins,
customCss: state.customCss,

View File

@@ -70,6 +70,7 @@ export default function NoteContentPropertiesDialog(props: NoteContentProperties
useEffect(() => {
const strippedText: string = markupToHtml().stripMarkup(props.markupLanguage, props.text);
countElements(strippedText, setStrippedWords, setStrippedCharacters, setStrippedCharactersNoSpace, setStrippedLines);
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [props.text]);
useEffect(() => {

View File

@@ -259,6 +259,7 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) {
return commandOutput;
},
};
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [props.content, props.visiblePanes, addListItem, wrapSelectionWithStrings, setEditorPercentScroll, setViewerPercentScroll, resetScroll]);
const onEditorPaste = useCallback(async (event: any = null) => {
@@ -565,6 +566,7 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) {
return () => {
document.head.removeChild(element);
};
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [props.themeId, props.contentMaxWidth]);
const webview_domReady = useCallback(() => {
@@ -592,6 +594,7 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) {
} else {
props.onMessage(event);
}
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [props.onMessage, props.content, setEditorPercentScroll]);
useEffect(() => {
@@ -614,6 +617,10 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) {
resourceInfos: props.resourceInfos,
contentMaxWidth: props.contentMaxWidth,
mapsToLine: true,
// Always using useCustomPdfViewer for now, we can add a new setting for it in future if we need to.
useCustomPdfViewer: true,
noteId: props.noteId,
vendorDir: bridge().vendorDir(),
}));
if (cancelled) return;
@@ -633,6 +640,7 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) {
cancelled = true;
shim.clearTimeout(timeoutId);
};
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [props.content, props.contentKey, renderedBodyContentKey, props.contentMarkupLanguage, props.visiblePanes, props.resourceInfos, props.markupToHtml]);
useEffect(() => {
@@ -658,6 +666,7 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) {
} else {
console.error('Trying to set HTML on an undefined webview ref');
}
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [renderedBody, webviewReady]);
useEffect(() => {
@@ -681,6 +690,7 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) {
props.setLocalSearchResultCount(matches);
}
}
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [props.searchMarkers, previousSearchMarkers, props.setLocalSearchResultCount, props.content, previousContent, renderedBody, previousRenderedBody, renderedBody]);
const cellEditorStyle = useMemo(() => {
@@ -833,10 +843,13 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) {
return () => {
bridge().window().webContents.off('context-menu', onContextMenu);
};
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [props.plugins]);
function renderEditor() {
const matchBracesOptions = Setting.value('editor.autoMatchingBraces') ? { override: true, pairs: '<>()[]{}\'\'""‘’“”()《》「」『』【】〔〕〖〗〘〙〚〛' } : false;
return (
<div style={cellEditorStyle}>
<Editor
@@ -847,7 +860,7 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) {
codeMirrorTheme={styles.editor.codeMirrorTheme}
style={styles.editor}
readOnly={props.visiblePanes.indexOf('editor') < 0}
autoMatchBraces={Setting.value('editor.autoMatchingBraces')}
autoMatchBraces={matchBracesOptions}
keyMap={props.keyboardMode}
plugins={props.plugins}
onChange={codeMirror_change}

View File

@@ -86,7 +86,7 @@ export interface EditorProps {
style: any;
codeMirrorTheme: any;
readOnly: boolean;
autoMatchBraces: boolean;
autoMatchBraces: boolean | object;
keyMap: string;
plugins: PluginStates;
onChange: any;
@@ -219,9 +219,11 @@ function Editor(props: EditorProps, ref: any) {
cm.off('dragover', editor_drag);
cm.off('refresh', editor_resize);
cm.off('update', editor_update);
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
editorParent.current.removeChild(cm.getWrapperElement());
setEditor(null);
};
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, []);
useEffect(() => {
@@ -234,36 +236,42 @@ function Editor(props: EditorProps, ref: any) {
}
editor.setOption('screenReaderLabel', props.value);
}
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [props.value]);
useEffect(() => {
if (editor) {
editor.setOption('theme', props.codeMirrorTheme);
}
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [props.codeMirrorTheme]);
useEffect(() => {
if (editor) {
editor.setOption('mode', props.mode);
}
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [props.mode]);
useEffect(() => {
if (editor) {
editor.setOption('readOnly', props.readOnly);
}
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [props.readOnly]);
useEffect(() => {
if (editor) {
editor.setOption('autoCloseBrackets', props.autoMatchBraces);
}
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [props.autoMatchBraces]);
useEffect(() => {
if (editor) {
editor.setOption('keyMap', props.keyMap ? props.keyMap : 'default');
}
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [props.keyMap]);
useEffect(() => {

View File

@@ -7,6 +7,8 @@ import uuid from '@joplin/lib/uuid';
import { reg } from '@joplin/lib/registry';
const loadedPluginIdSet = new Set<string>();
export default function useExternalPlugins(CodeMirror: any, plugins: PluginStates) {
const [options, setOptions] = useState({});
@@ -17,6 +19,10 @@ export default function useExternalPlugins(CodeMirror: any, plugins: PluginState
for (const contentScript of contentScripts) {
try {
if (loadedPluginIdSet.has(contentScript.id)) {
continue;
}
const mod = contentScript.module;
if (mod.codeMirrorResources) {
@@ -64,11 +70,14 @@ export default function useExternalPlugins(CodeMirror: any, plugins: PluginState
if (mod.plugin) {
mod.plugin(CodeMirror);
}
loadedPluginIdSet.add(contentScript.id);
} catch (error) {
reg.logger().error(error.toString());
}
}
setOptions(newOptions);
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [plugins]);
function addInlineCss(cssStrings: string[], id: string) {

View File

@@ -184,5 +184,6 @@ export default function useKeymap(CodeMirror: any) {
setupEmacs();
setupVim();
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, []);
}

View File

@@ -94,6 +94,7 @@ export default function useScrollHandler(editorRef: any, webviewRef: any, onScro
if (editorRef.current) {
scheduleOnScroll({ percent });
}
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [scheduleOnScroll]);
const setViewerPercentScroll = useCallback((percent: number) => {
@@ -101,6 +102,7 @@ export default function useScrollHandler(editorRef: any, webviewRef: any, onScro
webviewRef.current.wrappedInstance.send('setPercentScroll', percent);
scheduleOnScroll({ percent });
}
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [scheduleOnScroll]);
const editor_scroll = useCallback(() => {
@@ -126,6 +128,7 @@ export default function useScrollHandler(editorRef: any, webviewRef: any, onScro
lastResizeHeight_.current = NaN;
lastLinesHeight_.current = NaN;
}
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [setViewerPercentScroll]);
const resetScroll = useCallback(() => {
@@ -134,6 +137,7 @@ export default function useScrollHandler(editorRef: any, webviewRef: any, onScro
editorRef.current.setScrollPercent(0);
scrollTopIsUncertain_.current = false;
}
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, []);
const editor_resize = useCallback((cm) => {
@@ -152,6 +156,7 @@ export default function useScrollHandler(editorRef: any, webviewRef: any, onScro
lastResizeHeight_.current = NaN;
lastLinesHeight_.current = NaN;
}
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, []);
// When heights of lines are updated in CodeMirror, 'update' events are raised.
@@ -173,6 +178,7 @@ export default function useScrollHandler(editorRef: any, webviewRef: any, onScro
lastResizeHeight_.current = NaN;
lastLinesHeight_.current = NaN;
}
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, []);
const getLineScrollPercent = useCallback(() => {
@@ -183,6 +189,7 @@ export default function useScrollHandler(editorRef: any, webviewRef: any, onScro
} else {
return scrollPercent_.current;
}
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, []);
return {

View File

@@ -280,6 +280,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {
return true;
},
};
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [editor, props.contentMarkupLanguage, props.contentOriginalCss]);
// -----------------------------------------------------------------------------------------
@@ -512,6 +513,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {
// style and re-applying it on editorReady gives our styles precedence and prevents any flashing
//
// tl;dr: editorReady is used here because the css needs to be re-applied after TinyMCE init
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [editorReady, props.themeId]);
// -----------------------------------------------------------------------------------------
@@ -680,6 +682,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {
};
void loadEditor();
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [scriptLoaded]);
// -----------------------------------------------------------------------------------------
@@ -829,6 +832,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {
return () => {
cancelled = true;
};
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [editor, props.markupToHtml, props.allAssets, props.content, props.resourceInfos, props.contentKey]);
useEffect(() => {
@@ -909,6 +913,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {
return () => {
void execOnChangeEvent();
};
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, []);
const onChangeHandlerTimeoutRef = useRef<any>(null);
@@ -1003,7 +1008,9 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {
const result = await markupToHtml.current(MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN, pastedText, markupRenderOptions({ bodyOnly: true }));
editor.insertContent(result.html);
} else { // Paste regular text
const pastedHtml = event.clipboardData.getData('text/html');
// event.clipboardData.getData('text/html') wraps the content with <html><body></body></html>,
// which seems to be not supported in editor.insertContent().
const pastedHtml = clipboard.readHTML();
if (pastedHtml) { // Handles HTML
const modifiedHtml = await processPastedHtml(pastedHtml);
editor.insertContent(modifiedHtml);
@@ -1091,6 +1098,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {
console.warn('Error removing events', error);
}
};
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [props.onWillChange, props.onChange, props.contentMarkupLanguage, props.contentOriginalCss, editor]);
// -----------------------------------------------------------------------------------------

View File

@@ -59,6 +59,7 @@ function NoteEditor(props: NoteEditorProps) {
const formNote_beforeLoad = useCallback(async (event: OnLoadEvent) => {
await saveNoteIfWillChange(event.formNote);
setShowRevisions(false);
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, []);
const formNote_afterLoad = useCallback(async () => {
@@ -177,6 +178,7 @@ function NoteEditor(props: NoteEditorProps) {
id: formNote.id,
});
}
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [props.isProvisional, formNote.id]);
const previousNoteId = usePrevious(formNote.id);
@@ -194,6 +196,7 @@ function NoteEditor(props: NoteEditorProps) {
});
void ResourceEditWatcher.instance().stopWatchingAll();
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [formNote.id, previousNoteId]);
const onFieldChange = useCallback((field: string, value: any, changeId = 0) => {
@@ -238,6 +241,7 @@ function NoteEditor(props: NoteEditorProps) {
setFormNote(newNote);
scheduleSaveNote(newNote);
}
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [handleProvisionalFlag, formNote, isNewNote, titleHasBeenManuallyChanged]);
useWindowCommandHandler({
@@ -288,6 +292,7 @@ function NoteEditor(props: NoteEditorProps) {
id: formNote.id,
status: 'saving',
});
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [formNote, handleProvisionalFlag]);
const onMessage = useMessageHandler(scrollWhenReady, setScrollWhenReady, editorRef, setLocalSearchResultCount, props.dispatch, formNote);
@@ -302,6 +307,7 @@ function NoteEditor(props: NoteEditorProps) {
setFormNote(newFormNote);
}
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [formNote]);
const onNotePropertyChange = useCallback((event) => {
@@ -317,6 +323,7 @@ function NoteEditor(props: NoteEditorProps) {
return newFormNote;
});
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, []);
useEffect(() => {
@@ -350,6 +357,7 @@ function NoteEditor(props: NoteEditorProps) {
noteId: formNoteRef.current.id,
percent: event.percent,
});
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [props.dispatch, formNote]);
function renderNoNotes(rootStyle: any) {
@@ -413,6 +421,9 @@ function NoteEditor(props: NoteEditorProps) {
fontSize: Setting.value('style.editor.fontSize'),
contentMaxWidth: props.contentMaxWidth,
isSafeMode: props.isSafeMode,
// We need it to identify the context for which media is rendered.
// It is currently used to remember pdf scroll position for each attacments of each note uniquely.
noteId: props.noteId,
};
let editor = null;

View File

@@ -75,6 +75,7 @@ export interface NoteBodyEditorProps {
fontSize: number;
contentMaxWidth: number;
isSafeMode: boolean;
noteId: string;
}
export interface FormNote {

View File

@@ -51,5 +51,6 @@ export default function useDropHandler(dependencies: HookDependencies) {
},
});
}
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, []);
}

View File

@@ -138,6 +138,7 @@ export default function useFormNote(dependencies: HookDependencies) {
return () => {
cancelled = true;
};
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [prevSyncStarted, syncStarted, formNote]);
useEffect(() => {
@@ -188,6 +189,7 @@ export default function useFormNote(dependencies: HookDependencies) {
return () => {
cancelled = true;
};
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [noteId, isProvisional, formNote]);
const onResourceChange = useCallback(async function(event: any = null) {

View File

@@ -20,6 +20,9 @@ export interface MarkupToHtmlOptions {
plugins?: Record<string, any>;
bodyOnly?: boolean;
mapsToLine?: boolean;
useCustomPdfViewer?: boolean;
noteId?: string;
vendorDir?: string;
}
export default function useMarkupToHtml(deps: HookDependencies) {
@@ -30,6 +33,7 @@ export default function useMarkupToHtml(deps: HookDependencies) {
resourceBaseUrl: `file://${Setting.value('resourceDir')}/`,
customCss: customCss || '',
});
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [plugins, customCss]);
return useCallback(async (markupLanguage: number, md: string, options: MarkupToHtmlOptions = null): Promise<any> => {
@@ -61,5 +65,6 @@ export default function useMarkupToHtml(deps: HookDependencies) {
}, options));
return result;
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [themeId, customCss, markupToHtml]);
}

View File

@@ -52,9 +52,12 @@ export default function useMessageHandler(scrollWhenReady: any, setScrollWhenRea
void CommandService.instance().execute(commandName, ...commandArgs);
} else if (msg === 'postMessageService.message') {
void PostMessageService.instance().postMessage(arg0);
} else if (msg === 'openPdfViewer') {
await CommandService.instance().execute('openPdfViewer', arg0.resourceId, arg0.pageNo);
} else {
await CommandService.instance().execute('openItem', msg);
// bridge().showErrorMessageBox(_('Unsupported link or message: %s', msg));
}
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [dispatch, setLocalSearchResultCount, scrollWhenReady, formNote]);
}

View File

@@ -8,5 +8,6 @@ export default function usePluginServiceRegistration(ref: any) {
return () => {
PlatformImplementation.instance().unregisterComponent('textEditor');
};
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, []);
}

View File

@@ -32,5 +32,6 @@ export default function useSearchMarkers(showLocalSearch: boolean, localSearchMa
output.keywords = highlightedWords;
return output;
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [highlightedWords, showLocalSearch, localSearchMarkerOptions, searches, selectedSearchId]);
}

View File

@@ -96,5 +96,6 @@ export default function useWindowCommandHandler(dependencies: HookDependencies)
CommandService.instance().unregisterRuntime(command.declaration.name);
}
};
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [editorRef, setShowLocalSearch, noteSearchBarRef, titleInputRef]);
}

View File

@@ -273,6 +273,7 @@ const NoteListComponent = (props: Props) => {
onTitleClick={noteItem_titleClick}
onContextMenu={itemContextMenu}
/>;
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [style, props.themeId, width, itemHeight, dragOverTargetNoteIndex, props.provisionalNoteIds, props.selectedNoteIds, props.watchedNoteFiles,
props.notes,
props.notesParentType,
@@ -305,6 +306,7 @@ const NoteListComponent = (props: Props) => {
if (previousVisible !== props.visible) {
updateSizeState();
}
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [previousSelectedNoteIds,previousNotes, previousVisible, props.selectedNoteIds, props.notes]);
const scrollNoteIndex_ = (keyCode: any, ctrlKey: any, metaKey: any, noteIndex: any) => {
@@ -439,6 +441,7 @@ const NoteListComponent = (props: Props) => {
return () => {
props.resizableLayoutEventEmitter.off('resize', resizableLayout_resize);
};
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [props.resizableLayoutEventEmitter]);
useEffect(() => {
@@ -453,6 +456,7 @@ const NoteListComponent = (props: Props) => {
};
}, []);
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
useEffect(() => {
// When a note list item is styled by userchrome.css, its height is reflected.
// Ref. https://github.com/laurent22/joplin/pull/6542

View File

@@ -0,0 +1,101 @@
import * as React from 'react';
import { useCallback, useRef, useEffect } from 'react';
import Resource from '@joplin/lib/models/Resource';
import bridge from '../services/bridge';
import contextMenu from './NoteEditor/utils/contextMenu';
import { ContextMenuItemType, ContextMenuOptions } from './NoteEditor/utils/contextMenuUtils';
import CommandService from '@joplin/lib/services/CommandService';
import styled from 'styled-components';
import { themeStyle } from '@joplin/lib/theme';
const Window = styled.div`
height: 100%;
width: 100%;
position: fixed;
top: 0px;
left: 0px;
z-index: 999;
background-color: ${(props: any) => props.theme.backgroundColor};
color: ${(props: any) => props.theme.color};
`;
const IFrame = styled.iframe`
height: 100%;
width: 100%;
border: none;
`;
interface Props {
themeId: number;
dispatch: Function;
resource: any;
pageNo: number;
}
export default function PdfViewer(props: Props) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const onClose = useCallback(() => {
props.dispatch({
type: 'DIALOG_CLOSE',
name: 'pdfViewer',
});
}, [props.dispatch]);
const openExternalViewer = useCallback(async () => {
await CommandService.instance().execute('openItem', `joplin://${props.resource.id}`);
}, [props.resource.id]);
const textSelected = useCallback(async (text: string) => {
if (!text) return;
const itemType = ContextMenuItemType.Text;
const menu = await contextMenu({
itemType,
resourceId: null,
filename: null,
mime: 'text/plain',
textToCopy: text,
linkToCopy: null,
htmlToCopy: '',
insertContent: () => { console.warn('insertContent() not implemented'); },
} as ContextMenuOptions, props.dispatch);
menu.popup(bridge().window());
}, [props.dispatch]);
useEffect(() => {
const onMessage_ = async (event: any) =>{
if (!event.data || !event.data.name) {
return;
}
if (event.data.name === 'close') {
onClose();
} else if (event.data.name === 'externalViewer') {
await openExternalViewer();
} else if (event.data.name === 'textSelected') {
await textSelected(event.data.text);
} else {
console.error('Unknown event received', event.data.name);
}
};
const iframe = iframeRef.current;
iframe.contentWindow.addEventListener('message', onMessage_);
return () => {
iframe.contentWindow.removeEventListener('message', onMessage_);
};
}, [onClose, openExternalViewer, textSelected]);
const theme = themeStyle(props.themeId);
return (
<Window theme={theme}>
<IFrame src="./vendor/lib/@joplin/pdf-viewer/index.html" x-url={Resource.fullPath(props.resource)}
x-appearance={theme.appearance} ref={iframeRef}
x-title={props.resource.title}
x-anchorpage={props.pageNo}
x-type="full"></IFrame>
</Window>
);
}

View File

@@ -13,5 +13,6 @@ export default function useWindowResizeEvent(eventEmitter: any) {
window_resize.clear();
window.removeEventListener('resize', window_resize);
};
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, []);
}

View File

@@ -174,9 +174,11 @@ class ResourceScreenComponent extends React.Component<Props, State> {
return;
}
Resource.delete(resource.id)
// eslint-disable-next-line promise/prefer-await-to-then -- Old code before rule was applied
.catch((error: Error) => {
bridge().showErrorMessageBox(error.message);
})
// eslint-disable-next-line promise/prefer-await-to-then -- Old code before rule was applied
.finally(() => {
void this.reloadResources(this.state.sorting);
});

View File

@@ -22,6 +22,7 @@ import Dialog from './Dialog';
import SyncWizardDialog from './SyncWizard/Dialog';
import MasterPasswordDialog from './MasterPasswordDialog/Dialog';
import EditFolderDialog from './EditFolderDialog/Dialog';
import PdfViewer from './PdfViewer';
import StyleSheetContainer from './StyleSheets/StyleSheetContainer';
const { ImportScreen } = require('./ImportScreen.min.js');
const { ResourceScreen } = require('./ResourceScreen.js');
@@ -75,6 +76,11 @@ const registeredDialogs: Record<string, RegisteredDialog> = {
return <EditFolderDialog key={props.key} dispatch={props.dispatch} themeId={props.themeId} {...customProps}/>;
},
},
pdfViewer: {
render: (props: RegisteredDialogProps, customProps: any) => {
return <PdfViewer key={props.key} dispatch={props.dispatch} themeId={props.themeId} {...customProps}/>;
},
},
};
const GlobalStyle = createGlobalStyle`

View File

@@ -66,6 +66,7 @@ function useRestartOnDone(upgradeResult: SyncTargetUpgradeResult) {
if (upgradeResult.done && !upgradeResult.error) {
void restart();
}
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [upgradeResult.done]);
}

View File

@@ -55,6 +55,7 @@ function SearchBar(props: Props) {
return () => {
debouncedSearch.clear();
};
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [query, searchStarted]);
const onExitSearch = useCallback(async (navigateAway = true) => {
@@ -80,6 +81,7 @@ function SearchBar(props: Props) {
}
}
}
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [props.selectedNoteId]);
function onChange(event: any) {
@@ -129,6 +131,7 @@ function SearchBar(props: Props) {
field: 'globalSearch',
});
}
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [onExitSearch, props.isFocused, searchStarted]);
useEffect(() => {
@@ -150,6 +153,7 @@ function SearchBar(props: Props) {
}
void onExitSearch(true);
}
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, []);
return (

View File

@@ -140,6 +140,7 @@ function ShareFolderDialog(props: Props) {
useEffect(() => {
const s = props.shares.find(s => s.folder_id === props.folderId);
setShare(s);
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [props.shares]);
useEffect(() => {

View File

@@ -1,4 +1,5 @@
import * as React from 'react';
import { useEffect, useRef, useCallback, useMemo } from 'react';
import { StyledRoot, StyledAddButton, StyledShareIcon, StyledHeader, StyledHeaderIcon, StyledAllNotesIcon, StyledHeaderLabel, StyledListItem, StyledListItemAnchor, StyledExpandLink, StyledNoteCount, StyledSyncReportText, StyledSyncReport, StyledSynchronizeButton } from './styles';
import { ButtonLevel } from '../Button/Button';
import CommandService from '@joplin/lib/services/CommandService';
@@ -17,12 +18,14 @@ import Folder from '@joplin/lib/models/Folder';
import Note from '@joplin/lib/models/Note';
import Tag from '@joplin/lib/models/Tag';
import Logger from '@joplin/lib/Logger';
import { FolderEntity, FolderIcon } from '@joplin/lib/services/database/types';
import { FolderEntity, FolderIcon, FolderIconType } from '@joplin/lib/services/database/types';
import stateToWhenClauseContext from '../../services/commands/stateToWhenClauseContext';
import { store } from '@joplin/lib/reducer';
import PerFolderSortOrderService from '../../services/sortOrder/PerFolderSortOrderService';
import { getFolderCallbackUrl, getTagCallbackUrl } from '@joplin/lib/callbackUrlUtils';
import FolderIconBox from '../FolderIconBox';
import { Theme } from '@joplin/lib/themes/type';
import { RuntimeProps } from './commands/focusElementSideBar';
const { connect } = require('react-redux');
const shared = require('@joplin/lib/components/shared/side-menu-shared.js');
const { themeStyle } = require('@joplin/lib/theme');
@@ -50,11 +53,8 @@ interface Props {
tags: any[];
syncStarted: boolean;
plugins: PluginStates;
}
interface State {
tagHeaderIsExpanded: boolean;
folderHeaderIsExpanded: boolean;
tagHeaderIsExpanded: boolean;
}
const commands = [
@@ -79,13 +79,21 @@ function ExpandLink(props: any) {
}
const renderFolderIcon = (folderIcon: FolderIcon) => {
if (!folderIcon) return null;
if (!folderIcon) {
const defaultFolderIcon: FolderIcon = {
dataUrl: '',
emoji: '',
name: 'far fa-folder',
type: FolderIconType.FontAwesome,
};
return <div style={{ marginRight: 7, display: 'flex' }}><FolderIconBox opacity={0.7} folderIcon={defaultFolderIcon}/></div>;
}
return <div style={{ marginRight: 5, display: 'flex' }}><FolderIconBox folderIcon={folderIcon}/></div>;
return <div style={{ marginRight: 7, display: 'flex' }}><FolderIconBox folderIcon={folderIcon}/></div>;
};
function FolderItem(props: any) {
const { hasChildren, isExpanded, parentId, depth, selected, folderId, folderTitle, folderIcon, anchorRef, noteCount, onFolderDragStart_, onFolderDragOver_, onFolderDrop_, itemContextMenu, folderItem_click, onFolderToggleClick_, shareId } = props;
const { hasChildren, showFolderIcon, isExpanded, parentId, depth, selected, folderId, folderTitle, folderIcon, anchorRef, noteCount, onFolderDragStart_, onFolderDragOver_, onFolderDrop_, itemContextMenu, folderItem_click, onFolderToggleClick_, shareId } = props;
const noteCountComp = noteCount ? <StyledNoteCount className="note-count-label">{noteCount}</StyledNoteCount> : null;
@@ -110,7 +118,7 @@ function FolderItem(props: any) {
}}
onDoubleClick={onFolderToggleClick_}
>
{renderFolderIcon(folderIcon)}<span className="title" style={{ lineHeight: 0 }}>{folderTitle}</span>
{showFolderIcon ? renderFolderIcon(folderIcon) : null}<span className="title" style={{ lineHeight: 0 }}>{folderTitle}</span>
{shareIcon} {noteCountComp}
</StyledListItemAnchor>
</StyledListItem>
@@ -119,56 +127,88 @@ function FolderItem(props: any) {
const menuUtils = new MenuUtils(CommandService.instance());
class SidebarComponent extends React.Component<Props, State> {
const SidebarComponent = (props: Props) => {
private folderItemsOrder_: any[] = [];
private tagItemsOrder_: any[] = [];
private rootRef: any = null;
private anchorItemRefs: any = {};
private pluginsRef: any;
const folderItemsOrder_ = useRef<any[]>();
folderItemsOrder_.current = [];
const tagItemsOrder_ = useRef<any[]>();
tagItemsOrder_.current = [];
constructor(props: any) {
super(props);
const rootRef = useRef(null);
const anchorItemRefs = useRef<Record<string, any>>(null);
anchorItemRefs.current = {};
CommandService.instance().componentRegisterCommands(this, commands);
// This whole component is a bit of a mess and rather than passing
// a plugins prop around, not knowing how it's going to affect
// re-rendering, we just keep a ref to it. Currently that's enough
// as plugins are only accessed from context menus. However if want
// to do more complex things with plugins in the sidebar, it will
// probably have to be refactored using React Hooks first.
const pluginsRef = useRef<PluginStates>(null);
pluginsRef.current = props.plugins;
this.state = {
tagHeaderIsExpanded: Setting.value('tagHeaderIsExpanded'),
folderHeaderIsExpanded: Setting.value('folderHeaderIsExpanded'),
// If at least one of the folder has an icon, then we display icons for all
// folders (those without one will get the default icon). This is so that
// visual alignment is correct for all folders, otherwise the folder tree
// looks messy.
const showFolderIcons = useMemo(() => {
return Folder.shouldShowFolderIcons(props.folders);
}, [props.folders]);
const getSelectedItem = useCallback(() => {
if (props.notesParentType === 'Folder' && props.selectedFolderId) {
return { type: 'folder', id: props.selectedFolderId };
} else if (props.notesParentType === 'Tag' && props.selectedTagId) {
return { type: 'tag', id: props.selectedTagId };
}
return null;
}, [props.notesParentType, props.selectedFolderId, props.selectedTagId]);
const getFirstAnchorItemRef = useCallback((type: string) => {
const refs = anchorItemRefs.current[type];
if (!refs) return null;
const p = type === 'folder' ? props.folders : props.tags;
const item = p && p.length ? p[0] : null;
if (!item) return null;
return refs[item.id];
}, [anchorItemRefs, props.folders, props.tags]);
useEffect(() => {
const runtimeProps: RuntimeProps = {
getSelectedItem,
anchorItemRefs,
getFirstAnchorItemRef,
};
// This whole component is a bit of a mess and rather than passing
// a plugins prop around, not knowing how it's going to affect
// re-rendering, we just keep a ref to it. Currently that's enough
// as plugins are only accessed from context menus. However if want
// to do more complex things with plugins in the sidebar, it will
// probably have to be refactored using React Hooks first.
this.pluginsRef = React.createRef();
CommandService.instance().componentRegisterCommands(runtimeProps, commands);
this.onFolderToggleClick_ = this.onFolderToggleClick_.bind(this);
this.onKeyDown = this.onKeyDown.bind(this);
this.onAllNotesClick_ = this.onAllNotesClick_.bind(this);
this.header_contextMenu = this.header_contextMenu.bind(this);
this.onAddFolderButtonClick = this.onAddFolderButtonClick.bind(this);
this.folderItem_click = this.folderItem_click.bind(this);
this.itemContextMenu = this.itemContextMenu.bind(this);
}
return () => {
CommandService.instance().componentUnregisterCommands(commands);
};
}, [
getSelectedItem,
anchorItemRefs,
getFirstAnchorItemRef,
]);
onFolderDragStart_(event: any) {
const onFolderDragStart_ = useCallback((event: any) => {
const folderId = event.currentTarget.getAttribute('data-folder-id');
if (!folderId) return;
event.dataTransfer.setDragImage(new Image(), 1, 1);
event.dataTransfer.clearData();
event.dataTransfer.setData('text/x-jop-folder-ids', JSON.stringify([folderId]));
}
}, []);
onFolderDragOver_(event: any) {
const onFolderDragOver_ = useCallback((event: any) => {
if (event.dataTransfer.types.indexOf('text/x-jop-note-ids') >= 0) event.preventDefault();
if (event.dataTransfer.types.indexOf('text/x-jop-folder-ids') >= 0) event.preventDefault();
}
}, []);
async onFolderDrop_(event: any) {
const onFolderDrop_ = useCallback(async (event: any) => {
const folderId = event.currentTarget.getAttribute('data-folder-id');
const dt = event.dataTransfer;
if (!dt) return;
@@ -199,9 +239,9 @@ class SidebarComponent extends React.Component<Props, State> {
logger.error(error);
alert(error.message);
}
}
}, []);
async onTagDrop_(event: any) {
const onTagDrop_ = useCallback(async (event: any) => {
const tagId = event.currentTarget.getAttribute('data-tag-id');
const dt = event.dataTransfer;
if (!dt) return;
@@ -214,22 +254,18 @@ class SidebarComponent extends React.Component<Props, State> {
await Tag.addNote(tagId, noteIds[i]);
}
}
}
}, []);
async onFolderToggleClick_(event: any) {
const onFolderToggleClick_ = useCallback((event: any) => {
const folderId = event.currentTarget.getAttribute('data-folder-id');
this.props.dispatch({
props.dispatch({
type: 'FOLDER_TOGGLE',
id: folderId,
});
}
}, [props.dispatch]);
componentWillUnmount() {
CommandService.instance().componentUnregisterCommands(commands);
}
async header_contextMenu() {
const header_contextMenu = useCallback(async () => {
const menu = new Menu();
menu.append(
@@ -237,9 +273,9 @@ class SidebarComponent extends React.Component<Props, State> {
);
menu.popup(bridge().window());
}
}, []);
async itemContextMenu(event: any) {
const itemContextMenu = useCallback(async (event: any) => {
const itemId = event.currentTarget.getAttribute('data-id');
if (itemId === Folder.conflictFolderId()) return;
@@ -265,7 +301,7 @@ class SidebarComponent extends React.Component<Props, State> {
let item = null;
if (itemType === BaseModel.TYPE_FOLDER) {
item = BaseModel.byId(this.props.folders, itemId);
item = BaseModel.byId(props.folders, itemId);
}
if (itemType === BaseModel.TYPE_FOLDER && !item.encryption_applied) {
@@ -289,7 +325,7 @@ class SidebarComponent extends React.Component<Props, State> {
} else if (itemType === BaseModel.TYPE_TAG) {
await Tag.untagAll(itemId);
} else if (itemType === BaseModel.TYPE_SEARCH) {
this.props.dispatch({
props.dispatch({
type: 'SEARCH_DELETE',
id: itemId,
});
@@ -314,7 +350,7 @@ class SidebarComponent extends React.Component<Props, State> {
new MenuItem({
label: module.fullLabel(),
click: async () => {
await InteropServiceHelper.export(this.props.dispatch.bind(this), module, { sourceFolderIds: [itemId], plugins: this.pluginsRef.current });
await InteropServiceHelper.export(props.dispatch, module, { sourceFolderIds: [itemId], plugins: pluginsRef.current });
},
})
);
@@ -374,7 +410,7 @@ class SidebarComponent extends React.Component<Props, State> {
);
}
const pluginViews = pluginUtils.viewsByType(this.pluginsRef.current, 'menuItem');
const pluginViews = pluginUtils.viewsByType(pluginsRef.current, 'menuItem');
for (const view of pluginViews) {
const location = view.location;
@@ -389,80 +425,79 @@ class SidebarComponent extends React.Component<Props, State> {
}
menu.popup(bridge().window());
}
}, [props.folders, props.dispatch, pluginsRef]);
folderItem_click(folderId: string) {
this.props.dispatch({
const folderItem_click = useCallback((folderId: string) => {
props.dispatch({
type: 'FOLDER_SELECT',
id: folderId ? folderId : null,
});
}
}, [props.dispatch]);
tagItem_click(tag: any) {
this.props.dispatch({
const tagItem_click = useCallback((tag: any) => {
props.dispatch({
type: 'TAG_SELECT',
id: tag ? tag.id : null,
});
}
}, [props.dispatch]);
anchorItemRef(type: string, id: string) {
if (!this.anchorItemRefs[type]) this.anchorItemRefs[type] = {};
if (this.anchorItemRefs[type][id]) return this.anchorItemRefs[type][id];
this.anchorItemRefs[type][id] = React.createRef();
return this.anchorItemRefs[type][id];
}
const onHeaderClick_ = useCallback((key: string) => {
const isExpanded = key === 'tag' ? props.tagHeaderIsExpanded : props.folderHeaderIsExpanded;
Setting.setValue(key === 'tag' ? 'tagHeaderIsExpanded' : 'folderHeaderIsExpanded', !isExpanded);
}, [props.folderHeaderIsExpanded, props.tagHeaderIsExpanded]);
firstAnchorItemRef(type: string) {
const refs = this.anchorItemRefs[type];
if (!refs) return null;
const onAllNotesClick_ = () => {
props.dispatch({
type: 'SMART_FILTER_SELECT',
id: ALL_NOTES_FILTER_ID,
});
};
const n = `${type}s`;
const p = this.props as any;
const item = p[n] && p[n].length ? p[n][0] : null;
if (!item) return null;
const anchorItemRef = (type: string, id: string) => {
if (!anchorItemRefs.current[type]) anchorItemRefs.current[type] = {};
if (anchorItemRefs.current[type][id]) return anchorItemRefs.current[type][id];
anchorItemRefs.current[type][id] = React.createRef();
return anchorItemRefs.current[type][id];
};
return refs[item.id];
}
renderNoteCount(count: number) {
const renderNoteCount = (count: number) => {
return count ? <StyledNoteCount className="note-count-label">{count}</StyledNoteCount> : null;
}
};
renderExpandIcon(isExpanded: boolean, isVisible: boolean = true) {
const theme = themeStyle(this.props.themeId);
const renderExpandIcon = (theme: any, isExpanded: boolean, isVisible: boolean) => {
const style: any = { width: 16, maxWidth: 16, opacity: 0.5, fontSize: Math.round(theme.toolbarIconSize * 0.8), display: 'flex', justifyContent: 'center' };
if (!isVisible) style.visibility = 'hidden';
return <i className={isExpanded ? 'fas fa-caret-down' : 'fas fa-caret-right'} style={style}></i>;
}
};
renderAllNotesItem(selected: boolean) {
const renderAllNotesItem = (theme: Theme, selected: boolean) => {
return (
<StyledListItem key="allNotesHeader" selected={selected} className={'list-item-container list-item-depth-0 all-notes'} isSpecialItem={true}>
<StyledExpandLink>{this.renderExpandIcon(false, false)}</StyledExpandLink>
<StyledExpandLink>{renderExpandIcon(theme, false, false)}</StyledExpandLink>
<StyledAllNotesIcon className="icon-notes"/>
<StyledListItemAnchor
className="list-item"
isSpecialItem={true}
href="#"
selected={selected}
onClick={this.onAllNotesClick_}
onClick={onAllNotesClick_}
>
{_('All notes')}
</StyledListItemAnchor>
</StyledListItem>
);
}
};
renderFolderItem(folder: FolderEntity, selected: boolean, hasChildren: boolean, depth: number) {
const anchorRef = this.anchorItemRef('folder', folder.id);
const isExpanded = this.props.collapsedFolderIds.indexOf(folder.id) < 0;
const renderFolderItem = (folder: FolderEntity, selected: boolean, hasChildren: boolean, depth: number) =>{
const anchorRef = anchorItemRef('folder', folder.id);
const isExpanded = props.collapsedFolderIds.indexOf(folder.id) < 0;
let noteCount = (folder as any).note_count;
// Thunderbird count: Subtract children note_count from parent folder if it expanded.
if (isExpanded) {
for (let i = 0; i < this.props.folders.length; i++) {
if (this.props.folders[i].parent_id === folder.id) {
noteCount -= this.props.folders[i].note_count;
for (let i = 0; i < props.folders.length; i++) {
if (props.folders[i].parent_id === folder.id) {
noteCount -= props.folders[i].note_count;
}
}
}
@@ -472,40 +507,41 @@ class SidebarComponent extends React.Component<Props, State> {
folderId={folder.id}
folderTitle={Folder.displayTitle(folder)}
folderIcon={Folder.unserializeIcon(folder.icon)}
themeId={this.props.themeId}
themeId={props.themeId}
depth={depth}
selected={selected}
isExpanded={isExpanded}
hasChildren={hasChildren}
anchorRef={anchorRef}
noteCount={noteCount}
onFolderDragStart_={this.onFolderDragStart_}
onFolderDragOver_={this.onFolderDragOver_}
onFolderDrop_={this.onFolderDrop_}
itemContextMenu={this.itemContextMenu}
folderItem_click={this.folderItem_click}
onFolderToggleClick_={this.onFolderToggleClick_}
onFolderDragStart_={onFolderDragStart_}
onFolderDragOver_={onFolderDragOver_}
onFolderDrop_={onFolderDrop_}
itemContextMenu={itemContextMenu}
folderItem_click={folderItem_click}
onFolderToggleClick_={onFolderToggleClick_}
shareId={folder.share_id}
parentId={folder.parent_id}
showFolderIcon={showFolderIcons}
/>;
}
};
renderTag(tag: any, selected: boolean) {
const anchorRef = this.anchorItemRef('tag', tag.id);
const renderTag = (tag: any, selected: boolean) => {
const anchorRef = anchorItemRef('tag', tag.id);
let noteCount = null;
if (Setting.value('showNoteCounts')) {
if (Setting.value('showCompletedTodos')) noteCount = this.renderNoteCount(tag.note_count);
else noteCount = this.renderNoteCount(tag.note_count - tag.todo_completed_count);
if (Setting.value('showCompletedTodos')) noteCount = renderNoteCount(tag.note_count);
else noteCount = renderNoteCount(tag.note_count - tag.todo_completed_count);
}
return (
<StyledListItem selected={selected}
className={`list-item-container ${selected ? 'selected' : ''}`}
key={tag.id}
onDrop={this.onTagDrop_}
onDrop={onTagDrop_}
data-tag-id={tag.id}
>
<StyledExpandLink>{this.renderExpandIcon(false, false)}</StyledExpandLink>
<StyledExpandLink>{renderExpandIcon(theme, false, false)}</StyledExpandLink>
<StyledListItemAnchor
ref={anchorRef}
className="list-item"
@@ -513,9 +549,9 @@ class SidebarComponent extends React.Component<Props, State> {
selected={selected}
data-id={tag.id}
data-type={BaseModel.TYPE_TAG}
onContextMenu={this.itemContextMenu}
onContextMenu={itemContextMenu}
onClick={() => {
this.tagItem_click(tag);
tagItem_click(tag);
}}
>
<span className="tag-label">{Tag.displayTitle(tag)}</span>
@@ -523,16 +559,12 @@ class SidebarComponent extends React.Component<Props, State> {
</StyledListItemAnchor>
</StyledListItem>
);
}
};
makeDivider(key: string) {
return <div style={{ height: 2, backgroundColor: 'blue' }} key={key} />;
}
renderHeader(key: string, label: string, iconName: string, contextMenuHandler: Function = null, onPlusButtonClick: Function = null, extraProps: any = {}) {
const renderHeader = (key: string, label: string, iconName: string, contextMenuHandler: Function = null, onPlusButtonClick: Function = null, extraProps: any = {}) => {
const headerClick = extraProps.onClick || null;
delete extraProps.onClick;
const ref = this.anchorItemRef('headers', key);
const ref = anchorItemRef('headers', key);
return (
<div key={key} style={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}>
@@ -545,7 +577,7 @@ class SidebarComponent extends React.Component<Props, State> {
if (headerClick) {
headerClick(key, event);
}
this.onHeaderClick_(key);
onHeaderClick_(key);
}}
>
<StyledHeaderIcon className={iconName}/>
@@ -554,21 +586,11 @@ class SidebarComponent extends React.Component<Props, State> {
{ onPlusButtonClick && <StyledAddButton onClick={onPlusButtonClick} iconName="fas fa-plus" level={ButtonLevel.SidebarSecondary}/> }
</div>
);
}
};
selectedItem() {
if (this.props.notesParentType === 'Folder' && this.props.selectedFolderId) {
return { type: 'folder', id: this.props.selectedFolderId };
} else if (this.props.notesParentType === 'Tag' && this.props.selectedTagId) {
return { type: 'tag', id: this.props.selectedTagId };
}
return null;
}
onKeyDown(event: any) {
const onKeyDown = useCallback((event: any) => {
const keyCode = event.keyCode;
const selectedItem = this.selectedItem();
const selectedItem = getSelectedItem();
if (keyCode === 40 || keyCode === 38) {
// DOWN / UP
@@ -576,14 +598,14 @@ class SidebarComponent extends React.Component<Props, State> {
const focusItems = [];
for (let i = 0; i < this.folderItemsOrder_.length; i++) {
const id = this.folderItemsOrder_[i];
focusItems.push({ id: id, ref: this.anchorItemRefs['folder'][id], type: 'folder' });
for (let i = 0; i < folderItemsOrder_.current.length; i++) {
const id = folderItemsOrder_.current[i];
focusItems.push({ id: id, ref: anchorItemRefs.current['folder'][id], type: 'folder' });
}
for (let i = 0; i < this.tagItemsOrder_.length; i++) {
const id = this.tagItemsOrder_[i];
focusItems.push({ id: id, ref: this.anchorItemRefs['tag'][id], type: 'tag' });
for (let i = 0; i < tagItemsOrder_.current.length; i++) {
const id = tagItemsOrder_.current[i];
focusItems.push({ id: id, ref: anchorItemRefs.current['tag'][id], type: 'tag' });
}
let currentIndex = 0;
@@ -604,7 +626,7 @@ class SidebarComponent extends React.Component<Props, State> {
const actionName = `${focusItem.type.toUpperCase()}_SELECT`;
this.props.dispatch({
props.dispatch({
type: actionName,
id: focusItem.id,
});
@@ -627,7 +649,7 @@ class SidebarComponent extends React.Component<Props, State> {
// SPACE
event.preventDefault();
this.props.dispatch({
props.dispatch({
type: 'FOLDER_TOGGLE',
id: selectedItem.id,
});
@@ -637,24 +659,9 @@ class SidebarComponent extends React.Component<Props, State> {
// Ctrl+A key
event.preventDefault();
}
}
}, [getSelectedItem, props.dispatch]);
onHeaderClick_(key: string) {
const toggleKey = `${key}IsExpanded`;
const isExpanded = (this.state as any)[toggleKey];
const newState: any = { [toggleKey]: !isExpanded };
this.setState(newState);
Setting.setValue(toggleKey, !isExpanded);
}
onAllNotesClick_() {
this.props.dispatch({
type: 'SMART_FILTER_SELECT',
id: ALL_NOTES_FILTER_ID,
});
}
renderSynchronizeButton(type: string) {
const renderSynchronizeButton = (type: string) => {
const label = type === 'sync' ? _('Synchronise') : _('Cancel');
const iconAnimation = type !== 'sync' ? 'icon-infinite-rotation 1s linear infinite' : '';
@@ -670,116 +677,98 @@ class SidebarComponent extends React.Component<Props, State> {
}}
/>
);
}
};
onAddFolderButtonClick() {
const onAddFolderButtonClick = useCallback(() => {
void CommandService.instance().execute('newFolder');
}, []);
const theme = themeStyle(props.themeId);
const items = [];
items.push(
renderHeader('folderHeader', _('Notebooks'), 'icon-notebooks', header_contextMenu, onAddFolderButtonClick, {
onDrop: onFolderDrop_,
['data-folder-id']: '',
toggleblock: 1,
})
);
if (props.folders.length) {
const allNotesSelected = props.notesParentType === 'SmartFilter' && props.selectedSmartFilterId === ALL_NOTES_FILTER_ID;
const result = shared.renderFolders(props, renderFolderItem);
const folderItems = [renderAllNotesItem(theme, allNotesSelected)].concat(result.items);
folderItemsOrder_.current = result.order;
items.push(
<div
className={`folders ${props.folderHeaderIsExpanded ? 'expanded' : ''}`}
key="folder_items"
style={{ display: props.folderHeaderIsExpanded ? 'block' : 'none', paddingBottom: 10 }}
>
{folderItems}
</div>
);
}
// componentDidUpdate(prevProps:any, prevState:any) {
// for (const n in prevProps) {
// if (prevProps[n] !== (this.props as any)[n]) {
// console.info('CHANGED PROPS', n);
// }
// }
items.push(
renderHeader('tagHeader', _('Tags'), 'icon-tags', null, null, {
toggleblock: 1,
})
);
// for (const n in prevState) {
// if (prevState[n] !== (this.state as any)[n]) {
// console.info('CHANGED STATE', n);
// }
// }
// }
render() {
this.pluginsRef.current = this.props.plugins;
const theme = themeStyle(this.props.themeId);
const items = [];
if (props.tags.length) {
const result = shared.renderTags(props, renderTag);
const tagItems = result.items;
tagItemsOrder_.current = result.order;
items.push(
this.renderHeader('folderHeader', _('Notebooks'), 'icon-notebooks', this.header_contextMenu, this.onAddFolderButtonClick, {
onDrop: this.onFolderDrop_,
['data-folder-id']: '',
toggleblock: 1,
})
);
if (this.props.folders.length) {
const allNotesSelected = this.props.notesParentType === 'SmartFilter' && this.props.selectedSmartFilterId === ALL_NOTES_FILTER_ID;
const result = shared.renderFolders(this.props, this.renderFolderItem.bind(this));
const folderItems = [this.renderAllNotesItem(allNotesSelected)].concat(result.items);
this.folderItemsOrder_ = result.order;
items.push(
<div
className={`folders ${this.state.folderHeaderIsExpanded ? 'expanded' : ''}`}
key="folder_items"
style={{ display: this.state.folderHeaderIsExpanded ? 'block' : 'none', paddingBottom: 10 }}
>
{folderItems}
</div>
);
}
items.push(
this.renderHeader('tagHeader', _('Tags'), 'icon-tags', null, null, {
toggleblock: 1,
})
);
if (this.props.tags.length) {
const result = shared.renderTags(this.props, this.renderTag.bind(this));
const tagItems = result.items;
this.tagItemsOrder_ = result.order;
items.push(
<div className="tags" key="tag_items" style={{ display: this.state.tagHeaderIsExpanded ? 'block' : 'none' }}>
{tagItems}
</div>
);
}
let decryptionReportText = '';
if (this.props.decryptionWorker && this.props.decryptionWorker.state !== 'idle' && this.props.decryptionWorker.itemCount) {
decryptionReportText = _('Decrypting items: %d/%d', this.props.decryptionWorker.itemIndex + 1, this.props.decryptionWorker.itemCount);
}
let resourceFetcherText = '';
if (this.props.resourceFetcher && this.props.resourceFetcher.toFetchCount) {
resourceFetcherText = _('Fetching resources: %d/%d', this.props.resourceFetcher.fetchingCount, this.props.resourceFetcher.toFetchCount);
}
const lines = Synchronizer.reportToLines(this.props.syncReport);
if (resourceFetcherText) lines.push(resourceFetcherText);
if (decryptionReportText) lines.push(decryptionReportText);
const syncReportText = [];
for (let i = 0; i < lines.length; i++) {
syncReportText.push(
<StyledSyncReportText key={i}>
{lines[i]}
</StyledSyncReportText>
);
}
const syncButton = this.renderSynchronizeButton(this.props.syncStarted ? 'cancel' : 'sync');
const syncReportComp = !syncReportText.length ? null : (
<StyledSyncReport key="sync_report">
{syncReportText}
</StyledSyncReport>
);
return (
<StyledRoot ref={this.rootRef} onKeyDown={this.onKeyDown} className="sidebar">
<div style={{ flex: 1, overflowX: 'hidden', overflowY: 'auto' }}>{items}</div>
<div style={{ flex: 0, padding: theme.mainPadding }}>
{syncReportComp}
{syncButton}
</div>
</StyledRoot>
<div className="tags" key="tag_items" style={{ display: props.tagHeaderIsExpanded ? 'block' : 'none' }}>
{tagItems}
</div>
);
}
}
let decryptionReportText = '';
if (props.decryptionWorker && props.decryptionWorker.state !== 'idle' && props.decryptionWorker.itemCount) {
decryptionReportText = _('Decrypting items: %d/%d', props.decryptionWorker.itemIndex + 1, props.decryptionWorker.itemCount);
}
let resourceFetcherText = '';
if (props.resourceFetcher && props.resourceFetcher.toFetchCount) {
resourceFetcherText = _('Fetching resources: %d/%d', props.resourceFetcher.fetchingCount, props.resourceFetcher.toFetchCount);
}
const lines = Synchronizer.reportToLines(props.syncReport);
if (resourceFetcherText) lines.push(resourceFetcherText);
if (decryptionReportText) lines.push(decryptionReportText);
const syncReportText = [];
for (let i = 0; i < lines.length; i++) {
syncReportText.push(
<StyledSyncReportText key={i}>
{lines[i]}
</StyledSyncReportText>
);
}
const syncButton = renderSynchronizeButton(props.syncStarted ? 'cancel' : 'sync');
const syncReportComp = !syncReportText.length ? null : (
<StyledSyncReport key="sync_report">
{syncReportText}
</StyledSyncReport>
);
return (
<StyledRoot ref={rootRef} onKeyDown={onKeyDown} className="sidebar">
<div style={{ flex: 1, overflowX: 'hidden', overflowY: 'auto' }}>{items}</div>
<div style={{ flex: 0, padding: theme.mainPadding }}>
{syncReportComp}
{syncButton}
</div>
</StyledRoot>
);
};
const mapStateToProps = (state: AppState) => {
return {
@@ -799,6 +788,8 @@ const mapStateToProps = (state: AppState) => {
decryptionWorker: state.decryptionWorker,
resourceFetcher: state.resourceFetcher,
plugins: state.pluginService.plugins,
tagHeaderIsExpanded: state.settings.tagHeaderIsExpanded,
folderHeaderIsExpanded: state.settings.folderHeaderIsExpanded,
};
};

View File

@@ -9,18 +9,24 @@ export const declaration: CommandDeclaration = {
parentLabel: () => _('Focus'),
};
export const runtime = (comp: any): CommandRuntime => {
export interface RuntimeProps {
getSelectedItem(): any;
getFirstAnchorItemRef(type: string): any;
anchorItemRefs: any;
}
export const runtime = (props: RuntimeProps): CommandRuntime => {
return {
execute: async (context: CommandContext) => {
const sidebarVisible = layoutItemProp((context.state as AppState).mainLayout, 'sideBar', 'visible');
if (sidebarVisible) {
const item = comp.selectedItem();
const item = props.getSelectedItem();
if (item) {
const anchorRef = comp.anchorItemRefs[item.type][item.id];
const anchorRef = props.anchorItemRefs.current[item.type][item.id];
if (anchorRef) anchorRef.current.focus();
} else {
const anchorRef = comp.firstAnchorItemRef('folder');
const anchorRef = props.getFirstAnchorItemRef('folder');
if (anchorRef) anchorRef.current.focus();
}
}

View File

@@ -23,5 +23,6 @@ export default function useEffectDebugger(effectHook: any, dependencies: any, de
console.log('[use-effet-debugger] ', changedDeps);
}
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
useEffect(effectHook, dependencies);
}

View File

@@ -23,5 +23,6 @@ export default function useImperativeHandleDebugger(ref: any, effectHook: any, d
console.log('[use-imperativeHandler-debugger] ', changedDeps);
}
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
useImperativeHandle(ref, effectHook, dependencies);
}

View File

@@ -579,6 +579,17 @@
}
}
ipc.textSelected = function(event) {
ipcProxySendToHost('contextMenu', {
type: 'text',
textToCopy: event.text,
});
}
ipc.openPdfViewer = function(event) {
ipcProxySendToHost('openPdfViewer', { resourceId: event.resourceId, mime: 'application/pdf', pageNo: event.pageNo || 1 });
}
window.addEventListener('hashchange', webviewLib.logEnabledEventHandler(e => {
if (!window.location.hash) return;
@@ -653,6 +664,17 @@
e.preventDefault();
}));
document.addEventListener('click', webviewLib.logEnabledEventHandler(e => {
document.querySelectorAll('.media-pdf').forEach(element => {
if(!!element.contentWindow){
element.contentWindow.postMessage({
type: 'blur'
}, '*');
}
}
);
}));
let lastClientWidth_ = NaN, lastClientHeight_ = NaN, lastScrollTop_ = NaN;
window.addEventListener('resize', webviewLib.logEnabledEventHandler(() => {

View File

@@ -1,6 +1,6 @@
{
"name": "@joplin/app-desktop",
"version": "2.9.1",
"version": "2.9.8",
"description": "Joplin for Desktop",
"main": "main.js",
"private": true,
@@ -31,7 +31,8 @@
"afterSign": "./tools/notarizeMacApp.js",
"extraResources": [
"build/icons/**",
"build/images/**"
"build/images/**",
"build/defaultPlugins/**"
],
"afterAllArtifactBuild": "./generateSha512.js",
"asar": true,
@@ -138,6 +139,7 @@
"@fortawesome/fontawesome-free": "^5.13.0",
"@joeattardi/emoji-button": "^4.6.0",
"@joplin/lib": "~2.9",
"@joplin/pdf-viewer": "~2.9",
"@joplin/renderer": "~2.9",
"async-mutex": "^0.1.3",
"codemirror": "^5.56.0",

View File

@@ -69,6 +69,7 @@ function UserWebview(props: Props, ref: any) {
useEffect(() => {
if (isReady && props.onReady) props.onReady();
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [isReady]);
function frameWindow() {

View File

@@ -33,6 +33,7 @@ export default function(frameWindow: any, htmlHash: string, minWidth: number, mi
useEffect(() => {
updateContentSize(htmlHash);
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [htmlHash]);
useEffect(() => {
@@ -55,6 +56,7 @@ export default function(frameWindow: any, htmlHash: string, minWidth: number, mi
return () => {
clearInterval(updateFrameSizeIID);
};
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [fitToContent, isReady, minWidth, minHeight, htmlHash]);
return contentSize;

View File

@@ -45,6 +45,7 @@ export default function(frameWindow: any, isReady: boolean, postMessage: Functio
hash: htmlHash,
html: html,
});
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [html, htmlHash, isReady]);
return loadedHtmlHash;

View File

@@ -4,10 +4,12 @@ export default function(postMessage: Function, isReady: boolean, scripts: string
useEffect(() => {
if (!isReady) return;
postMessage('setScripts', { scripts: scripts });
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [scripts, isReady]);
useEffect(() => {
if (!isReady || !cssFilePath) return;
postMessage('setScript', { script: cssFilePath, key: 'themeCss' });
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [isReady, cssFilePath]);
}

View File

@@ -43,8 +43,10 @@ export default function useViewIsReady(viewRef: any) {
return () => {
viewRef.current.removeEventListener('dom-ready', onIFrameReady);
viewRef.current.removeEventListener('load', onIFrameReady);
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
viewRef.current.contentWindow.removeEventListener('message', onMessage);
};
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, []);
return iframeReady && iframeContentReady;

View File

@@ -10,6 +10,7 @@ export default function(frameWindow: any, isReady: boolean, pluginId: string, vi
return () => {
PostMessageService.instance().unregisterResponder(ResponderComponentType.UserWebview, viewId);
};
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [viewId]);
useEffect(() => {
@@ -39,5 +40,6 @@ export default function(frameWindow: any, isReady: boolean, pluginId: string, vi
return () => {
frameWindow.removeEventListener('message', onMessage_);
};
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [frameWindow, isReady, pluginId, viewId]);
}

View File

@@ -2,7 +2,6 @@
import SpellCheckerServiceDriverBase from '@joplin/lib/services/spellChecker/SpellCheckerServiceDriverBase';
import bridge from '../bridge';
import { languageCodeOnly, localesFromLanguageCode } from '@joplin/lib/locale';
import Logger from '@joplin/lib/Logger';
const logger = Logger.create('SpellCheckerServiceDriverNative');
@@ -17,35 +16,17 @@ export default class SpellCheckerServiceDriverNative extends SpellCheckerService
return this.session().availableSpellCheckerLanguages;
}
// Language can be set to '' to disable spell-checking
public setLanguage(v: string) {
// Language can be set to [] to disable spell-checking
public setLanguages(v: string[]) {
// If we pass an empty array, it disables spell checking
// https://github.com/electron/electron/issues/25228
if (!v) {
if (v.length === 0) {
this.session().setSpellCheckerLanguages([]);
return;
}
// The below function will throw an error if the provided language is
// not supported, so we provide fallbacks.
// https://github.com/laurent22/joplin/issues/4146
const languagesToTry = [
v,
languageCodeOnly(v),
].concat(localesFromLanguageCode(languageCodeOnly(v), this.availableLanguages));
for (const toTry of languagesToTry) {
try {
this.session().setSpellCheckerLanguages([toTry]);
logger.info(`Set effective language from "${v}" to "${toTry}"`);
return;
} catch (error) {
logger.warn(`Failed to set language to "${toTry}". Will try the next one in this list: ${JSON.stringify(languagesToTry)}`);
logger.warn('Error was:', error);
}
}
logger.error(`Could not set language to: ${v}`);
this.session().setSpellCheckerLanguages(v);
logger.info(`Set effective languages to "${v}"`);
}
public get language(): string {

View File

@@ -13,46 +13,49 @@ function fileIsNewerThan(path1, path2) {
return stat1.mtime > stat2.mtime;
}
function convertJsx(path) {
function convertJsx(paths) {
chdir(`${__dirname}/..`);
fs.readdirSync(path).forEach((filename) => {
const jsxPath = `${path}/${filename}`;
const p = jsxPath.split('.');
if (p.length <= 1) return;
const ext = p[p.length - 1];
if (ext !== 'jsx') return;
p.pop();
paths.forEach(path => {
fs.readdirSync(path).forEach((filename) => {
const jsxPath = `${path}/${filename}`;
const p = jsxPath.split('.');
if (p.length <= 1) return;
const ext = p[p.length - 1];
if (ext !== 'jsx') return;
p.pop();
const basePath = p.join('.');
const basePath = p.join('.');
const jsPath = `${basePath}.min.js`;
const jsPath = `${basePath}.min.js`;
if (fileIsNewerThan(jsxPath, jsPath)) {
console.info(`Compiling ${jsxPath}...`);
if (fileIsNewerThan(jsxPath, jsPath)) {
console.info(`Compiling ${jsxPath}...`);
// { shell: true } is needed to get it working on Windows:
// https://discourse.joplinapp.org/t/attempting-to-build-on-windows/22559/12
const result = spawnSync('yarn', ['run', 'babel', '--presets', 'react', '--out-file', jsPath, jsxPath], { shell: true });
if (result.status !== 0) {
const msg = [];
if (result.stdout) msg.push(result.stdout.toString());
if (result.stderr) msg.push(result.stderr.toString());
console.error(msg.join('\n'));
if (result.error) console.error(result.error);
process.exit(result.status);
// { shell: true } is needed to get it working on Windows:
// https://discourse.joplinapp.org/t/attempting-to-build-on-windows/22559/12
const result = spawnSync('yarn', ['run', 'babel', '--presets', 'react', '--out-file', jsPath, jsxPath], { shell: true });
if (result.status !== 0) {
const msg = [];
if (result.stdout) msg.push(result.stdout.toString());
if (result.stderr) msg.push(result.stderr.toString());
console.error(msg.join('\n'));
if (result.error) console.error(result.error);
process.exit(result.status);
}
}
}
});
});
}
module.exports = function() {
convertJsx(`${__dirname}/../gui`);
convertJsx(`${__dirname}/../gui/MainScreen`);
convertJsx(`${__dirname}/../gui/NoteList`);
convertJsx(`${__dirname}/../plugins`);
convertJsx([
`${__dirname}/../gui`,
`${__dirname}/../gui/MainScreen`,
`${__dirname}/../gui/NoteList`,
`${__dirname}/../plugins`,
]);
// TODO: should get from node_modules @joplin/lib
const libContent = [
fs.readFileSync(`${basePath}/packages/lib/string-utils-common.js`, 'utf8'),
fs.readFileSync(`${basePath}/packages/lib/markJsUtils.js`, 'utf8'),

View File

@@ -72,6 +72,10 @@ async function main() {
src: langSourceDir,
dest: `${buildLibDir}/tinymce/langs`,
},
{
src: resolve(__dirname, '../../pdf-viewer/dist'),
dest: `${buildLibDir}/@joplin/pdf-viewer`,
},
];
const files = [
@@ -87,6 +91,10 @@ async function main() {
src: resolve(__dirname, '../../lib/services/plugins/sandboxProxy.js'),
dest: `${buildLibDir}/@joplin/lib/services/plugins/sandboxProxy.js`,
},
{
src: resolve(__dirname, '../../pdf-viewer/index.html'),
dest: `${buildLibDir}/@joplin/pdf-viewer/index.html`,
},
];
// First we delete all the destination directories, then we copy the files.

View File

@@ -146,8 +146,8 @@ android {
applicationId "net.cozic.joplin"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 2097668
versionName "2.9.0"
versionCode 2097673
versionName "2.9.5"
ndk {
abiFilters "armeabi-v7a", "x86", "arm64-v8a", "x86_64"
}

View File

@@ -1,6 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="net.cozic.joplin"
android:installLocation="auto">
package="net.cozic.joplin"
android:installLocation="auto">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
@@ -8,7 +8,7 @@
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<!-- Make these features optional to enable Chromebooks -->
<!-- Make these features optional to enable Chromebooks -->
<!-- https://github.com/laurent22/joplin/issues/37 -->
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />
@@ -58,14 +58,19 @@
This is recommended in the React docs: https://reactnavigation.org/docs/deep-linking. In practice, "singleTask" and "singleTop" are
largely similar, but "singleTask" is more strict in preventing multiple instances of the app from being created if another app
explicitly requests it.
2022-08-12: Added `screenLayout` and `smallestScreenSize` to `android:configChanges`.
This prevents the application from being re-constructed on
screen orientation change/window resizes on some devices.
See https://github.com/laurent22/joplin/pull/6737.
-->
<activity
android:name=".MainActivity"
android:label="@string/app_name"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|uiMode"
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize">
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
@@ -93,6 +98,6 @@
</activity>
<!-- /SHARE EXTENSION -->
</application>
</application>
</manifest>

View File

@@ -0,0 +1,190 @@
//
// A button with a long-press action. Long-pressing the button displays a tooltip
//
const React = require('react');
import { ReactNode } from 'react';
import { themeStyle } from '@joplin/lib/theme';
import { Theme } from '@joplin/lib/themes/type';
import { useState, useMemo, useCallback, useRef } from 'react';
import { View, Text, Pressable, ViewStyle, PressableStateCallbackType, StyleProp, StyleSheet, LayoutChangeEvent, LayoutRectangle, Animated, AccessibilityState, AccessibilityRole } from 'react-native';
import { Menu, MenuOptions, MenuTrigger, renderers } from 'react-native-popup-menu';
type ButtonClickListener = ()=> void;
interface ButtonProps {
onPress: ButtonClickListener;
// Accessibility label and text shown in a tooltip
description?: string;
children: ReactNode;
themeId: number;
style?: ViewStyle;
pressedStyle?: ViewStyle;
contentStyle?: ViewStyle;
// Additional accessibility information. See View.accessibilityHint
accessibilityHint?: string;
// Role of the button. Defaults to 'button'.
accessibilityRole?: AccessibilityRole;
accessibilityState?: AccessibilityState;
disabled?: boolean;
}
const CustomButton = (props: ButtonProps) => {
const [tooltipVisible, setTooltipVisible] = useState(false);
const [buttonLayout, setButtonLayout] = useState<LayoutRectangle|null>(null);
const tooltipStyles = useTooltipStyles(props.themeId);
// See https://blog.logrocket.com/react-native-touchable-vs-pressable-components/
// for more about animating Pressable buttons.
const fadeAnim = useRef(new Animated.Value(1)).current;
const animationDuration = 100; // ms
const onPressIn = useCallback(() => {
// Fade out.
Animated.timing(fadeAnim, {
toValue: 0.5,
duration: animationDuration,
useNativeDriver: true,
}).start();
}, [fadeAnim]);
const onPressOut = useCallback(() => {
// Fade in.
Animated.timing(fadeAnim, {
toValue: 1,
duration: animationDuration,
useNativeDriver: true,
}).start();
setTooltipVisible(false);
}, [fadeAnim]);
const onLongPress = useCallback(() => {
setTooltipVisible(true);
}, []);
// Select different user-specified styles if selected/unselected.
const onStyleChange = useCallback((state: PressableStateCallbackType): StyleProp<ViewStyle> => {
let result = { ...props.style };
if (state.pressed) {
result = {
...result,
...props.pressedStyle,
};
}
return result;
}, [props.pressedStyle, props.style]);
const onButtonLayout = useCallback((event: LayoutChangeEvent) => {
const layoutEvt = event.nativeEvent.layout;
// Copy the layout event
setButtonLayout({ ...layoutEvt });
}, []);
const button = (
<Pressable
onPress={props.onPress}
onLongPress={onLongPress}
onPressIn={onPressIn}
onPressOut={onPressOut}
style={ onStyleChange }
disabled={ props.disabled ?? false }
onLayout={ onButtonLayout }
accessibilityLabel={props.description}
accessibilityHint={props.accessibilityHint}
accessibilityRole={props.accessibilityRole ?? 'button'}
accessibilityState={props.accessibilityState}
>
<Animated.View style={{
opacity: fadeAnim,
...props.contentStyle,
}}>
{ props.children }
</Animated.View>
</Pressable>
);
const tooltip = (
<View
// Any information given by the tooltip should also be provided via
// [accessibilityLabel]/[accessibilityHint]. As such, we can hide the tooltip
// from the screen reader.
// On Android:
importantForAccessibility='no-hide-descendants'
// On iOS:
accessibilityElementsHidden={true}
// Position the menu beneath the button so the tooltip appears in the
// correct location.
style={{
left: buttonLayout?.x,
top: buttonLayout?.y,
position: 'absolute',
zIndex: -1,
}}
>
<Menu
opened={tooltipVisible}
renderer={renderers.Popover}
rendererProps={{
preferredPlacement: 'bottom',
anchorStyle: tooltipStyles.anchor,
}}>
<MenuTrigger
// Don't show/hide when pressed (let the Pressable handle opening/closing)
disabled={true}
style={{
// Ensure that the trigger region has the same size as the button.
width: buttonLayout?.width ?? 0,
height: buttonLayout?.height ?? 0,
}}
/>
<MenuOptions
customStyles={{ optionsContainer: tooltipStyles.optionsContainer }}
>
<Text style={tooltipStyles.text}>
{props.description}
</Text>
</MenuOptions>
</Menu>
</View>
);
return (
<>
{props.description ? tooltip : null}
{button}
</>
);
};
const useTooltipStyles = (themeId: number) => {
return useMemo(() => {
const themeData: Theme = themeStyle(themeId);
return StyleSheet.create({
text: {
color: themeData.raisedColor,
padding: 4,
},
anchor: {
backgroundColor: themeData.raisedBackgroundColor,
},
optionsContainer: {
backgroundColor: themeData.raisedBackgroundColor,
},
});
}, [themeId]);
};
export default CustomButton;

View File

@@ -1,31 +1,60 @@
const React = require('react');
const { TouchableOpacity, TouchableWithoutFeedback, Dimensions, Text, Modal, View } = require('react-native');
import { TouchableOpacity, TouchableWithoutFeedback, Dimensions, Text, Modal, View, LayoutRectangle, ViewStyle, TextStyle } from 'react-native';
import { Component } from 'react';
const { ItemList } = require('./ItemList.js');
class Dropdown extends React.Component {
constructor() {
super();
type ValueType = string;
export interface DropdownListItem {
label: string;
value: ValueType;
}
this.headerRef_ = null;
}
export type OnValueChangedListener = (newValue: ValueType)=> void;
UNSAFE_componentWillMount() {
this.setState({
interface DropdownProps {
listItemStyle?: ViewStyle;
itemListStyle?: ViewStyle;
itemWrapperStyle?: ViewStyle;
headerWrapperStyle?: ViewStyle;
headerStyle?: TextStyle;
itemStyle?: TextStyle;
disabled?: boolean;
labelTransform?: 'trim';
items: DropdownListItem[];
selectedValue: ValueType|null;
onValueChange?: OnValueChangedListener;
}
interface DropdownState {
headerSize: LayoutRectangle;
listVisible: boolean;
}
class Dropdown extends Component<DropdownProps, DropdownState> {
private headerRef: TouchableOpacity;
public constructor(props: DropdownProps) {
super(props);
this.headerRef = null;
this.state = {
headerSize: { x: 0, y: 0, width: 0, height: 0 },
listVisible: false,
});
};
}
updateHeaderCoordinates() {
private updateHeaderCoordinates() {
// https://stackoverflow.com/questions/30096038/react-native-getting-the-position-of-an-element
this.headerRef_.measure((fx, fy, width, height, px, py) => {
this.headerRef.measure((_fx, _fy, width, height, px, py) => {
this.setState({
headerSize: { x: px, y: py, width: width, height: height },
});
});
}
render() {
public render() {
const items = this.props.items;
const itemHeight = 60;
const windowHeight = Dimensions.get('window').height - 50;
@@ -84,23 +113,26 @@ class Dropdown extends React.Component {
}
}
if (this.props.labelTransform && this.props.labelTransform === 'trim') headerLabel = headerLabel.trim();
if (this.props.labelTransform && this.props.labelTransform === 'trim') {
headerLabel = headerLabel.trim();
}
const closeList = () => {
this.setState({ listVisible: false });
};
const itemRenderer = item => {
const itemRenderer = (item: DropdownListItem) => {
const key = item.value ? item.value.toString() : '__null'; // The top item ("Move item to notebook...") has a null value.
return (
<TouchableOpacity
style={itemWrapperStyle}
key={item.value}
key={key}
onPress={() => {
closeList();
if (this.props.onValueChange) this.props.onValueChange(item.value);
}}
>
<Text ellipsizeMode="tail" numberOfLines={1} style={itemStyle} key={item.value}>
<Text ellipsizeMode="tail" numberOfLines={1} style={itemStyle} key={key}>
{item.label}
</Text>
</TouchableOpacity>
@@ -111,7 +143,7 @@ class Dropdown extends React.Component {
<View style={{ flex: 1, flexDirection: 'column' }}>
<TouchableOpacity
style={headerWrapperStyle}
ref={ref => (this.headerRef_ = ref)}
ref={ref => (this.headerRef = ref)}
disabled={this.props.disabled}
onPress={() => {
this.updateHeaderCoordinates();
@@ -141,9 +173,7 @@ class Dropdown extends React.Component {
style={itemListStyle}
items={this.props.items}
itemHeight={itemHeight}
itemRenderer={item => {
return itemRenderer(item);
}}
itemRenderer={itemRenderer}
/>
</View>
</View>
@@ -154,4 +184,5 @@ class Dropdown extends React.Component {
}
}
module.exports = { Dropdown };
export default Dropdown;
export { Dropdown };

View File

@@ -0,0 +1,147 @@
// Wraps react-native-webview. Allows loading HTML directly.
import * as React from 'react';
import {
forwardRef, Ref, useEffect, useImperativeHandle, useRef, useState,
} from 'react';
import { WebView, WebViewMessageEvent } from 'react-native-webview';
import { WebViewErrorEvent, WebViewEvent, WebViewSource } from 'react-native-webview/lib/WebViewTypes';
import Setting from '@joplin/lib/models/Setting';
import { themeStyle } from '@joplin/lib/theme';
import { Theme } from '@joplin/lib/themes/type';
import shim from '@joplin/lib/shim';
import { StyleProp, ViewStyle } from 'react-native';
export interface WebViewControl {
// Evaluate the given [script] in the context of the page.
// Unlike react-native-webview/WebView, this does not need to return true.
injectJS(script: string): void;
}
interface SourceFileUpdateEvent {
uri: string;
baseUrl: string;
filePath: string;
}
type OnMessageCallback = (event: WebViewMessageEvent)=> void;
type OnErrorCallback = (event: WebViewErrorEvent)=> void;
type OnLoadEndCallback = (event: WebViewEvent)=> void;
type OnFileUpdateCallback = (event: SourceFileUpdateEvent)=> void;
interface Props {
themeId: number;
// A name to be associated with the WebView (e.g. NoteEditor)
// This name should be unique.
webviewInstanceId: string;
// If HTML is still being loaded, [html] should be an empty string.
html: string;
// Allow a secure origin to load content from any other origin.
// Defaults to 'never'.
// See react-native-webview's prop with the same name.
mixedContentMode?: 'never' | 'always';
// Initial javascript. Must evaluate to true.
injectedJavaScript: string;
style?: StyleProp<ViewStyle>;
onMessage: OnMessageCallback;
onError: OnErrorCallback;
onLoadEnd?: OnLoadEndCallback;
// Triggered when the file containing [html] is overwritten with new content.
onFileUpdate?: OnFileUpdateCallback;
}
const ExtendedWebView = (props: Props, ref: Ref<WebViewControl>) => {
const theme: Theme = themeStyle(props.themeId);
const webviewRef = useRef(null);
const [source, setSource] = useState<WebViewSource|undefined>(undefined);
useImperativeHandle(ref, (): WebViewControl => {
return {
injectJS(js: string) {
webviewRef.current.injectJavaScript(`
try {
${js}
}
catch(e) {
logMessage('Error in injected JS:' + e, e);
throw e;
};
true;`);
},
};
});
useEffect(() => {
let cancelled = false;
async function createHtmlFile() {
const tempFile = `${Setting.value('resourceDir')}/${props.webviewInstanceId}.html`;
await shim.fsDriver().writeFile(tempFile, props.html, 'utf8');
if (cancelled) return;
// Now that we are sending back a file instead of an HTML string, we're always sending back the
// same file. So we add a cache busting query parameter to it, to make sure that the WebView re-renders.
//
// `baseUrl` is where the images will be loaded from. So images must use a path relative to resourceDir.
const newSource = {
uri: `file://${tempFile}?r=${Math.round(Math.random() * 100000000)}`,
baseUrl: `file://${Setting.value('resourceDir')}/`,
};
setSource(newSource);
props.onFileUpdate?.({
...newSource,
filePath: tempFile,
});
}
if (props.html && props.html.length > 0) {
void createHtmlFile();
} else {
setSource(undefined);
}
return () => {
cancelled = true;
};
}, [props.html, props.webviewInstanceId, props.onFileUpdate]);
// - `setSupportMultipleWindows` must be `true` for security reasons:
// https://github.com/react-native-webview/react-native-webview/releases/tag/v11.0.0
// - `scrollEnabled` prevents iOS from scrolling the document (has no effect on Android)
// when an editable region (e.g. a the full-screen NoteEditor) is focused.
return (
<WebView
style={{
backgroundColor: theme.backgroundColor,
...(props.style as any),
}}
ref={webviewRef}
scrollEnabled={false}
useWebKit={true}
source={source}
setSupportMultipleWindows={true}
hideKeyboardAccessoryView={true}
allowingReadAccessToURL={`file://${Setting.value('resourceDir')}`}
originWhitelist={['file://*', './*', 'http://*', 'https://*']}
mixedContentMode={props.mixedContentMode}
allowFileAccess={true}
injectedJavaScript={props.injectedJavaScript}
onMessage={props.onMessage}
onError={props.onError}
onLoadEnd={props.onLoadEnd}
/>
);
};
export default forwardRef(ExtendedWebView);

View File

@@ -1,16 +1,14 @@
import { useRef, useCallback } from 'react';
import Setting from '@joplin/lib/models/Setting';
import useSource from './hooks/useSource';
import useOnMessage from './hooks/useOnMessage';
import useOnResourceLongPress from './hooks/useOnResourceLongPress';
const React = require('react');
const { View } = require('react-native');
const { WebView } = require('react-native-webview');
const { themeStyle } = require('../global-style.js');
import { View } from 'react-native';
import BackButtonDialogBox from '../BackButtonDialogBox';
import { reg } from '@joplin/lib/registry';
import ExtendedWebView from '../ExtendedWebView';
interface Props {
themeId: number;
@@ -32,11 +30,9 @@ const webViewStyle = {
};
export default function NoteBodyViewer(props: Props) {
const theme = themeStyle(props.themeId);
const dialogBoxRef = useRef(null);
const { source, injectedJs } = useSource(
const { html, injectedJs } = useSource(
props.noteBody,
props.noteMarkupLanguage,
props.themeId,
@@ -67,6 +63,8 @@ export default function NoteBodyViewer(props: Props) {
reg.logger().error('WebView error');
}
const BackButtonDialogBox_ = BackButtonDialogBox as any;
// On iOS scalesPageToFit work like this:
//
// Find the widest image, resize it *and everything else* by x% so that
@@ -88,21 +86,15 @@ export default function NoteBodyViewer(props: Props) {
// 2020-10-15: As we've now fully switched to WebKit for iOS (useWebKit=true) and
// since the WebView package went through many versions it's possible that
// the above no longer applies.
const BackButtonDialogBox_ = BackButtonDialogBox as any;
return (
<View style={props.style}>
<WebView
theme={theme}
useWebKit={true}
allowingReadAccessToURL={`file://${Setting.value('resourceDir')}`}
<ExtendedWebView
webviewInstanceId='NoteBodyViewer'
themeId={props.themeId}
style={webViewStyle}
source={source}
html={html}
injectedJavaScript={injectedJs.join('\n')}
originWhitelist={['file://*', './*', 'http://*', 'https://*']}
mixedContentMode="always"
allowFileAccess={true}
onLoadEnd={onLoadEnd}
onError={onError}
onMessage={onMessage}

View File

@@ -36,5 +36,6 @@ export default function useOnResourceLongPress(onJoplinLinkClick: Function, dial
reg.logger().error('Could not handle link long press', e);
ToastAndroid.show('An error occurred, check log for details', ToastAndroid.SHORT);
}
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [onJoplinLinkClick]);
}

View File

@@ -5,13 +5,9 @@ const { themeStyle } = require('../../global-style.js');
import markupLanguageUtils from '@joplin/lib/markupLanguageUtils';
const { assetsToHeaders } = require('@joplin/renderer');
interface Source {
uri: string;
baseUrl: string;
}
interface UseSourceResult {
source: Source;
// [html] can be null if the note is still being rendered.
html: string|null;
injectedJs: string[];
}
@@ -24,7 +20,7 @@ function usePrevious(value: any, initialValue: any = null): any {
}
export default function useSource(noteBody: string, noteMarkupLanguage: number, themeId: number, highlightedKeywords: string[], noteResources: any, paddingBottom: number, noteHash: string): UseSourceResult {
const [source, setSource] = useState<Source>(undefined);
const [html, setHtml] = useState<string>('');
const [injectedJs, setInjectedJs] = useState<string[]>([]);
const [resourceLoadedTime, setResourceLoadedTime] = useState(0);
const [isFirstRender, setIsFirstRender] = useState(true);
@@ -39,6 +35,7 @@ export default function useSource(noteBody: string, noteMarkupLanguage: number,
const markupToHtml = useMemo(() => {
return markupLanguageUtils.newMarkupToHtml();
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [isFirstRender]);
// To address https://github.com/laurent22/joplin/issues/433
@@ -82,7 +79,7 @@ export default function useSource(noteBody: string, noteMarkupLanguage: number,
resources: noteResources,
codeTheme: theme.codeThemeCss,
postMessageSyntax: 'window.joplinPostMessage_',
enableLongPress: shim.mobilePlatform() === 'android', // On iOS, there's already a built-on open/share menu
enableLongPress: true,
};
// Whenever a resource state changes, for example when it goes from "not downloaded" to "downloaded", the "noteResources"
@@ -168,20 +165,7 @@ export default function useSource(noteBody: string, noteMarkupLanguage: number,
</html>
`;
const tempFile = `${Setting.value('resourceDir')}/NoteBodyViewer.html`;
await shim.fsDriver().writeFile(tempFile, html, 'utf8');
if (cancelled) return;
// Now that we are sending back a file instead of an HTML string, we're always sending back the
// same file. So we add a cache busting query parameter to it, to make sure that the WebView re-renders.
//
// `baseUrl` is where the images will be loaded from. So images must use a path relative to resourceDir.
setSource({
uri: `file://${tempFile}?r=${Math.round(Math.random() * 100000000)}`,
baseUrl: `file://${Setting.value('resourceDir')}/`,
});
setHtml(html);
setInjectedJs(js);
}
@@ -193,7 +177,7 @@ export default function useSource(noteBody: string, noteMarkupLanguage: number,
if (isFirstRender) {
setIsFirstRender(false);
setSource(undefined);
setHtml('');
setInjectedJs([]);
} else {
void renderNote();
@@ -202,7 +186,8 @@ export default function useSource(noteBody: string, noteMarkupLanguage: number,
return () => {
cancelled = true;
};
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, effectDependencies);
return { source, injectedJs };
return { html, injectedJs };
}

View File

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

View File

@@ -0,0 +1,26 @@
import { markdown } from '@codemirror/lang-markdown';
import { GFM as GithubFlavoredMarkdownExt } from '@lezer/markdown';
import { forceParsing, indentUnit } from '@codemirror/language';
import { SelectionRange, EditorSelection, EditorState } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
import { MarkdownMathExtension } from './markdownMathParser';
// Creates and returns a minimal editor with markdown extensions
const createEditor = (initialText: string, initialSelection: SelectionRange): EditorView => {
const editor = new EditorView({
doc: initialText,
selection: EditorSelection.create([initialSelection]),
extensions: [
markdown({
extensions: [MarkdownMathExtension, GithubFlavoredMarkdownExt],
}),
indentUnit.of('\t'),
EditorState.tabSize.of(4),
],
});
forceParsing(editor);
return editor;
};
export default createEditor;

View File

@@ -0,0 +1,48 @@
<!--
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 = {
katexEnabled: true,
themeData: {
fontSize: 1, // em
fontFamily: 'serif',
backgroundColor: 'black',
color: 'white',
backgroundColor2: '#330',
color2: '#ff0',
backgroundColor3: '#404',
color3: '#f0f',
backgroundColor4: '#555',
color4: '#0ff',
appearance: 'dark',
},
};
window.cm = codeMirrorBundle.initCodeMirror(parent, initialText, settings);
</script>
</body>
</html>

View File

@@ -0,0 +1,47 @@
/**
* @jest-environment jsdom
*/
import { EditorSelection } from '@codemirror/state';
import { ListType } from '../types';
import createEditor from './createEditor';
import { toggleList } from './markdownCommands';
describe('markdownCommands.bulletedVsChecklist', () => {
const bulletedListPart = '- Test\n- This is a test.\n- 3\n- 4\n- 5';
const checklistPart = '- [ ] This is a checklist\n- [ ] with multiple items.\n- [ ] ☑';
const initialDocText = `${bulletedListPart}\n\n${checklistPart}`;
it('should remove a checklist following a bulleted list without modifying the bulleted list', () => {
const editor = createEditor(
initialDocText, EditorSelection.cursor(bulletedListPart.length + 5)
);
toggleList(ListType.CheckList)(editor);
expect(editor.state.doc.toString()).toBe(
`${bulletedListPart}\n\nThis is a checklist\nwith multiple items.\n☑`
);
});
it('should remove an unordered list following a checklist without modifying the checklist', () => {
const editor = createEditor(
initialDocText, EditorSelection.cursor(bulletedListPart.length - 5)
);
toggleList(ListType.UnorderedList)(editor);
expect(editor.state.doc.toString()).toBe(
`Test\nThis is a test.\n3\n4\n5\n\n${checklistPart}`
);
});
it('should replace a selection of unordered and task lists with a correctly-numbered list', () => {
const editor = createEditor(
initialDocText, EditorSelection.range(0, initialDocText.length)
);
toggleList(ListType.OrderedList)(editor);
expect(editor.state.doc.toString()).toBe(
'1. Test\n2. This is a test.\n3. 3\n4. 4\n5. 5'
+ '\n\n6. This is a checklist\n7. with multiple items.\n8. ☑'
);
});
});

View File

@@ -0,0 +1,266 @@
/**
* @jest-environment jsdom
*/
import { EditorSelection, EditorState, SelectionRange } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
import {
toggleBolded, toggleCode, toggleHeaderLevel, toggleItalicized, toggleMath, updateLink,
} from './markdownCommands';
import { GFM as GithubFlavoredMarkdownExt } from '@lezer/markdown';
import { markdown } from '@codemirror/lang-markdown';
import { MarkdownMathExtension } from './markdownMathParser';
import { indentUnit } from '@codemirror/language';
// Creates and returns a minimal editor with markdown extensions
const createEditor = (initialText: string, initialSelection: SelectionRange): EditorView => {
return new EditorView({
doc: initialText,
selection: EditorSelection.create([initialSelection]),
extensions: [
markdown({
extensions: [MarkdownMathExtension, GithubFlavoredMarkdownExt],
}),
indentUnit.of('\t'),
EditorState.tabSize.of(4),
],
});
};
describe('markdownCommands', () => {
it('should bold/italicize everything selected', () => {
const initialDocText = 'Testing...';
const editor = createEditor(
initialDocText, EditorSelection.range(0, initialDocText.length)
);
toggleBolded(editor);
let mainSel = editor.state.selection.main;
const boldedText = '**Testing...**';
expect(editor.state.doc.toString()).toBe(boldedText);
expect(mainSel.from).toBe(0);
expect(mainSel.to).toBe(boldedText.length);
toggleBolded(editor);
mainSel = editor.state.selection.main;
expect(editor.state.doc.toString()).toBe(initialDocText);
expect(mainSel.from).toBe(0);
expect(mainSel.to).toBe(initialDocText.length);
toggleItalicized(editor);
expect(editor.state.doc.toString()).toBe('*Testing...*');
toggleItalicized(editor);
expect(editor.state.doc.toString()).toBe('Testing...');
});
it('for a cursor, bolding, then italicizing, should produce a bold-italic region', () => {
const initialDocText = '';
const editor = createEditor(
initialDocText, EditorSelection.cursor(0)
);
toggleBolded(editor);
toggleItalicized(editor);
expect(editor.state.doc.toString()).toBe('******');
editor.dispatch(editor.state.replaceSelection('Test'));
expect(editor.state.doc.toString()).toBe('***Test***');
toggleItalicized(editor);
editor.dispatch(editor.state.replaceSelection(' Test'));
expect(editor.state.doc.toString()).toBe('***Test*** Test');
});
it('toggling math should both create and navigate out of math regions', () => {
const initialDocText = 'Testing... ';
const editor = createEditor(initialDocText, EditorSelection.cursor(initialDocText.length));
toggleMath(editor);
expect(editor.state.doc.toString()).toBe('Testing... $$');
expect(editor.state.selection.main.empty).toBe(true);
editor.dispatch(editor.state.replaceSelection('3 + 3 \\neq 5'));
expect(editor.state.doc.toString()).toBe('Testing... $3 + 3 \\neq 5$');
toggleMath(editor);
editor.dispatch(editor.state.replaceSelection('...'));
expect(editor.state.doc.toString()).toBe('Testing... $3 + 3 \\neq 5$...');
});
it('toggling inline code should both create and navigate out of an inline code region', () => {
const initialDocText = 'Testing...\n\n';
const editor = createEditor(initialDocText, EditorSelection.cursor(initialDocText.length));
toggleCode(editor);
editor.dispatch(editor.state.replaceSelection('f(x) = ...'));
toggleCode(editor);
editor.dispatch(editor.state.replaceSelection(' is a function.'));
expect(editor.state.doc.toString()).toBe('Testing...\n\n`f(x) = ...` is a function.');
});
it('should set headers to the proper levels (when toggling)', () => {
const initialDocText = 'Testing...\nThis is a test.';
const editor = createEditor(initialDocText, EditorSelection.cursor(3));
toggleHeaderLevel(1)(editor);
let mainSel = editor.state.selection.main;
expect(editor.state.doc.toString()).toBe('# Testing...\nThis is a test.');
expect(mainSel.empty).toBe(true);
expect(mainSel.from).toBe('# Testing...'.length);
toggleHeaderLevel(2)(editor);
mainSel = editor.state.selection.main;
expect(editor.state.doc.toString()).toBe('## Testing...\nThis is a test.');
expect(mainSel.empty).toBe(true);
expect(mainSel.from).toBe('## Testing...'.length);
toggleHeaderLevel(2)(editor);
mainSel = editor.state.selection.main;
expect(editor.state.doc.toString()).toEqual(initialDocText);
expect(mainSel.empty).toBe(true);
expect(mainSel.from).toBe('Testing...'.length);
});
it('headers should toggle properly within block quotes', () => {
const initialDocText = 'Testing...\n\n> This is a test.\n> ...a test';
const editor = createEditor(
initialDocText,
EditorSelection.cursor('Testing...\n\n> This'.length)
);
toggleHeaderLevel(1)(editor);
const mainSel = editor.state.selection.main;
expect(editor.state.doc.toString()).toBe(
'Testing...\n\n> # This is a test.\n> ...a test'
);
expect(mainSel.empty).toBe(true);
expect(mainSel.from).toBe('Testing...\n\n> # This is a test.'.length);
toggleHeaderLevel(3)(editor);
expect(editor.state.doc.toString()).toBe(
'Testing...\n\n> ### This is a test.\n> ...a test'
);
});
it('block math should properly toggle within block quotes', () => {
const initialDocText = 'Testing...\n\n> This is a test.\n> y = mx + b\n> ...a test';
const editor = createEditor(
initialDocText,
EditorSelection.range(
'Testing...\n\n> This'.length,
'Testing...\n\n> This is a test.\n> y = mx + b'.length
)
);
toggleMath(editor);
// Toggling math should surround the content in '$$'s
let mainSel = editor.state.selection.main;
expect(editor.state.doc.toString()).toEqual(
'Testing...\n\n> $$\n> This is a test.\n> y = mx + b\n> $$\n> ...a test'
);
expect(mainSel.from).toBe('Testing...\n\n'.length);
expect(mainSel.to).toBe('Testing...\n\n> $$\n> This is a test.\n> y = mx + b\n> $$'.length);
// Change to a cursor --- test cursor expansion
editor.dispatch({
selection: EditorSelection.cursor('Testing...\n\n> $$\n> This is'.length),
});
// Toggling math again should remove the '$$'s
toggleMath(editor);
mainSel = editor.state.selection.main;
expect(editor.state.doc.toString()).toEqual(initialDocText);
expect(mainSel.from).toBe('Testing...\n\n'.length);
expect(mainSel.to).toBe('Testing...\n\n> This is a test.\n> y = mx + b'.length);
});
it('updateLink should replace link titles and isolate URLs if no title is given', () => {
const initialDocText = '[foo](http://example.com/)';
const editor = createEditor(initialDocText, EditorSelection.cursor('[f'.length));
updateLink('bar', 'https://example.com/')(editor);
expect(editor.state.doc.toString()).toBe(
'[bar](https://example.com/)'
);
updateLink('', 'https://example.com/')(editor);
expect(editor.state.doc.toString()).toBe(
'https://example.com/'
);
});
it('toggling math twice, starting on a line with content, should a math block', () => {
const initialDocText = 'Testing... ';
const editor = createEditor(initialDocText, EditorSelection.cursor(initialDocText.length));
toggleMath(editor);
toggleMath(editor);
editor.dispatch(editor.state.replaceSelection('f(x) = ...'));
expect(editor.state.doc.toString()).toBe('Testing... \n$$\nf(x) = ...\n$$');
});
it('toggling math twice on an empty line should create an empty math block', () => {
const initialDocText = 'Testing...\n\n';
const editor = createEditor(initialDocText, EditorSelection.cursor(initialDocText.length));
toggleMath(editor);
toggleMath(editor);
editor.dispatch(editor.state.replaceSelection('f(x) = ...'));
expect(editor.state.doc.toString()).toBe('Testing...\n\n$$\nf(x) = ...\n$$');
});
it('toggling code twice on an empty line should create an empty code block', () => {
const initialDocText = 'Testing...\n\n';
const editor = createEditor(initialDocText, EditorSelection.cursor(initialDocText.length));
// Toggling code twice should create a block code region
toggleCode(editor);
toggleCode(editor);
editor.dispatch(editor.state.replaceSelection('f(x) = ...'));
expect(editor.state.doc.toString()).toBe('Testing...\n\n```\nf(x) = ...\n```');
toggleCode(editor);
expect(editor.state.doc.toString()).toBe('Testing...\n\nf(x) = ...\n');
});
it('toggling math twice inside a block quote should produce an empty math block', () => {
const initialDocText = '> Testing...> \n> ';
const editor = createEditor(initialDocText, EditorSelection.cursor(initialDocText.length));
toggleMath(editor);
toggleMath(editor);
editor.dispatch(editor.state.replaceSelection('f(x) = ...'));
expect(editor.state.doc.toString()).toBe(
'> Testing...> \n> \n> $$\n> f(x) = ...\n> $$'
);
// If we toggle math again, everything from the start of the line with the first
// $$ to the end of the document should be selected.
toggleMath(editor);
const sel = editor.state.selection.main;
expect(sel.from).toBe('> Testing...> \n> \n'.length);
expect(sel.to).toBe(editor.state.doc.length);
});
it('toggling inline code should both create and navigate out of an inline code region', () => {
const initialDocText = 'Testing...\n\n';
const editor = createEditor(initialDocText, EditorSelection.cursor(initialDocText.length));
toggleCode(editor);
editor.dispatch(editor.state.replaceSelection('f(x) = ...'));
toggleCode(editor);
editor.dispatch(editor.state.replaceSelection(' is a function.'));
expect(editor.state.doc.toString()).toBe('Testing...\n\n`f(x) = ...` is a function.');
});
});

View File

@@ -0,0 +1,189 @@
/**
* @jest-environment jsdom
*/
import { EditorSelection, EditorState } from '@codemirror/state';
import {
increaseIndent, toggleList,
} from './markdownCommands';
import { ListType } from '../types';
import createEditor from './createEditor';
describe('markdownCommands.toggleList', () => {
it('should remove the same type of list', () => {
const initialDocText = '- testing\n- this is a test';
const editor = createEditor(
initialDocText,
EditorSelection.cursor(5)
);
toggleList(ListType.UnorderedList)(editor);
expect(editor.state.doc.toString()).toBe(
'testing\nthis is a test'
);
});
it('should insert a numbered list with correct numbering', () => {
const initialDocText = 'Testing...\nThis is a test\nof list toggling...';
const editor = createEditor(
initialDocText,
EditorSelection.cursor('Testing...\nThis is a'.length)
);
toggleList(ListType.OrderedList)(editor);
expect(editor.state.doc.toString()).toBe(
'Testing...\n1. This is a test\nof list toggling...'
);
editor.setState(EditorState.create({
doc: initialDocText,
selection: EditorSelection.range(4, initialDocText.length),
}));
toggleList(ListType.OrderedList)(editor);
expect(editor.state.doc.toString()).toBe(
'1. Testing...\n2. This is a test\n3. of list toggling...'
);
});
const numberedListText = '- 1\n- 2\n- 3\n- 4\n- 5\n- 6\n- 7';
it('should correctly replace an unordered list with a numbered list', () => {
const editor = createEditor(
numberedListText,
EditorSelection.cursor(numberedListText.length)
);
toggleList(ListType.OrderedList)(editor);
expect(editor.state.doc.toString()).toBe(
'1. 1\n2. 2\n3. 3\n4. 4\n5. 5\n6. 6\n7. 7'
);
});
it('should correctly replace an unordered list with a checklist', () => {
const editor = createEditor(
numberedListText,
EditorSelection.cursor(numberedListText.length)
);
toggleList(ListType.CheckList)(editor);
expect(editor.state.doc.toString()).toBe(
'- [ ] 1\n- [ ] 2\n- [ ] 3\n- [ ] 4\n- [ ] 5\n- [ ] 6\n- [ ] 7'
);
});
it('should properly toggle a sublist of a bulleted list', () => {
const preSubListText = '# List test\n * This\n * is\n';
const initialDocText = `${preSubListText}\t* a\n\t* test\n * of list toggling`;
const editor = createEditor(
initialDocText,
EditorSelection.cursor(preSubListText.length + '\t* a'.length)
);
// Indentation should be preserved when changing list types
toggleList(ListType.OrderedList)(editor);
expect(editor.state.doc.toString()).toBe(
'# List test\n * This\n * is\n\t1. a\n\t2. test\n * of list toggling'
);
// The changed region should be selected
expect(editor.state.selection.main.from).toBe(preSubListText.length);
expect(editor.state.selection.main.to).toBe(
`${preSubListText}\t1. a\n\t2. test`.length
);
// Indentation should not be preserved when removing lists
toggleList(ListType.OrderedList)(editor);
expect(editor.state.selection.main.from).toBe(preSubListText.length);
expect(editor.state.doc.toString()).toBe(
'# List test\n * This\n * is\na\ntest\n * of list toggling'
);
// Put the cursor in the middle of the list
editor.dispatch({ selection: EditorSelection.cursor(preSubListText.length) });
// Sublists should be changed
toggleList(ListType.CheckList)(editor);
const expectedChecklistPart =
'# List test\n - [ ] This\n - [ ] is\n - [ ] a\n - [ ] test\n - [ ] of list toggling';
expect(editor.state.doc.toString()).toBe(
expectedChecklistPart
);
editor.dispatch({ selection: EditorSelection.cursor(editor.state.doc.length) });
editor.dispatch(editor.state.replaceSelection('\n\n\n'));
// toggleList should also create a new list if the cursor is on an empty line.
toggleList(ListType.OrderedList)(editor);
editor.dispatch(editor.state.replaceSelection('Test.\n2. Test2\n3. Test3'));
expect(editor.state.doc.toString()).toBe(
`${expectedChecklistPart}\n\n\n1. Test.\n2. Test2\n3. Test3`
);
toggleList(ListType.CheckList)(editor);
expect(editor.state.doc.toString()).toBe(
`${expectedChecklistPart}\n\n\n- [ ] Test.\n- [ ] Test2\n- [ ] Test3`
);
// The entire checklist should have been selected (and thus will now be indented)
increaseIndent(editor);
expect(editor.state.doc.toString()).toBe(
`${expectedChecklistPart}\n\n\n\t- [ ] Test.\n\t- [ ] Test2\n\t- [ ] Test3`
);
});
it('should toggle a numbered list without changing its sublists', () => {
const initialDocText = '1. Foo\n2. Bar\n3. Baz\n\t- Test\n\t- of\n\t- sublists\n4. Foo';
const editor = createEditor(
initialDocText,
EditorSelection.cursor(0)
);
toggleList(ListType.CheckList)(editor);
expect(editor.state.doc.toString()).toBe(
'- [ ] Foo\n- [ ] Bar\n- [ ] Baz\n\t- Test\n\t- of\n\t- sublists\n- [ ] Foo'
);
});
it('should toggle a sublist without changing the parent list', () => {
const initialDocText = '1. This\n2. is\n3. ';
const editor = createEditor(
initialDocText,
EditorSelection.cursor(initialDocText.length)
);
increaseIndent(editor);
expect(editor.state.selection.main.empty).toBe(true);
toggleList(ListType.CheckList)(editor);
expect(editor.state.doc.toString()).toBe(
'1. This\n2. is\n\t- [ ] '
);
editor.dispatch(editor.state.replaceSelection('a test.'));
expect(editor.state.doc.toString()).toBe(
'1. This\n2. is\n\t- [ ] a test.'
);
});
it('should toggle lists properly within block quotes', () => {
const preSubListText = '> # List test\n> * This\n> * is\n';
const initialDocText = `${preSubListText}> \t* a\n> \t* test\n> * of list toggling`;
const editor = createEditor(
initialDocText, EditorSelection.cursor(preSubListText.length + 3)
);
toggleList(ListType.OrderedList)(editor);
expect(editor.state.doc.toString()).toBe(
'> # List test\n> * This\n> * is\n> \t1. a\n> \t2. test\n> * of list toggling'
);
expect(editor.state.selection.main.from).toBe(preSubListText.length);
});
});

View File

@@ -0,0 +1,485 @@
// CodeMirror 6 commands that modify markdown formatting (e.g. toggleBold).
import { EditorView, Command } from '@codemirror/view';
import { ListType } from '../types';
import {
SelectionRange, EditorSelection, ChangeSpec, Line, TransactionSpec,
} from '@codemirror/state';
import { getIndentUnit, indentString, syntaxTree } from '@codemirror/language';
import {
RegionSpec, growSelectionToNode, renumberList,
toggleInlineFormatGlobally, toggleRegionFormatGlobally, toggleSelectedLinesStartWith,
isIndentationEquivalent, stripBlockquote, tabsToSpaces,
} from './markdownReformatter';
const startingSpaceRegex = /^(\s*)/;
export const toggleBolded: Command = (view: EditorView): boolean => {
const spec = RegionSpec.of({ template: '**', nodeName: 'StrongEmphasis' });
const changes = toggleInlineFormatGlobally(view.state, spec);
view.dispatch(changes);
return true;
};
export const toggleItalicized: Command = (view: EditorView): boolean => {
let handledBoldItalicRegion = false;
// Bold-italic regions' starting and ending patterns are similar to italicized regions.
// Thus, we need additional logic to convert bold regions to bold-italic regions.
view.dispatch(view.state.changeByRange((sel: SelectionRange) => {
const changes: ChangeSpec[] = [];
// Only handle cursors (empty selections)
if (sel.empty) {
const doc = view.state.doc;
const selLine = doc.lineAt(sel.from);
const selStartLineIdx = sel.from - selLine.from;
const selEndLineIdx = sel.to - selLine.from;
const beforeSel = selLine.text.substring(0, selStartLineIdx);
const afterSel = selLine.text.substring(selEndLineIdx);
const isBolded = beforeSel.endsWith('**') && afterSel.startsWith('**');
// If at the end of a bold-italic region, exit the region.
if (afterSel.startsWith('***')) {
sel = EditorSelection.cursor(sel.to + 3);
handledBoldItalicRegion = true;
} else if (isBolded) {
// Create a bold-italic region.
changes.push({
from: sel.from,
to: sel.to,
insert: '**',
});
// Move to the center of the bold-italic region (**|**** -> ***|***)
sel = EditorSelection.cursor(sel.to + 1);
handledBoldItalicRegion = true;
}
}
return {
changes,
range: sel,
};
}));
if (!handledBoldItalicRegion) {
const changes = toggleInlineFormatGlobally(view.state, {
nodeName: 'Emphasis',
template: { start: '*', end: '*' },
matcher: { start: /[_*]/g, end: /[_*]/g },
});
view.dispatch(changes);
}
return true;
};
// If the selected region is an empty inline code block, it will be converted to
// a block (fenced) code block.
export const toggleCode: Command = (view: EditorView): boolean => {
const codeFenceRegex = /^```\w*\s*$/;
const inlineRegionSpec = RegionSpec.of({ template: '`', nodeName: 'InlineCode' });
const blockRegionSpec: RegionSpec = {
nodeName: 'FencedCode',
template: { start: '```', end: '```' },
matcher: { start: codeFenceRegex, end: codeFenceRegex },
};
const changes = toggleRegionFormatGlobally(view.state, inlineRegionSpec, blockRegionSpec);
view.dispatch(changes);
return true;
};
export const toggleMath: Command = (view: EditorView): boolean => {
const blockStartRegex = /^\$\$/;
const blockEndRegex = /\$\$\s*$/;
const inlineRegionSpec = RegionSpec.of({ nodeName: 'InlineMath', template: '$' });
const blockRegionSpec = RegionSpec.of({
nodeName: 'BlockMath',
template: '$$',
matcher: {
start: blockStartRegex,
end: blockEndRegex,
},
});
const changes = toggleRegionFormatGlobally(view.state, inlineRegionSpec, blockRegionSpec);
view.dispatch(changes);
return true;
};
export const toggleList = (listType: ListType): Command => {
return (view: EditorView): boolean => {
let state = view.state;
let doc = state.doc;
const orderedListTag = 'OrderedList';
const unorderedListTag = 'BulletList';
// RegExps for different list types. The regular expressions MUST
// be mutually exclusive.
// `(?!\[[ xX]+\]\s?)` means "not followed by [x] or [ ]".
const bulletedRegex = /^\s*([-*])(?!\s\[[ xX]+\])\s?/;
const checklistRegex = /^\s*[-*]\s\[[ xX]+\]\s?/;
const numberedRegex = /^\s*\d+\.\s?/;
const listRegexes: Record<ListType, RegExp> = {
[ListType.OrderedList]: numberedRegex,
[ListType.CheckList]: checklistRegex,
[ListType.UnorderedList]: bulletedRegex,
};
const getContainerType = (line: Line): ListType|null => {
const lineContent = stripBlockquote(line);
// Determine the container's type.
const checklistMatch = lineContent.match(checklistRegex);
const bulletListMatch = lineContent.match(bulletedRegex);
const orderedListMatch = lineContent.match(numberedRegex);
if (checklistMatch) {
return ListType.CheckList;
} else if (bulletListMatch) {
return ListType.UnorderedList;
} else if (orderedListMatch) {
return ListType.OrderedList;
}
return null;
};
const changes: TransactionSpec = state.changeByRange((sel: SelectionRange) => {
const changes: ChangeSpec[] = [];
let containerType: ListType|null = null;
// Total number of characters added (deleted if negative)
let charsAdded = 0;
const originalSel = sel;
let fromLine: Line;
let toLine: Line;
let firstLineIndentation: string;
let firstLineInBlockQuote: boolean;
let fromLineContent: string;
const computeSelectionProps = () => {
fromLine = doc.lineAt(sel.from);
toLine = doc.lineAt(sel.to);
fromLineContent = stripBlockquote(fromLine);
firstLineIndentation = fromLineContent.match(startingSpaceRegex)[0];
firstLineInBlockQuote = (fromLineContent !== fromLine.text);
containerType = getContainerType(fromLine);
};
computeSelectionProps();
const origFirstLineIndentation = firstLineIndentation;
const origContainerType = containerType;
// Grow [sel] to the smallest containing list
if (sel.empty) {
sel = growSelectionToNode(state, sel, [orderedListTag, unorderedListTag]);
computeSelectionProps();
}
// Reset the selection if it seems likely the user didn't want the selection
// to be expanded
const isIndentationDiff =
!isIndentationEquivalent(state, firstLineIndentation, origFirstLineIndentation);
if (isIndentationDiff) {
const expandedRegionIndentation = firstLineIndentation;
sel = originalSel;
computeSelectionProps();
// Use the indentation level of the expanded region if it's greater.
// This makes sense in the case where unindented text is being converted to
// the same type of list as its container. For example,
// 1. Foobar
// unindented text
// that should be made a part of the above list.
//
// becoming
//
// 1. Foobar
// 2. unindented text
// 3. that should be made a part of the above list.
const wasGreaterIndentation = (
tabsToSpaces(state, expandedRegionIndentation).length
> tabsToSpaces(state, firstLineIndentation).length
);
if (wasGreaterIndentation) {
firstLineIndentation = expandedRegionIndentation;
}
} else if (
(origContainerType !== containerType && (origContainerType ?? null) !== null)
|| containerType !== getContainerType(toLine)
) {
// If the container type changed, this could be an artifact of checklists/bulleted
// lists sharing the same node type.
// Find the closest range of the same type of list to the original selection
let newFromLineNo = doc.lineAt(originalSel.from).number;
let newToLineNo = doc.lineAt(originalSel.to).number;
let lastFromLineNo;
let lastToLineNo;
while (newFromLineNo !== lastFromLineNo || newToLineNo !== lastToLineNo) {
lastFromLineNo = newFromLineNo;
lastToLineNo = newToLineNo;
if (lastFromLineNo - 1 >= 1) {
const testFromLine = doc.line(lastFromLineNo - 1);
if (getContainerType(testFromLine) === origContainerType) {
newFromLineNo --;
}
}
if (lastToLineNo + 1 <= doc.lines) {
const testToLine = doc.line(lastToLineNo + 1);
if (getContainerType(testToLine) === origContainerType) {
newToLineNo ++;
}
}
}
sel = EditorSelection.range(
doc.line(newFromLineNo).from,
doc.line(newToLineNo).to
);
computeSelectionProps();
}
// Determine whether the expanded selection should be empty
if (originalSel.empty && fromLine.number === toLine.number) {
sel = EditorSelection.cursor(toLine.to);
}
// Select entire lines (if not just a cursor)
if (!sel.empty) {
sel = EditorSelection.range(fromLine.from, toLine.to);
}
// Number of the item in the list (e.g. 2 for the 2nd item in the list)
let listItemCounter = 1;
for (let lineNum = fromLine.number; lineNum <= toLine.number; lineNum ++) {
const line = doc.line(lineNum);
const lineContent = stripBlockquote(line);
const lineContentFrom = line.to - lineContent.length;
const inBlockQuote = (lineContent !== line.text);
const indentation = lineContent.match(startingSpaceRegex)[0];
const wrongIndentaton = !isIndentationEquivalent(state, indentation, firstLineIndentation);
// If not the right list level,
if (inBlockQuote !== firstLineInBlockQuote || wrongIndentaton) {
// We'll be starting a new list
listItemCounter = 1;
continue;
}
// Don't add list numbers to otherwise empty lines (unless it's the first line)
if (lineNum !== fromLine.number && line.text.trim().length === 0) {
// Do not reset the counter -- the markdown renderer doesn't!
continue;
}
const deleteFrom = lineContentFrom;
let deleteTo = deleteFrom + indentation.length;
// If we need to remove an existing list,
const currentContainer = getContainerType(line);
if (currentContainer !== null) {
const containerRegex = listRegexes[currentContainer];
const containerMatch = lineContent.match(containerRegex);
if (!containerMatch) {
throw new Error(
'Assertion failed: container regex does not match line content.'
);
}
deleteTo = lineContentFrom + containerMatch[0].length;
}
let replacementString;
if (listType === containerType) {
// Delete the existing list if it's the same type as the current
replacementString = '';
} else if (listType === ListType.OrderedList) {
replacementString = `${firstLineIndentation}${listItemCounter}. `;
} else if (listType === ListType.CheckList) {
replacementString = `${firstLineIndentation}- [ ] `;
} else {
replacementString = `${firstLineIndentation}- `;
}
changes.push({
from: deleteFrom,
to: deleteTo,
insert: replacementString,
});
charsAdded -= deleteTo - deleteFrom;
charsAdded += replacementString.length;
listItemCounter++;
}
// Don't change cursors to selections
if (sel.empty) {
// Position the cursor at the end of the last line modified
sel = EditorSelection.cursor(toLine.to + charsAdded);
} else {
sel = EditorSelection.range(
sel.from,
sel.to + charsAdded
);
}
return {
changes,
range: sel,
};
});
view.dispatch(changes);
state = view.state;
doc = state.doc;
// Renumber the list
view.dispatch(state.changeByRange((sel: SelectionRange) => {
return renumberList(state, sel);
}));
return true;
};
};
export const toggleHeaderLevel = (level: number): Command => {
return (view: EditorView): boolean => {
let headerStr = '';
for (let i = 0; i < level; i++) {
headerStr += '#';
}
const matchEmpty = true;
// Remove header formatting for any other level
let changes = toggleSelectedLinesStartWith(
view.state,
new RegExp(
// Check all numbers of #s lower than [level]
`${level - 1 >= 1 ? `(?:^[#]{1,${level - 1}}\\s)|` : ''
// Check all number of #s higher than [level]
}(?:^[#]{${level + 1},}\\s)`
),
'',
matchEmpty
);
view.dispatch(changes);
// Set to the proper header level
changes = toggleSelectedLinesStartWith(
view.state,
// We want exactly [level] '#' characters.
new RegExp(`^[#]{${level}} `),
`${headerStr} `,
matchEmpty
);
view.dispatch(changes);
return true;
};
};
// Prepends the given editor's indentUnit to all lines of the current selection
// and re-numbers modified ordered lists (if any).
export const increaseIndent: Command = (view: EditorView): boolean => {
const matchEmpty = true;
const matchNothing = /$ ^/;
const indentUnit = indentString(view.state, getIndentUnit(view.state));
const changes = toggleSelectedLinesStartWith(
view.state,
// Delete nothing
matchNothing,
// ...and thus always add indentUnit.
indentUnit,
matchEmpty
);
view.dispatch(changes);
// Fix any lists
view.dispatch(view.state.changeByRange((sel: SelectionRange) => {
return renumberList(view.state, sel);
}));
return true;
};
export const decreaseIndent: Command = (view: EditorView): boolean => {
const matchEmpty = true;
const changes = toggleSelectedLinesStartWith(
view.state,
// Assume indentation is either a tab or in units
// of n spaces.
new RegExp(`^(?:[\\t]|[ ]{1,${getIndentUnit(view.state)}})`),
// Don't add new text
'',
matchEmpty
);
view.dispatch(changes);
// Fix any lists
view.dispatch(view.state.changeByRange((sel: SelectionRange) => {
return renumberList(view.state, sel);
}));
return true;
};
export const updateLink = (label: string, url: string): Command => {
// Empty label? Just include the URL.
const linkText = label === '' ? url : `[${label}](${url})`;
return (editor: EditorView): boolean => {
const transaction = editor.state.changeByRange((sel: SelectionRange) => {
const changes = [];
// Search for a link that overlaps [sel]
let linkFrom: number | null = null;
let linkTo: number | null = null;
syntaxTree(editor.state).iterate({
from: sel.from, to: sel.to,
enter: node => {
const haveFoundLink = (linkFrom !== null && linkTo !== null);
if (node.name === 'Link' || (node.name === 'URL' && !haveFoundLink)) {
linkFrom = node.from;
linkTo = node.to;
}
},
});
linkFrom ??= sel.from;
linkTo ??= sel.to;
changes.push({
from: linkFrom, to: linkTo,
insert: linkText,
});
return {
changes,
range: EditorSelection.range(linkFrom, linkFrom + linkText.length),
};
});
editor.dispatch(transaction);
return true;
};
};

View File

@@ -0,0 +1,142 @@
import {
findInlineMatch, MatchSide, RegionSpec, tabsToSpaces, toggleRegionFormatGlobally,
} from './markdownReformatter';
import { Text as DocumentText, EditorSelection, EditorState } from '@codemirror/state';
import { indentUnit } from '@codemirror/language';
describe('markdownReformatter', () => {
const boldSpec: RegionSpec = RegionSpec.of({
template: '**',
});
it('matching a bolded region: should return the length of the match', () => {
const doc = DocumentText.of(['**test**']);
const sel = EditorSelection.range(0, 5);
// matchStart returns the length of the match
expect(findInlineMatch(doc, boldSpec, sel, MatchSide.Start)).toBe(2);
});
it('matching a bolded region: should match the end of a region, if next to the cursor', () => {
const doc = DocumentText.of(['**...** test.']);
const sel = EditorSelection.range(5, 5);
expect(findInlineMatch(doc, boldSpec, sel, MatchSide.End)).toBe(2);
});
it('matching a bolded region: should return -1 if no match is found', () => {
const doc = DocumentText.of(['**...** test.']);
const sel = EditorSelection.range(3, 3);
expect(findInlineMatch(doc, boldSpec, sel, MatchSide.Start)).toBe(-1);
});
it('should match a custom specification of italicized regions', () => {
const spec: RegionSpec = {
template: { start: '*', end: '*' },
matcher: { start: /[*_]/g, end: /[*_]/g },
};
const testString = 'This is a _test_';
const testDoc = DocumentText.of([testString]);
const fullSel = EditorSelection.range('This is a '.length, testString.length);
// should match the start of the region
expect(findInlineMatch(testDoc, spec, fullSel, MatchSide.Start)).toBe(1);
// should match the end of the region
expect(findInlineMatch(testDoc, spec, fullSel, MatchSide.End)).toBe(1);
});
const listSpec: RegionSpec = {
template: { start: ' - ', end: '' },
matcher: {
start: /^\s*[-*]\s/g,
end: /$/g,
},
};
it('matching a custom list: should not match a list if not within the selection', () => {
const doc = DocumentText.of(['- Test...']);
const sel = EditorSelection.range(1, 6);
// Beginning of list not selected: no match
expect(findInlineMatch(doc, listSpec, sel, MatchSide.Start)).toBe(-1);
});
it('matching a custom list: should match start of selected, unindented list', () => {
const doc = DocumentText.of(['- Test...']);
const sel = EditorSelection.range(0, 6);
expect(findInlineMatch(doc, listSpec, sel, MatchSide.Start)).toBe(2);
});
it('matching a custom list: should match start of indented list', () => {
const doc = DocumentText.of([' - Test...']);
const sel = EditorSelection.range(0, 6);
expect(findInlineMatch(doc, listSpec, sel, MatchSide.Start)).toBe(5);
});
it('matching a custom list: should match the end of an item in an indented list', () => {
const doc = DocumentText.of([' - Test...']);
const sel = EditorSelection.range(0, 6);
// Zero-length, but found, selection
expect(findInlineMatch(doc, listSpec, sel, MatchSide.End)).toBe(0);
});
const multiLineTestText = `Internal text manipulation
This is a test...
of block and inline region toggling.`;
const codeFenceRegex = /^``````\w*\s*$/;
const inlineCodeRegionSpec = RegionSpec.of({
template: '`',
nodeName: 'InlineCode',
});
const blockCodeRegionSpec: RegionSpec = {
template: { start: '``````', end: '``````' },
matcher: { start: codeFenceRegex, end: codeFenceRegex },
};
it('should create an empty inline region around the cursor, if given an empty selection', () => {
const initialState: EditorState = EditorState.create({
doc: multiLineTestText,
selection: EditorSelection.cursor(0),
});
const changes = toggleRegionFormatGlobally(
initialState, inlineCodeRegionSpec, blockCodeRegionSpec
);
const newState = initialState.update(changes).state;
expect(newState.doc.toString()).toEqual(`\`\`${multiLineTestText}`);
});
it('should wrap multiple selected lines in block formatting', () => {
const initialState: EditorState = EditorState.create({
doc: multiLineTestText,
selection: EditorSelection.range(0, multiLineTestText.length),
});
const changes = toggleRegionFormatGlobally(
initialState, inlineCodeRegionSpec, blockCodeRegionSpec
);
const newState = initialState.update(changes).state;
const editorText = newState.doc.toString();
expect(editorText).toBe(`\`\`\`\`\`\`\n${multiLineTestText}\n\`\`\`\`\`\``);
expect(newState.selection.main.from).toBe(0);
expect(newState.selection.main.to).toBe(editorText.length);
});
it('should convert tabs to spaces based on indentUnit', () => {
const state: EditorState = EditorState.create({
doc: multiLineTestText,
selection: EditorSelection.cursor(0),
extensions: [
indentUnit.of(' '),
],
});
expect(tabsToSpaces(state, '\t')).toBe(' ');
expect(tabsToSpaces(state, '\t ')).toBe(' ');
expect(tabsToSpaces(state, ' \t ')).toBe(' ');
});
});

View File

@@ -0,0 +1,712 @@
import {
Text as DocumentText, EditorSelection, SelectionRange, ChangeSpec, EditorState, Line, TransactionSpec,
} from '@codemirror/state';
import { getIndentUnit, syntaxTree } from '@codemirror/language';
import { SyntaxNodeRef } from '@lezer/common';
// pregQuote escapes text for usage in regular expressions
const { pregQuote } = require('@joplin/lib/string-utils-common');
// Length of the symbol that starts a block quote
const blockQuoteStartLen = '> '.length;
const blockQuoteRegex = /^>\s/;
// Specifies the update of a single selection region and its contents
type SelectionUpdate = { range: SelectionRange; changes?: ChangeSpec };
// Specifies how a to find the start/stop of a type of formatting
interface RegionMatchSpec {
start: RegExp;
end: RegExp;
}
// Describes a region's formatting
export interface RegionSpec {
// The name of the node corresponding to the region in the syntax tree
nodeName?: string;
// Text to be inserted before and after the region when toggling.
template: { start: string; end: string };
// How to identify the region
matcher: RegionMatchSpec;
}
export namespace RegionSpec { // eslint-disable-line no-redeclare
interface RegionSpecConfig {
nodeName?: string;
template: string | { start: string; end: string };
matcher?: RegionMatchSpec;
}
// Creates a new RegionSpec, given a simplified set of options.
// If [config.template] is a string, it is used as both the starting and ending
// templates.
// Similarly, if [config.matcher] is not given, a matcher is created based on
// [config.template].
export const of = (config: RegionSpecConfig): RegionSpec => {
let templateStart: string, templateEnd: string;
if (typeof config.template === 'string') {
templateStart = config.template;
templateEnd = config.template;
} else {
templateStart = config.template.start;
templateEnd = config.template.end;
}
const matcher: RegionMatchSpec =
config.matcher ?? matcherFromTemplate(templateStart, templateEnd);
return {
nodeName: config.nodeName,
template: { start: templateStart, end: templateEnd },
matcher,
};
};
const matcherFromTemplate = (start: string, end: string): RegionMatchSpec => {
// See https://stackoverflow.com/a/30851002
const escapedStart = pregQuote(start);
const escapedEnd = pregQuote(end);
return {
start: new RegExp(escapedStart, 'g'),
end: new RegExp(escapedEnd, 'g'),
};
};
}
export enum MatchSide {
Start,
End,
}
// Returns the length of a match for this in the given selection,
// -1 if no match is found.
export const findInlineMatch = (
doc: DocumentText, spec: RegionSpec, sel: SelectionRange, side: MatchSide
): number => {
const [regex, template] = (() => {
if (side === MatchSide.Start) {
return [spec.matcher.start, spec.template.start];
} else {
return [spec.matcher.end, spec.template.end];
}
})();
const [startIndex, endIndex] = (() => {
if (!sel.empty) {
return [sel.from, sel.to];
}
const bufferSize = template.length;
if (side === MatchSide.Start) {
return [sel.from - bufferSize, sel.to];
} else {
return [sel.from, sel.to + bufferSize];
}
})();
const searchText = doc.sliceString(startIndex, endIndex);
// Returns true if [idx] is in the right place (the match is at
// the end of the string or the beginning based on startIndex/endIndex).
const indexSatisfies = (idx: number, len: number): boolean => {
idx += startIndex;
if (side === MatchSide.Start) {
return idx === startIndex;
} else {
return idx + len === endIndex;
}
};
// Enforce 'g' flag.
if (!regex.global) {
throw new Error('Regular expressions used by RegionSpec must have the global flag!');
}
// Search from the beginning.
regex.lastIndex = 0;
let foundMatch = null;
let match;
while ((match = regex.exec(searchText)) !== null) {
if (indexSatisfies(match.index, match[0].length)) {
foundMatch = match;
break;
}
}
if (foundMatch) {
const matchLength = foundMatch[0].length;
const matchIndex = foundMatch.index;
// If the match isn't in the right place,
if (indexSatisfies(matchIndex, matchLength)) {
return matchLength;
}
}
return -1;
};
export const stripBlockquote = (line: Line): string => {
const match = line.text.match(blockQuoteRegex);
if (match) {
return line.text.substring(match[0].length);
}
return line.text;
};
export const tabsToSpaces = (state: EditorState, text: string): string => {
const chunks = text.split('\t');
const spaceLen = getIndentUnit(state);
let result = chunks[0];
for (let i = 1; i < chunks.length; i++) {
for (let j = result.length % spaceLen; j < spaceLen; j++) {
result += ' ';
}
result += chunks[i];
}
return result;
};
// Returns true iff [a] (an indentation string) is roughly equivalent to [b].
export const isIndentationEquivalent = (state: EditorState, a: string, b: string): boolean => {
// Consider sublists to be the same as their parent list if they have the same
// label plus or minus 1 space.
return Math.abs(tabsToSpaces(state, a).length - tabsToSpaces(state, b).length) <= 1;
};
// Expands and returns a copy of [sel] to the smallest container node with name in [nodeNames].
export const growSelectionToNode = (
state: EditorState, sel: SelectionRange, nodeNames: string|string[]|null
): SelectionRange => {
if (!nodeNames) {
return sel;
}
const isAcceptableNode = (name: string): boolean => {
if (typeof nodeNames === 'string') {
return name === nodeNames;
}
for (const otherName of nodeNames) {
if (otherName === name) {
return true;
}
}
return false;
};
let newFrom = null;
let newTo = null;
let smallestLen = Infinity;
// Find the smallest range.
syntaxTree(state).iterate({
from: sel.from, to: sel.to,
enter: node => {
if (isAcceptableNode(node.name)) {
if (node.to - node.from < smallestLen) {
newFrom = node.from;
newTo = node.to;
smallestLen = newTo - newFrom;
}
}
},
});
// If it's in such a node,
if (newFrom !== null && newTo !== null) {
return EditorSelection.range(newFrom, newTo);
} else {
return sel;
}
};
// Toggles whether the given selection matches the inline region specified by [spec].
//
// For example, something similar to toggleSurrounded('**', '**') would surround
// every selection range with asterisks (including the caret).
// If the selection is already surrounded by these characters, they are
// removed.
const toggleInlineRegionSurrounded = (
doc: DocumentText, sel: SelectionRange, spec: RegionSpec
): SelectionUpdate => {
let content = doc.sliceString(sel.from, sel.to);
const startMatchLen = findInlineMatch(doc, spec, sel, MatchSide.Start);
const endMatchLen = findInlineMatch(doc, spec, sel, MatchSide.End);
const startsWithBefore = startMatchLen >= 0;
const endsWithAfter = endMatchLen >= 0;
const changes = [];
let finalSelStart = sel.from;
let finalSelEnd = sel.to;
if (startsWithBefore && endsWithAfter) {
// Remove the before and after.
content = content.substring(startMatchLen);
content = content.substring(0, content.length - endMatchLen);
finalSelEnd -= startMatchLen + endMatchLen;
changes.push({
from: sel.from,
to: sel.to,
insert: content,
});
} else {
changes.push({
from: sel.from,
insert: spec.template.start,
});
changes.push({
from: sel.to,
insert: spec.template.start,
});
// If not a caret,
if (!sel.empty) {
// Select the surrounding chars.
finalSelEnd += spec.template.start.length + spec.template.end.length;
} else {
// Position the caret within the added content.
finalSelStart = sel.from + spec.template.start.length;
finalSelEnd = finalSelStart;
}
}
return {
changes,
range: EditorSelection.range(finalSelStart, finalSelEnd),
};
};
// Returns updated selections: For all selections in the given `EditorState`, toggles
// whether each is contained in an inline region of type [spec].
export const toggleInlineSelectionFormat = (
state: EditorState, spec: RegionSpec, sel: SelectionRange
): SelectionUpdate => {
const endMatchLen = findInlineMatch(state.doc, spec, sel, MatchSide.End);
// If at the end of the region, move the
// caret to the end.
// E.g.
// **foobar|**
// **foobar**|
if (sel.empty && endMatchLen > -1) {
const newCursorPos = sel.from + endMatchLen;
return {
range: EditorSelection.cursor(newCursorPos),
};
}
// Grow the selection to encompass the entire node.
const newRange = growSelectionToNode(state, sel, spec.nodeName);
return toggleInlineRegionSurrounded(state.doc, newRange, spec);
};
// Like toggleInlineSelectionFormat, but for all selections in [state].
export const toggleInlineFormatGlobally = (
state: EditorState, spec: RegionSpec
): TransactionSpec => {
const changes = state.changeByRange((sel: SelectionRange) => {
return toggleInlineSelectionFormat(state, spec, sel);
});
return changes;
};
// Toggle formatting in a region, applying block formatting
export const toggleRegionFormatGlobally = (
state: EditorState,
inlineSpec: RegionSpec,
blockSpec: RegionSpec
): TransactionSpec => {
const doc = state.doc;
const preserveBlockQuotes = true;
const getMatchEndPoints = (
match: RegExpMatchArray, line: Line, inBlockQuote: boolean
): [startIdx: number, stopIdx: number] => {
const startIdx = line.from + match.index;
let stopIdx;
// Don't treat '> ' as part of the line's content if we're in a blockquote.
let contentLength = line.text.length;
if (inBlockQuote && preserveBlockQuotes) {
contentLength -= blockQuoteStartLen;
}
// If it matches the entire line, remove the newline character.
if (match[0].length === contentLength) {
stopIdx = line.to + 1;
} else {
stopIdx = startIdx + match[0].length;
// Take into account the extra '> ' characters, if necessary
if (inBlockQuote && preserveBlockQuotes) {
stopIdx += blockQuoteStartLen;
}
}
stopIdx = Math.min(stopIdx, doc.length);
return [startIdx, stopIdx];
};
// Returns a change spec that converts an inline region to a block region
// only if the user's cursor is in an empty inline region.
// For example,
// $|$ -> $$\n|\n$$ where | represents the cursor.
const handleInlineToBlockConversion = (sel: SelectionRange) => {
if (!sel.empty) {
return null;
}
const startMatchLen = findInlineMatch(doc, inlineSpec, sel, MatchSide.Start);
const stopMatchLen = findInlineMatch(doc, inlineSpec, sel, MatchSide.End);
if (startMatchLen >= 0 && stopMatchLen >= 0) {
const fromLine = doc.lineAt(sel.from);
const inBlockQuote = fromLine.text.match(blockQuoteRegex);
let lineStartStr = '\n';
if (inBlockQuote && preserveBlockQuotes) {
lineStartStr = '\n> ';
}
const inlineStart = sel.from - startMatchLen;
const inlineStop = sel.from + stopMatchLen;
// Determine the text that starts the new block (e.g. \n$$\n for
// a math block).
let blockStart = `${blockSpec.template.start}${lineStartStr}`;
if (fromLine.from !== inlineStart) {
// Add a line before to put the start of the block
// on its own line.
blockStart = lineStartStr + blockStart;
}
return {
changes: [
{
from: inlineStart,
to: inlineStop,
insert: `${blockStart}${lineStartStr}${blockSpec.template.end}`,
},
],
range: EditorSelection.cursor(inlineStart + blockStart.length),
};
}
return null;
};
const changes = state.changeByRange((sel: SelectionRange) => {
const blockConversion = handleInlineToBlockConversion(sel);
if (blockConversion) {
return blockConversion;
}
// If we're in the block version, grow the selection to cover the entire region.
sel = growSelectionToNode(state, sel, blockSpec.nodeName);
const fromLine = doc.lineAt(sel.from);
const toLine = doc.lineAt(sel.to);
let fromLineText = fromLine.text;
let toLineText = toLine.text;
let charsAdded = 0;
const changes = [];
// Single line: Inline toggle.
if (fromLine.number === toLine.number) {
return toggleInlineSelectionFormat(state, inlineSpec, sel);
}
// Are all lines in a block quote?
let inBlockQuote = true;
for (let i = fromLine.number; i <= toLine.number; i++) {
const line = doc.line(i);
if (!line.text.match(blockQuoteRegex)) {
inBlockQuote = false;
break;
}
}
// Ignore block quote characters if in a block quote.
if (inBlockQuote && preserveBlockQuotes) {
fromLineText = fromLineText.substring(blockQuoteStartLen);
toLineText = toLineText.substring(blockQuoteStartLen);
}
// Otherwise, we're toggling the block version
const startMatch = blockSpec.matcher.start.exec(fromLineText);
const stopMatch = blockSpec.matcher.end.exec(toLineText);
if (startMatch && stopMatch) {
// Get start and stop indicies for the starting and ending matches
const [fromMatchFrom, fromMatchTo] = getMatchEndPoints(startMatch, fromLine, inBlockQuote);
const [toMatchFrom, toMatchTo] = getMatchEndPoints(stopMatch, toLine, inBlockQuote);
// Delete content of the first line
changes.push({
from: fromMatchFrom,
to: fromMatchTo,
});
charsAdded -= fromMatchTo - fromMatchFrom;
// Delete content of the last line
changes.push({
from: toMatchFrom,
to: toMatchTo,
});
charsAdded -= toMatchTo - toMatchFrom;
} else {
let insertBefore, insertAfter;
if (inBlockQuote && preserveBlockQuotes) {
insertBefore = `> ${blockSpec.template.start}\n`;
insertAfter = `\n> ${blockSpec.template.end}`;
} else {
insertBefore = `${blockSpec.template.start}\n`;
insertAfter = `\n${blockSpec.template.end}`;
}
changes.push({
from: fromLine.from,
insert: insertBefore,
});
changes.push({
from: toLine.to,
insert: insertAfter,
});
charsAdded += insertBefore.length + insertAfter.length;
}
return {
changes,
// Selection should now encompass all lines that were changed.
range: EditorSelection.range(
fromLine.from, toLine.to + charsAdded
),
};
});
return changes;
};
// Toggles whether all lines in the user's selection start with [regex].
export const toggleSelectedLinesStartWith = (
state: EditorState,
regex: RegExp,
template: string,
matchEmpty: boolean,
// Name associated with what [regex] matches (e.g. FencedCode)
nodeName?: string
): TransactionSpec => {
const ignoreBlockQuotes = true;
const getLineContentStart = (line: Line): number => {
if (!ignoreBlockQuotes) {
return line.from;
}
const blockQuoteMatch = line.text.match(blockQuoteRegex);
if (blockQuoteMatch) {
return line.from + blockQuoteMatch[0].length;
}
return line.from;
};
const getLineContent = (line: Line): string => {
const contentStart = getLineContentStart(line);
return line.text.substring(contentStart - line.from);
};
const changes = state.changeByRange((sel: SelectionRange) => {
// Attempt to select all lines in the region
if (nodeName && sel.empty) {
sel = growSelectionToNode(state, sel, nodeName);
}
const doc = state.doc;
const fromLine = doc.lineAt(sel.from);
const toLine = doc.lineAt(sel.to);
let hasProp = false;
let charsAdded = 0;
const changes = [];
const lines = [];
for (let i = fromLine.number; i <= toLine.number; i++) {
const line = doc.line(i);
const text = getLineContent(line);
// If already matching [regex],
if (text.search(regex) === 0) {
hasProp = true;
}
lines.push(line);
}
for (const line of lines) {
const text = getLineContent(line);
const contentFrom = getLineContentStart(line);
// Only process if the line is non-empty.
if (!matchEmpty && text.trim().length === 0
// Treat the first line differently
&& fromLine.number < line.number) {
continue;
}
if (hasProp) {
const match = text.match(regex);
if (!match) {
continue;
}
changes.push({
from: contentFrom,
to: contentFrom + match[0].length,
insert: '',
});
charsAdded -= match[0].length;
} else {
changes.push({
from: contentFrom,
insert: template,
});
charsAdded += template.length;
}
}
// If the selection is empty and a single line was changed, don't grow it.
// (user might be adding a list/header, in which case, selecting the just
// added text isn't helpful)
let newSel;
if (sel.empty && fromLine.number === toLine.number) {
const regionEnd = toLine.to + charsAdded;
newSel = EditorSelection.cursor(regionEnd);
} else {
newSel = EditorSelection.range(fromLine.from, toLine.to + charsAdded);
}
return {
changes,
// Selection should now encompass all lines that were changed.
range: newSel,
};
});
return changes;
};
// Ensures that ordered lists within [sel] are numbered in ascending order.
export const renumberList = (state: EditorState, sel: SelectionRange): SelectionUpdate => {
const doc = state.doc;
const listItemRegex = /^(\s*)(\d+)\.\s?/;
const changes: ChangeSpec[] = [];
const fromLine = doc.lineAt(sel.from);
const toLine = doc.lineAt(sel.to);
let charsAdded = 0;
// Re-numbers ordered lists and sublists with numbers on each line in [linesToHandle]
const handleLines = (linesToHandle: Line[]) => {
let currentGroupIndentation = '';
let nextListNumber = 1;
const listNumberStack: number[] = [];
let prevLineNumber;
for (const line of linesToHandle) {
// Don't re-handle lines.
if (line.number === prevLineNumber) {
continue;
}
prevLineNumber = line.number;
const filteredText = stripBlockquote(line);
const match = filteredText.match(listItemRegex);
const indentation = match[1];
const indentationLen = tabsToSpaces(state, indentation).length;
const targetIndentLen = tabsToSpaces(state, currentGroupIndentation).length;
if (targetIndentLen < indentationLen) {
listNumberStack.push(nextListNumber);
nextListNumber = 1;
} else if (targetIndentLen > indentationLen) {
nextListNumber = listNumberStack.pop() ?? parseInt(match[2], 10);
}
if (targetIndentLen !== indentationLen) {
currentGroupIndentation = indentation;
}
const from = line.to - filteredText.length;
const to = from + match[0].length;
const inserted = `${indentation}${nextListNumber}. `;
nextListNumber++;
changes.push({
from,
to,
insert: inserted,
});
charsAdded -= to - from;
charsAdded += inserted.length;
}
};
const linesToHandle: Line[] = [];
syntaxTree(state).iterate({
from: sel.from,
to: sel.to,
enter: (nodeRef: SyntaxNodeRef) => {
if (nodeRef.name === 'ListItem') {
for (const node of nodeRef.node.parent.getChildren('ListItem')) {
const line = doc.lineAt(node.from);
const filteredText = stripBlockquote(line);
const match = filteredText.match(listItemRegex);
if (match) {
linesToHandle.push(line);
}
}
}
},
});
linesToHandle.sort((a, b) => a.number - b.number);
handleLines(linesToHandle);
// Re-position the selection in a way that makes sense
if (sel.empty) {
sel = EditorSelection.cursor(toLine.to + charsAdded);
} else {
sel = EditorSelection.range(
fromLine.from,
toLine.to + charsAdded
);
}
return {
range: sel,
changes,
};
};

View File

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

View File

@@ -0,0 +1,19 @@
// Handle logging strings when running in a WebView.
// Because this will be running both in a WebView and in nodeJS, we need to use
// globalThis in place of window. We need to tell ESLint that we're doing this:
/* global globalThis*/
export function postMessage(name: string, data: any) {
// Only call postMessage if we're running in a WebView (this code may be called
// in integration tests).
(globalThis as any).ReactNativeWebView?.postMessage(JSON.stringify({
data,
name,
}));
}
export function logMessage(...msg: any[]) {
postMessage('onLog', { value: msg });
}

View File

@@ -0,0 +1,156 @@
// Dialog allowing the user to update/create links
const React = require('react');
const { useState, useEffect, useMemo, useRef } = require('react');
const { StyleSheet } = require('react-native');
const { View, Modal, Text, TextInput, Button } = require('react-native');
import { themeStyle } from '@joplin/lib/theme';
import { _ } from '@joplin/lib/locale';
import { EditorControl } from './types';
import SelectionFormatting from './SelectionFormatting';
import { useCallback } from 'react';
interface LinkDialogProps {
editorControl: EditorControl;
selectionState: SelectionFormatting;
visible: boolean;
themeId: number;
}
const EditLinkDialog = (props: LinkDialogProps) => {
// The content of the link selected in the editor (if any)
const editorLinkData = props.selectionState.linkData ?? {};
const [linkLabel, setLinkLabel] = useState('');
const [linkURL, setLinkURL] = useState('');
const linkInputRef = useRef();
// Reset the label and URL when shown/hidden
useEffect(() => {
setLinkLabel(editorLinkData.linkText ?? props.selectionState.selectedText);
setLinkURL(editorLinkData.linkURL ?? '');
}, [
props.visible, editorLinkData.linkText, props.selectionState.selectedText,
editorLinkData.linkURL,
]);
const [styles, placeholderColor] = useMemo(() => {
const theme = themeStyle(props.themeId);
const styleSheet = StyleSheet.create({
modalContent: {
margin: 15,
padding: 30,
backgroundColor: theme.backgroundColor,
elevation: 5,
shadowOffset: {
width: 1,
height: 1,
},
shadowOpacity: 0.4,
shadowRadius: 1,
},
button: {
color: theme.color2,
backgroundColor: theme.backgroundColor2,
},
text: {
color: theme.color,
},
header: {
color: theme.color,
fontSize: 22,
},
input: {
color: theme.color,
backgroundColor: theme.backgroundColor,
minHeight: 48,
borderBottomColor: theme.backgroundColor3,
borderBottomWidth: 1,
},
inputContainer: {
flexDirection: 'column',
paddingBottom: 10,
},
});
const placeholderColor = theme.colorFaded;
return [styleSheet, placeholderColor];
}, [props.themeId]);
const onSubmit = useCallback(() => {
props.editorControl.updateLink(linkLabel, linkURL);
props.editorControl.hideLinkDialog();
}, [props.editorControl, linkLabel, linkURL]);
// See https://www.hingehealth.com/engineering-blog/accessible-react-native-textinput/
// for more about creating accessible RN inputs.
const linkTextInput = (
<View style={styles.inputContainer} accessible>
<Text style={styles.text}>{_('Link Text')}</Text>
<TextInput
style={styles.input}
placeholder={_('Description of the link')}
placeholderTextColor={placeholderColor}
value={linkLabel}
returnKeyType="next"
autoFocus
onSubmitEditing={() => {
linkInputRef.current.focus();
}}
onChangeText={(text: string) => setLinkLabel(text)}
/>
</View>
);
const linkURLInput = (
<View style={styles.inputContainer} accessible>
<Text style={styles.text}>{_('URL')}</Text>
<TextInput
style={styles.input}
placeholder={_('URL')}
placeholderTextColor={placeholderColor}
value={linkURL}
ref={linkInputRef}
autoCorrect={false}
autoCapitalize="none"
keyboardType="url"
textContentType="URL"
returnKeyType="done"
onSubmitEditing={onSubmit}
onChangeText={(text: string) => setLinkURL(text)}
/>
</View>
);
return (
<Modal
animationType="slide"
transparent={true}
visible={props.visible}
onRequestClose={() => {
props.editorControl.hideLinkDialog();
}}>
<View style={styles.modalContent}>
<Text style={styles.header}>{_('Edit Link')}</Text>
<View>
{linkTextInput}
{linkURLInput}
</View>
<Button
style={styles.button}
onPress={onSubmit}
title={_('Done')}
/>
</View>
</Modal>
);
};
export default EditLinkDialog;

View File

@@ -0,0 +1,363 @@
// A toolbar for the markdown editor.
const React = require('react');
import { Platform, StyleSheet } from 'react-native';
import { useMemo, useState, useCallback } from 'react';
// See https://oblador.github.io/react-native-vector-icons/ for a list of
// available icons.
const AntIcon = require('react-native-vector-icons/AntDesign').default;
const FontAwesomeIcon = require('react-native-vector-icons/FontAwesome5').default;
const MaterialIcon = require('react-native-vector-icons/MaterialIcons').default;
import { _ } from '@joplin/lib/locale';
import time from '@joplin/lib/time';
import { useEffect } from 'react';
import { Keyboard, ViewStyle } from 'react-native';
import { EditorControl, EditorSettings, ListType, SearchState } from '../types';
import SelectionFormatting from '../SelectionFormatting';
import { ButtonSpec, StyleSheetData } from './types';
import Toolbar from './Toolbar';
import { buttonSize } from './ToolbarButton';
import { Theme } from '@joplin/lib/themes/type';
import ToggleSpaceButton from './ToggleSpaceButton';
type OnAttachCallback = ()=> void;
interface MarkdownToolbarProps {
editorControl: EditorControl;
selectionState: SelectionFormatting;
searchState: SearchState;
editorSettings: EditorSettings;
onAttach: OnAttachCallback;
style?: ViewStyle;
}
const MarkdownToolbar = (props: MarkdownToolbarProps) => {
const themeData = props.editorSettings.themeData;
const styles = useStyles(props.style, themeData);
const selState = props.selectionState;
const editorControl = props.editorControl;
const headerButtons: ButtonSpec[] = [];
for (let level = 1; level <= 5; level++) {
const active = selState.headerLevel === level;
let label;
if (!active) {
label = _('Create header level %d', level);
} else {
label = _('Remove level %d header', level);
}
headerButtons.push({
icon: `H${level}`,
description: label,
active,
// We only call addHeaderButton 5 times and in the same order, so
// the linter error is safe to ignore.
// eslint-disable-next-line @seiyab/react-hooks/rules-of-hooks
onPress: useCallback(() => {
editorControl.toggleHeaderLevel(level);
}, [editorControl, level]),
// Make it likely for the first three header buttons to show, less likely for
// the others.
priority: level < 3 ? 2 : 0,
});
}
const listButtons: ButtonSpec[] = [];
listButtons.push({
icon: (
<FontAwesomeIcon name="list-ul" style={styles.text}/>
),
description:
selState.inUnorderedList ? _('Remove unordered list') : _('Create unordered list'),
active: selState.inUnorderedList,
onPress: useCallback(() => {
editorControl.toggleList(ListType.UnorderedList);
}, [editorControl]),
priority: -2,
});
listButtons.push({
icon: (
<FontAwesomeIcon name="list-ol" style={styles.text}/>
),
description:
selState.inOrderedList ? _('Remove ordered list') : _('Create ordered list'),
active: selState.inOrderedList,
onPress: useCallback(() => {
editorControl.toggleList(ListType.OrderedList);
}, [editorControl]),
priority: -2,
});
listButtons.push({
icon: (
<FontAwesomeIcon name="tasks" style={styles.text}/>
),
description:
selState.inChecklist ? _('Remove task list') : _('Create task list'),
active: selState.inChecklist,
onPress: useCallback(() => {
editorControl.toggleList(ListType.CheckList);
}, [editorControl]),
priority: -2,
});
listButtons.push({
icon: (
<AntIcon name="indent-left" style={styles.text}/>
),
description: _('Decrease indent level'),
onPress: editorControl.decreaseIndent,
priority: -1,
});
listButtons.push({
icon: (
<AntIcon name="indent-right" style={styles.text}/>
),
description: _('Increase indent level'),
onPress: editorControl.increaseIndent,
priority: -1,
});
// Inline formatting
const inlineFormattingBtns: ButtonSpec[] = [];
inlineFormattingBtns.push({
icon: (
<FontAwesomeIcon name="bold" style={styles.text}/>
),
description:
selState.bolded ? _('Unbold') : _('Bold text'),
active: selState.bolded,
onPress: editorControl.toggleBolded,
priority: 3,
});
inlineFormattingBtns.push({
icon: (
<FontAwesomeIcon name="italic" style={styles.text}/>
),
description:
selState.italicized ? _('Unitalicize') : _('Italicize'),
active: selState.italicized,
onPress: editorControl.toggleItalicized,
priority: 2,
});
inlineFormattingBtns.push({
icon: '{;}',
description:
selState.inCode ? _('Remove code formatting') : _('Format as code'),
active: selState.inCode,
onPress: editorControl.toggleCode,
priority: 2,
});
if (props.editorSettings.katexEnabled) {
inlineFormattingBtns.push({
icon: '∑',
description:
selState.inMath ? _('Remove TeX region') : _('Create TeX region'),
active: selState.inMath,
onPress: editorControl.toggleMath,
priority: 1,
});
}
inlineFormattingBtns.push({
icon: (
<FontAwesomeIcon name="link" style={styles.text}/>
),
description:
selState.inLink ? _('Edit link') : _('Create link'),
active: selState.inLink,
onPress: editorControl.showLinkDialog,
priority: -3,
});
// Actions
const actionButtons: ButtonSpec[] = [];
actionButtons.push({
icon: (
<FontAwesomeIcon name="calendar-plus" style={styles.text}/>
),
description: _('Insert time'),
onPress: useCallback(() => {
editorControl.insertText(time.formatDateToLocal(new Date()));
}, [editorControl]),
});
const onDismissKeyboard = useCallback(() => {
// Keyboard.dismiss() doesn't dismiss the keyboard if it's editing the WebView.
Keyboard.dismiss();
// As such, dismiss the keyboard by sending a message to the View.
editorControl.hideKeyboard();
}, [editorControl]);
actionButtons.push({
icon: (
<MaterialIcon name="attachment" style={styles.text}/>
),
description: _('Attach'),
onPress: useCallback(() => {
onDismissKeyboard();
props.onAttach();
}, [props.onAttach, onDismissKeyboard]),
});
actionButtons.push({
icon: (
<MaterialIcon name="search" style={styles.text}/>
),
description: (
props.searchState.dialogVisible ? _('Close find and replace') : _('Find and replace')
),
active: props.searchState.dialogVisible,
onPress: useCallback(() => {
if (props.searchState.dialogVisible) {
editorControl.searchControl.hideSearch();
} else {
editorControl.searchControl.showSearch();
}
}, [editorControl, props.searchState.dialogVisible]),
priority: -3,
});
const [keyboardVisible, setKeyboardVisible] = useState(false);
const [hasSoftwareKeyboard, setHasSoftwareKeyboard] = useState(false);
useEffect(() => {
const showListener = Keyboard.addListener('keyboardDidShow', () => {
setKeyboardVisible(true);
setHasSoftwareKeyboard(true);
});
const hideListener = Keyboard.addListener('keyboardDidHide', () => {
setKeyboardVisible(false);
});
return (() => {
showListener.remove();
hideListener.remove();
});
});
actionButtons.push({
icon: (
<MaterialIcon name="keyboard-hide" style={styles.text}/>
),
description: _('Hide keyboard'),
disabled: !keyboardVisible,
visible: hasSoftwareKeyboard && Platform.OS === 'ios',
onPress: onDismissKeyboard,
priority: -3,
});
const styleData: StyleSheetData = {
styles: styles,
themeId: props.editorSettings.themeId,
};
return (
<ToggleSpaceButton
spaceApplicable={ Platform.OS === 'ios' && keyboardVisible }
themeId={props.editorSettings.themeId}
style={styles.container}
>
<Toolbar
styleSheet={styleData}
buttons={[
{
title: _('Formatting'),
items: inlineFormattingBtns,
},
{
title: _('Headers'),
items: headerButtons,
},
{
title: _('Lists'),
items: listButtons,
},
{
title: _('Actions'),
items: actionButtons,
},
]}
/>
</ToggleSpaceButton>
);
};
const useStyles = (styleProps: any, theme: Theme) => {
return useMemo(() => {
return StyleSheet.create({
container: {
...styleProps,
},
button: {
width: buttonSize,
height: buttonSize,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: theme.backgroundColor,
},
buttonDisabled: {
opacity: 0.5,
},
buttonDisabledContent: {
},
buttonActive: {
backgroundColor: theme.backgroundColor3,
color: theme.color3,
borderWidth: 1,
borderColor: theme.color3,
borderRadius: 6,
},
buttonActiveContent: {
color: theme.color3,
},
text: {
fontSize: 22,
color: theme.color,
},
toolbarRow: {
flex: 0,
flexDirection: 'row',
alignItems: 'baseline',
justifyContent: 'center',
// Add a small amount of additional padding for button borders
height: buttonSize + 6,
},
toolbarContainer: {
flexShrink: 1,
},
toolbarContent: {
flexGrow: 1,
justifyContent: 'center',
},
});
}, [styleProps, theme]);
};
export default MarkdownToolbar;

View File

@@ -0,0 +1,34 @@
const React = require('react');
import { _ } from '@joplin/lib/locale';
import ToolbarButton from './ToolbarButton';
import { ButtonSpec, StyleSheetData } from './types';
const MaterialIcon = require('react-native-vector-icons/MaterialIcons').default;
type OnToggleOverflowCallback = ()=> void;
interface ToggleOverflowButtonProps {
overflowVisible: boolean;
onToggleOverflowVisible: OnToggleOverflowCallback;
styleSheet: StyleSheetData;
}
// Button that shows/hides the overflow menu.
const ToggleOverflowButton = (props: ToggleOverflowButtonProps) => {
const spec: ButtonSpec = {
icon: (
<MaterialIcon name="more-horiz" style={props.styleSheet.styles.text}/>
),
description:
props.overflowVisible ? _('Hide more actions') : _('Show more actions'),
active: props.overflowVisible,
onPress: props.onToggleOverflowVisible,
};
return (
<ToolbarButton
styleSheet={props.styleSheet}
spec={spec}
/>
);
};
export default ToggleOverflowButton;

View File

@@ -0,0 +1,96 @@
// On some devices, the SafeAreaView conflicts with the KeyboardAvoidingView, creating
// additional (or a lack of additional) space at the bottom of the screen. Because this
// is different on different devices, this button allows toggling additional space a the bottom
// of the screen to compensate.
// Works around https://github.com/facebook/react-native/issues/13393 by adding additional
// space below the given component when the keyboard is visible unless a button is pressed.
import Setting from '@joplin/lib/models/Setting';
import { themeStyle } from '@joplin/lib/theme';
import { Theme } from '@joplin/lib/themes/type';
import * as React from 'react';
import { ReactNode, useCallback, useState, useEffect } from 'react';
import { View, ViewStyle } from 'react-native';
import CustomButton from '../../CustomButton';
const AntIcon = require('react-native-vector-icons/AntDesign').default;
interface Props {
children: ReactNode;
spaceApplicable: boolean;
themeId: number;
style?: ViewStyle;
}
const ToggleSpaceButton = (props: Props) => {
const [additionalSpace, setAdditionalSpace] = useState(0);
const [decreaseSpaceBtnVisible, setDecreaseSpaceBtnVisible] = useState(true);
// Some devices need space added, others need space removed.
const additionalPositiveSpace = 14;
const additionalNegativeSpace = -14;
// Switch from adding +14px to -14px.
const onDecreaseSpace = useCallback(() => {
setAdditionalSpace(additionalNegativeSpace);
setDecreaseSpaceBtnVisible(false);
Setting.setValue('editor.mobile.removeSpaceBelowToolbar', true);
}, [setAdditionalSpace, setDecreaseSpaceBtnVisible, additionalNegativeSpace]);
useEffect(() => {
if (Setting.value('editor.mobile.removeSpaceBelowToolbar')) {
onDecreaseSpace();
}
}, [onDecreaseSpace]);
const theme: Theme = themeStyle(props.themeId);
const decreaseSpaceButton = (
<>
<View style={{
height: additionalPositiveSpace,
zIndex: -2,
}} />
<CustomButton
themeId={props.themeId}
description={'Move toolbar to bottom of screen'}
style={{
height: additionalPositiveSpace,
width: '100%',
// Ensure that the icon is near the bottom of the screen,
// and thus invisible on devices where it isn't necessary.
position: 'absolute',
bottom: 0,
// Don't show the button on top of views with content.
zIndex: -1,
alignItems: 'center',
}}
onPress={onDecreaseSpace}
>
<AntIcon name='down' style={{
color: theme.color,
}}/>
</CustomButton>
</>
);
const style: ViewStyle = {
marginBottom: props.spaceApplicable ? additionalSpace : 0,
...props.style,
};
return (
<View style={style}>
{props.children}
{ decreaseSpaceBtnVisible && props.spaceApplicable ? decreaseSpaceButton : null }
</View>
);
};
export default ToggleSpaceButton;

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