1
0
mirror of https://github.com/laurent22/joplin.git synced 2026-04-05 11:01:11 +02:00

Compare commits

...

79 Commits

Author SHA1 Message Date
renovate[bot]
b90458f387 Update dependency esbuild to v0.27.1 (#15009)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-04 18:15:55 +01:00
renovate[bot]
1a2d045ed2 Update dependency @crowdin/cli to v4.12.0 (#14985)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-04 16:44:25 +01:00
renovate[bot]
719d5ce4bb Update dependency nan to v2.24.0 (#14986)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-04 16:44:20 +01:00
Henry Heino
76f0f1494e Chore: Development: Fix VSCode 1.114.0 shows a large number of tsc errors (#14989) 2026-04-04 16:44:12 +01:00
Harsh Gupta
e4f916bea5 Desktop: Fixes #14990: Fix inline formatting with trailing/leading whitespace (#14991) 2026-04-04 16:43:57 +01:00
Sriram Varun Kumar
cf9098e6a3 Mobile: Fixes #14974: Fix editor font setting being ignored in the Rich Text Editor (#14995) 2026-04-04 16:38:21 +01:00
Vishal Patel
18ffdb2f50 Mobile: Add toolbar button reordering with up/down arrows (#14485)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Happy <yesreply@happy.engineering>
2026-04-04 16:23:30 +01:00
Himanshu Mishra
acd2ef4edf Desktop: Replace smalltalk with React Dialog to add password visibility in encryption setup (#14739) 2026-04-02 16:43:52 +01:00
Aissa Benfodda
9d91d4f85c Desktop: Fixes #12994: Share owner sees "Leave notebook" instead of "Share notebook" when server is offline (#14923) 2026-04-02 16:34:48 +01:00
Devrajsinh Gohil
635af9748a macOS: Resolves #9637: Added fullscreen shortcut (Ctrl + Cmd + F) (#14926) 2026-04-02 16:34:20 +01:00
Harsh Gupta
612e5a08f3 Desktop: Fixes #14950: Inline computed styles when copying from the Markdown preview pane (#14973) 2026-04-02 16:29:13 +01:00
Henry Heino
d3477f8626 Desktop: Importing from OneNote: Fix import of ink with negative bounding box coordinates (#14981) 2026-04-02 16:22:49 +01:00
Zain Ul Abedin
9e836a8984 Mobile: Fixes #14771: Show confirmation dialog before closing tags dialog with unsaved changes (#14777) 2026-04-02 16:08:26 +01:00
Laurent Cozic
3f14ffdf73 lock file 2026-04-02 14:42:35 +01:00
Joplin Bot
fd9f6c11ab Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-04-01 02:47:55 +00:00
FischLu
0cc79724c3 Desktop: Enable Copy and Select All in viewer and read-only modes (#14956) 2026-03-31 19:30:44 +01:00
mrjo118
e6fddd054a Desktop: Fixes #14628: Fix incorrectly re-instated code (#14962) 2026-03-31 15:58:12 +01:00
Henry Heino
860b22b0e7 Desktop,Cli: Fixes #14954: Fix changes made in an external editor are sometimes ignored (#14957) 2026-03-31 15:57:52 +01:00
Fardin96
281b0ed124 Mobile: Fixes #11122: Tag's note list fails to update after removing the tag from a note (#14944) 2026-03-31 15:47:27 +01:00
krevad
5dc5cb62db All: Translation: Update sv.po (#14943) 2026-03-31 15:45:46 +01:00
renovate[bot]
28bb43b3b5 Update dependency @playwright/test to v1.57.0 (#14902)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-03-31 15:33:32 +01:00
Dipanshu Rawat
c8bfcb16be Desktop: Fixes #14890: Disable "Expand all notebooks" button when no sub-notebooks exist (#14891) 2026-03-31 15:24:36 +01:00
Alex
634956bcc6 Desktop: Fixes #9436: Fix Markdown export losing folders that differ only by special characters (#14869) 2026-03-31 15:13:22 +01:00
Henry Heino
346ab98133 Desktop: Upgrade Electron to v40.8.3 (#14882) 2026-03-31 15:11:15 +01:00
Henry Heino
55008c9de9 Chore: Mobile: Add /pluginAssets to .gitignore (#14961) 2026-03-31 11:30:42 +01:00
mrjo118
f4ba70c49c Desktop: Fixes #14628: Fix renderer crashes still occuring due to incorrect merge (#14953) 2026-03-30 13:06:52 +01:00
renovate[bot]
e61379ed59 Update dependency git to v2.51.2 (#14951)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-29 20:28:09 +01:00
Sandesh Prakash Dawkhar
75cd9b4cb7 Desktop: Fixes #14919: Prevent Plugin API callback registry memory leak (#14920) 2026-03-29 20:21:30 +01:00
Kaushalendra Singh
43120d2b3e Desktop: Fixes #14628: prevent renderer crash when closing secondary window (#14849) 2026-03-29 11:40:30 +01:00
jellyfrostt
5656731dca Chore: Mobile: Fixes #14834: Fix JSDOM scrollIntoView error in tests (#14870) 2026-03-29 11:39:22 +01:00
Laurent Cozic
4cfe54161d Chore: Add appClose logger statements to desktop app (#14927) 2026-03-29 11:38:05 +01:00
renovate[bot]
7f2a95f66e Update dependency ldapts to v8.0.36 (#14938)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-29 05:53:44 +00:00
renovate[bot]
75819f3be3 Update dependency react-native-localize to v3.6.1 (#14932)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-28 14:30:05 +00:00
Manvendra Kumar Singh
e709921310 Chore: Fixes #14878: Prevent focusing undefined titleInputRef in dialog (#14879) 2026-03-27 12:25:28 +00:00
renovate[bot]
b19d47ca4a Update dependency python to v3.14.0 (#14884)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-27 12:22:53 +00:00
Henry Heino
516981b80c Desktop: Fixes #14628: Fix crash when closing secondary windows (#14892) 2026-03-27 12:21:13 +00:00
Laurent Cozic
a90d162989 Server: Move deletion of objects outside of transaction block (#14898) 2026-03-27 12:18:40 +00:00
Henry Heino
6cf9f1cc11 Windows: Fixes #14903: Fix most Windows-specific test failures (#14904) 2026-03-27 12:17:12 +00:00
Henry Heino
ee7362564c Chore: CI: Fix test warnings (#14907) 2026-03-27 12:13:25 +00:00
Muhammad Zohaib Irshad
cdf5367934 Clipper: Make the styling of dialogues consistent (#14908) 2026-03-27 12:13:18 +00:00
Henry Heino
7a76c31c26 Chore: Server: Fix incorrect error message (#14915) 2026-03-27 12:10:56 +00:00
Sandesh Prakash Dawkhar
004ab78a7a Desktop: Fixes #14914: RTE checklists should create unchecked items on Enter (#14918) 2026-03-27 12:10:27 +00:00
Ashutosh Singh
a7067c30c4 Desktop: Fixes #9673: Frontmatter export: Include notebook icon in frontmatter export (#14582) 2026-03-27 11:47:41 +00:00
renovate[bot]
be081316b3 Update dependency @types/serviceworker to v0.0.172 (#14901)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-25 15:05:36 +00:00
renovate[bot]
c9fb33cb20 Update dependency ldapts to v8.0.35 (#14894)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-25 13:47:05 +00:00
renovate[bot]
dfdc0f3c35 Update dependency ldapts to v8.0.33 (#14887)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-24 12:42:21 +00:00
Laurent Cozic
0fa3a509d6 Mobile, Desktop: Revert: Start sync when app opens or resumes (#14889) 2026-03-24 12:39:45 +00:00
renovate[bot]
18cf0a95ad Update dependency @types/serviceworker to v0.0.171 (#14885)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-24 09:05:36 +00:00
Laurent Cozic
7d454123f9 CI: Revert cancel-in-progress change as it does not do anything 2026-03-23 20:39:37 +00:00
renovate[bot]
e4fb72cd08 Update dependency ldapts to v8.0.32 (#14876)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-23 20:28:25 +00:00
Laurent Cozic
741e1b19e5 Revert "Desktop Fix: #14788 prevent sync panel from jumping during sync" (#14875) 2026-03-23 15:51:59 +00:00
dipok
6637c05cc8 CLI: Add clear command to clear console output (#14844) 2026-03-23 12:37:18 +00:00
Yousef Genedy
5877670e33 Mobile: Resolves #14789: Implement note attachments management screen (#14818) 2026-03-23 12:33:19 +00:00
Dipanshu Rawat
2320beec39 Desktop Fixes #14788: Prevent sync panel from jumping during sync (#14792) 2026-03-23 12:26:14 +00:00
Ehtesham Zahid
a0effc9ff8 Desktop: Resolves #14778: Improve checkbox completion icon in detailed note list (#14780) 2026-03-23 12:12:34 +00:00
Henry Heino
92cd5630f7 Chore: OneNote importer: Fix linter errors and standardize formatting (#14858) 2026-03-23 09:19:18 +00:00
Laurent Cozic
9fbca68062 Chore: Removed some "any" variables (#14825) 2026-03-22 23:14:16 +00:00
Jose Riha
953fb20006 All: Translation: Update sk_SK.po (#14867) 2026-03-22 14:25:27 -04:00
renovate[bot]
fb18be14a1 Update dependency ldapts to v8.0.31 (#14866)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-22 14:02:33 +00:00
Eric Duarte
75c4dbc9df All: Translation: Update es_ES.po (#14862)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-03-21 18:15:22 -04:00
renovate[bot]
1f5b4269ab Update dependency pg-boss to v10.4.0 (#14837)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-03-21 12:21:29 +00:00
Henry Heino
9c23574977 Chore: Server: Remove auto-generated explicit any in types.ts (#14855) 2026-03-21 12:17:43 +00:00
Henry Heino
fe5ff98429 Chore: Editor: Strengthen compatibility layer event types (#14856) 2026-03-21 12:17:30 +00:00
Henry Heino
b721b3ac77 Chore: OneNote importer: Remove unused callback (#14859) 2026-03-21 12:17:06 +00:00
Rohit
638485376c All: Fixes #14540: Prevent duplicate tags caused by Unicode normalization (#14599) 2026-03-21 12:14:37 +00:00
Victor Gherardi
575f4235c3 iOS: Fixes #12968: Fix mobile app unable to attach file with special characters in the name (#14736) 2026-03-21 12:05:55 +00:00
renovate[bot]
8184d3ef37 Update Slashgear/action-check-pr-title action to v5 (#14857)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-20 22:58:59 +00:00
renovate[bot]
1262a5a1ff Update dependency ldapts to v8.0.30 (#14853)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-20 21:19:49 +00:00
moaaz
05fc3e9104 Desktop: Fixes #14613: Fix JPEG image paste from clipboard on Linux (#14750) 2026-03-20 20:02:06 +00:00
Laurent Cozic
064e72c43a Revert "Desktop: Resolves #7914: Add support for Ctrl/Cmd+Wheel to zoom in and out (#14684)"
This reverts commit 21d12a2b46.

Due to: https://github.com/laurent22/joplin/pull/14817
2026-03-20 10:20:10 +00:00
mrjo118
088d8eb159 Mobile: Disable auto correct, auto complete and auto capitalize for setting search field (#14810) 2026-03-20 10:18:42 +00:00
renovate[bot]
333bc5d123 Update dependency ldapts to v8.0.29 (#14842)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-20 10:17:47 +00:00
Henry Heino
93f4c97433 Chore: Desktop: Fix warning during build: Migrate away from the deprecated Sass API (#14803) 2026-03-20 10:17:23 +00:00
Henry Heino
eeeb7d6ba1 Desktop: Accessibility: Fix accessibility issues flagged by automated tools in the note properties dialog (#14798) 2026-03-20 10:16:04 +00:00
renovate[bot]
bda1dc2aa8 Update dependency @types/serviceworker to v0.0.170 (#14838)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-20 08:45:24 +00:00
Victor Gherardi
4073596373 Chore: Fixes #14832: CI: Update handleAnchorClick tests to use dispatchEvent for click simulation (#14833) 2026-03-20 00:46:43 +00:00
Henry Heino
c16eb16af4 Chore: Desktop: Re-enable settings screen accessibility tests (#14793) 2026-03-19 07:20:02 -07:00
renovate[bot]
0c0d7713df Update dependency ldapts to v8.0.28 (#14829)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-19 08:20:01 +00:00
renovate[bot]
5161d18d19 Update dependency fs-extra to v11.3.3 (#14819)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-18 21:05:09 +00:00
295 changed files with 4349 additions and 1874 deletions

View File

@@ -103,6 +103,7 @@ packages/app-cli/app/command-apidoc.js
packages/app-cli/app/command-attach.js
packages/app-cli/app/command-batch.js
packages/app-cli/app/command-cat.js
packages/app-cli/app/command-clear.js
packages/app-cli/app/command-config.js
packages/app-cli/app/command-cp.js
packages/app-cli/app/command-done.test.js
@@ -535,8 +536,6 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/utils/useSyncDialogState.js
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/useWindowCommands.js
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/useWindowControl.js
packages/app-desktop/gui/dialogs.js
packages/app-desktop/gui/hooks/useCtrlWheelZoom.test.js
packages/app-desktop/gui/hooks/useCtrlWheelZoom.js
packages/app-desktop/gui/hooks/useEffectDebugger.js
packages/app-desktop/gui/hooks/useElementHeight.js
packages/app-desktop/gui/hooks/useImperativeHandlerDebugger.js
@@ -699,10 +698,15 @@ packages/app-mobile/components/EditorToolbar/utils/isSelected.js
packages/app-mobile/components/EditorToolbar/utils/selectedCommandNamesFromState.js
packages/app-mobile/components/EditorToolbar/utils/toolbarButtonsFromState.js
packages/app-mobile/components/EditorToolbar/utils/useButtonSize.js
packages/app-mobile/components/EditorToolbar/utils/useSaveToolbarButtons.test.js
packages/app-mobile/components/EditorToolbar/utils/useSaveToolbarButtons.js
packages/app-mobile/components/EditorToolbar/utils/useToolbarEditorState.test.js
packages/app-mobile/components/EditorToolbar/utils/useToolbarEditorState.js
packages/app-mobile/components/ExtendedWebView/index.jest.js
packages/app-mobile/components/ExtendedWebView/index.js
packages/app-mobile/components/ExtendedWebView/index.web.js
packages/app-mobile/components/ExtendedWebView/types.js
packages/app-mobile/components/ExtendedWebView/utils/polyfillScrollFunctions.js
packages/app-mobile/components/ExtendedWebView/utils/useCss.js
packages/app-mobile/components/FolderPicker.js
packages/app-mobile/components/Icon.js
@@ -873,6 +877,7 @@ packages/app-mobile/components/screens/Notes/NewNoteButton.test.js
packages/app-mobile/components/screens/Notes/NewNoteButton.js
packages/app-mobile/components/screens/Notes/Notes.js
packages/app-mobile/components/screens/Notes/TextWrapCalculator.js
packages/app-mobile/components/screens/ResourceScreen.js
packages/app-mobile/components/screens/SearchScreen/SearchBar.js
packages/app-mobile/components/screens/SearchScreen/SearchResults.test.js
packages/app-mobile/components/screens/SearchScreen/SearchResults.js
@@ -889,6 +894,8 @@ packages/app-mobile/components/screens/dropbox-login.js
packages/app-mobile/components/screens/encryption-config.test.js
packages/app-mobile/components/screens/encryption-config.js
packages/app-mobile/components/screens/folder.js
packages/app-mobile/components/screens/resourceScreenUtils.test.js
packages/app-mobile/components/screens/resourceScreenUtils.js
packages/app-mobile/components/screens/status.js
packages/app-mobile/components/screens/tags.js
packages/app-mobile/components/side-menu-content.js
@@ -1443,6 +1450,7 @@ packages/lib/services/CommandService.test.js
packages/lib/services/CommandService.js
packages/lib/services/DecryptionWorker.test.js
packages/lib/services/DecryptionWorker.js
packages/lib/services/ExternalEditWatcher.test.js
packages/lib/services/ExternalEditWatcher.js
packages/lib/services/ExternalEditWatcher/utils.js
packages/lib/services/ItemChangeUtils.js

View File

@@ -4,10 +4,6 @@
name: react-native-android-build-apk
on: [push, pull_request]
concurrency:
group: ${{ github.ref == 'refs/heads/dev' && github.run_id || format('{0}-{1}', github.workflow, github.ref) }}
cancel-in-progress: true
jobs:
AssembleRelease:
if: github.repository == 'laurent22/joplin'

View File

@@ -1,10 +1,6 @@
name: Build macOS M1
on: [push, pull_request]
concurrency:
group: ${{ github.ref == 'refs/heads/dev' && github.run_id || format('{0}-{1}', github.workflow, github.ref) }}
cancel-in-progress: true
jobs:
Main:
# We always process desktop release tags, because they also publish the release

View File

@@ -4,6 +4,6 @@ jobs:
main:
runs-on: ubuntu-latest
steps:
- uses: Slashgear/action-check-pr-title@v4.3.0
- uses: Slashgear/action-check-pr-title@v5.0.1
with:
regexp: "(Desktop|Mobile|All|Cli|Tools|Chore|Clipper|Server|Android|iOS|Plugins|CI|Plugin Repo|Doc): (Fixes|Resolves) #[0-9]+: .+"

View File

@@ -1,10 +1,6 @@
name: Joplin Continuous Integration
on: [push, pull_request]
concurrency:
group: ${{ github.ref == 'refs/heads/dev' && github.run_id || format('{0}-{1}', github.workflow, github.ref) }}
cancel-in-progress: true
jobs:
Main:
# We always process server or desktop release tags, because they also publish the release

View File

@@ -1,10 +1,6 @@
name: Joplin UI tests
on: [push, pull_request]
concurrency:
group: ${{ github.ref == 'refs/heads/dev' && github.run_id || format('{0}-{1}', github.workflow, github.ref) }}
cancel-in-progress: true
permissions:
contents: read
jobs:

12
.gitignore vendored
View File

@@ -76,6 +76,7 @@ packages/app-cli/app/command-apidoc.js
packages/app-cli/app/command-attach.js
packages/app-cli/app/command-batch.js
packages/app-cli/app/command-cat.js
packages/app-cli/app/command-clear.js
packages/app-cli/app/command-config.js
packages/app-cli/app/command-cp.js
packages/app-cli/app/command-done.test.js
@@ -508,8 +509,6 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/utils/useSyncDialogState.js
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/useWindowCommands.js
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/useWindowControl.js
packages/app-desktop/gui/dialogs.js
packages/app-desktop/gui/hooks/useCtrlWheelZoom.test.js
packages/app-desktop/gui/hooks/useCtrlWheelZoom.js
packages/app-desktop/gui/hooks/useEffectDebugger.js
packages/app-desktop/gui/hooks/useElementHeight.js
packages/app-desktop/gui/hooks/useImperativeHandlerDebugger.js
@@ -672,10 +671,15 @@ packages/app-mobile/components/EditorToolbar/utils/isSelected.js
packages/app-mobile/components/EditorToolbar/utils/selectedCommandNamesFromState.js
packages/app-mobile/components/EditorToolbar/utils/toolbarButtonsFromState.js
packages/app-mobile/components/EditorToolbar/utils/useButtonSize.js
packages/app-mobile/components/EditorToolbar/utils/useSaveToolbarButtons.test.js
packages/app-mobile/components/EditorToolbar/utils/useSaveToolbarButtons.js
packages/app-mobile/components/EditorToolbar/utils/useToolbarEditorState.test.js
packages/app-mobile/components/EditorToolbar/utils/useToolbarEditorState.js
packages/app-mobile/components/ExtendedWebView/index.jest.js
packages/app-mobile/components/ExtendedWebView/index.js
packages/app-mobile/components/ExtendedWebView/index.web.js
packages/app-mobile/components/ExtendedWebView/types.js
packages/app-mobile/components/ExtendedWebView/utils/polyfillScrollFunctions.js
packages/app-mobile/components/ExtendedWebView/utils/useCss.js
packages/app-mobile/components/FolderPicker.js
packages/app-mobile/components/Icon.js
@@ -846,6 +850,7 @@ packages/app-mobile/components/screens/Notes/NewNoteButton.test.js
packages/app-mobile/components/screens/Notes/NewNoteButton.js
packages/app-mobile/components/screens/Notes/Notes.js
packages/app-mobile/components/screens/Notes/TextWrapCalculator.js
packages/app-mobile/components/screens/ResourceScreen.js
packages/app-mobile/components/screens/SearchScreen/SearchBar.js
packages/app-mobile/components/screens/SearchScreen/SearchResults.test.js
packages/app-mobile/components/screens/SearchScreen/SearchResults.js
@@ -862,6 +867,8 @@ packages/app-mobile/components/screens/dropbox-login.js
packages/app-mobile/components/screens/encryption-config.test.js
packages/app-mobile/components/screens/encryption-config.js
packages/app-mobile/components/screens/folder.js
packages/app-mobile/components/screens/resourceScreenUtils.test.js
packages/app-mobile/components/screens/resourceScreenUtils.js
packages/app-mobile/components/screens/status.js
packages/app-mobile/components/screens/tags.js
packages/app-mobile/components/side-menu-content.js
@@ -1416,6 +1423,7 @@ packages/lib/services/CommandService.test.js
packages/lib/services/CommandService.js
packages/lib/services/DecryptionWorker.test.js
packages/lib/services/DecryptionWorker.js
packages/lib/services/ExternalEditWatcher.test.js
packages/lib/services/ExternalEditWatcher.js
packages/lib/services/ExternalEditWatcher/utils.js
packages/lib/services/ItemChangeUtils.js

View File

@@ -11,13 +11,13 @@
},
"nodejs": "24.11.1",
"pkg-config": "latest",
"python": "3.13.3",
"python": "3.14.0",
"bat": "latest",
"electron": {
"version": "latest",
"excluded_platforms": ["aarch64-darwin", "x86_64-darwin"],
},
"git": "2.51.0",
"git": "2.51.2",
},
"shell": {
"init_hook": [

View File

@@ -72,6 +72,7 @@
"@crowdin/cli": "4",
"@joplin/utils": "~2.12",
"@seiyab/eslint-plugin-react-hooks": "4.5.1-beta.0",
"@types/jest": "29.5.14",
"@typescript-eslint/eslint-plugin": "6.21.0",
"@typescript-eslint/parser": "6.21.0",
"cspell": "5.21.2",
@@ -82,7 +83,7 @@
"eslint-plugin-promise": "6.6.0",
"eslint-plugin-react": "7.37.5",
"execa": "5.1.1",
"fs-extra": "11.3.2",
"fs-extra": "11.3.3",
"glob": "11.1.0",
"gulp": "4.0.2",
"husky": "9.1.7",

View File

@@ -1,7 +1,7 @@
import BaseApplication from '@joplin/lib/BaseApplication';
import { refreshFolders } from '@joplin/lib/folders-screen-utils.js';
import ResourceService from '@joplin/lib/services/ResourceService';
import BaseModel, { ModelType } from '@joplin/lib/BaseModel';
import { ModelType } from '@joplin/lib/BaseModel';
import Folder from '@joplin/lib/models/Folder';
import BaseItem from '@joplin/lib/models/BaseItem';
import Note from '@joplin/lib/models/Note';
@@ -15,20 +15,22 @@ import RevisionService from '@joplin/lib/services/RevisionService';
import shim from '@joplin/lib/shim';
import setupCommand from './setupCommand';
import { FolderEntity, NoteEntity } from '@joplin/lib/services/database/types';
type FolderOrNoteType = ModelType.Note | ModelType.Folder | 'folderOrNote';
import initializeCommandService from './utils/initializeCommandService';
const { cliUtils } = require('./cli-utils.js');
const Cache = require('@joplin/lib/Cache');
class Application extends BaseApplication {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Dynamic command loading system
private commands_: Record<string, any> = {};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Dynamic command metadata
private commandMetadata_: any = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Dynamic command type
private activeCommand_: any = null;
private allCommandsLoaded_ = false;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Dynamic GUI type with many optional methods
private gui_: any = null;
private cache_ = new Cache();
@@ -40,18 +42,16 @@ class Application extends BaseApplication {
return this.gui().stdoutMaxWidth();
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
public async guessTypeAndLoadItem(pattern: string, options: any = null) {
let type = BaseModel.TYPE_NOTE;
public async guessTypeAndLoadItem(pattern: string, options: { parent?: FolderEntity } | null = null) {
let type: FolderOrNoteType = ModelType.Note;
if (pattern.indexOf('/') === 0) {
type = BaseModel.TYPE_FOLDER;
type = ModelType.Folder;
pattern = pattern.substr(1);
}
return this.loadItem(type, pattern, options);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
public async loadItem(type: ModelType | 'folderOrNote', pattern: string, options: any = null) {
public async loadItem(type: FolderOrNoteType, pattern: string, options: { parent?: FolderEntity } | null = null) {
const output = await this.loadItems(type, pattern, options);
if (output.length > 1) {
@@ -75,37 +75,36 @@ class Application extends BaseApplication {
}
}
public async loadItemOrFail(type: ModelType | 'folderOrNote', pattern: string) {
public async loadItemOrFail(type: FolderOrNoteType, pattern: string) {
const output = await this.loadItem(type, pattern);
if (!output) throw new Error(_('Cannot find "%s".', pattern));
return output;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
public async loadItems(type: ModelType | 'folderOrNote', pattern: string, options: any = null): Promise<(FolderEntity | NoteEntity)[]> {
public async loadItems(type: FolderOrNoteType, pattern: string, options: { parent?: FolderEntity } | null = null): Promise<(FolderEntity | NoteEntity)[]> {
if (type === 'folderOrNote') {
const folders: FolderEntity[] = await this.loadItems(BaseModel.TYPE_FOLDER, pattern, options);
const folders: FolderEntity[] = await this.loadItems(ModelType.Folder, pattern, options);
if (folders.length) return folders;
return await this.loadItems(BaseModel.TYPE_NOTE, pattern, options);
return await this.loadItems(ModelType.Note, pattern, options);
}
pattern = pattern ? pattern.toString() : '';
if (type === BaseModel.TYPE_FOLDER && (pattern === Folder.conflictFolderTitle() || pattern === Folder.conflictFolderId())) return [Folder.conflictFolder()];
if (type === ModelType.Folder && (pattern === Folder.conflictFolderTitle() || pattern === Folder.conflictFolderId())) return [Folder.conflictFolder()];
if (!options) options = {};
const parent = options.parent ? options.parent : app().currentFolder();
const ItemClass = BaseItem.itemClass(type);
if (type === BaseModel.TYPE_NOTE && pattern.indexOf('*') >= 0) {
if (type === ModelType.Note && pattern.indexOf('*') >= 0) {
// Handle it as pattern
if (!parent) throw new Error(_('No notebook selected.'));
return await Note.previews(parent.id, { titlePattern: pattern });
} else {
// Single item
let item = null;
if (type === BaseModel.TYPE_NOTE) {
if (type === ModelType.Note) {
if (!parent) throw new Error(_('No notebook has been specified.'));
item = await (ItemClass as typeof Note).loadFolderNoteByField(parent.id, 'title', pattern);
} else {
@@ -172,7 +171,7 @@ class Application extends BaseApplication {
}
if (uiType !== null) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Dynamic command type
const temp: Record<string, any> = {};
for (const n in this.commands_) {
if (!this.commands_.hasOwnProperty(n)) continue;
@@ -233,8 +232,7 @@ class Application extends BaseApplication {
CommandClass = require(`${__dirname}/command-${name}.js`);
} catch (error) {
if (error.message && error.message.indexOf('Cannot find module') >= 0) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const e: any = new Error(_('No such command: %s', name));
const e: Error & { type?: string } = new Error(_('No such command: %s', name));
e.type = 'notFound';
throw e;
} else {
@@ -253,8 +251,7 @@ class Application extends BaseApplication {
isDummy: () => {
return true;
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
prompt: (initialText = '', promptString = '', options: any = null) => {
prompt: (initialText = '', promptString = '', options: Record<string, unknown> | null = null) => {
return cliUtils.prompt(initialText, promptString, options);
},
showConsole: () => {},
@@ -276,8 +273,7 @@ class Application extends BaseApplication {
};
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
public async execCommand(argv: string[]): Promise<any> {
public async execCommand(argv: string[]): Promise<void> {
if (!argv.length) return this.execCommand(['help']);
// reg.logger().debug('execCommand()', argv);
const commandName = argv[0];
@@ -396,8 +392,7 @@ class Application extends BaseApplication {
const keychainEnabled = this.checkIfKeychainEnabled(argv);
argv = await super.start(argv, { keychainEnabled });
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
cliUtils.setStdout((object: any) => {
cliUtils.setStdout((object: string) => {
return this.stdout(object);
});
@@ -448,7 +443,7 @@ class Application extends BaseApplication {
this.gui_.setLogger(this.logger());
await this.gui_.start();
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Redux dispatch type requires AnyAction
await refreshFolders((action: any) => this.store().dispatch(action), '');
const tags = await Tag.allWithNotes();

View File

@@ -1,7 +1,7 @@
import BaseCommand from './base-command';
import app from './app';
import { _ } from '@joplin/lib/locale';
import BaseModel from '@joplin/lib/BaseModel';
import { ModelType } from '@joplin/lib/BaseModel';
import shim from '@joplin/lib/shim';
class Command extends BaseCommand {
@@ -17,7 +17,7 @@ class Command extends BaseCommand {
public override async action(args: any) {
const title = args['note'];
const note = await app().loadItem(BaseModel.TYPE_NOTE, title, { parent: app().currentFolder() });
const note = await app().loadItem(ModelType.Note, title, { parent: app().currentFolder() });
this.encryptionCheck(note);
if (!note) throw new Error(_('Cannot find "%s".', title));

View File

@@ -1,7 +1,7 @@
import BaseCommand from './base-command';
import app from './app';
import { _ } from '@joplin/lib/locale';
import BaseModel from '@joplin/lib/BaseModel';
import { ModelType } from '@joplin/lib/BaseModel';
import BaseItem from '@joplin/lib/models/BaseItem';
import Note from '@joplin/lib/models/Note';
@@ -22,7 +22,7 @@ class Command extends BaseCommand {
public override async action(args: any) {
const title = args['note'];
const item = await app().loadItem(BaseModel.TYPE_NOTE, title, { parent: app().currentFolder() });
const item = await app().loadItem(ModelType.Note, title, { parent: app().currentFolder() });
if (!item) throw new Error(_('Cannot find "%s".', title));
let content = '';

View File

@@ -0,0 +1,19 @@
import BaseCommand from './base-command';
import app from './app';
import { _ } from '@joplin/lib/locale';
class Command extends BaseCommand {
public override usage() {
return 'clear';
}
public override description() {
return _('Clears the console output.');
}
public override async action() {
app().gui().widget('console').clear();
}
}
module.exports = Command;

View File

@@ -1,7 +1,7 @@
import BaseCommand from './base-command';
import app from './app';
import { _ } from '@joplin/lib/locale';
import BaseModel from '@joplin/lib/BaseModel';
import { ModelType } from '@joplin/lib/BaseModel';
import Note from '@joplin/lib/models/Note';
class Command extends BaseCommand {
@@ -17,14 +17,14 @@ class Command extends BaseCommand {
public override async action(args: any) {
let folder = null;
if (args['notebook']) {
folder = await app().loadItem(BaseModel.TYPE_FOLDER, args['notebook']);
folder = await app().loadItem(ModelType.Folder, args['notebook']);
} else {
folder = app().currentFolder();
}
if (!folder) throw new Error(_('Cannot find "%s".', args['notebook']));
const notes = await app().loadItems(BaseModel.TYPE_NOTE, args['note']);
const notes = await app().loadItems(ModelType.Note, args['note']);
if (!notes.length) throw new Error(_('Cannot find "%s".', args['note']));
for (let i = 0; i < notes.length; i++) {

View File

@@ -1,7 +1,7 @@
import BaseCommand from './base-command';
import app from './app';
import { _ } from '@joplin/lib/locale';
import BaseModel from '@joplin/lib/BaseModel';
import { ModelType } from '@joplin/lib/BaseModel';
import Note from '@joplin/lib/models/Note';
import time from '@joplin/lib/time';
import { NoteEntity } from '@joplin/lib/services/database/types';
@@ -17,7 +17,7 @@ class Command extends BaseCommand {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
public static async handleAction(commandInstance: BaseCommand, args: any, isCompleted: boolean) {
const note: NoteEntity = await app().loadItem(BaseModel.TYPE_NOTE, args.note);
const note: NoteEntity = await app().loadItem(ModelType.Note, args.note);
commandInstance.encryptionCheck(note);
if (!note) throw new Error(_('Cannot find "%s".', args.note));
if (!note.is_todo) throw new Error(_('Note is not a to-do: "%s"', args.note));

View File

@@ -6,7 +6,7 @@ import app from './app';
import { _ } from '@joplin/lib/locale';
import Note from '@joplin/lib/models/Note';
import Setting from '@joplin/lib/models/Setting';
import BaseModel from '@joplin/lib/BaseModel';
import { ModelType } from '@joplin/lib/BaseModel';
class Command extends BaseCommand {
public override usage() {
@@ -39,7 +39,7 @@ class Command extends BaseCommand {
const title = args['note'];
if (!app().currentFolder()) throw new Error(_('No active notebook.'));
let note = await app().loadItem(BaseModel.TYPE_NOTE, title);
let note = await app().loadItem(ModelType.Note, title);
this.encryptionCheck(note);

View File

@@ -1,6 +1,6 @@
import BaseCommand from './base-command';
import InteropService from '@joplin/lib/services/interop/InteropService';
import BaseModel from '@joplin/lib/BaseModel';
import { ModelType } from '@joplin/lib/BaseModel';
import app from './app';
import { _ } from '@joplin/lib/locale';
import { ExportOptions } from '@joplin/lib/services/interop/types';
@@ -34,12 +34,12 @@ class Command extends BaseCommand {
if (exportOptions.format === 'html') throw new Error('HTML export is not supported. Please use the desktop application.');
if (args.options.note) {
const notes = await app().loadItems(BaseModel.TYPE_NOTE, args.options.note, { parent: app().currentFolder() });
const notes = await app().loadItems(ModelType.Note, args.options.note, { parent: app().currentFolder() });
if (!notes.length) throw new Error(_('Cannot find "%s".', args.options.note));
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
exportOptions.sourceNoteIds = notes.map((n: any) => n.id);
} else if (args.options.notebook) {
const folders = await app().loadItems(BaseModel.TYPE_FOLDER, args.options.notebook);
const folders = await app().loadItems(ModelType.Folder, args.options.notebook);
if (!folders.length) throw new Error(_('Cannot find "%s".', args.options.notebook));
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
exportOptions.sourceFolderIds = folders.map((n: any) => n.id);

View File

@@ -1,7 +1,7 @@
import BaseCommand from './base-command';
import app from './app';
import { _ } from '@joplin/lib/locale';
import BaseModel from '@joplin/lib/BaseModel';
import { ModelType } from '@joplin/lib/BaseModel';
import Note from '@joplin/lib/models/Note';
class Command extends BaseCommand {
@@ -17,7 +17,7 @@ class Command extends BaseCommand {
public override async action(args: any) {
const title = args['note'];
const item = await app().loadItem(BaseModel.TYPE_NOTE, title, { parent: app().currentFolder() });
const item = await app().loadItem(ModelType.Note, title, { parent: app().currentFolder() });
if (!item) throw new Error(_('Cannot find "%s".', title));
const url = Note.geolocationUrl(item);
this.stdout(url);

View File

@@ -1,6 +1,6 @@
import BaseCommand from './base-command';
import InteropService from '@joplin/lib/services/interop/InteropService';
import BaseModel from '@joplin/lib/BaseModel';
import { ModelType } from '@joplin/lib/BaseModel';
const { cliUtils } = require('./cli-utils.js');
import app from './app';
import { _ } from '@joplin/lib/locale';
@@ -33,7 +33,7 @@ class Command extends BaseCommand {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
public override async action(args: any) {
let destinationFolder = await app().loadItem(BaseModel.TYPE_FOLDER, args.notebook);
let destinationFolder = await app().loadItem(ModelType.Folder, args.notebook);
if (args.notebook && !destinationFolder) throw new Error(_('Cannot find "%s".', args.notebook));

View File

@@ -1,7 +1,7 @@
const BaseCommand = require('./base-command').default;
import app from './app';
import { _ } from '@joplin/lib/locale';
import BaseModel from '@joplin/lib/BaseModel';
import { ModelType } from '@joplin/lib/BaseModel';
import Folder from '@joplin/lib/models/Folder';
import { FolderEntity } from '@joplin/lib/services/database/types';
@@ -23,7 +23,7 @@ class Command extends BaseCommand {
// validDestinationFolder check for presents and ambiguous folders
public async validDestinationFolder(targetFolder: string) {
const destinationFolder = await app().loadItem(BaseModel.TYPE_FOLDER, targetFolder);
const destinationFolder = await app().loadItem(ModelType.Folder, targetFolder);
if (!destinationFolder) {
throw new Error(_('Cannot find: "%s"', targetFolder));
}

View File

@@ -1,7 +1,7 @@
import BaseCommand from './base-command';
import app from './app';
import { _ } from '@joplin/lib/locale';
import BaseModel from '@joplin/lib/BaseModel';
import { ModelType } from '@joplin/lib/BaseModel';
import Folder from '@joplin/lib/models/Folder';
import Note from '@joplin/lib/models/Note';
@@ -21,7 +21,7 @@ class Command extends BaseCommand {
let folder = null;
if (destination !== 'root') {
folder = await app().loadItem(BaseModel.TYPE_FOLDER, destination);
folder = await app().loadItem(ModelType.Folder, destination);
if (!folder) throw new Error(_('Cannot find "%s".', destination));
}
@@ -30,7 +30,7 @@ class Command extends BaseCommand {
throw new Error(_('Ambiguous notebook "%s". Please use short notebook id instead - press "ti" to see the short notebook id', destination));
}
const itemFolder = await app().loadItem(BaseModel.TYPE_FOLDER, pattern);
const itemFolder = await app().loadItem(ModelType.Folder, pattern);
if (itemFolder) {
const sourceDuplicates = await Folder.search({ titlePattern: pattern, limit: 2 });
if (sourceDuplicates.length > 1) {
@@ -42,7 +42,7 @@ class Command extends BaseCommand {
await Folder.moveToFolder(itemFolder.id, folder.id);
}
} else {
const notes = await app().loadItems(BaseModel.TYPE_NOTE, pattern);
const notes = await app().loadItems(ModelType.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

@@ -2,7 +2,7 @@ import BaseCommand from './base-command';
import app from './app';
import { _ } from '@joplin/lib/locale';
import Folder from '@joplin/lib/models/Folder';
import BaseModel from '@joplin/lib/BaseModel';
import { ModelType } from '@joplin/lib/BaseModel';
import { substrWithEllipsis } from '@joplin/lib/string-utils';
class Command extends BaseCommand {
@@ -26,7 +26,7 @@ class Command extends BaseCommand {
const pattern = args['notebook'];
const force = args.options && args.options.force === true;
const folder = await app().loadItemOrFail(BaseModel.TYPE_FOLDER, pattern);
const folder = await app().loadItemOrFail(ModelType.Folder, pattern);
const permanent = args.options?.permanent === true || !!folder.deleted_time;
const ellipsizedFolderTitle = substrWithEllipsis(folder.title, 0, 32);

View File

@@ -2,7 +2,7 @@ import BaseCommand from './base-command';
import app from './app';
import { _, _n } from '@joplin/lib/locale';
import Note from '@joplin/lib/models/Note';
import BaseModel, { DeleteOptions } from '@joplin/lib/BaseModel';
import { DeleteOptions, ModelType } from '@joplin/lib/BaseModel';
import { NoteEntity } from '@joplin/lib/services/database/types';
class Command extends BaseCommand {
@@ -26,7 +26,7 @@ class Command extends BaseCommand {
const pattern = args['note-pattern'];
const force = args.options && args.options.force === true;
const notes: NoteEntity[] = await app().loadItems(BaseModel.TYPE_NOTE, pattern);
const notes: NoteEntity[] = await app().loadItems(ModelType.Note, pattern);
if (!notes.length) throw new Error(_('Cannot find "%s".', pattern));
let ok = true;

View File

@@ -1,7 +1,7 @@
import BaseCommand from './base-command';
import app from './app';
import { _ } from '@joplin/lib/locale';
import BaseModel from '@joplin/lib/BaseModel';
import { ModelType } from '@joplin/lib/BaseModel';
import Database from '@joplin/lib/database';
import Note from '@joplin/lib/models/Note';
@@ -29,7 +29,7 @@ class Command extends BaseCommand {
let propValue = args['value'];
if (!propValue) propValue = '';
const notes = await app().loadItems(BaseModel.TYPE_NOTE, title);
const notes = await app().loadItems(ModelType.Note, title);
if (!notes.length) throw new Error(_('Cannot find "%s".', title));
for (let i = 0; i < notes.length; i++) {

View File

@@ -1,7 +1,7 @@
import BaseCommand from './base-command';
import app from './app';
import { _ } from '@joplin/lib/locale';
import BaseModel from '@joplin/lib/BaseModel';
import { ModelType } from '@joplin/lib/BaseModel';
import Folder from '@joplin/lib/models/Folder';
class Command extends BaseCommand {
@@ -19,7 +19,7 @@ class Command extends BaseCommand {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
public override async action(args: any) {
const folder = await app().loadItem(BaseModel.TYPE_FOLDER, args['notebook']);
const folder = await app().loadItem(ModelType.Folder, args['notebook']);
if (!folder) throw new Error(_('Cannot find "%s".', args['notebook']));
// Auto-expand parent folders in GUI if present

View File

@@ -34,6 +34,12 @@ class ConsoleWidget extends TextWidget {
super.onBlur();
}
clear() {
this.lines_ = [];
this.updateText_ = true;
this.invalidate();
}
render() {
if (this.updateText_) {
if (this.lines_.length > this.maxLines_) {

View File

@@ -48,7 +48,7 @@
"chalk": "4.1.2",
"compare-version": "0.1.2",
"file-type": "16.5.4",
"fs-extra": "11.3.2",
"fs-extra": "11.3.3",
"html-entities": "1.4.0",
"keytar": "7.9.0",
"md5": "2.3.0",

View File

@@ -297,7 +297,11 @@ class AppComponent extends Component {
if (!this.state.contentScriptLoaded) {
let msg = 'Loading...';
if (this.state.contentScriptError) msg = `The Joplin extension is not available on this tab due to: ${this.state.contentScriptError}`;
return <div style={{ padding: 10, fontSize: 12, maxWidth: 200 }}>{msg}</div>;
return (
<div className="App Startup">
{msg}
</div>
);
}
const warningComponent = !this.props.warning ? null : <div className="Warning">{ this.props.warning }</div>;

View File

@@ -6,7 +6,7 @@ const shim: typeof ShimType = require('@joplin/lib/shim').default;
import { isCallbackUrl } from '@joplin/lib/callbackUrlUtils';
import { FileLocker } from '@joplin/utils/fs';
import { IpcMessageHandler, IpcServer, Message, newHttpError, sendMessage, SendMessageOptions, startServer, stopServer } from '@joplin/utils/ipc';
import { BrowserWindow, Tray, WebContents, screen, App, nativeTheme, powerMonitor } from 'electron';
import { BrowserWindow, Tray, WebContents, screen, App, nativeTheme, Menu } from 'electron';
import bridge from './bridge';
import * as url from 'url';
const path = require('path');
@@ -30,8 +30,7 @@ interface RendererProcessQuitReply {
}
interface PluginWindows {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
[key: string]: any;
[key: string]: BrowserWindow;
}
type SecondaryWindowId = string;
@@ -48,7 +47,6 @@ export interface Options {
}
export default class ElectronAppWrapper {
private logger_: Logger = null;
private electronApp_: App;
private env_: string;
private isDebugMode_: boolean;
@@ -61,8 +59,7 @@ export default class ElectronAppWrapper {
private secondaryWindows_: Map<SecondaryWindowId, SecondaryWindowData> = new Map();
private willQuitApp_ = false;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
private tray_: any = null;
private tray_: Tray = null;
private buildDir_: string = null;
private rendererProcessQuitReply_: RendererProcessQuitReply = null;
@@ -75,8 +72,9 @@ export default class ElectronAppWrapper {
private ipcServer_: IpcServer|null = null;
private ipcStartPort_ = 2658;
private ipcLogger_: Logger;
private ipcLoggerFilePath_: string;
private mainProcessLoggerFilePath_: string;
private ipcLogger_: LoggerWrapper;
private appLogger_: LoggerWrapper;
public constructor(electronApp: App, { env, profilePath, isDebugMode, initialCallbackUrl, isEndToEndTesting }: Options) {
this.electronApp_ = electronApp;
@@ -88,28 +86,20 @@ export default class ElectronAppWrapper {
this.profileLocker_ = new FileLocker(`${this.profilePath_}/lock`);
// Note: in certain contexts `this.logger_` doesn't seem to be available, especially for IPC
// calls, either because it hasn't been set or other issue. So we set one here specifically
// for this.
this.ipcLogger_ = new Logger();
this.ipcLoggerFilePath_ = `${profilePath}/log-cross-app-ipc.txt`;
this.ipcLogger_.addTarget(TargetType.File, {
path: this.ipcLoggerFilePath_,
const mainProcessLogger = new Logger();
this.mainProcessLoggerFilePath_ = `${profilePath}/log-main-process.txt`;
mainProcessLogger.addTarget(TargetType.File, {
path: this.mainProcessLoggerFilePath_,
});
this.ipcLogger_ = Logger.create('IPC', mainProcessLogger);
this.appLogger_ = Logger.create('App', mainProcessLogger);
}
public electronApp() {
return this.electronApp_;
}
public setLogger(v: Logger) {
this.logger_ = v;
}
public logger() {
return this.logger_;
}
public mainWindow() {
return this.win_;
}
@@ -122,8 +112,8 @@ export default class ElectronAppWrapper {
return !!this.ipcServer_;
}
public ipcLoggerFilePath() {
return this.ipcLoggerFilePath_;
public mainProcessLogFilePath() {
return this.mainProcessLoggerFilePath_;
}
public windowById(joplinId: string) {
@@ -352,7 +342,7 @@ export default class ElectronAppWrapper {
} catch (error) {
// This will throw an exception "Object has been destroyed" if the app is closed
// in less that the timeout interval. It can be ignored.
console.warn('Error opening dev tools', error);
this.appLogger_.warn('Error opening dev tools', error);
}
}, 1000);
}
@@ -405,15 +395,6 @@ export default class ElectronAppWrapper {
};
addWindowEventHandlers(this.win_.webContents);
// BrowserWindow 'focus' fires when the OS gives focus to the application window
// (i.e. coming from another app or from the taskbar), not on intra-app focus switches.
// We use a dedicated IPC channel so the renderer can trigger an immediate sync on
// OS-level focus gain without conflating it with the 'window-focused' channel that
// handles Joplin-internal window routing.
this.win_.on('focus', () => {
this.win_?.webContents.send('main-window-focused');
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
this.win_.on('close', (event: any) => {
// If it's on macOS, the app is completely closed only if the user chooses to close the app (willQuitApp_ will be true)
@@ -423,12 +404,15 @@ export default class ElectronAppWrapper {
// On Windows and Linux, the app is closed when the window is closed *except* if the tray icon is used. In which
// case the app must be explicitly closed with Ctrl+Q or by right-clicking on the tray icon and selecting "Exit".
this.appLogger_.info('[appClose] Window close event - willQuitApp_:', this.willQuitApp_, 'rendererProcessQuitReply_:', this.rendererProcessQuitReply_, 'secondaryWindows:', this.secondaryWindows_.size, 'trayShown:', this.trayShown());
let isGoingToExit = false;
if (process.platform === 'darwin') {
if (this.willQuitApp_) {
isGoingToExit = true;
} else {
this.appLogger_.info('[appClose] macOS: willQuitApp_ is false, hiding window instead of closing');
event.preventDefault();
const w = this.win_;
@@ -452,21 +436,27 @@ export default class ElectronAppWrapper {
}
}
this.appLogger_.info('[appClose] isGoingToExit:', isGoingToExit);
if (isGoingToExit) {
if (!this.rendererProcessQuitReply_) {
// If we haven't notified the renderer process yet, do it now
// so that it can tell us if we can really close the app or not.
// Search for "appClose" event for closing logic on renderer side.
this.appLogger_.info('[appClose] Sending appClose to renderer, waiting for reply...');
event.preventDefault();
if (this.win_) this.win_.webContents.send('appClose');
} else {
// If the renderer process has responded, check if we can close or not
this.appLogger_.info('[appClose] Got renderer reply - canClose:', this.rendererProcessQuitReply_.canClose);
if (this.rendererProcessQuitReply_.canClose) {
// Really quit the app
this.appLogger_.info('[appClose] Closing app now');
this.rendererProcessQuitReply_ = null;
this.win_ = null;
} else {
// Wait for renderer to finish task
this.appLogger_.info('[appClose] Renderer says cannot close yet, waiting...');
event.preventDefault();
this.rendererProcessQuitReply_ = null;
}
@@ -482,8 +472,31 @@ export default class ElectronAppWrapper {
// Match the main window's zoom:
window.webContents.setZoomFactor(this.mainWindow().webContents.getZoomFactor());
window.once('close', () => {
this.secondaryWindows_.delete(windowId);
window.once('close', (event) => {
// Check both: BrowserWindow and webContents can be destroyed independently
if (this.win_ && !this.win_.isDestroyed() && !this.win_.webContents.isDestroyed()) {
this.win_.webContents.send('secondary-window-closing', windowId);
}
if (this.secondaryWindows_.has(windowId)) {
this.secondaryWindows_.delete(windowId);
// Avoid closing a destroyed window. Closing a destroyed window results in the following error:
// Error: Render frame was disposed before WebFrameMain could be accessed
const stillOpen = !window.isDestroyed();
if (stillOpen) {
event.preventDefault();
// As of March 2026, Electron crashes with "Assertion failed: (Environment::GetCurrent(isolate)) == (env)" if the native 'close'
// event is allowed to close a secondary window. As a workaround, briefly hide the window and .close() it later.
// See https://github.com/laurent22/joplin/issues/14628.
window.hide();
setTimeout(() => {
if (!window.isDestroyed()) {
window.close();
}
}, 100);
}
}
const allSecondaryWindowsClosed = this.secondaryWindows_.size === 0;
const mainWindowVisuallyClosed = this.mainWindowHidden_;
@@ -531,8 +544,8 @@ export default class ElectronAppWrapper {
// sends a message. In which case, the above code would try to
// access a destroyed webview.
// https://github.com/laurent22/joplin/issues/4570
console.error('Could not process plugin message:', message);
console.error(error);
this.appLogger_.error('Could not process plugin message:', message);
this.appLogger_.error(error);
}
});
@@ -557,8 +570,7 @@ export default class ElectronAppWrapper {
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
public registerPluginWindow(pluginId: string, window: any) {
public registerPluginWindow(pluginId: string, window: BrowserWindow) {
this.pluginWindows_[pluginId] = window;
}
@@ -587,6 +599,7 @@ export default class ElectronAppWrapper {
}
public quit() {
this.appLogger_.info('[appClose] quit() called');
this.onExit();
this.electronApp_.quit();
}
@@ -595,6 +608,7 @@ export default class ElectronAppWrapper {
dispatch: (action: { type: string; [key: string]: unknown })=> void,
syncPending: boolean,
) {
this.appLogger_.info('[appClose] quitWithSyncCheck() called - syncPending:', syncPending);
if (syncPending) {
dispatch({ type: 'QUIT_SYNC_DIALOG_OPEN' });
} else {
@@ -644,8 +658,7 @@ export default class ElectronAppWrapper {
}
// Note: this must be called only after the "ready" event of the app has been dispatched
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
public createTray(contextMenu: any) {
public createTray(contextMenu: Menu) {
try {
this.tray_ = new Tray(`${this.buildDir()}/icons/${this.trayIconFilename_()}`);
this.tray_.setToolTip(this.electronApp_.name);
@@ -653,7 +666,7 @@ export default class ElectronAppWrapper {
this.tray_.on('click', () => {
if (!this.mainWindow()) {
console.warn('The window object was not available during the click event from tray icon');
this.appLogger_.warn('The window object was not available during the click event from tray icon');
return;
}
if (!this.mainWindow().isVisible()) {
@@ -663,7 +676,7 @@ export default class ElectronAppWrapper {
}
});
} catch (error) {
console.error('Cannot create tray', error);
this.appLogger_.error('Cannot create tray', error);
}
}
@@ -810,7 +823,7 @@ export default class ElectronAppWrapper {
}
this.quit();
if (this.env() === 'dev') console.warn(`Closing the application because another instance is already running, or the previous instance was force-quit within the last ${Math.round(this.profileLocker_.options.interval / Second)} seconds.`);
if (this.env() === 'dev') this.appLogger_.warn(`Closing the application because another instance is already running, or the previous instance was force-quit within the last ${Math.round(this.profileLocker_.options.interval / Second)} seconds.`);
return true;
}
@@ -858,8 +871,7 @@ export default class ElectronAppWrapper {
return matchingProcesses.trim().length > 0;
} catch (error) {
if (error.stderr || error.exitCode !== 1) {
// eslint-disable-next-line no-console -- The main logger is not available at this point.
console.error('Failed to check for and enable accessibility support:', error.stderr);
this.appLogger_.error('Failed to check for and enable accessibility support:', error.stderr);
}
return false;
@@ -869,8 +881,7 @@ export default class ElectronAppWrapper {
// Work around https://issues.chromium.org/issues/431257156 by force-enabling accessibility
// when Orca (a screen reader) is running:
if (await isOrcaRunning()) {
// eslint-disable-next-line no-console -- The main logger is not available at this point.
console.log('Linux accessibility: Enabling full accessibility support.');
this.appLogger_.info('Linux accessibility: Enabling full accessibility support.');
this.electronApp().setAccessibilitySupportEnabled(true);
}
}
@@ -889,10 +900,12 @@ export default class ElectronAppWrapper {
this.createWindow();
this.electronApp_.on('before-quit', () => {
this.appLogger_.info('[appClose] before-quit event fired, setting willQuitApp_ = true');
this.willQuitApp_ = true;
});
this.electronApp_.on('window-all-closed', () => {
this.appLogger_.info('[appClose] window-all-closed event fired');
this.quit();
});
@@ -905,11 +918,6 @@ export default class ElectronAppWrapper {
event.preventDefault();
void this.openCallbackUrl(url);
});
// When the OS wakes from sleep, notify the renderer so it can trigger an immediate sync.
powerMonitor.on('resume', () => {
this.win_?.webContents.send('system-resumed');
});
}
public async openCallbackUrl(url: string) {

View File

@@ -11,8 +11,7 @@ const logger = Logger.create('app.reducer');
export interface AppStateRoute {
type: string;
routeName: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
props: any;
props: Record<string, unknown>;
}
export enum AppStateDialogName {
@@ -22,8 +21,7 @@ export enum AppStateDialogName {
export interface AppStateDialog {
name: AppStateDialogName;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
props: Record<string, any>;
props: Record<string, unknown>;
}
export interface NoteIdToScrollPercent {

View File

@@ -78,8 +78,7 @@ type StartupTask = { label: string; task: ()=> void|Promise<void> };
class Application extends BaseApplication {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
private checkAllPluginStartedIID_: any = null;
private checkAllPluginStartedIID_: ReturnType<typeof setInterval> = null;
private initPluginServiceDone_ = false;
private ocrService_: OcrService;
private protocolHandler_: CustomContentProtocolHandler;
@@ -734,22 +733,9 @@ class Application extends BaseApplication {
}
});
// Trigger an immediate sync when the main window gains OS-level focus (i.e. the user
// switches back to Joplin from another application) or when the system wakes from sleep.
// A 30-second cool-down prevents duplicate syncs during rapid focus-in/focus-out cycles.
const minResumeSyncIntervalMs = 30_000;
let lastFocusSyncTime = 0;
const scheduleResumeSync = () => {
const now = Date.now();
if (now - lastFocusSyncTime > minResumeSyncIntervalMs) {
lastFocusSyncTime = now;
void reg.scheduleSync(0);
}
};
ipcRenderer.on('main-window-focused', scheduleResumeSync);
ipcRenderer.on('system-resumed', scheduleResumeSync);
ipcRenderer.on('secondary-window-closing', (_event, windowId: string) => {
this.dispatch({ type: 'WINDOW_CLOSE', windowId });
});
});
addTask('app/initPluginService', () => this.initPluginService());

View File

@@ -1,7 +1,7 @@
import ElectronAppWrapper from './ElectronAppWrapper';
import shim, { MessageBoxType } from '@joplin/lib/shim';
import { _, setLocale } from '@joplin/lib/locale';
import { BrowserWindow, nativeTheme, nativeImage, shell, dialog, MessageBoxSyncOptions, safeStorage, Menu, MenuItemConstructorOptions, MenuItem } from 'electron';
import { BrowserWindow, nativeTheme, nativeImage, shell, dialog, MessageBoxSyncOptions, safeStorage, Menu, MenuItemConstructorOptions, MenuItem, BrowserWindowConstructorOptions, FileFilter, SaveDialogOptions } from 'electron';
import { dirname, toSystemSlashes } from '@joplin/lib/path-utils';
import { fileUriToPath } from '@joplin/utils/url';
import { urlDecode } from '@joplin/lib/string-utils';
@@ -25,8 +25,7 @@ interface OpenDialogOptions {
properties?: string[];
defaultPath?: string;
createDirectory?: boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
filters?: any[];
filters?: FileFilter[];
}
type OnAllowedExtensionsChange = (newExtensions: string[])=> void;
@@ -208,8 +207,7 @@ export class Bridge {
this.onAllowedExtensionsChangeListener_ = listener;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
public async captureException(error: any) {
public async captureException(error: unknown) {
Sentry.captureException(error);
// We wait to give the "beforeSend" event handler time to process the crash dump and write
// it to file.
@@ -335,8 +333,7 @@ export class Bridge {
return require('electron').shell.showItemInFolder(toSystemSlashes(fullPath));
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
public newBrowserWindow(options: any) {
public newBrowserWindow(options: BrowserWindowConstructorOptions) {
return new BrowserWindow(options);
}
@@ -353,8 +350,7 @@ export class Bridge {
return this.activeWindow().webContents.closeDevTools();
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
public async showSaveDialog(options: any) {
public async showSaveDialog(options: SaveDialogOptions) {
if (!options) options = {};
if (!('defaultPath' in options) && this.lastSelectedPaths_.file) options.defaultPath = this.lastSelectedPaths_.file;
const { filePath } = await dialog.showSaveDialog(this.activeWindow(), options);
@@ -381,8 +377,7 @@ export class Bridge {
}
// Don't use this directly - call one of the showXxxxxxxMessageBox() instead
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
private showMessageBox_(window: any, options: MessageDialogOptions): number {
private showMessageBox_(window: BrowserWindow, options: MessageDialogOptions): number {
if (!window) window = this.activeWindow();
return dialog.showMessageBoxSync(window, { message: '', ...options });
}
@@ -428,8 +423,7 @@ export class Bridge {
return result;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
public showInfoMessageBox(message: string, options: any = {}) {
public showInfoMessageBox(message: string, options: MessageDialogOptions = {}) {
const result = this.showMessageBox_(this.activeWindow(), { type: 'info',
message: message,
buttons: [_('OK')], ...options });
@@ -559,7 +553,7 @@ export class Bridge {
});
if (buttonIndex === 1) {
void this.openItem(this.electronApp().ipcLoggerFilePath());
void this.openItem(this.electronApp().mainProcessLogFilePath());
}
}
}

View File

@@ -49,8 +49,8 @@ function truncateText(text: string, length: number) {
}
async function getSkippedVersions(): Promise<string[]> {
const r = await KvStore.instance().value<string>('updateCheck::skippedVersions');
return r ? JSON.parse(r) : [];
const r = await KvStore.instance().value('updateCheck::skippedVersions');
return r && typeof r === 'string' ? JSON.parse(r) : [];
}
async function isSkippedVersion(v: string): Promise<boolean> {

View File

@@ -1,7 +1,6 @@
import styled from 'styled-components';
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const Root = styled.h1<any>`
const Root = styled.h1<{ justifyContent?: string }>`
display: flex;
justify-content: ${props => props.justifyContent ? props.justifyContent : 'center'};
font-family: ${props => props.theme.fontFamily};

View File

@@ -8,7 +8,7 @@ const { themeStyle } = require('@joplin/lib/theme');
const Shared = require('@joplin/lib/components/shared/dropbox-login-shared');
interface Props {
themeId: string;
themeId: number;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
@@ -63,8 +63,7 @@ class DropboxLoginScreenComponent extends React.Component<any, any> {
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const mapStateToProps = (state: any) => {
const mapStateToProps = (state: { settings: { theme: number } }) => {
return {
themeId: state.settings.theme,
};

View File

@@ -47,10 +47,13 @@ export default function(props: Props) {
}, [props.dispatch]);
useEffect(() => {
if (!titleInputRef.current) return;
focus('Dialog::titleInputRef', titleInputRef.current);
setTimeout(() => {
titleInputRef.current.select();
if (titleInputRef.current) {
titleInputRef.current.select();
}
}, 100);
}, []);

View File

@@ -20,6 +20,10 @@ import ToggleAdvancedSettingsButton from '../ConfigScreen/controls/ToggleAdvance
import MacOSMissingPasswordHelpLink from '../ConfigScreen/controls/MissingPasswordHelpLink';
import { Dispatch } from 'redux';
import { shouldCancelPendingEnableAfterMasterPasswordDialog, shouldOpenMasterPasswordDialogForEnable, shouldResumeEnableAfterMasterPasswordDialog } from './enableFlow';
import Dialog from '@joplin/lib/components/Dialog';
import DialogButtonRow from '../DialogButtonRow';
import DialogTitle from '../DialogTitle';
import PasswordInput from '../PasswordInput/PasswordInput';
interface Props {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
@@ -39,6 +43,10 @@ interface Props {
export const EncryptionConfigScreen = (props: Props) => {
const { inputPasswords, onInputPasswordChange } = useInputPasswords(props.passwords);
const [pendingEnableEncryption, setPendingEnableEncryption] = useState(false);
const [enableEncryptionPromptVisible, setEnableEncryptionPromptVisible] = useState(false);
const [enableEncryptionPassword, setEnableEncryptionPassword] = useState('');
const promptPromiseRef = useRef<(password: string | null)=> void>(null);
const wasMasterPasswordDialogOpen = useRef(props.masterPasswordDialogOpen);
const theme = useMemo(() => {
@@ -235,7 +243,7 @@ export const EncryptionConfigScreen = (props: Props) => {
const newEnabled = !isEnabled;
const masterKey = getDefaultMasterKey();
const hasMasterPassword = !!props.masterPassword;
let newPassword = '';
let newPassword: string | null = '';
if (isEnabled) {
const answer = await dialogs.confirm(_('Disabling encryption means *all* your notes and attachments are going to be re-synchronised and sent unencrypted to the sync target. Do you wish to continue?'));
@@ -253,8 +261,14 @@ export const EncryptionConfigScreen = (props: Props) => {
return;
}
const msg = enableEncryptionConfirmationMessages(masterKey, hasMasterPassword);
newPassword = await dialogs.prompt(msg.join('\n\n'), '', '', { type: 'password' });
// Wait for the custom React Dialog to resolve
setEnableEncryptionPassword('');
setEnableEncryptionPromptVisible(true);
newPassword = await new Promise<string | null>((resolve) => {
promptPromiseRef.current = resolve;
});
if (newPassword === null) return; // User cancelled
}
if (hasMasterPassword && newEnabled) {
@@ -271,6 +285,63 @@ export const EncryptionConfigScreen = (props: Props) => {
}
}, [props.dispatch, props.masterPassword, props.masterPasswordDialogOpen]);
const renderEnableEncryptionDialog = () => {
if (!enableEncryptionPromptVisible) return null;
const masterKey = getDefaultMasterKey();
const hasMasterPassword = !!props.masterPassword;
const msg = enableEncryptionConfirmationMessages(masterKey, hasMasterPassword);
const messageComps = msg.map((m, index) => <p key={index} style={theme.textStyle}>{m}</p>);
const onClose = () => {
setEnableEncryptionPromptVisible(false);
if (promptPromiseRef.current) promptPromiseRef.current(null);
};
const onDialogButtonRowClick = (event: { buttonName: string }) => {
if (event.buttonName === 'cancel') {
onClose();
return;
}
if (event.buttonName === 'ok') {
setEnableEncryptionPromptVisible(false);
if (promptPromiseRef.current) promptPromiseRef.current(enableEncryptionPassword);
}
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Required because PasswordInput's ChangeEventHandler type is incorrect
const onPasswordInputChange = (event: any) => {
setEnableEncryptionPassword(event.target.value);
};
return (
<Dialog onCancel={onClose} className="enable-encryption-dialog">
<div className="dialog-root">
<DialogTitle title={_('Enable encryption')}/>
<div className="dialog-content">
<div style={{ marginBottom: 16 }}>
{messageComps}
</div>
<div style={{ marginBottom: 16 }}>
<label style={{ ...theme.textStyle, marginBottom: 5, display: 'block' }} htmlFor="enable-encryption-password">{_('Password:')}</label>
<PasswordInput
inputId="enable-encryption-password"
value={enableEncryptionPassword}
onChange={onPasswordInputChange}
/>
</div>
</div>
<DialogButtonRow
themeId={props.themeId}
onClick={onDialogButtonRowClick}
okButtonDisabled={!enableEncryptionPassword}
/>
</div>
</Dialog>
);
};
const renderEncryptionSection = () => {
const decryptedItemsInfo = <p>{decryptedStatText(stats)}</p>;
const toggleButton = (
@@ -451,6 +522,7 @@ export const EncryptionConfigScreen = (props: Props) => {
{renderMasterKeySection(props.masterKeys.filter(mk => !masterKeyEnabled(mk)), false)}
{renderNonExistingMasterKeysSection()}
{renderAdvancedSection()}
{renderEnableEncryptionDialog()}
</div>
);
};

View File

@@ -31,8 +31,7 @@ interface State {
interface Props {
message?: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
children: any;
children: React.ReactNode;
}
interface BannerProps {

View File

@@ -6,14 +6,12 @@ import { _ } from '@joplin/lib/locale';
interface Props {
tip: string;
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
onClick: Function;
onClick: ()=> void;
themeId: number;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
style: any;
style?: React.CSSProperties;
'aria-controls'?: string;
'aria-expanded'?: string;
'aria-expanded'?: boolean;
}
class HelpButtonComponent extends React.Component<Props> {
@@ -31,8 +29,7 @@ class HelpButtonComponent extends React.Component<Props> {
const theme = themeStyle(this.props.themeId);
const style = { ...this.props.style, color: theme.color, textDecoration: 'none' };
const helpIconStyle = { flex: 0, width: 16, height: 16, marginLeft: 10 };
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const extraProps: any = {};
const extraProps: Record<string, string> = {};
if (this.props.tip) {
extraProps['data-tip'] = this.props.tip;
extraProps['aria-description'] = this.props.tip;

View File

@@ -3,11 +3,9 @@ import { themeStyle } from '@joplin/lib/theme';
interface Props {
themeId: number;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
style: any;
style?: React.CSSProperties;
iconName: string;
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
onClick: Function;
onClick: ()=> void;
}
class IconButton extends React.Component<Props> {
@@ -20,7 +18,7 @@ class IconButton extends React.Component<Props> {
};
const icon = <i style={iconStyle} className={`fas ${this.props.iconName}`}></i>;
const rootStyle = {
const rootStyle: React.CSSProperties = {
display: 'flex',
textDecoration: 'none',
padding: 10,

View File

@@ -45,6 +45,9 @@ import PluginNotification from './PluginNotification/PluginNotification';
import { Toast } from '@joplin/lib/services/plugins/api/types';
import PluginService from '@joplin/lib/services/plugins/PluginService';
import QuitSyncDialog from './QuitSyncDialog';
import Logger from '@joplin/utils/Logger';
const logger = Logger.create('MainScreen');
const ipcRenderer = require('electron').ipcRenderer;
@@ -277,10 +280,12 @@ class MainScreenComponent extends React.Component<Props, State> {
// If a note is being saved, we wait till it is saved and then call
// "appCloseReply" again.
ipcRenderer.on('appClose', async () => {
logger.info('[appClose] Received appClose event - hasNotesBeingSaved:', this.props.hasNotesBeingSaved);
if (this.waitForNotesSavedIID_) shim.clearInterval(this.waitForNotesSavedIID_);
this.waitForNotesSavedIID_ = null;
const sendCanClose = async (canClose: boolean) => {
logger.info('[appClose] Sending appCloseReply - canClose:', canClose);
if (canClose) {
Setting.setValue('wasClosedSuccessfully', true);
await Setting.saveAll();
@@ -291,8 +296,10 @@ class MainScreenComponent extends React.Component<Props, State> {
await sendCanClose(!this.props.hasNotesBeingSaved);
if (this.props.hasNotesBeingSaved) {
logger.info('[appClose] Notes are being saved, waiting...');
this.waitForNotesSavedIID_ = shim.setInterval(() => {
if (!this.props.hasNotesBeingSaved) {
logger.info('[appClose] Notes saved, now sending canClose: true');
shim.clearInterval(this.waitForNotesSavedIID_);
this.waitForNotesSavedIID_ = null;
void sendCanClose(true);

View File

@@ -823,6 +823,12 @@ function useMenu(props: Props) {
Setting.incValue('windowContentZoomFactor', -10);
},
accelerator: 'CommandOrControl+-',
}, {
type: 'separator',
visible: shim.isMac(),
}, {
role: 'togglefullscreen',
visible: shim.isMac(),
}],
},
go: {

View File

@@ -5,7 +5,6 @@ import { AppState, AppStateRoute } from '../app.reducer';
import bridge from '../services/bridge';
import { useContext, useEffect, useMemo, useRef, useState } from 'react';
import { WindowIdContext } from './NewWindowOrIFrame';
import useCtrlWheelZoom from './hooks/useCtrlWheelZoom';
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Partial refactor of code from before rule was applied
type ScreenProps = any;
@@ -99,7 +98,6 @@ const NavigatorComponent: React.FC<Props> = props => {
useWindowTitleManager(screenInfo);
useWindowRefocusManager(route);
useCtrlWheelZoom();
const size = useContainerSize(container);
if (!route) throw new Error('Route must not be null');

View File

@@ -1,5 +1,4 @@
import { defaultWindowId } from '@joplin/lib/reducer';
import shim from '@joplin/lib/shim';
import * as React from 'react';
import { useState, useEffect, useRef, createContext } from 'react';
import { createPortal } from 'react-dom';
@@ -40,7 +39,7 @@ const useDocument = (
useEffect(() => {
let openedWindow: Window|null = null;
const unmounted = false;
let unmounted = false;
if (iframeElement) {
setDoc(iframeElement?.contentWindow?.document);
} else if (mode === WindowMode.NewWindow) {
@@ -52,11 +51,16 @@ const useDocument = (
void (async () => {
while (!unmounted) {
await new Promise<void>(resolve => {
shim.setTimeout(() => resolve(), 2000);
setTimeout(() => resolve(), 2000);
});
// Re-check after sleep to avoid duplicate WINDOW_CLOSE if IPC already fired.
if (unmounted) break;
if (openedWindow?.closed) {
onCloseRef.current?.();
// Null out doc first so React stops rendering into the destroyed window
// before WINDOW_CLOSE triggers unmounting (prevents renderer crash on Windows).
setDoc(null);
openedWindow = null;
break;
}
@@ -65,6 +69,8 @@ const useDocument = (
}
return () => {
unmounted = true;
// Delay: Closing immediately causes Electron to crash
setTimeout(() => {
if (!openedWindow?.closed) {

View File

@@ -22,18 +22,21 @@ interface KeyToLabelMap {
[key: string]: string;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
let markupToHtml_: any = null;
let markupToHtml_: ReturnType<typeof markupLanguageUtils.newMarkupToHtml> = null;
function markupToHtml() {
if (markupToHtml_) return markupToHtml_;
markupToHtml_ = markupLanguageUtils.newMarkupToHtml();
return markupToHtml_;
}
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
function countElements(text: string, wordSetter: Function, characterSetter: Function, characterNoSpaceSetter: Function, cjkCharacterSetter: React.Dispatch<React.SetStateAction<number>>, lineSetter: Function) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
Countable.count(text, (counter: any) => {
interface CounterResult {
words: number;
all: number;
characters: number;
}
function countElements(text: string, wordSetter: React.Dispatch<React.SetStateAction<number>>, characterSetter: React.Dispatch<React.SetStateAction<number>>, characterNoSpaceSetter: React.Dispatch<React.SetStateAction<number>>, cjkCharacterSetter: React.Dispatch<React.SetStateAction<number>>, lineSetter: React.Dispatch<React.SetStateAction<number>>) {
Countable.count(text, (counter: CounterResult) => {
wordSetter(counter.words);
characterSetter(counter.all);
characterNoSpaceSetter(counter.characters);
@@ -53,8 +56,7 @@ function formatReadTime(readTimeMinutes: number) {
export default function NoteContentPropertiesDialog(props: NoteContentPropertiesDialogProps) {
const theme = themeStyle(props.themeId);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const tableBodyComps: any[] = [];
const tableBodyComps: React.JSX.Element[] = [];
// For the source Markdown
const [lines, setLines] = useState<number>(0);
const [words, setWords] = useState<number>(0);

View File

@@ -45,8 +45,8 @@ const Editor = (props: Props, ref: ForwardedRef<CodeMirrorControl>) => {
return () => {};
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const pasteEventHandler = (_editor: any, event: Event) => {
const pasteEventHandler = (_editor: unknown, ...args: unknown[]) => {
const event = args[0] as Event;
props.onEditorPaste(event);
};

View File

@@ -1493,6 +1493,23 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
}
}
const clearInheritedCheckedStateOnChecklistEnter = () => {
const currentNode = editor.selection.getStart();
const currentListItem = editor.dom.getParent(currentNode, 'li') as HTMLLIElement;
if (!currentListItem) return;
const parentChecklist = editor.dom.getParent(currentListItem, 'ul.joplin-checklist');
if (!parentChecklist) return;
if (!currentListItem.classList.contains('checked')) return;
const textContent = (currentListItem.textContent ?? '').replace(/\u200B/g, '').trim();
if (textContent !== '') return;
currentListItem.classList.remove('checked');
onChangeHandler();
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
async function onKeyDown(event: any) {
// It seems "paste as text" is handled automatically on Windows and Linux,
@@ -1508,6 +1525,13 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
event.preventDefault();
pasteAsPlainText(null);
}
if (event.key === 'Enter' && !event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey && !event.isComposing) {
shim.setTimeout(() => {
if (!editor || !editor.getDoc()) return;
clearInheritedCheckedStateOnChecklistEnter();
}, 0);
}
}
function onPasteAsText() {

View File

@@ -1,6 +1,7 @@
import { CommandRuntime, CommandDeclaration } from '@joplin/lib/services/CommandService';
import { _ } from '@joplin/lib/locale';
import { focus } from '@joplin/lib/utils/focusHandler';
import { RefObject } from 'react';
export const declaration: CommandDeclaration = {
name: 'focusElementNoteTitle',
@@ -8,8 +9,7 @@ export const declaration: CommandDeclaration = {
parentLabel: () => _('Focus'),
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
export const runtime = (comp: any): CommandRuntime => {
export const runtime = (comp: { titleInputRef: RefObject<HTMLInputElement> }): CommandRuntime => {
return {
execute: async () => {
if (!comp.titleInputRef.current) return;

View File

@@ -6,6 +6,7 @@ const baseContext: Record<string, any> = {
modalDialogVisible: false,
gotoAnythingVisible: false,
markdownEditorPaneVisible: true,
markdownViewerPaneVisible: false,
oneNoteSelected: true,
noteIsMarkdown: true,
noteIsReadOnly: false,
@@ -98,9 +99,38 @@ describe('editorCommandDeclarations', () => {
{
textBold: false,
textPaste: false,
// TODO: textCopy should be enabled in read-only notes:
// textCopy: false,
textCopy: true,
textSelectAll: true,
},
],
[
// Viewer-only mode (no editor pane visible, only the rendered viewer)
{
markdownEditorPaneVisible: false,
richTextEditorVisible: false,
markdownViewerPaneVisible: true,
},
{
textCopy: true,
textSelectAll: true,
textCut: false,
textPaste: false,
textBold: false,
},
],
[
// Viewer-only mode with a read-only note
{
markdownEditorPaneVisible: false,
richTextEditorVisible: false,
markdownViewerPaneVisible: true,
noteIsReadOnly: true,
},
{
textCopy: true,
textSelectAll: true,
textCut: false,
textPaste: false,
},
],
])('should correctly determine whether command is enabled (case %#)', (context, expectedStates) => {

View File

@@ -10,18 +10,31 @@ const workWithHtmlNotes = [
'textSelectAll',
];
// Commands that should remain enabled in viewer mode and when the note is read-only.
const worksInViewerAndReadOnlyMode = [
'textCopy',
'textSelectAll',
];
export const enabledCondition = (commandName: string) => {
const markdownEditorOnly = !Object.keys(joplinCommandToTinyMceCommands).includes(commandName);
const noteMustBeMarkdown = !workWithHtmlNotes.includes(commandName);
const allowInViewerAndReadOnlyMode = worksInViewerAndReadOnlyMode.includes(commandName);
const editorPaneCondition = markdownEditorOnly
? 'markdownEditorPaneVisible'
: allowInViewerAndReadOnlyMode
? '(markdownEditorPaneVisible || richTextEditorVisible || markdownViewerPaneVisible)'
: '(markdownEditorPaneVisible || richTextEditorVisible)';
const output = [
// gotoAnythingVisible: Enable if the command palette (which is a modal dialog) is visible
'(!modalDialogVisible || gotoAnythingVisible)',
markdownEditorOnly ? 'markdownEditorPaneVisible' : '(markdownEditorPaneVisible || richTextEditorVisible)',
editorPaneCondition,
'oneNoteSelected',
noteMustBeMarkdown ? 'noteIsMarkdown' : '',
'!noteIsReadOnly',
allowInViewerAndReadOnlyMode ? '' : '!noteIsReadOnly',
];
return output.filter(c => !!c).join(' && ');

View File

@@ -1,10 +1,24 @@
import Setting from '@joplin/lib/models/Setting';
import { processImagesInPastedHtml, processPastedHtml } from './resourceHandling';
import { processImagesInPastedHtml, processPastedHtml, getResourcesFromPasteEvent } from './resourceHandling';
import markupLanguageUtils from '@joplin/lib/markupLanguageUtils';
import HtmlToMd from '@joplin/lib/HtmlToMd';
import { HtmlToMarkdownHandler, MarkupToHtmlHandler } from './types';
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
jest.mock('electron', () => ({
clipboard: {
has: jest.fn(),
readBuffer: jest.fn(),
},
}));
interface ClipboardMock {
has: jest.Mock;
readBuffer: jest.Mock;
}
const mockClipboard = (require('electron') as { clipboard: ClipboardMock }).clipboard;
const createTestMarkupConverters = () => {
const markupToHtml: MarkupToHtmlHandler = async (markupLanguage, markup, options) => {
const conv = markupLanguageUtils.newMarkupToHtml({}, {
@@ -23,6 +37,11 @@ const createTestMarkupConverters = () => {
};
describe('resourceHandling', () => {
afterEach(() => {
mockClipboard.has.mockReset();
mockClipboard.readBuffer.mockReset();
});
it('should sanitize pasted HTML', async () => {
Setting.setConstant('resourceDir', '/home/.config/joplin/resources');
@@ -129,4 +148,39 @@ describe('resourceHandling', () => {
expect(result).not.toContain(expectAbsent);
expect(result).not.toContain('data:');
});
// Tests for getResourcesFromPasteEvent - clipboard image paste (issue #14613)
// The test environment (non-Electron, no sharp) skips image validation and
// just copies the file, so any non-empty buffer works as test data.
const testImageBuffer = Buffer.from(minimalPng, 'base64');
test.each([
{ format: 'image/jpeg', description: 'JPEG (bug #14613)' },
{ format: 'image/jpg', description: 'JPG alias' },
{ format: 'image/png', description: 'PNG (regression check)' },
])('should paste $description image from clipboard via getResourcesFromPasteEvent', async ({ format }) => {
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
mockClipboard.has.mockImplementation((f: string) => f === format);
mockClipboard.readBuffer.mockImplementation((f: string) => {
return f === format ? testImageBuffer : Buffer.alloc(0);
});
const mockEvent = { preventDefault: jest.fn() };
const result = await getResourcesFromPasteEvent(mockEvent);
expect(result.length).toBe(1);
expect(result[0]).toContain('](:/');
expect(mockEvent.preventDefault).toHaveBeenCalledTimes(1);
});
test.each([
{ description: 'clipboard has no image', hasResult: false },
{ description: 'buffer is empty despite has() returning true', hasResult: true },
])('should return empty when $description', async ({ hasResult }) => {
mockClipboard.has.mockReturnValue(hasResult);
mockClipboard.readBuffer.mockReturnValue(Buffer.alloc(0));
const mockEvent = { preventDefault: jest.fn() };
const result = await getResourcesFromPasteEvent(mockEvent);
expect(result).toEqual([]);
expect(mockEvent.preventDefault).not.toHaveBeenCalled();
});
});

View File

@@ -93,28 +93,38 @@ export function resourcesStatus(resourceInfos: any) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
export async function getResourcesFromPasteEvent(event: any) {
const output = [];
const formats = clipboard.availableFormats();
for (let i = 0; i < formats.length; i++) {
const format = formats[i].toLowerCase();
const formatType = format.split('/')[0];
if (formatType === 'image') {
// writeImageToFile can process only image/jpeg, image/jpg or image/png mime types
if (['image/png', 'image/jpg', 'image/jpeg'].indexOf(format) < 0) {
continue;
// clipboard.has() and readBuffer() are used instead of availableFormats() and
// readImage(), which don't work for JPEG on Linux.
// https://github.com/laurent22/joplin/issues/14613
const supportedFormats = ['image/png', 'image/jpeg', 'image/jpg'];
for (const format of supportedFormats) {
if (!clipboard.has(format)) continue;
const data = clipboard.readBuffer(format);
if (!data || data.length === 0) continue;
if (event) event.preventDefault();
const fileExt = mimeUtils.toFileExtension(format);
const filePath = `${Setting.value('tempDir')}/${md5(Date.now() + Math.random())}.${fileExt}`;
let md = null;
try {
await shim.fsDriver().writeFile(filePath, data, 'buffer');
md = await commandAttachFileToBody('', [filePath]);
} finally {
try {
await shim.fsDriver().remove(filePath);
} catch (cleanupError) {
logger.warn('getResourcesFromPasteEvent: Failed to remove temporary file.', cleanupError);
}
if (event) event.preventDefault();
}
const image = clipboard.readImage();
const fileExt = mimeUtils.toFileExtension(format);
const filePath = `${Setting.value('tempDir')}/${md5(Date.now())}.${fileExt}`;
await shim.writeImageToFile(image, format, filePath);
const md = await commandAttachFileToBody('', [filePath]);
await shim.fsDriver().remove(filePath);
if (md) output.push(md);
if (md) {
output.push(md);
break;
}
}
return output;

View File

@@ -54,7 +54,16 @@ export default (props: Props) => {
classes.push(props.isReverse ? 'fa-chevron-down' : 'fa-chevron-up');
chevron = <i className={classes.join(' ')}></i>;
}
return <span className="titlewrapper">{getColumnTitle(column.name, true)}{chevron}</span>;
const title = getColumnTitle(column.name);
let titleElement: React.ReactNode = title;
if (column.name === 'note.checkboxes') {
titleElement = <i className="fas fa-adjust" aria-label={title} title={title}></i>;
} else if (column.name === 'note.is_todo') {
titleElement = <i className="fas fa-check" aria-label={title} title={title}></i>;
}
return <span className="titlewrapper">{titleElement}{chevron}</span>;
};
const renderResizer = () => {
@@ -77,6 +86,7 @@ export default (props: Props) => {
draggable={true}
className={classes.join(' ')}
style={style}
title={getColumnTitle(column.name)}
onClick={onClick}
onDragStart={props.onDragStart}
onDragOver={props.onDragOver}

View File

@@ -16,14 +16,6 @@ const titles: Record<ColumnName, ()=> string> = {
'note.user_updated_time': () => _('Updated'),
};
const titlesForHeader: Partial<Record<ColumnName, ()=> string>> = {
'note.checkboxes': () => '◐',
'note.is_todo': () => '✓',
};
export default (name: ColumnName, forHeader = false) => {
let fn: ()=> string = null;
if (forHeader) fn = titlesForHeader[name];
if (!fn) fn = titles[name];
return fn ? fn() : name;
export default (name: ColumnName) => {
return titles[name]();
};

View File

@@ -18,8 +18,7 @@ interface RenderedNote {
html: string;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const hashContent = (content: any) => {
const hashContent = (content: unknown) => {
return createHash('sha1').update(JSON.stringify(content)).digest('hex');
};

View File

@@ -317,7 +317,7 @@ class NotePropertiesDialog extends React.Component<Props, State> {
const styles = this.styles(this.props.themeId);
const theme = themeStyle(this.props.themeId);
const labelText = this.formatLabel(key);
const labelComp = <label htmlFor={uniqueId(key)} role='rowheader' style={{ ...theme.textStyle, ...theme.controlBoxLabel }}>{labelText}</label>;
const labelComp = <label htmlFor={uniqueId(key)} style={{ ...theme.textStyle, ...theme.controlBoxLabel }}>{labelText}</label>;
let controlComp = null;
let editComp = null;
let editCompHandler = null;
@@ -422,11 +422,11 @@ class NotePropertiesDialog extends React.Component<Props, State> {
textOverflow: 'ellipsis',
display: 'inline-block',
};
controlComp = (
controlComp = displayedValue ? (
<a href="#" onClick={() => bridge().openExternal(url)} style={urlStyle}>
{displayedValue}
</a>
);
) : null;
} else if (key === 'revisionsLink') {
controlComp = (
<a href="#" onClick={this.revisionsLink_click} style={theme.urlStyle}>
@@ -468,10 +468,10 @@ class NotePropertiesDialog extends React.Component<Props, State> {
}
return (
<div role='row' key={key} style={theme.controlBox} className="note-property-box">
{labelComp}
<span role='cell'>{controlComp} {editComp}</span>
</div>
<tr key={key} style={theme.controlBox} className="note-property-box">
<th>{labelComp}</th>
<td>{controlComp} {editComp}</td>
</tr>
);
}
@@ -497,10 +497,12 @@ class NotePropertiesDialog extends React.Component<Props, State> {
return (
<Dialog onCancel={this.props.onClose}>
<div style={theme.dialogTitle} id='note-properties-dialog-title'>{_('Note properties')}</div>
<div role='table' aria-labelledby='note-properties-dialog-title'>
{noteComps}
</div>
<h1 style={theme.dialogTitle} id='note-properties-dialog-title'>{_('Note properties')}</h1>
<table aria-labelledby='note-properties-dialog-title'>
<tbody>
{noteComps}
</tbody>
</table>
<DialogButtonRow
themeId={this.props.themeId}
okButtonShow={!this.isReadOnly()}

View File

@@ -5,28 +5,22 @@ import { focus } from '@joplin/lib/utils/focusHandler';
interface Props {
themeId: number;
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
onNext: Function;
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
onPrevious: Function;
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
onClose: Function;
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
onChange: Function;
onNext: ()=> void;
onPrevious: ()=> void;
onClose: ()=> void;
onChange: (query: string)=> void;
query: string;
searching: boolean;
resultCount: number;
selectedIndex: number;
visiblePanes: string[];
editorType: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
style: any;
style: React.CSSProperties;
}
class NoteSearchBar extends React.Component<Props> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
private backgroundColor: any;
private backgroundColor: string;
private searchInputRef: React.RefObject<HTMLInputElement>;
public constructor(props: Props) {
@@ -56,8 +50,7 @@ class NoteSearchBar extends React.Component<Props> {
return style;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
public buttonIconComponent(iconName: string, clickHandler: any, isEnabled: boolean) {
public buttonIconComponent(iconName: string, clickHandler: ()=> void, isEnabled: boolean) {
const theme = themeStyle(this.props.themeId);
const searchButton = {
@@ -85,14 +78,12 @@ class NoteSearchBar extends React.Component<Props> {
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
private searchInput_change(event: any) {
private searchInput_change(event: React.ChangeEvent<HTMLInputElement>) {
const query = event.currentTarget.value;
this.triggerOnChange(query);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
private searchInput_keyDown(event: any) {
private searchInput_keyDown(event: React.KeyboardEvent<HTMLInputElement>) {
if (event.keyCode === 13) {
// ENTER
event.preventDefault();
@@ -114,7 +105,7 @@ class NoteSearchBar extends React.Component<Props> {
if (event.keyCode === 70) {
// F key
if (event.ctrlKey) {
event.target.select();
event.currentTarget.select();
}
}
}

View File

@@ -15,8 +15,7 @@ interface Props {
onDomReady: Function;
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
onIpcMessage: Function;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
viewerStyle: any;
viewerStyle: React.CSSProperties;
contentMaxWidth?: number;
themeId: number;
}

View File

@@ -12,8 +12,7 @@ import { AppState } from '../../app.reducer';
interface NoteToolbarProps {
themeId: number;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
style: any;
style: React.CSSProperties;
toolbarButtonInfos: ToolbarItem[];
disabled: boolean;
}

View File

@@ -10,7 +10,7 @@ const { themeStyle } = require('@joplin/lib/theme');
const { OneDriveApiNodeUtils } = require('@joplin/lib/onedrive-api-node-utils.js');
interface Props {
themeId: string;
themeId: number;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
@@ -24,10 +24,8 @@ class OneDriveLoginScreenComponent extends React.Component<any, any> {
}
public async componentDidMount() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const log = (s: any) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
this.setState((state: any) => {
const log = (s: string) => {
this.setState((state: { authLog: { key: string; text: string }[] }) => {
const authLog = state.authLog.slice();
authLog.push({ key: (Date.now() + Math.random()).toString(), text: s });
return { authLog: authLog };
@@ -38,8 +36,7 @@ class OneDriveLoginScreenComponent extends React.Component<any, any> {
const syncTarget = reg.syncTarget(syncTargetId);
const oneDriveApiUtils = new OneDriveApiNodeUtils(syncTarget.api());
const auth = await oneDriveApiUtils.oauthDance({
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
log: (s: any) => log(s),
log: (s: string) => log(s),
});
Setting.setValue(`sync.${syncTargetId}.auth`, auth ? JSON.stringify(auth) : null);
@@ -85,8 +82,7 @@ class OneDriveLoginScreenComponent extends React.Component<any, any> {
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const mapStateToProps = (state: any) => {
const mapStateToProps = (state: { settings: { theme: number } }) => {
return {
themeId: state.settings.theme,
};

View File

@@ -277,8 +277,52 @@ export default class PromptDialog extends React.Component<Props, any> {
style={styles.dateTimeInput}
/>;
} else if (this.props.inputType === 'tags') {
const uniqueAutocomplete = [];
const seenLabels = new Set();
const autocompleteOptions = this.props.autocomplete || [];
for (const option of autocompleteOptions) {
const key = (option.label || '').trim().normalize('NFC').toLowerCase();
if (!seenLabels.has(key)) {
uniqueAutocomplete.push(option);
seenLabels.add(key);
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
inputComp = <CreatableSelect className="tag-selector" onMenuOpen={this.select_menuOpen} onMenuClose={this.select_menuClose} styles={styles.select} theme={styles.selectTheme} ref={this.answerInput_} value={this.state.answer} placeholder="" components={makeAnimated() as any} isMulti={true} isClearable={false} backspaceRemovesValue={true} options={this.props.autocomplete} onChange={onSelectChange} onKeyDown={(event: any) => onKeyDown(event)} />;
inputComp = <CreatableSelect
className="tag-selector"
onMenuOpen={this.select_menuOpen}
onMenuClose={this.select_menuClose}
styles={styles.select}
theme={styles.selectTheme}
ref={this.answerInput_}
value={this.state.answer}
placeholder=""
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
components={makeAnimated() as any}
isMulti={true}
isClearable={false}
backspaceRemovesValue={true}
options={uniqueAutocomplete}
onChange={onSelectChange}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
onKeyDown={(event: any) => onKeyDown(event)}
filterOption={(option, rawInput) => {
const input = (rawInput || '').trim().normalize('NFC').toLowerCase();
const label = (option.label || '').trim().normalize('NFC').toLowerCase();
return label.includes(input);
}}
isValidNewOption={(inputValue, _selectValue, selectOptions) => {
const input = (inputValue || '').trim().normalize('NFC').toLowerCase();
if (!input) return false;
// If it matches an existing option (case-insensitive + normalized), it's not a valid "new" option
const exists = selectOptions.some(option => {
return (option.label || '').trim().normalize('NFC').toLowerCase() === input;
});
return !exists;
}}
/>;
} else if (this.props.inputType === 'dropdown') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
inputComp = <Select className="item-selector" onMenuOpen={this.select_menuOpen} onMenuClose={this.select_menuClose} styles={styles.select} theme={styles.selectTheme} ref={this.answerInput_} components={makeAnimated() as any} value={this.props.answer} defaultValue={this.props.defaultValue} isClearable={false} options={this.props.autocomplete} onChange={onSelectChange} onKeyDown={(event: any) => onKeyDown(event)} />;

View File

@@ -2,6 +2,7 @@ import * as React from 'react';
import { AppState } from '../../app.reducer';
import { FolderEntity, TagsWithNoteCountEntity } from '@joplin/lib/services/database/types';
import areAllFoldersCollapsed from '@joplin/lib/models/utils/areAllFoldersCollapsed';
import getCanBeCollapsedFolderIds from '@joplin/lib/models/utils/getCanBeCollapsedFolderIds';
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
import { connect } from 'react-redux';
import { Dispatch } from 'redux';
@@ -48,6 +49,11 @@ const FolderAndTagList: React.FC<Props> = props => {
return areAllFoldersCollapsed(props.folders, props.collapsedFolderIds);
}, [props.collapsedFolderIds, props.folders]);
const hasSubFolders = useMemo(() => {
return getCanBeCollapsedFolderIds(props.folders).length > 0;
}, [props.folders]);
const listContainerRef = useRef<HTMLDivElement|null>(null);
const onRenderItem = useOnRenderItem({
...props,
@@ -76,7 +82,7 @@ const FolderAndTagList: React.FC<Props> = props => {
const listHeight = useElementHeight(itemListContainer);
const listStyle = useMemo(() => ({ height: listHeight }), [listHeight]);
const onRenderContentWrapper = useOnRenderListWrapper({ allFoldersCollapsed, selectedIndex, onKeyDown: onKeyEventHandler });
const onRenderContentWrapper = useOnRenderListWrapper({ allFoldersCollapsed, selectedIndex, onKeyDown: onKeyEventHandler, hasSubFolders });
return (
<div

View File

@@ -7,6 +7,7 @@ interface Props {
selectedIndex: number;
onKeyDown: React.KeyboardEventHandler;
allFoldersCollapsed: boolean;
hasSubFolders: boolean;
}
const onAddFolderButtonClick = () => {
@@ -19,6 +20,7 @@ const onToggleAllFolders = (allFoldersCollapsed: boolean) => {
interface CollapseExpandAllButtonProps {
allFoldersCollapsed: boolean;
hasSubFolders: boolean;
}
const CollapseExpandAllButton = (props: CollapseExpandAllButtonProps) => {
@@ -27,7 +29,12 @@ const CollapseExpandAllButton = (props: CollapseExpandAllButtonProps) => {
const icon = props.allFoldersCollapsed ? 'far fa-caret-square-right' : 'far fa-caret-square-down';
const label = props.allFoldersCollapsed ? _('Expand all notebooks') : _('Collapse all notebooks');
return <button onClick={() => onToggleAllFolders(props.allFoldersCollapsed)} className='sidebar-header-button -collapseall' title={label}>
return <button
onClick={() => onToggleAllFolders(props.allFoldersCollapsed)}
className={`sidebar-header-button -collapseall ${props.hasSubFolders ? '' : '-disabled'}`}
title={label}
disabled={!props.hasSubFolders}
>
<i
aria-label={label}
role='img'
@@ -55,7 +62,7 @@ const useOnRenderListWrapper = (props: Props) => {
const listHasValidSelection = props.selectedIndex >= 0;
const allowContainerFocus = !listHasValidSelection;
return <>
<CollapseExpandAllButton allFoldersCollapsed={props.allFoldersCollapsed}/>
<CollapseExpandAllButton allFoldersCollapsed={props.allFoldersCollapsed} hasSubFolders={props.hasSubFolders}/>
<NewFolderButton/>
<div
role='tree'
@@ -66,7 +73,7 @@ const useOnRenderListWrapper = (props: Props) => {
{...listItems}
</div>
</>;
}, [props.selectedIndex, props.onKeyDown, props.allFoldersCollapsed]);
}, [props.selectedIndex, props.onKeyDown, props.allFoldersCollapsed, props.hasSubFolders]);
};
export default useOnRenderListWrapper;

View File

@@ -13,12 +13,12 @@
font-size: var(--joplin-toolbar-icon-size);
color: var(--joplin-color2);
&:hover {
&:hover:not(:disabled) {
color: var(--joplin-color-hover2);
background: none;
}
&:active {
&:active:not(:disabled) {
color: var(--joplin-color-active2);
background: none;
}
@@ -26,4 +26,8 @@
&.-collapseall {
right: 25px;
}
&:disabled {
opacity: 0.3;
}
}

View File

@@ -7,12 +7,15 @@ import { getCollator, getCollatorLocale } from '@joplin/lib/models/utils/getColl
const { connect } = require('react-redux');
const { themeStyle } = require('@joplin/lib/theme');
interface TagData {
id: string;
title: string;
}
interface Props {
themeId: number;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
style: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
items: any[];
style: React.CSSProperties;
items: TagData[];
}
function TagList(props: Props) {
@@ -34,8 +37,7 @@ function TagList(props: Props) {
const tags = useMemo(() => {
const output = props.items.slice();
const collator = getCollator(collatorLocale);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
output.sort((a: any, b: any) => {
output.sort((a: TagData, b: TagData) => {
return collator.compare(a.title, b.title);
});

View File

@@ -33,8 +33,7 @@ class Dialogs {
await this.smalltalk.alert(title, message);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
public async confirm(message: string, title = '', options: any = {}) {
public async confirm(message: string, title = '', options: unknown = {}) {
try {
await this.smalltalk.confirm(title, message, options);
return true;

View File

@@ -1,46 +0,0 @@
import { renderHook } from '@testing-library/react';
import Setting from '@joplin/lib/models/Setting';
import useCtrlWheelZoom from './useCtrlWheelZoom';
jest.mock('@joplin/lib/models/Setting', () => ({
__esModule: true,
default: {
incValue: jest.fn(),
},
}));
const dispatchWheel = (options: WheelEventInit) => {
document.dispatchEvent(new WheelEvent('wheel', { bubbles: true, ...options }));
};
describe('useCtrlWheelZoom', () => {
beforeEach(() => {
jest.clearAllMocks();
});
test('should zoom on Ctrl/Meta+Wheel', () => {
renderHook(() => useCtrlWheelZoom());
dispatchWheel({ deltaY: -100, ctrlKey: true });
expect(Setting.incValue).toHaveBeenCalledWith('windowContentZoomFactor', 10);
jest.clearAllMocks();
dispatchWheel({ deltaY: 100, ctrlKey: true });
expect(Setting.incValue).toHaveBeenCalledWith('windowContentZoomFactor', -10);
jest.clearAllMocks();
dispatchWheel({ deltaY: -100, metaKey: true });
expect(Setting.incValue).toHaveBeenCalledWith('windowContentZoomFactor', 10);
});
test('should not zoom on wheel without modifier', () => {
renderHook(() => useCtrlWheelZoom());
dispatchWheel({ deltaY: -100 });
expect(Setting.incValue).not.toHaveBeenCalled();
});
});

View File

@@ -1,17 +0,0 @@
import { useEffect } from 'react';
import Setting from '@joplin/lib/models/Setting';
const useCtrlWheelZoom = () => {
useEffect(() => {
const handleWheel = (e: WheelEvent) => {
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
Setting.incValue('windowContentZoomFactor', e.deltaY < 0 ? 10 : -10);
}
};
document.addEventListener('wheel', handleWheel, { passive: false });
return () => document.removeEventListener('wheel', handleWheel);
}, []);
};
export default useCtrlWheelZoom;

View File

@@ -827,7 +827,37 @@
}));
// By default, Chromium inlines body styles (e.g. theme background color) into the clipboard HTML.
// Intercept the copy event and write only the selected content to bypass this behaviour.
// Intercept the copy event and write only the selected content with inlined light-theme styles to bypass this behaviour.
const clipboardCssProps = [
'font-family', 'font-size', 'font-weight', 'font-style',
'text-decoration-line', 'line-height', 'color',
'background-color',
'margin-top', 'margin-bottom', 'margin-left', 'margin-right',
'padding-top', 'padding-bottom', 'padding-left', 'padding-right',
'border-top', 'border-bottom', 'border-left', 'border-right',
'border-collapse', 'border-radius',
'list-style-type',
'white-space', 'text-align',
'opacity',
];
const lightThemeOverrideCss = [
'body, #joplin-container-content, #rendered-md { background-color: transparent !important; color: #32373F !important; }',
'html *, html *::before, html *::after { background-color: transparent !important; }',
'a { color: #155BDA !important; }',
'code, .inline-code, .mce-content-body code { color: rgb(0,0,0) !important; background-color: rgb(243,243,243) !important; border-color: rgb(220,220,220) !important; }',
'pre.hljs { background-color: rgb(243,243,243) !important; }',
'pre.hljs code { background-color: transparent !important; }',
'kbd { color: rgb(0,0,0) !important; background-color: rgb(243,243,243) !important; }',
'table, table thead, table tbody, table tr, table td, table th { background-color: transparent !important; color: #32373F !important; }',
'table th { background-color: rgb(247,247,247) !important; }',
'table:has(thead) tr:nth-child(even) { background-color: rgb(247,247,247) !important; }',
'table td, table th { border-color: rgb(220,220,220) !important; }',
'blockquote { border-left-color: rgb(220,220,220) !important; opacity: 0.7 !important; }',
'h1 { border-bottom-color: #dddddd !important; }',
'mark { background-color: #F3B717 !important; color: black !important; }',
].join('\n');
document.addEventListener('copy', webviewLib.logEnabledEventHandler(e => {
const selection = window.getSelection();
if (!selection || selection.isCollapsed) return;
@@ -852,6 +882,35 @@
node = node.parentElement;
}
const overrideStyle = document.createElement('style');
overrideStyle.textContent = lightThemeOverrideCss;
document.head.appendChild(overrideStyle);
const renderedMd = document.getElementById('rendered-md') || contentElement;
wrapper.style.cssText = 'position:absolute;left:-9999px;visibility:hidden;pointer-events:none';
renderedMd.appendChild(wrapper);
try {
const elements = wrapper.querySelectorAll('*');
for (const el of elements) {
let cs;
try { cs = window.getComputedStyle(el); } catch (_) { continue; }
const parts = [];
for (const prop of clipboardCssProps) {
const val = cs.getPropertyValue(prop);
if (val) parts.push(`${prop}: ${val}`);
}
if (parts.length > 0) {
el.setAttribute('style', parts.join('; '));
}
}
} finally {
renderedMd.removeChild(wrapper);
overrideStyle.remove();
wrapper.style.cssText = '';
}
e.clipboardData.setData('text/html', wrapper.innerHTML);
e.clipboardData.setData('text/plain', selection.toString());
e.preventDefault();

View File

@@ -3,6 +3,7 @@
@use './base-button.scss';
@use './dialog-modal-layer.scss';
@use './user-webview-dialog.scss';
@use './note-property-box.scss';
@use './prompt-dialog.scss';
@use './flat-button.scss';
@use './link-button.scss';

View File

@@ -0,0 +1,10 @@
.note-property-box {
> th, > td {
border: none;
padding: 0;
}
.rdt {
display: inline-block;
}
}

View File

@@ -432,7 +432,9 @@ test.describe('markdownEditor', () => {
});
expect(clipboardHtml).toContain('hello');
expect(clipboardHtml).not.toMatch(/background-color\s*:/i);
expect(clipboardHtml).toContain('<strong>');
// Dark theme background (#1D2024) must not leak into clipboard
expect(clipboardHtml).not.toMatch(/1D2024/i);
expect(clipboardHtml).toContain('<strong');
expect(clipboardHtml).toMatch(/font-weight/i);
});
});

View File

@@ -3,7 +3,29 @@ import MainScreen from './models/MainScreen';
import NoteEditorScreen from './models/NoteEditorScreen';
test.describe('multiWindow', () => {
// Disabled: This test often hangs when closing secondary windows (see https://github.com/laurent22/joplin/issues/14628):
// Disabled: Playwright's page.close() triggers a different code path than
// a user closing the window, causing the test to be unreliable.
// The fix works correctly in manual testing (see https://github.com/laurent22/joplin/issues/14628).
test.fixme('should not crash when closing a secondary window', async ({ mainWindow, electronApp }) => {
const mainPage = await new MainScreen(mainWindow).setup();
await mainPage.createNewNote('Test');
const window = await mainPage.openNewWindow(electronApp);
// Should load successfully
const screen = new NoteEditorScreen(window);
await screen.waitFor();
// Close the secondary window
await window.close();
// Wait for the Portal cleanup to complete before checking main window stability
await mainWindow.waitForTimeout(2000);
// Main window should remain stable — no white screen or renderer crash
await expect(await mainPage.noteEditor.contentLocator()).toBeVisible();
});
test.fixme('should support quickly creating, then closing secondary windows', async ({ mainWindow, electronApp }) => {
const mainPage = await new MainScreen(mainWindow).setup();
await mainPage.createNewNote('Test');
@@ -28,4 +50,3 @@ test.describe('multiWindow', () => {
await expect(await mainPage.noteEditor.contentLocator()).toBeVisible();
});
});

View File

@@ -2,6 +2,7 @@ import { test, expect } from './util/test';
import MainScreen from './models/MainScreen';
import AxeBuilder from '@axe-core/playwright';
import { Page } from '@playwright/test';
import SettingsScreen from './models/SettingsScreen';
const createScanner = (page: Page) => {
return new AxeBuilder({ page })
@@ -38,25 +39,24 @@ const expectNoViolations = async (page: Page) => {
};
test.describe('wcag', () => {
// Disabled due to random failure in CI:
// for (const tabName of ['General', 'Plugins']) {
// test(`should not detect significant issues in the settings screen ${tabName} tab`, async ({ electronApp, mainWindow }) => {
// const mainScreen = await new MainScreen(mainWindow).setup();
// await mainScreen.waitFor();
//
// await mainScreen.openSettings(electronApp);
//
// // Should be on the settings screen
// const settingsScreen = new SettingsScreen(mainWindow);
// await settingsScreen.waitFor();
//
// const tabLocator = settingsScreen.getTabLocator(tabName);
// await tabLocator.click();
// await expect(tabLocator).toBeFocused();
//
// await expectNoViolations(mainWindow);
// });
// }
for (const tabName of ['General', 'Plugins']) {
test(`should not detect significant issues in the settings screen ${tabName} tab`, async ({ electronApp, mainWindow }) => {
const mainScreen = await new MainScreen(mainWindow).setup();
await mainScreen.waitFor();
await mainScreen.openSettings(electronApp);
// Should be on the settings screen
const settingsScreen = new SettingsScreen(mainWindow);
await settingsScreen.waitFor();
const tabLocator = settingsScreen.getTabLocator(tabName);
await tabLocator.click();
await expect(tabLocator).toBeFocused();
await expectNoViolations(mainWindow);
});
}
test('should not detect significant issues in the main screen with an open note', async ({ mainWindow }) => {
const mainScreen = await new MainScreen(mainWindow).setup();
@@ -82,6 +82,17 @@ test.describe('wcag', () => {
await expectNoViolations(mainWindow);
});
test('should not detect significant issues in the note properties screen', async ({ mainWindow, electronApp }) => {
const mainScreen = await new MainScreen(mainWindow).setup();
await mainScreen.createNewNote('Test');
await mainScreen.goToAnything.runCommand(electronApp, 'showNoteProperties');
const header = mainScreen.dialog.locator('h1');
await expect(header).toBeVisible();
await expectNoViolations(mainWindow);
});
test('should not detect significant issues in the change app layout screen', async ({ mainWindow, electronApp }) => {
const mainScreen = await new MainScreen(mainWindow).setup();
await mainScreen.changeLayoutScreen.open(electronApp);

View File

@@ -106,10 +106,6 @@ a {
font-family: sans-serif;
}
.note-property-box .rdt {
display: inline-block;
}
.help-tooltip {
font-family: sans-serif;
max-width: 200px;
@@ -290,18 +286,18 @@ Component-specific classes
padding-bottom: 20px;
}
.master-password-dialog .dialog-root {
.master-password-dialog .dialog-root, .enable-encryption-dialog .dialog-root {
min-width: 500px;
max-width: 600px;
}
.master-password-dialog .dialog-content {
.master-password-dialog .dialog-content, .enable-encryption-dialog .dialog-content {
background-color: var(--joplin-background-color3);
padding: 1em;
padding-bottom: 1px;
}
.master-password-dialog .current-password-wrapper {
.master-password-dialog .current-password-wrapper, .enable-encryption-dialog .current-password-wrapper {
display: flex;
flex-direction: row;
align-items: center;

View File

@@ -151,7 +151,7 @@
"@joplin/renderer": "~3.6",
"@joplin/tools": "~3.6",
"@joplin/utils": "~3.6",
"@playwright/test": "1.56.1",
"@playwright/test": "1.57.0",
"@sentry/electron": "4.24.0",
"@testing-library/dom": "10.4.1",
"@testing-library/react": "16.3.2",
@@ -168,11 +168,11 @@
"color": "3.2.1",
"compare-versions": "6.1.1",
"debounce": "1.2.1",
"electron": "39.2.3",
"electron": "40.8.3",
"electron-builder": "24.13.3",
"electron-updater": "6.6.8",
"electron-window-state": "5.0.3",
"esbuild": "^0.26.0",
"esbuild": "^0.27.0",
"formatcoords": "1.1.3",
"glob": "11.1.0",
"gulp": "4.0.2",
@@ -186,7 +186,7 @@
"md5": "2.3.0",
"moment": "2.30.1",
"mustache": "4.2.0",
"nan": "2.23.1",
"nan": "2.24.0",
"node-notifier": "10.0.1",
"node-rsa": "1.1.1",
"pdfjs-dist": "3.11.174",
@@ -215,7 +215,7 @@
"dependencies": {
"@electron/remote": "2.1.3",
"@joplin/onenote-converter": "~3.6",
"fs-extra": "11.3.2",
"fs-extra": "11.3.3",
"keytar": "7.9.0",
"node-fetch": "2.6.7",
"sqlite3": "5.1.6"

View File

@@ -62,8 +62,7 @@ export default class BackOffHandler {
return this.backOffIntervals_[effectiveIndex];
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
public async wait(path: string, args: any) {
public async wait(path: string, args: unknown) {
const interval = this.backOffInterval();
if (!interval) return;

View File

@@ -155,13 +155,16 @@ export default class PluginRunner extends BasePluginRunner {
if (message.pluginId !== plugin.id) return;
if (message.mainWindowCallbackId) {
const promise = callbackPromises[message.mainWindowCallbackId];
const callbackId = message.mainWindowCallbackId;
const promise = callbackPromises[callbackId];
if (!promise) {
console.error('Got a callback without matching promise: ', message);
return;
}
delete callbackPromises[callbackId];
if (message.error) {
promise.reject(message.error);
} else {

View File

@@ -9,8 +9,7 @@ interface HookDependencies {
themeId: number;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
function themeToCssVariables(theme: any) {
function themeToCssVariables(theme: Record<string, unknown>) {
const lines = [];
lines.push(':root {');

View File

@@ -113,12 +113,15 @@
}
if (message.pluginCallbackId) {
const promise = callbackPromises[message.pluginCallbackId];
const callbackId = message.pluginCallbackId;
const promise = callbackPromises[callbackId];
if (!promise) {
console.error('Got a callback without matching promise: ', message);
return;
}
delete callbackPromises[callbackId];
if (message.error) {
promise.reject(message.error);
} else {

View File

@@ -4,13 +4,13 @@ import SpellCheckerServiceDriverBase from '@joplin/lib/services/spellChecker/Spe
import bridge from '../bridge';
import Logger from '@joplin/utils/Logger';
import { languageCodeOnly, localesFromLanguageCode } from '@joplin/lib/locale';
import { Session } from 'electron';
const logger = Logger.create('SpellCheckerServiceDriverNative');
export default class SpellCheckerServiceDriverNative extends SpellCheckerServiceDriverBase {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
private session(): any {
private session(): Session {
return bridge().mainWindow().webContents.session;
}

View File

@@ -24,8 +24,8 @@ async function main() {
// We need to force the ABI because Electron Builder or node-abi picks the
// wrong one. However it means it will have to be manually upgraded for each
// new Electron release. Some ABI map there:
// https://github.com/electron/node-abi/tree/master/test
const forceAbiArgs = '--force-abi 142';
// https://github.com/electron/node-abi/blob/main/abi_registry.json
const forceAbiArgs = '--force-abi 143';
if (isWindows()) {
// Cannot run this in parallel, or the 64-bit version might end up

View File

@@ -74,6 +74,8 @@ components/**/*.bundle.js.md5
components/**/*.bundle.min.js
web/public/pluginAssets/*
/pluginAssets/
utils/fs-driver-android.js
android/app/build-*

View File

@@ -67,14 +67,14 @@ const useSearchResults = ({
const collatorLocale = getCollatorLocale();
const results = useMemo(() => {
const collator = getCollator(collatorLocale);
const lowerSearch = search?.toLowerCase();
const lowerSearch = (search || '').trim().normalize('NFC').toLowerCase();
return options
.filter(option => option.title.toLowerCase().includes(lowerSearch))
.filter(option => (option.title || '').trim().normalize('NFC').toLowerCase().includes(lowerSearch))
.sort((a, b) => {
if (a.title === b.title) return 0;
// Full matches should go first
if (a.title.toLowerCase() === lowerSearch) return -1;
if (b.title.toLowerCase() === lowerSearch) return 1;
if ((a.title || '').trim().normalize('NFC').toLowerCase() === lowerSearch) return -1;
if ((b.title || '').trim().normalize('NFC').toLowerCase() === lowerSearch) return 1;
return collator.compare(a.title, b.title);
});
}, [search, options, collatorLocale]);

View File

@@ -1,5 +1,5 @@
import * as React from 'react';
import { fireEvent, render, screen, waitFor } from '../../utils/testing/testingLibrary';
import { act, fireEvent, render, screen, waitFor } from '../../utils/testing/testingLibrary';
import { Store } from 'redux';
import { AppState } from '../../utils/types';
@@ -27,8 +27,9 @@ const queryToolbarButton = (label: string) => {
};
const openSettings = async () => {
const settingButton = screen.getByRole('button', { name: 'Settings' });
fireEvent.press(settingButton);
await act(async () => {
fireEvent.press(screen.getByRole('button', { name: 'Settings' }));
});
// Settings should be open:
const settingsHeader = await screen.findByRole('heading', { name: 'Manage toolbar options' });
@@ -50,13 +51,19 @@ const toggleSettingsItem = async (props: ToggleSettingItemProps) => {
} else {
expect(itemCheckbox).not.toBeChecked();
}
fireEvent.press(itemCheckbox);
await act(async () => {
fireEvent.press(itemCheckbox);
});
// Re-query after the press: the item may be re-mounted with a new key when
// it moves between the enabled and disabled sections.
await waitFor(() => {
const updatedCheckbox = screen.queryByRole('checkbox', { name: props.name });
expect(updatedCheckbox).not.toBeNull();
if (finalChecked) {
expect(itemCheckbox).toBeChecked();
expect(updatedCheckbox).toBeChecked();
} else {
expect(itemCheckbox).not.toBeChecked();
expect(updatedCheckbox).not.toBeChecked();
}
});
};

View File

@@ -1,8 +1,9 @@
import * as React from 'react';
import { useCallback, useMemo } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import createRootStyle from '../../utils/createRootStyle';
import { View, StyleSheet, ScrollView } from 'react-native';
import { AccessibilityInfo, View, StyleSheet, ScrollView } from 'react-native';
import { Divider, Text, TouchableRipple } from 'react-native-paper';
import IconButton from '../IconButton';
import { _ } from '@joplin/lib/locale';
import { themeStyle } from '../global-style';
import { connect } from 'react-redux';
@@ -17,6 +18,9 @@ import selectedCommandNamesFromState from './utils/selectedCommandNamesFromState
import stateToWhenClauseContext from '../../services/commands/stateToWhenClauseContext';
import { DeleteButton } from '../buttons';
import shim from '@joplin/lib/shim';
import useToolbarEditorState, { ReorderableItem } from './utils/useToolbarEditorState';
import useSaveToolbarButtons from './utils/useSaveToolbarButtons';
import focusView from '../../utils/focusView';
const toolbarButtonUtils = new ToolbarButtonUtils(CommandService.instance());
@@ -41,8 +45,13 @@ const useStyle = (themeId: number) => {
color: theme.color,
fontSize: theme.fontSizeLarge,
},
disabledIcon: {
color: theme.colorFaded,
fontSize: theme.fontSizeLarge,
},
labelText: {
fontSize: theme.fontSize,
flex: 1,
},
listContainer: {
marginTop: theme.marginTop,
@@ -59,62 +68,229 @@ const useStyle = (themeId: number) => {
padding: 4,
paddingTop: theme.itemMarginTop,
paddingBottom: theme.itemMarginBottom,
minHeight: 44,
},
// Like listItem but without vertical padding -- the TouchableRipple inside
// carries the padding, so its minHeight drives the row height directly.
enabledListItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-start',
gap: theme.margin,
paddingLeft: 4,
paddingRight: 4,
},
arrowButtonsContainer: {
flexDirection: 'row',
alignItems: 'center',
},
arrowIcon: {
color: theme.color,
fontSize: 24,
},
arrowIconDisabled: {
color: theme.colorFaded,
fontSize: 24,
opacity: 0.38,
},
sectionHeader: {
paddingVertical: 8,
paddingHorizontal: 4,
color: theme.colorFaded,
},
enabledItemTouchable: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
gap: theme.margin,
paddingTop: theme.itemMarginTop,
paddingBottom: theme.itemMarginBottom,
minHeight: 44,
},
disabledLabelText: {
fontSize: theme.fontSize,
flex: 1,
color: theme.colorFaded,
},
});
}, [themeId]);
};
type Styles = ReturnType<typeof useStyle>;
const setCommandIncluded = (
commandName: string,
lastSelectedCommands: string[],
allCommandNames: string[],
include: boolean,
interface EnabledItemRowProps {
item: ReorderableItem;
index: number;
isFirst: boolean;
isLast: boolean;
styles: Styles;
themeId: number;
shouldFocus?: boolean;
onFocused?: ()=> void;
onToggle: (commandName: string)=> void;
onMoveUp: (index: number)=> void;
onMoveDown: (index: number)=> void;
}
// After a move re-render, focus the arrow that was pressed.
// If we hit a boundary (now first or last), swap to the opposite arrow.
// index/isFirst/isLast reflect the new position after the parent re-renders.
//
// We delay the focusView call: when an item moves DOWN, TalkBack re-evaluates
// focus after the accessibility tree update (content changed ahead of the focused
// element), jumping to X. The delay lets TalkBack settle so our call wins.
// Refs are captured before the timeout to avoid stale closures.
// useEffect (not useLayoutEffect) is correct here since the 100ms delay
// already negates any synchronous-paint timing advantage.
const useArrowFocusAfterMove = (
upArrowRef: React.RefObject<View>,
downArrowRef: React.RefObject<View>,
pendingArrowFocusRef: React.MutableRefObject<'up'|'down'|null>,
index: number,
isFirst: boolean,
isLast: boolean,
) => {
let newSelectedCommands;
if (include) {
newSelectedCommands = [];
for (const name of allCommandNames) {
const isDivider = name === '-';
if (isDivider || name === commandName || lastSelectedCommands.includes(name)) {
newSelectedCommands.push(name);
}
}
} else {
newSelectedCommands = lastSelectedCommands.filter(name => name !== commandName);
}
Setting.setValue('editor.toolbarButtons', newSelectedCommands);
useEffect(() => {
const direction = pendingArrowFocusRef.current;
pendingArrowFocusRef.current = null;
const upRef = upArrowRef.current;
const downRef = downArrowRef.current;
const atFirst = isFirst;
const atLast = isLast;
const timeoutId = setTimeout(() => {
if (!direction) return;
const target = direction === 'up'
? (atFirst ? downRef : upRef)
: (atLast ? upRef : downRef);
if (target) focusView('toolbar-editor-arrow', target);
}, 100);
return () => clearTimeout(timeoutId);
}, [index, isFirst, isLast, upArrowRef, downArrowRef, pendingArrowFocusRef]);
};
interface ItemToggleProps {
item: ToolbarButtonInfo;
selectedCommandNames: string[];
allCommandNames: string[];
styles: Styles;
}
const ToolbarItemToggle: React.FC<ItemToggleProps> = ({
item, selectedCommandNames, styles, allCommandNames,
}) => {
const title = item.title || item.tooltip;
const checked = selectedCommandNames.includes(item.name);
// When a row becomes the pending-focus target (e.g. after being added), focus its checkbox.
// We defer via queueMicrotask: UIManager.focus (used by focusView on web) silently fails if
// called during React's commit phase before the DOM has settled. A microtask fires after the
// current call stack clears but before the next frame, making it faster and more deterministic
// than a setTimeout while still giving the DOM time to update.
const useCheckboxFocusOnAdd = (
shouldFocus: boolean|undefined,
onFocused: (()=> void)|undefined,
checkboxRef: React.RefObject<View>,
) => {
useEffect(() => {
const ref = checkboxRef.current;
const focused = onFocused;
let cancelled = false;
queueMicrotask(() => {
if (cancelled || !shouldFocus || !ref) return;
focusView('toolbar-editor', ref);
focused?.();
});
return () => { cancelled = true; };
}, [shouldFocus, onFocused, checkboxRef]);
};
const onToggle = useCallback(() => {
setCommandIncluded(item.name, selectedCommandNames, allCommandNames, !checked);
}, [item, selectedCommandNames, allCommandNames, checked]);
const EnabledItemRow: React.FC<EnabledItemRowProps> = ({
item, index, isFirst, isLast, styles, themeId, shouldFocus, onFocused, onToggle, onMoveUp, onMoveDown,
}) => {
const title = item.buttonInfo.title || item.buttonInfo.tooltip;
// Local refs for checkbox and arrow focus management
const checkboxRef = useRef<View>(null);
const upArrowRef = useRef<View>(null);
const downArrowRef = useRef<View>(null);
const pendingArrowFocusRef = useRef<'up'|'down'|null>(null);
const handleToggle = useCallback(() => {
onToggle(item.commandName);
AccessibilityInfo.announceForAccessibility(_('%s removed from toolbar', title));
}, [onToggle, item.commandName, title]);
const handleMoveUp = useCallback(() => {
pendingArrowFocusRef.current = 'up';
onMoveUp(index);
AccessibilityInfo.announceForAccessibility(_('%s moved up', title));
}, [onMoveUp, index, title]);
const handleMoveDown = useCallback(() => {
pendingArrowFocusRef.current = 'down';
onMoveDown(index);
AccessibilityInfo.announceForAccessibility(_('%s moved down', title));
}, [onMoveDown, index, title]);
useArrowFocusAfterMove(upArrowRef, downArrowRef, pendingArrowFocusRef, index, isFirst, isLast);
useCheckboxFocusOnAdd(shouldFocus, onFocused, checkboxRef);
return (
<View style={styles.enabledListItem}>
<TouchableRipple
ref={checkboxRef}
accessibilityRole='checkbox'
accessibilityState={{ checked: true }}
aria-checked={true}
onPress={handleToggle}
style={styles.enabledItemTouchable}
>
<>
<Icon name='ionicon checkbox-outline' style={styles.icon} accessibilityLabel={null}/>
<Icon name={item.buttonInfo.iconName} style={styles.icon} accessibilityLabel={null}/>
<Text style={styles.labelText}>{title}</Text>
</>
</TouchableRipple>
<View style={styles.arrowButtonsContainer}>
<IconButton
pressableRef={upArrowRef}
iconName='material arrow-up'
iconStyle={isFirst ? styles.arrowIconDisabled : styles.arrowIcon}
onPress={handleMoveUp}
disabled={isFirst}
description={_('Move %s up', title)}
themeId={themeId}
/>
<IconButton
pressableRef={downArrowRef}
iconName='material arrow-down'
iconStyle={isLast ? styles.arrowIconDisabled : styles.arrowIcon}
onPress={handleMoveDown}
disabled={isLast}
description={_('Move %s down', title)}
themeId={themeId}
/>
</View>
</View>
);
};
interface DisabledItemRowProps {
item: ReorderableItem;
styles: Styles;
onToggle: (commandName: string)=> void;
}
const DisabledItemRow: React.FC<DisabledItemRowProps> = ({
item, styles, onToggle,
}) => {
const title = item.buttonInfo.title || item.buttonInfo.tooltip;
const handleToggle = useCallback(() => {
onToggle(item.commandName);
AccessibilityInfo.announceForAccessibility(_('%s added to toolbar', title));
}, [onToggle, item.commandName, title]);
return (
<TouchableRipple
accessibilityRole='checkbox'
accessibilityState={{ checked }}
aria-checked={checked}
onPress={onToggle}
accessibilityState={{ checked: false }}
aria-checked={false}
onPress={handleToggle}
>
<View style={styles.listItem}>
<Icon name={checked ? 'ionicon checkbox-outline' : 'ionicon square-outline'} style={styles.icon} accessibilityLabel={null}/>
<Icon name={item.iconName} style={styles.icon} accessibilityLabel={null}/>
<Text style={styles.labelText}>
{title}
</Text>
<Icon name='ionicon square-outline' style={styles.disabledIcon} accessibilityLabel={null}/>
<Icon name={item.buttonInfo.iconName} style={styles.disabledIcon} accessibilityLabel={null}/>
<Text style={styles.disabledLabelText}>{title}</Text>
</View>
</TouchableRipple>
);
@@ -123,19 +299,62 @@ const ToolbarItemToggle: React.FC<ItemToggleProps> = ({
const ToolbarEditorScreen: React.FC<EditorDialogProps> = props => {
const styles = useStyle(props.themeId);
const renderItem = (item: ToolbarItem, index: number) => {
if (item.type === 'separator') {
return <Divider key={`separator-${index}`} />;
// Filter button infos to only include actual buttons (not separators)
const allButtonInfos = useMemo(() => {
return props.defaultToolbarButtonInfos.filter(
(item): item is ToolbarButtonInfo => item.type === 'button',
);
}, [props.defaultToolbarButtonInfos]);
const [pendingFocusCommand, setPendingFocusCommand] = useState<string|null>(null);
const isReinitializingRef = useRef(false);
const {
enabledItems,
disabledItems,
handleMoveUp,
handleMoveDown,
handleToggle: doToggle,
reinitialize: baseReinitialize,
} = useToolbarEditorState({
initialSelectedCommandNames: props.selectedCommandNames,
allCommandNames: props.allCommandNames,
allButtonInfos,
});
useSaveToolbarButtons(enabledItems, isReinitializingRef);
const reinitialize = useCallback((selectedNames: string[]) => {
isReinitializingRef.current = true;
baseReinitialize(selectedNames);
}, [baseReinitialize]);
const handleToggle = useCallback((commandName: string) => {
const enabledIndex = enabledItems.findIndex(item => item.commandName === commandName);
const isBeingEnabled = enabledIndex === -1;
if (isBeingEnabled) {
setPendingFocusCommand(commandName);
} else if (enabledItems.length > 1) {
const nextFocus = enabledIndex < enabledItems.length - 1
? enabledItems[enabledIndex + 1].commandName
: enabledItems[enabledIndex - 1].commandName;
setPendingFocusCommand(nextFocus);
}
return <ToolbarItemToggle
key={`command-${item.name}`}
item={item}
styles={styles}
allCommandNames={props.allCommandNames}
selectedCommandNames={props.selectedCommandNames}
/>;
};
doToggle(commandName);
}, [doToggle, enabledItems]);
const handleFocused = useCallback(() => setPendingFocusCommand(null), []);
// Re-sync local state whenever the dialog becomes visible (e.g. after Restore defaults)
const prevVisible = useRef(props.visible);
useEffect(() => {
if (props.visible && !prevVisible.current) {
reinitialize(props.selectedCommandNames);
}
prevVisible.current = props.visible;
}, [props.visible, props.selectedCommandNames, reinitialize]);
const onRestoreDefaultLayout = useCallback(async () => {
// Dismiss before showing the confirm dialog to prevent modal conflicts.
@@ -168,7 +387,41 @@ const ToolbarEditorScreen: React.FC<EditorDialogProps> = props => {
<Text variant='bodyMedium'>{_('Check elements to display in the toolbar')}</Text>
</View>
<ScrollView style={styles.listContainer}>
{props.defaultToolbarButtonInfos.map((item, index) => renderItem(item, index))}
{enabledItems.map((item, index) => (
<EnabledItemRow
key={`enabled-${item.commandName}`}
item={item}
index={index}
isFirst={index === 0}
isLast={index === enabledItems.length - 1}
styles={styles}
themeId={props.themeId}
shouldFocus={item.commandName === pendingFocusCommand}
onFocused={handleFocused}
onToggle={handleToggle}
onMoveUp={handleMoveUp}
onMoveDown={handleMoveDown}
/>
))}
{disabledItems.length > 0 && (
<>
<Divider />
<Text variant='labelMedium' style={styles.sectionHeader}>
{_('Available')}
</Text>
</>
)}
{disabledItems.map((item) => (
<DisabledItemRow
key={`disabled-${item.commandName}`}
item={item}
styles={styles}
onToggle={handleToggle}
/>
))}
{props.hasCustomizedLayout ? restoreButton : null}
</ScrollView>
</DismissibleDialog>

View File

@@ -0,0 +1,75 @@
import { renderHook, act, waitFor } from '../../../utils/testing/testingLibrary';
import { setupDatabase, switchClient } from '@joplin/lib/testing/test-utils';
import useSaveToolbarButtons from './useSaveToolbarButtons';
import { ReorderableItem } from './useToolbarEditorState';
import { ToolbarButtonInfo } from '@joplin/lib/services/commands/ToolbarButtonUtils';
import Setting from '@joplin/lib/models/Setting';
const mockItem = (name: string): ReorderableItem => ({
commandName: name,
buttonInfo: {
type: 'button',
name,
title: name,
tooltip: name,
iconName: `icon-${name}`,
enabled: true,
visible: true,
onClick: jest.fn(),
} as ToolbarButtonInfo,
});
describe('useSaveToolbarButtons', () => {
beforeEach(async () => {
await setupDatabase(0);
await switchClient(0);
Setting.setValue('editor.toolbarButtons', []);
});
it('should not save on initial mount', async () => {
const isReinitializing = { current: false };
renderHook(() => useSaveToolbarButtons([mockItem('textBold')], isReinitializing));
// Wait a tick to ensure the effect has run
await act(async () => {});
expect(Setting.value('editor.toolbarButtons')).toEqual([]);
});
it('should save when enabledItems changes after initial mount', async () => {
const isReinitializing = { current: false };
const { rerender } = renderHook(
({ items }: { items: ReorderableItem[] }) => useSaveToolbarButtons(items, isReinitializing),
{ initialProps: { items: [mockItem('textBold')] } },
);
rerender({ items: [mockItem('textBold'), mockItem('textItalic')] });
await waitFor(() => {
expect(Setting.value('editor.toolbarButtons')).toEqual(['textBold', 'textItalic']);
});
});
it('should not save when isReinitializing is set, and should reset the flag', async () => {
const isReinitializing = { current: false };
const { rerender } = renderHook(
({ items }: { items: ReorderableItem[] }) => useSaveToolbarButtons(items, isReinitializing),
{ initialProps: { items: [mockItem('textBold')] } },
);
// First do a real save so the initial-mount skip is consumed
rerender({ items: [mockItem('textBold'), mockItem('textItalic')] });
await waitFor(() => {
expect(Setting.value('editor.toolbarButtons')).toEqual(['textBold', 'textItalic']);
});
// Now simulate reinitialize
isReinitializing.current = true;
rerender({ items: [mockItem('textCode')] });
// Give the effect time to run
await act(async () => {});
// Setting should be unchanged
expect(Setting.value('editor.toolbarButtons')).toEqual(['textBold', 'textItalic']);
// Flag should have been reset
expect(isReinitializing.current).toBe(false);
});
});

View File

@@ -0,0 +1,26 @@
import { useRef, useEffect, MutableRefObject } from 'react';
import Setting from '@joplin/lib/models/Setting';
import { ReorderableItem } from './useToolbarEditorState';
// Persists the enabled toolbar button order to settings after user edits.
// Skips the initial mount and any change triggered by reinitialize (indicated
// by the caller setting isReinitializing.current = true before the state update).
const useSaveToolbarButtons = (
enabledItems: ReorderableItem[],
isReinitializing: MutableRefObject<boolean>,
) => {
const isInitialMount = useRef(true);
useEffect(() => {
if (isInitialMount.current) {
isInitialMount.current = false;
return;
}
if (isReinitializing.current) {
isReinitializing.current = false;
return;
}
Setting.setValue('editor.toolbarButtons', enabledItems.map(item => item.commandName));
}, [enabledItems, isReinitializing]);
};
export default useSaveToolbarButtons;

View File

@@ -0,0 +1,113 @@
import { renderHook, act } from '../../../utils/testing/testingLibrary';
import { setupDatabase, switchClient } from '@joplin/lib/testing/test-utils';
import useToolbarEditorState, { ReorderableItem } from './useToolbarEditorState';
import { ToolbarButtonInfo } from '@joplin/lib/services/commands/ToolbarButtonUtils';
const createMockButtonInfo = (name: string, title: string): ToolbarButtonInfo => ({
type: 'button',
name,
title,
tooltip: title,
iconName: `icon-${name}`,
enabled: true,
visible: true,
onClick: jest.fn(),
});
describe('useToolbarEditorState', () => {
beforeEach(async () => {
await setupDatabase(0);
await switchClient(0);
});
const defaultAllCommandNames = [
'attachFile',
'-',
'textBold',
'textItalic',
'-',
'textCode',
'textMath',
'-',
'hideKeyboard',
];
const defaultAllButtonInfos: ToolbarButtonInfo[] = [
createMockButtonInfo('attachFile', 'Attach File'),
createMockButtonInfo('textBold', 'Bold'),
createMockButtonInfo('textItalic', 'Italic'),
createMockButtonInfo('textCode', 'Code'),
createMockButtonInfo('textMath', 'Math'),
createMockButtonInfo('hideKeyboard', 'Hide Keyboard'),
];
const toNames = (items: ReorderableItem[]) => items.map(i => i.commandName);
const renderToolbarHook = (initialSelectedCommandNames: string[]) => renderHook(() =>
useToolbarEditorState({
initialSelectedCommandNames,
allCommandNames: defaultAllCommandNames,
allButtonInfos: defaultAllButtonInfos,
}),
);
it('should partition items into enabled and disabled, excluding separators', () => {
const { result } = renderToolbarHook(['-', 'textBold', '-', 'textItalic']);
expect(toNames(result.current.enabledItems)).toEqual(['textBold', 'textItalic']);
expect(toNames(result.current.disabledItems)).toEqual([
'attachFile', 'textCode', 'textMath', 'hideKeyboard',
]);
expect(toNames(result.current.disabledItems)).not.toContain('-');
});
it('handleMoveUp and handleMoveDown should reorder items, with no-op at boundaries', async () => {
const { result } = renderToolbarHook(['textBold', 'textItalic', 'textCode']);
// Move first item down
await act(async () => { result.current.handleMoveDown(0); });
expect(toNames(result.current.enabledItems)).toEqual(['textItalic', 'textBold', 'textCode']);
// Move it back up
await act(async () => { result.current.handleMoveUp(1); });
expect(toNames(result.current.enabledItems)).toEqual(['textBold', 'textItalic', 'textCode']);
// No-op at boundaries
const orderBefore = toNames(result.current.enabledItems);
await act(async () => { result.current.handleMoveUp(0); });
await act(async () => { result.current.handleMoveDown(2); });
expect(toNames(result.current.enabledItems)).toEqual(orderBefore);
});
it('handleToggle should move items between enabled and disabled, preserving default order', async () => {
const { result } = renderToolbarHook(['textCode', 'textBold', 'textItalic']);
// Toggle an enabled item off
await act(async () => { result.current.handleToggle('textBold'); });
expect(toNames(result.current.enabledItems)).toEqual(['textCode', 'textItalic']);
expect(toNames(result.current.disabledItems)).toContain('textBold');
// Disabled list should respect default order
const disabled = toNames(result.current.disabledItems);
expect(disabled.indexOf('attachFile')).toBeLessThan(disabled.indexOf('textBold'));
expect(disabled.indexOf('textBold')).toBeLessThan(disabled.indexOf('textMath'));
// Toggle it back on: should append to end of enabled list
await act(async () => { result.current.handleToggle('textBold'); });
expect(toNames(result.current.enabledItems)).toEqual(['textCode', 'textItalic', 'textBold']);
});
it('reinitialize should reset state to new selection', async () => {
const { result } = renderToolbarHook(['textBold', 'textItalic']);
// Make a change first
await act(async () => { result.current.handleMoveDown(0); });
expect(toNames(result.current.enabledItems)).toEqual(['textItalic', 'textBold']);
// Reinitialize with a different selection
await act(async () => { result.current.reinitialize(['textCode', 'textMath']); });
expect(toNames(result.current.enabledItems)).toEqual(['textCode', 'textMath']);
expect(toNames(result.current.disabledItems)).toContain('textBold');
expect(toNames(result.current.disabledItems)).toContain('textItalic');
});
});

View File

@@ -0,0 +1,154 @@
import { useState, useCallback, useMemo } from 'react';
import { ToolbarButtonInfo } from '@joplin/lib/services/commands/ToolbarButtonUtils';
export interface ReorderableItem {
commandName: string;
buttonInfo: ToolbarButtonInfo;
}
interface UseToolbarEditorStateProps {
initialSelectedCommandNames: string[];
allCommandNames: string[];
allButtonInfos: ToolbarButtonInfo[];
}
interface UseToolbarEditorStateResult {
enabledItems: ReorderableItem[];
disabledItems: ReorderableItem[];
handleMoveUp: (index: number)=> void;
handleMoveDown: (index: number)=> void;
handleToggle: (commandName: string)=> void;
reinitialize: (selectedNames: string[])=> void;
}
type ItemsState = {
enabledItems: ReorderableItem[];
disabledItems: ReorderableItem[];
};
const useToolbarEditorState = (props: UseToolbarEditorStateProps): UseToolbarEditorStateResult => {
const { initialSelectedCommandNames, allCommandNames, allButtonInfos } = props;
// Build a lookup map from command name to button info
const buttonInfoMap = useMemo(() => {
const map = new Map<string, ToolbarButtonInfo>();
for (const info of allButtonInfos) {
if (info.type === 'button') {
map.set(info.name, info);
}
}
return map;
}, [allButtonInfos]);
// Filter out separators from allCommandNames for building the disabled list
const allCommandNamesWithoutSeparators = useMemo(() => {
return allCommandNames.filter(name => name !== '-');
}, [allCommandNames]);
// Build initial enabled items from selectedCommandNames (filtering separators)
const buildEnabledItems = useCallback((selectedNames: string[]): ReorderableItem[] => {
const items: ReorderableItem[] = [];
for (const name of selectedNames) {
if (name === '-') continue;
const buttonInfo = buttonInfoMap.get(name);
if (buttonInfo) {
items.push({ commandName: name, buttonInfo });
}
}
return items;
}, [buttonInfoMap]);
// Build disabled items: commands in allCommandNames but not in enabled, preserving default order
const buildDisabledItems = useCallback((enabledNames: Set<string>): ReorderableItem[] => {
const items: ReorderableItem[] = [];
for (const name of allCommandNamesWithoutSeparators) {
if (!enabledNames.has(name)) {
const buttonInfo = buttonInfoMap.get(name);
if (buttonInfo) {
items.push({ commandName: name, buttonInfo });
}
}
}
return items;
}, [allCommandNamesWithoutSeparators, buttonInfoMap]);
// Both lists are combined into one state object so that handleToggle can update them
// atomically in a single functional updater. This eliminates the stale-closure race
// that would occur if they were separate useState values (rapid double-taps could see
// an outdated snapshot of enabledItems and toggle in the wrong direction).
const [{ enabledItems, disabledItems }, setItems] = useState<ItemsState>(() => ({
enabledItems: buildEnabledItems(initialSelectedCommandNames),
disabledItems: buildDisabledItems(new Set(initialSelectedCommandNames.filter(n => n !== '-'))),
}));
const reinitialize = useCallback((selectedNames: string[]) => {
setItems({
enabledItems: buildEnabledItems(selectedNames),
disabledItems: buildDisabledItems(new Set(selectedNames.filter(n => n !== '-'))),
});
}, [buildEnabledItems, buildDisabledItems]);
const handleMoveUp = useCallback((index: number) => {
setItems(prev => {
if (index <= 0) return prev;
const newEnabled = [...prev.enabledItems];
[newEnabled[index - 1], newEnabled[index]] = [newEnabled[index], newEnabled[index - 1]];
return { ...prev, enabledItems: newEnabled };
});
}, []);
const handleMoveDown = useCallback((index: number) => {
setItems(prev => {
if (index >= prev.enabledItems.length - 1) return prev;
const newEnabled = [...prev.enabledItems];
[newEnabled[index], newEnabled[index + 1]] = [newEnabled[index + 1], newEnabled[index]];
return { ...prev, enabledItems: newEnabled };
});
}, []);
const handleToggle = useCallback((commandName: string) => {
setItems(prev => {
const isCurrentlyEnabled = prev.enabledItems.some(item => item.commandName === commandName);
if (isCurrentlyEnabled) {
const newEnabled = prev.enabledItems.filter(item => item.commandName !== commandName);
const buttonInfo = buttonInfoMap.get(commandName);
if (!buttonInfo) return prev;
// Insert in default-relative order
const newDisabled: ReorderableItem[] = [];
let inserted = false;
for (const name of allCommandNamesWithoutSeparators) {
if (name === commandName) {
newDisabled.push({ commandName, buttonInfo });
inserted = true;
} else {
const existing = prev.disabledItems.find(item => item.commandName === name);
if (existing) newDisabled.push(existing);
}
}
if (!inserted) newDisabled.push({ commandName, buttonInfo });
return { enabledItems: newEnabled, disabledItems: newDisabled };
} else {
const buttonInfo = buttonInfoMap.get(commandName);
if (!buttonInfo) return prev;
return {
enabledItems: [...prev.enabledItems, { commandName, buttonInfo }],
disabledItems: prev.disabledItems.filter(item => item.commandName !== commandName),
};
}
});
}, [buttonInfoMap, allCommandNamesWithoutSeparators]);
return {
enabledItems,
disabledItems,
handleMoveUp,
handleMoveDown,
handleToggle,
reinitialize,
};
};
export default useToolbarEditorState;

View File

@@ -9,6 +9,7 @@ import Logger from '@joplin/utils/Logger';
import { Props, WebViewControl } from './types';
import { JSDOM } from 'jsdom';
import useCss from './utils/useCss';
import polyfillScrollFunctions from './utils/polyfillScrollFunctions';
const logger = Logger.create('ExtendedWebView');
@@ -55,9 +56,9 @@ const ExtendedWebView = (props: Props, ref: Ref<WebViewControl>) => {
useEffect(() => {
// JSDOM polyfills
dom.window.eval(`
window.scrollBy = (_amount) => { };
dom.window.eval(polyfillScrollFunctions);
dom.window.eval(`
// JSDOM iframes are missing certain functionality required by Joplin,
// including:
// - MessageEvent.source: Should point to the window that created a message.

View File

@@ -0,0 +1,10 @@
const polyfillScrollFunctions = `
if (!window.scrollBy) {
window.scrollBy = (_amount) => { };
}
if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = function() { };
}
`;
export default polyfillScrollFunctions;

View File

@@ -104,8 +104,7 @@ const useRerenderHandler = (props: Props) => {
props.fontSize, props.showNoteLinkIcon,
];
const previousDeps = usePrevious(effectDependencies, []);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const changedDeps = effectDependencies.reduce((accum: any, dependency: any, index: any) => {
const changedDeps = effectDependencies.reduce((accum: Record<number, boolean>, dependency: unknown, index: number) => {
if (dependency !== previousDeps[index]) {
return { ...accum, [index]: true };
}

View File

@@ -1,6 +1,8 @@
import { themeStyle } from '@joplin/lib/theme';
import themeToCss from '@joplin/lib/services/style/themeToCss';
import ExtendedWebView from '../ExtendedWebView';
import Setting from '@joplin/lib/models/Setting';
import { editorFont } from '../global-style';
import * as React from 'react';
import { useMemo, useCallback, useRef } from 'react';
@@ -17,10 +19,12 @@ import shim from '@joplin/lib/shim';
const logger = Logger.create('RichTextEditor');
function useCss(themeId: number, editorCss: string): string {
function useCss(themeId: number, editorCss: string, fontFamilyId: number): string {
return useMemo(() => {
const theme = themeStyle(themeId);
const themeVariableCss = themeToCss(theme);
const font = editorFont(fontFamilyId);
const fontFamily = font ? `${JSON.stringify(font)}, sans-serif` : 'sans-serif';
return `
${themeVariableCss}
${editorCss}
@@ -42,7 +46,7 @@ function useCss(themeId: number, editorCss: string): string {
padding-bottom: 1px;
padding-top: 10px;
font-family: ${JSON.stringify(theme.fontFamily)}, sans-serif;
font-family: ${fontFamily} !important;
}
.RichTextEditor {
@@ -52,7 +56,7 @@ function useCss(themeId: number, editorCss: string): string {
position: relative;
}
`;
}, [themeId, editorCss]);
}, [themeId, editorCss, fontFamilyId]);
}
function useHtml(initialCss: string): string {
@@ -127,7 +131,7 @@ const RichTextEditor: React.FC<EditorProps> = props => {
true;
`;
const css = useCss(props.themeId, editorWebViewSetup.pageSetup.css);
const css = useCss(props.themeId, editorWebViewSetup.pageSetup.css, Setting.value('style.editor.fontFamily') as number);
const html = useHtml(css);
const onMessage = useCallback((event: OnMessageEvent) => {

View File

@@ -33,8 +33,7 @@ export interface SearchPanelProps {
}
interface ActionButtonProps {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
styles: any;
styles: ReturnType<typeof useStyles>;
themeId: number;
iconName: string;
title: string;
@@ -55,8 +54,7 @@ const ActionButton = (props: ActionButtonProps) => {
};
interface ToggleButtonProps {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
styles: any;
styles: ReturnType<typeof useStyles>;
themeId: number;
iconName: string;
title: string;

View File

@@ -87,8 +87,7 @@ interface ScreenHeaderState {
}
class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeaderState> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
private cachedStyles: any;
private cachedStyles: Record<number, ReturnType<typeof StyleSheet.create>>;
public constructor(props: ScreenHeaderProps) {
super(props);
this.cachedStyles = {};

View File

@@ -21,12 +21,13 @@ const useStyles = (themeId: number, hasContent: boolean) => {
return StyleSheet.create({
root: {
flexDirection: 'row',
justifyContent: 'center',
justifyContent: 'flex-start',
alignItems: 'center',
},
inputStyle: {
fontSize: theme.fontSize,
flexGrow: 1,
flex: 1,
minWidth: 0,
borderWidth: 0,
borderBlockColor: 'transparent',
paddingLeft: 0,

View File

@@ -209,18 +209,27 @@ const TagEditor: React.FC<Props> = props => {
const styles = useStyles(props.themeId, props.headerStyle);
const comboBoxItems = useMemo(() => {
const seenTitles = new Set();
return props.allTags
// Exclude tags already associated with the note
.filter(tag => !props.tags.some(o => o.toLowerCase() === tag.title?.toLowerCase()))
.filter(tag => {
const tagTitle = (tag.title || '').trim().normalize('NFC').toLowerCase();
return !props.tags.some(o => (o || '').trim().normalize('NFC').toLowerCase() === tagTitle);
})
.map((tag): Option => {
const title = tag.title ?? 'Untitled';
const title = (tag.title || '').trim().normalize('NFC');
const key = title.toLowerCase();
if (!title || seenTitles.has(key)) return null;
seenTitles.add(key);
return {
title,
icon: null,
accessibilityHint: _('Adds tag'),
willRemoveOnPress: true,
};
});
})
.filter((item): item is Option => !!item);
}, [props.tags, props.allTags]);
const [autofocusTag, setAutofocusTag] = useState('');
@@ -230,14 +239,16 @@ const TagEditor: React.FC<Props> = props => {
}, []);
const onAddTag = useCallback((title: string) => {
AccessibilityInfo.announceForAccessibility(_('Added tag: %s', title));
props.onTagsChange([...props.tags, title.trim()]);
const trimmedTitle = (title || '').trim();
if (!trimmedTitle) return;
AccessibilityInfo.announceForAccessibility(_('Added tag: %s', trimmedTitle));
props.onTagsChange([...props.tags, trimmedTitle]);
}, [props.tags, props.onTagsChange]);
const onRemoveTag = useCallback(async (title: string) => {
if (!title) return;
const lowercaseTitle = title.toLowerCase();
const previousTagIndex = props.tags.findIndex(item => item.toLowerCase() === lowercaseTitle);
const normalizedTitle = title.trim().normalize('NFC').toLowerCase();
const previousTagIndex = props.tags.findIndex(item => (item || '').trim().normalize('NFC').toLowerCase() === normalizedTitle);
const targetTag = props.tags[previousTagIndex + 1] ?? props.tags[previousTagIndex - 1];
setAutofocusTag(targetTag);
@@ -245,7 +256,7 @@ const TagEditor: React.FC<Props> = props => {
// prevent focus from occasionally jumping away from the tag box.
await msleep(100);
AccessibilityInfo.announceForAccessibility(_('Removed tag: %s', title));
props.onTagsChange(props.tags.filter(tag => tag.toLowerCase() !== lowercaseTitle));
props.onTagsChange(props.tags.filter(tag => (tag || '').trim().normalize('NFC').toLowerCase() !== normalizedTitle));
}, [props.tags, props.onTagsChange]);
const onComboBoxSelect = useCallback((item: { title: string }) => {
@@ -255,13 +266,15 @@ const TagEditor: React.FC<Props> = props => {
const allTagsSetNormalized = useMemo(() => {
return new Set([
...props.allTags.map(tag => tag.title?.trim()?.toLowerCase()),
...props.tags.map(tag => tag.trim().toLowerCase()),
...props.allTags.map(tag => (tag.title || '').trim().normalize('NFC').toLowerCase()),
...props.tags.map(tag => (tag || '').trim().normalize('NFC').toLowerCase()),
]);
}, [props.allTags, props.tags]);
const onCanAddTag = useCallback((tag: string) => {
return !allTagsSetNormalized.has(tag.trim().toLowerCase());
const normalized = (tag || '').trim().normalize('NFC');
if (!normalized) return false;
return !allTagsSetNormalized.has(normalized.toLowerCase());
}, [allTagsSetNormalized]);
const showAssociatedTags = props.mode === TagEditorMode.Large || props.tags.length > 0;

View File

@@ -32,8 +32,7 @@ interface ActionButtonProps {
// Returns a render function compatible with React Native Paper.
const getIconRenderFunction = (iconName: string) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
return (props: any) => <Icon name={iconName} {...props} />;
return (props: Omit<React.ComponentProps<typeof Icon>, 'name'>) => <Icon name={iconName} {...props} />;
};
const useIcon = (iconName: string) => {

View File

@@ -5,8 +5,7 @@ const getFormData = () => {
const serializeForm = (form: HTMLFormElement) => {
const formData = new FormData(form);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const serializedData: Record<string, any> = {};
const serializedData: Record<string, FormDataEntryValue | null> = {};
for (const key of formData.keys()) {
serializedData[key] = formData.get(key);
}

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