You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2026-04-08 11:04:42 +02:00
Compare commits
224 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
737a494db8 | ||
|
|
b8bfe85f21 | ||
|
|
379a53eca5 | ||
|
|
548d1a49ba | ||
|
|
7e08d0c77d | ||
|
|
cb3878afa6 | ||
|
|
af0cab64cf | ||
|
|
671a7c9acf | ||
|
|
a8a6fe2520 | ||
|
|
c5bcb170e9 | ||
|
|
b6ce57aad5 | ||
|
|
d26c2cc96f | ||
|
|
c9cebd6016 | ||
|
|
b90458f387 | ||
|
|
1a2d045ed2 | ||
|
|
719d5ce4bb | ||
|
|
76f0f1494e | ||
|
|
e4f916bea5 | ||
|
|
cf9098e6a3 | ||
|
|
18ffdb2f50 | ||
|
|
acd2ef4edf | ||
|
|
9d91d4f85c | ||
|
|
635af9748a | ||
|
|
612e5a08f3 | ||
|
|
d3477f8626 | ||
|
|
9e836a8984 | ||
|
|
3f14ffdf73 | ||
|
|
fd9f6c11ab | ||
|
|
0cc79724c3 | ||
|
|
e6fddd054a | ||
|
|
860b22b0e7 | ||
|
|
281b0ed124 | ||
|
|
5dc5cb62db | ||
|
|
28bb43b3b5 | ||
|
|
c8bfcb16be | ||
|
|
634956bcc6 | ||
|
|
346ab98133 | ||
|
|
55008c9de9 | ||
|
|
f4ba70c49c | ||
|
|
e61379ed59 | ||
|
|
75cd9b4cb7 | ||
|
|
43120d2b3e | ||
|
|
5656731dca | ||
|
|
4cfe54161d | ||
|
|
7f2a95f66e | ||
|
|
75819f3be3 | ||
|
|
e709921310 | ||
|
|
b19d47ca4a | ||
|
|
516981b80c | ||
|
|
a90d162989 | ||
|
|
6cf9f1cc11 | ||
|
|
ee7362564c | ||
|
|
cdf5367934 | ||
|
|
7a76c31c26 | ||
|
|
004ab78a7a | ||
|
|
a7067c30c4 | ||
|
|
be081316b3 | ||
|
|
c9fb33cb20 | ||
|
|
dfdc0f3c35 | ||
|
|
0fa3a509d6 | ||
|
|
18cf0a95ad | ||
|
|
7d454123f9 | ||
|
|
e4fb72cd08 | ||
|
|
741e1b19e5 | ||
|
|
6637c05cc8 | ||
|
|
5877670e33 | ||
|
|
2320beec39 | ||
|
|
a0effc9ff8 | ||
|
|
92cd5630f7 | ||
|
|
9fbca68062 | ||
|
|
953fb20006 | ||
|
|
fb18be14a1 | ||
|
|
75c4dbc9df | ||
|
|
1f5b4269ab | ||
|
|
9c23574977 | ||
|
|
fe5ff98429 | ||
|
|
b721b3ac77 | ||
|
|
638485376c | ||
|
|
575f4235c3 | ||
|
|
8184d3ef37 | ||
|
|
1262a5a1ff | ||
|
|
05fc3e9104 | ||
|
|
064e72c43a | ||
|
|
088d8eb159 | ||
|
|
333bc5d123 | ||
|
|
93f4c97433 | ||
|
|
eeeb7d6ba1 | ||
|
|
bda1dc2aa8 | ||
|
|
4073596373 | ||
|
|
c16eb16af4 | ||
|
|
0c0d7713df | ||
|
|
5161d18d19 | ||
|
|
2dcb5374fa | ||
|
|
4c103173ba | ||
|
|
6d919376dc | ||
|
|
08dac7f60b | ||
|
|
087e271f61 | ||
|
|
7713bbf65d | ||
|
|
ea4efa6a16 | ||
|
|
c6dc7aa05c | ||
|
|
0e6cafbe9f | ||
|
|
559559c2d1 | ||
|
|
3223d9c6f9 | ||
|
|
991cbc4dc0 | ||
|
|
af0318293c | ||
|
|
6033f9c6e6 | ||
|
|
f400f3839d | ||
|
|
c7736f9e80 | ||
|
|
5abd5803fb | ||
|
|
615677cf18 | ||
|
|
21d12a2b46 | ||
|
|
11c17d43eb | ||
|
|
120cdaabac | ||
|
|
3a9fbc7e67 | ||
|
|
c9aaaa952e | ||
|
|
d97121a63a | ||
|
|
4bd98f4819 | ||
|
|
426500437d | ||
|
|
c9dace8b4d | ||
|
|
f9654a3438 | ||
|
|
7d888a50af | ||
|
|
65eb5e3afe | ||
|
|
7a9dc4a607 | ||
|
|
d20cc87756 | ||
|
|
6d310f6b27 | ||
|
|
d2b273bfb0 | ||
|
|
1ff71a64e1 | ||
|
|
e442544070 | ||
|
|
ba93bcc06d | ||
|
|
2d545158d0 | ||
|
|
364ea03e5d | ||
|
|
082aa70a48 | ||
|
|
e9fe4036b1 | ||
|
|
5ab02cfe52 | ||
|
|
ce8d9a1cdf | ||
|
|
745a68f26b | ||
|
|
667ff1797d | ||
|
|
e5274c5cff | ||
|
|
03c3d6ae4a | ||
|
|
9a3673a38f | ||
|
|
99124e4feb | ||
|
|
cf51782f4f | ||
|
|
8bde0bf0ec | ||
|
|
e8372c76aa | ||
|
|
e1dc36c0a5 | ||
|
|
8d168dc330 | ||
|
|
321afbe110 | ||
|
|
2e3daad78e | ||
|
|
2132c2cdf4 | ||
|
|
67aff20e39 | ||
|
|
3719e1eee0 | ||
|
|
4abe83fdb6 | ||
|
|
6ba912e5aa | ||
|
|
8533083730 | ||
|
|
754ff28b36 | ||
|
|
b663c64def | ||
|
|
998b26d9a4 | ||
|
|
b097cf9a6a | ||
|
|
e22c367566 | ||
|
|
71a2e98155 | ||
|
|
714bbd6d23 | ||
|
|
eda03333a6 | ||
|
|
93f17a87fa | ||
|
|
c765306e6f | ||
|
|
f05fe5754d | ||
|
|
d046bfa14b | ||
|
|
2a681008dd | ||
|
|
7214823c74 | ||
|
|
ed5b92a91e | ||
|
|
2c8a9eee61 | ||
|
|
6451305c89 | ||
|
|
5fd0dc23da | ||
|
|
fd3b133b16 | ||
|
|
118bc3edf1 | ||
|
|
d90836bc50 | ||
|
|
9a477dbeb9 | ||
|
|
5271081b3a | ||
|
|
b26370fc5a | ||
|
|
737c7dcdb4 | ||
|
|
04babe0261 | ||
|
|
85e5bbd246 | ||
|
|
f819e1c88b | ||
|
|
79c153c498 | ||
|
|
1db9903926 | ||
|
|
e736e05d1c | ||
|
|
5ef10676d8 | ||
|
|
b38613ca22 | ||
|
|
ea486fbe13 | ||
|
|
d2784aff54 | ||
|
|
7308d9541e | ||
|
|
d6ac709e5f | ||
|
|
b290046e66 | ||
|
|
c2321a04ae | ||
|
|
3df77a4395 | ||
|
|
38fd790719 | ||
|
|
40bfa9dd3d | ||
|
|
8d08e5df60 | ||
|
|
4121c47e18 | ||
|
|
d30e6ad0da | ||
|
|
be712df89d | ||
|
|
f7762c403e | ||
|
|
b89d37de84 | ||
|
|
a7b9af61c0 | ||
|
|
a3186cdfe1 | ||
|
|
0a580493a2 | ||
|
|
7a7bf72aa8 | ||
|
|
a20a584273 | ||
|
|
ae30e8cf00 | ||
|
|
1a7bb9131a | ||
|
|
81ed35b117 | ||
|
|
2704495ac6 | ||
|
|
a96f7c6ee7 | ||
|
|
af706ac1b3 | ||
|
|
766ef933b9 | ||
|
|
35de2aca18 | ||
|
|
c1827e1b9e | ||
|
|
89e3544a0c | ||
|
|
7f40e9e661 | ||
|
|
20405ea95f | ||
|
|
2574e18c2f | ||
|
|
36b25a9517 | ||
|
|
b3e0575361 | ||
|
|
f9f40b3c9b | ||
|
|
b59721f4b3 |
@@ -107,3 +107,4 @@ knowledge_base:
|
||||
filePatterns:
|
||||
- "readme/dev/coding_style.md"
|
||||
- "readme/dev/index.md"
|
||||
- "CLAUDE.md"
|
||||
|
||||
@@ -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
|
||||
@@ -218,6 +219,8 @@ packages/app-desktop/gui/EditFolderDialog/Dialog.js
|
||||
packages/app-desktop/gui/EditFolderDialog/IconSelector.js
|
||||
packages/app-desktop/gui/EmojiBox.js
|
||||
packages/app-desktop/gui/EncryptionConfigScreen/EncryptionConfigScreen.js
|
||||
packages/app-desktop/gui/EncryptionConfigScreen/enableFlow.test.js
|
||||
packages/app-desktop/gui/EncryptionConfigScreen/enableFlow.js
|
||||
packages/app-desktop/gui/ErrorBoundary.js
|
||||
packages/app-desktop/gui/ExtensionBadge.js
|
||||
packages/app-desktop/gui/FolderIconBox.js
|
||||
@@ -271,6 +274,7 @@ packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/Editor.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/useEditorCommands.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/localisation.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/useContentScriptRegistration.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/useEditorSettings.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/useKeymap.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/useRefocusOnVisiblePaneChange.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/useSyncEditorValue.js
|
||||
@@ -303,6 +307,7 @@ packages/app-desktop/gui/NoteEditor/commands/focusElementNoteTitle.js
|
||||
packages/app-desktop/gui/NoteEditor/commands/focusElementNoteViewer.js
|
||||
packages/app-desktop/gui/NoteEditor/commands/focusElementToolbar.js
|
||||
packages/app-desktop/gui/NoteEditor/commands/index.js
|
||||
packages/app-desktop/gui/NoteEditor/commands/pasteAsMarkdown.js
|
||||
packages/app-desktop/gui/NoteEditor/commands/pasteAsText.js
|
||||
packages/app-desktop/gui/NoteEditor/commands/showLocalSearch.js
|
||||
packages/app-desktop/gui/NoteEditor/commands/showRevisions.js
|
||||
@@ -340,9 +345,11 @@ packages/app-desktop/gui/NoteEditor/utils/useWindowCommandHandler.js
|
||||
packages/app-desktop/gui/NoteList/NoteList2.js
|
||||
packages/app-desktop/gui/NoteList/commands/focusElementNoteList.js
|
||||
packages/app-desktop/gui/NoteList/commands/index.js
|
||||
packages/app-desktop/gui/NoteList/utils/UseAutoScroll.test.js
|
||||
packages/app-desktop/gui/NoteList/utils/canManuallySortNotes.js
|
||||
packages/app-desktop/gui/NoteList/utils/types.js
|
||||
packages/app-desktop/gui/NoteList/utils/useActiveDescendantId.js
|
||||
packages/app-desktop/gui/NoteList/utils/useAutoScroll.js
|
||||
packages/app-desktop/gui/NoteList/utils/useDragAndDrop.js
|
||||
packages/app-desktop/gui/NoteList/utils/useFocusNote.js
|
||||
packages/app-desktop/gui/NoteList/utils/useFocusVisible.js
|
||||
@@ -351,6 +358,8 @@ packages/app-desktop/gui/NoteList/utils/useMoveNote.js
|
||||
packages/app-desktop/gui/NoteList/utils/useOnKeyDown.js
|
||||
packages/app-desktop/gui/NoteList/utils/useOnNoteClick.js
|
||||
packages/app-desktop/gui/NoteList/utils/useOnNoteDoubleClick.js
|
||||
packages/app-desktop/gui/NoteList/utils/useRefocusOnDeletion.test.js
|
||||
packages/app-desktop/gui/NoteList/utils/useRefocusOnDeletion.js
|
||||
packages/app-desktop/gui/NoteList/utils/useScroll.js
|
||||
packages/app-desktop/gui/NoteList/utils/useVisibleRange.test.js
|
||||
packages/app-desktop/gui/NoteList/utils/useVisibleRange.js
|
||||
@@ -372,8 +381,8 @@ packages/app-desktop/gui/NoteListItem/utils/getNoteTitleHtml.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/prepareViewProps.test.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/prepareViewProps.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/types.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/useItemElement.test.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/useItemElement.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/useItemEventHandlers.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/useOnContextMenu.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/useRenderedNote.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/useRootElement.test.js
|
||||
@@ -559,6 +568,7 @@ packages/app-desktop/integration-tests/models/NoteEditorScreen.js
|
||||
packages/app-desktop/integration-tests/models/NoteList.js
|
||||
packages/app-desktop/integration-tests/models/SettingsScreen.js
|
||||
packages/app-desktop/integration-tests/models/Sidebar.js
|
||||
packages/app-desktop/integration-tests/multiWindow.spec.js
|
||||
packages/app-desktop/integration-tests/noteList.spec.js
|
||||
packages/app-desktop/integration-tests/pluginApi.spec.js
|
||||
packages/app-desktop/integration-tests/resizableLayout.spec.js
|
||||
@@ -580,6 +590,7 @@ packages/app-desktop/integration-tests/util/setMessageBoxResponse.js
|
||||
packages/app-desktop/integration-tests/util/setSettingValue.js
|
||||
packages/app-desktop/integration-tests/util/test.js
|
||||
packages/app-desktop/integration-tests/util/waitForNextOpenPath.js
|
||||
packages/app-desktop/integration-tests/util/waitForNextWindowMatching.js
|
||||
packages/app-desktop/integration-tests/wcag.spec.js
|
||||
packages/app-desktop/main-html.js
|
||||
packages/app-desktop/main.js
|
||||
@@ -608,10 +619,6 @@ packages/app-desktop/services/plugins/hooks/useViewIsReady.js
|
||||
packages/app-desktop/services/plugins/hooks/useWebviewToPluginMessages.js
|
||||
packages/app-desktop/services/plugins/types.js
|
||||
packages/app-desktop/services/restart.js
|
||||
packages/app-desktop/services/sortOrder/PerFolderSortOrderService.test.js
|
||||
packages/app-desktop/services/sortOrder/PerFolderSortOrderService.js
|
||||
packages/app-desktop/services/sortOrder/notesSortOrderUtils.test.js
|
||||
packages/app-desktop/services/sortOrder/notesSortOrderUtils.js
|
||||
packages/app-desktop/services/spellChecker/SpellCheckerServiceDriverNative.js
|
||||
packages/app-desktop/tools/bundleJs.js
|
||||
packages/app-desktop/tools/copy7Zip.js
|
||||
@@ -647,6 +654,7 @@ packages/app-mobile/commands/newNote.js
|
||||
packages/app-mobile/commands/openItem.js
|
||||
packages/app-mobile/commands/openNote.js
|
||||
packages/app-mobile/commands/scrollToHash.js
|
||||
packages/app-mobile/commands/util/goToFolder.js
|
||||
packages/app-mobile/commands/util/goToNote.js
|
||||
packages/app-mobile/commands/util/showResource.js
|
||||
packages/app-mobile/components/BetaChip.js
|
||||
@@ -690,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
|
||||
@@ -864,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
|
||||
@@ -880,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
|
||||
@@ -902,6 +918,7 @@ packages/app-mobile/contentScripts/markdownEditorBundle/useWebViewSetup.js
|
||||
packages/app-mobile/contentScripts/markdownEditorBundle/utils/useCodeMirrorPlugins.js
|
||||
packages/app-mobile/contentScripts/rendererBundle/contentScript/Renderer.test.js
|
||||
packages/app-mobile/contentScripts/rendererBundle/contentScript/Renderer.js
|
||||
packages/app-mobile/contentScripts/rendererBundle/contentScript/index.handleAnchorClick.test.js
|
||||
packages/app-mobile/contentScripts/rendererBundle/contentScript/index.js
|
||||
packages/app-mobile/contentScripts/rendererBundle/contentScript/types.js
|
||||
packages/app-mobile/contentScripts/rendererBundle/contentScript/utils/addPluginAssets.js
|
||||
@@ -1433,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
|
||||
@@ -1520,6 +1538,7 @@ packages/lib/services/interop/InteropService_Importer_Md.test.js
|
||||
packages/lib/services/interop/InteropService_Importer_Md.js
|
||||
packages/lib/services/interop/InteropService_Importer_Md_frontmatter.test.js
|
||||
packages/lib/services/interop/InteropService_Importer_Md_frontmatter.js
|
||||
packages/lib/services/interop/InteropService_Importer_OneNote.postprocessHtml.test.js
|
||||
packages/lib/services/interop/InteropService_Importer_OneNote.test.js
|
||||
packages/lib/services/interop/InteropService_Importer_OneNote.js
|
||||
packages/lib/services/interop/InteropService_Importer_Raw.test.js
|
||||
@@ -1575,6 +1594,7 @@ packages/lib/services/plugins/api/JoplinCommands.js
|
||||
packages/lib/services/plugins/api/JoplinContentScripts.js
|
||||
packages/lib/services/plugins/api/JoplinData.js
|
||||
packages/lib/services/plugins/api/JoplinFilters.js
|
||||
packages/lib/services/plugins/api/JoplinFs.js
|
||||
packages/lib/services/plugins/api/JoplinImaging.js
|
||||
packages/lib/services/plugins/api/JoplinInterop.js
|
||||
packages/lib/services/plugins/api/JoplinPlugins.js
|
||||
@@ -1673,6 +1693,10 @@ packages/lib/services/share/ShareService.test.js
|
||||
packages/lib/services/share/ShareService.js
|
||||
packages/lib/services/share/invitationRespond.js
|
||||
packages/lib/services/share/reducer.js
|
||||
packages/lib/services/sortOrder/PerFolderSortOrderService.test.js
|
||||
packages/lib/services/sortOrder/PerFolderSortOrderService.js
|
||||
packages/lib/services/sortOrder/notesSortOrderUtils.test.js
|
||||
packages/lib/services/sortOrder/notesSortOrderUtils.js
|
||||
packages/lib/services/spellChecker/SpellCheckerService.js
|
||||
packages/lib/services/spellChecker/SpellCheckerServiceDriverBase.js
|
||||
packages/lib/services/style/cssToTheme.test.js
|
||||
|
||||
1
.github/workflows/build-macos-m1.yml
vendored
1
.github/workflows/build-macos-m1.yml
vendored
@@ -1,5 +1,6 @@
|
||||
name: Build macOS M1
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
Main:
|
||||
# We always process desktop release tags, because they also publish the release
|
||||
|
||||
2
.github/workflows/check-pr-title.yml
vendored
2
.github/workflows/check-pr-title.yml
vendored
@@ -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]+: .+"
|
||||
|
||||
1
.github/workflows/github-actions-main.yml
vendored
1
.github/workflows/github-actions-main.yml
vendored
@@ -1,5 +1,6 @@
|
||||
name: Joplin Continuous Integration
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
Main:
|
||||
# We always process server or desktop release tags, because they also publish the release
|
||||
|
||||
1
.github/workflows/ui-tests.yml
vendored
1
.github/workflows/ui-tests.yml
vendored
@@ -1,5 +1,6 @@
|
||||
name: Joplin UI tests
|
||||
on: [push, pull_request]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
|
||||
35
.gitignore
vendored
35
.gitignore
vendored
@@ -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
|
||||
@@ -191,6 +192,8 @@ packages/app-desktop/gui/EditFolderDialog/Dialog.js
|
||||
packages/app-desktop/gui/EditFolderDialog/IconSelector.js
|
||||
packages/app-desktop/gui/EmojiBox.js
|
||||
packages/app-desktop/gui/EncryptionConfigScreen/EncryptionConfigScreen.js
|
||||
packages/app-desktop/gui/EncryptionConfigScreen/enableFlow.test.js
|
||||
packages/app-desktop/gui/EncryptionConfigScreen/enableFlow.js
|
||||
packages/app-desktop/gui/ErrorBoundary.js
|
||||
packages/app-desktop/gui/ExtensionBadge.js
|
||||
packages/app-desktop/gui/FolderIconBox.js
|
||||
@@ -244,6 +247,7 @@ packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/Editor.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/useEditorCommands.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/localisation.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/useContentScriptRegistration.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/useEditorSettings.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/useKeymap.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/useRefocusOnVisiblePaneChange.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/useSyncEditorValue.js
|
||||
@@ -276,6 +280,7 @@ packages/app-desktop/gui/NoteEditor/commands/focusElementNoteTitle.js
|
||||
packages/app-desktop/gui/NoteEditor/commands/focusElementNoteViewer.js
|
||||
packages/app-desktop/gui/NoteEditor/commands/focusElementToolbar.js
|
||||
packages/app-desktop/gui/NoteEditor/commands/index.js
|
||||
packages/app-desktop/gui/NoteEditor/commands/pasteAsMarkdown.js
|
||||
packages/app-desktop/gui/NoteEditor/commands/pasteAsText.js
|
||||
packages/app-desktop/gui/NoteEditor/commands/showLocalSearch.js
|
||||
packages/app-desktop/gui/NoteEditor/commands/showRevisions.js
|
||||
@@ -313,9 +318,11 @@ packages/app-desktop/gui/NoteEditor/utils/useWindowCommandHandler.js
|
||||
packages/app-desktop/gui/NoteList/NoteList2.js
|
||||
packages/app-desktop/gui/NoteList/commands/focusElementNoteList.js
|
||||
packages/app-desktop/gui/NoteList/commands/index.js
|
||||
packages/app-desktop/gui/NoteList/utils/UseAutoScroll.test.js
|
||||
packages/app-desktop/gui/NoteList/utils/canManuallySortNotes.js
|
||||
packages/app-desktop/gui/NoteList/utils/types.js
|
||||
packages/app-desktop/gui/NoteList/utils/useActiveDescendantId.js
|
||||
packages/app-desktop/gui/NoteList/utils/useAutoScroll.js
|
||||
packages/app-desktop/gui/NoteList/utils/useDragAndDrop.js
|
||||
packages/app-desktop/gui/NoteList/utils/useFocusNote.js
|
||||
packages/app-desktop/gui/NoteList/utils/useFocusVisible.js
|
||||
@@ -324,6 +331,8 @@ packages/app-desktop/gui/NoteList/utils/useMoveNote.js
|
||||
packages/app-desktop/gui/NoteList/utils/useOnKeyDown.js
|
||||
packages/app-desktop/gui/NoteList/utils/useOnNoteClick.js
|
||||
packages/app-desktop/gui/NoteList/utils/useOnNoteDoubleClick.js
|
||||
packages/app-desktop/gui/NoteList/utils/useRefocusOnDeletion.test.js
|
||||
packages/app-desktop/gui/NoteList/utils/useRefocusOnDeletion.js
|
||||
packages/app-desktop/gui/NoteList/utils/useScroll.js
|
||||
packages/app-desktop/gui/NoteList/utils/useVisibleRange.test.js
|
||||
packages/app-desktop/gui/NoteList/utils/useVisibleRange.js
|
||||
@@ -345,8 +354,8 @@ packages/app-desktop/gui/NoteListItem/utils/getNoteTitleHtml.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/prepareViewProps.test.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/prepareViewProps.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/types.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/useItemElement.test.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/useItemElement.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/useItemEventHandlers.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/useOnContextMenu.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/useRenderedNote.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/useRootElement.test.js
|
||||
@@ -532,6 +541,7 @@ packages/app-desktop/integration-tests/models/NoteEditorScreen.js
|
||||
packages/app-desktop/integration-tests/models/NoteList.js
|
||||
packages/app-desktop/integration-tests/models/SettingsScreen.js
|
||||
packages/app-desktop/integration-tests/models/Sidebar.js
|
||||
packages/app-desktop/integration-tests/multiWindow.spec.js
|
||||
packages/app-desktop/integration-tests/noteList.spec.js
|
||||
packages/app-desktop/integration-tests/pluginApi.spec.js
|
||||
packages/app-desktop/integration-tests/resizableLayout.spec.js
|
||||
@@ -553,6 +563,7 @@ packages/app-desktop/integration-tests/util/setMessageBoxResponse.js
|
||||
packages/app-desktop/integration-tests/util/setSettingValue.js
|
||||
packages/app-desktop/integration-tests/util/test.js
|
||||
packages/app-desktop/integration-tests/util/waitForNextOpenPath.js
|
||||
packages/app-desktop/integration-tests/util/waitForNextWindowMatching.js
|
||||
packages/app-desktop/integration-tests/wcag.spec.js
|
||||
packages/app-desktop/main-html.js
|
||||
packages/app-desktop/main.js
|
||||
@@ -581,10 +592,6 @@ packages/app-desktop/services/plugins/hooks/useViewIsReady.js
|
||||
packages/app-desktop/services/plugins/hooks/useWebviewToPluginMessages.js
|
||||
packages/app-desktop/services/plugins/types.js
|
||||
packages/app-desktop/services/restart.js
|
||||
packages/app-desktop/services/sortOrder/PerFolderSortOrderService.test.js
|
||||
packages/app-desktop/services/sortOrder/PerFolderSortOrderService.js
|
||||
packages/app-desktop/services/sortOrder/notesSortOrderUtils.test.js
|
||||
packages/app-desktop/services/sortOrder/notesSortOrderUtils.js
|
||||
packages/app-desktop/services/spellChecker/SpellCheckerServiceDriverNative.js
|
||||
packages/app-desktop/tools/bundleJs.js
|
||||
packages/app-desktop/tools/copy7Zip.js
|
||||
@@ -620,6 +627,7 @@ packages/app-mobile/commands/newNote.js
|
||||
packages/app-mobile/commands/openItem.js
|
||||
packages/app-mobile/commands/openNote.js
|
||||
packages/app-mobile/commands/scrollToHash.js
|
||||
packages/app-mobile/commands/util/goToFolder.js
|
||||
packages/app-mobile/commands/util/goToNote.js
|
||||
packages/app-mobile/commands/util/showResource.js
|
||||
packages/app-mobile/components/BetaChip.js
|
||||
@@ -663,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
|
||||
@@ -837,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
|
||||
@@ -853,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
|
||||
@@ -875,6 +891,7 @@ packages/app-mobile/contentScripts/markdownEditorBundle/useWebViewSetup.js
|
||||
packages/app-mobile/contentScripts/markdownEditorBundle/utils/useCodeMirrorPlugins.js
|
||||
packages/app-mobile/contentScripts/rendererBundle/contentScript/Renderer.test.js
|
||||
packages/app-mobile/contentScripts/rendererBundle/contentScript/Renderer.js
|
||||
packages/app-mobile/contentScripts/rendererBundle/contentScript/index.handleAnchorClick.test.js
|
||||
packages/app-mobile/contentScripts/rendererBundle/contentScript/index.js
|
||||
packages/app-mobile/contentScripts/rendererBundle/contentScript/types.js
|
||||
packages/app-mobile/contentScripts/rendererBundle/contentScript/utils/addPluginAssets.js
|
||||
@@ -1406,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
|
||||
@@ -1493,6 +1511,7 @@ packages/lib/services/interop/InteropService_Importer_Md.test.js
|
||||
packages/lib/services/interop/InteropService_Importer_Md.js
|
||||
packages/lib/services/interop/InteropService_Importer_Md_frontmatter.test.js
|
||||
packages/lib/services/interop/InteropService_Importer_Md_frontmatter.js
|
||||
packages/lib/services/interop/InteropService_Importer_OneNote.postprocessHtml.test.js
|
||||
packages/lib/services/interop/InteropService_Importer_OneNote.test.js
|
||||
packages/lib/services/interop/InteropService_Importer_OneNote.js
|
||||
packages/lib/services/interop/InteropService_Importer_Raw.test.js
|
||||
@@ -1548,6 +1567,7 @@ packages/lib/services/plugins/api/JoplinCommands.js
|
||||
packages/lib/services/plugins/api/JoplinContentScripts.js
|
||||
packages/lib/services/plugins/api/JoplinData.js
|
||||
packages/lib/services/plugins/api/JoplinFilters.js
|
||||
packages/lib/services/plugins/api/JoplinFs.js
|
||||
packages/lib/services/plugins/api/JoplinImaging.js
|
||||
packages/lib/services/plugins/api/JoplinInterop.js
|
||||
packages/lib/services/plugins/api/JoplinPlugins.js
|
||||
@@ -1646,6 +1666,10 @@ packages/lib/services/share/ShareService.test.js
|
||||
packages/lib/services/share/ShareService.js
|
||||
packages/lib/services/share/invitationRespond.js
|
||||
packages/lib/services/share/reducer.js
|
||||
packages/lib/services/sortOrder/PerFolderSortOrderService.test.js
|
||||
packages/lib/services/sortOrder/PerFolderSortOrderService.js
|
||||
packages/lib/services/sortOrder/notesSortOrderUtils.test.js
|
||||
packages/lib/services/sortOrder/notesSortOrderUtils.js
|
||||
packages/lib/services/spellChecker/SpellCheckerService.js
|
||||
packages/lib/services/spellChecker/SpellCheckerServiceDriverBase.js
|
||||
packages/lib/services/style/cssToTheme.test.js
|
||||
@@ -1948,4 +1972,3 @@ packages/tools/website/utils/types.js
|
||||
packages/whisper-voice-typing/src/index.js
|
||||
packages/whisper-voice-typing/src/specs/Whisper.nitro.js
|
||||
# AUTO-GENERATED - EXCLUDED TYPESCRIPT BUILD
|
||||
|
||||
|
||||
@@ -1351,11 +1351,7 @@ footer .bottom-links-row p {
|
||||
ENGLISH VERSION
|
||||
*****************************************************************/
|
||||
|
||||
:lang(en-gb) #made-in-france-section {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:lang(en-gb) .top-section-img-cn {
|
||||
:not(:lang(zh-cn)) .top-section-img-cn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -145,7 +145,7 @@ function setupLocaleRedirect() {
|
||||
if (!isRootPage) return;
|
||||
|
||||
// Check if user has explicitly chosen to stay on current locale
|
||||
const localePreference = localStorage.getItem('joplin-locale-preference');
|
||||
const localePreference = (localStorage.getItem('joplin-locale-preference') || '').toLowerCase();
|
||||
if (localePreference === 'en') return;
|
||||
|
||||
// Get user's preferred language from browser
|
||||
@@ -160,9 +160,10 @@ function setupLocaleRedirect() {
|
||||
window.location.href = getLocalePath(langCode) + '/';
|
||||
}
|
||||
|
||||
// Allow users to switch back to English and remember their preference
|
||||
function setLocalePreference(locale) {
|
||||
// Allow users to switch language and remember their preference
|
||||
function setLocalePreference(locale, url) {
|
||||
localStorage.setItem('joplin-locale-preference', locale);
|
||||
window.location.href = url;
|
||||
}
|
||||
|
||||
// Expose globally for language switcher links
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
</div>
|
||||
<div class="col-9 text-right d-none d-md-block">
|
||||
{{> twitterLink}}
|
||||
<a href="{{baseUrl}}/plugins/" class="fw500">Plugins</a>
|
||||
<a href="{{baseUrl}}/news/" class="fw500">News</a>
|
||||
<a href="{{baseUrl}}/help/" class="fw500">Help</a>
|
||||
<a href="{{forumUrl}}" class="fw500">Forum</a>
|
||||
@@ -23,7 +24,7 @@
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
{{#availableLocales}}
|
||||
<li><a class="dropdown-item {{#isActive}}active{{/isActive}}" href="{{baseUrl}}/{{pathPrefix}}" onclick="setLocalePreference('{{code}}')">{{name}}</a></li>
|
||||
<li><a class="dropdown-item {{#isActive}}active{{/isActive}}" href="{{baseUrl}}/{{pathPrefix}}" onclick="setLocalePreference('{{code}}', this.href); return false;">{{name}}</a></li>
|
||||
{{/availableLocales}}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -58,6 +59,7 @@
|
||||
</div>
|
||||
|
||||
<div class="text-center menu-mobile-top">
|
||||
<a href="{{baseUrl}}/plugins/" class="fw500 mobile-menu-link">Plugins</a>
|
||||
<a href="{{baseUrl}}/news/" class="fw500 mobile-menu-link">News</a>
|
||||
<a href="{{baseUrl}}/help/" class="fw500 mobile-menu-link">Help</a>
|
||||
<a href="{{forumUrl}}" class="fw500 mobile-menu-link">Forum</a>
|
||||
@@ -73,7 +75,7 @@
|
||||
<div class="text-center menu-mobile-language">
|
||||
<p class="fw500 mobile-menu-language-label"><i class="fas fa-globe"></i> Language</p>
|
||||
{{#availableLocales}}
|
||||
<a href="{{baseUrl}}/{{pathPrefix}}" class="fw500 mobile-menu-link mobile-language-link {{#isActive}}active{{/isActive}}" onclick="setLocalePreference('{{code}}')">{{name}}</a>
|
||||
<a href="{{baseUrl}}/{{pathPrefix}}" class="fw500 mobile-menu-link mobile-language-link {{#isActive}}active{{/isActive}}" onclick="setLocalePreference('{{code}}', this.href); return false;">{{name}}</a>
|
||||
{{/availableLocales}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
<a class="social-link-bluesky" href="https://bsky.app/profile/joplinapp.bsky.social" title="Joplin Bluesky feed"><i class="fa-brands fa-bluesky"></i></a>
|
||||
<a class="social-link-mastodon" href="https://mastodon.social/@joplinapp" title="Joplin Mastodon feed"><i class="fab fa-mastodon"></i></a>
|
||||
<a class="social-link-patreon" href="https://www.patreon.com/joplin" title="Joplin Patreon"><i class="fab fa-patreon"></i></a>
|
||||
<a class="social-link-youtube" href="https://youtube.com/@joplinapp" title="Joplin YouTube channel"><i class="fab fa-youtube"></i></a>
|
||||
<a class="social-link-discord" href="https://discord.gg/VSj7AFHvpq" title="Joplin Discord chat"><i class="fab fa-discord"></i></a>
|
||||
<a class="social-link-linkedin" href="https://www.linkedin.com/company/joplin" title="Joplin LinkedIn Feed"><i class="fab fa-linkedin"></i></a>
|
||||
<a class="social-link-lemmy" href="https://sopuli.xyz/c/joplinapp" title="Joplin Lemmy Community"><i class="fas fa-otter"></i></a>
|
||||
<a class="social-link-github" href="https://github.com/laurent22/joplin/" title="Joplin GitHub repository"><i class="fab fa-github"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -8,6 +8,9 @@
|
||||
- Comments should be only with `//` and should not contain jsdoc syntax
|
||||
- If you duplicate a substantial block of code, add a comment above it noting the duplication and referencing the original location.
|
||||
- When creating Jest tests, there should be only one `describe()` statement in the file.
|
||||
- Focus on testing essential behaviour and edge cases — avoid adding tests for every minor detail.
|
||||
- Avoid duplicating code in tests; when testing the same logic with different inputs, use `test.each` or shared helpers instead of repeating similar test blocks.
|
||||
- Do not make white space changes - do not add unnecessary new lines, or spaces to existing code, or wrap existing code.
|
||||
|
||||
## Full Documentation
|
||||
|
||||
|
||||
@@ -9,15 +9,15 @@
|
||||
"vips.dev": {
|
||||
"platforms": ["aarch64-darwin"],
|
||||
},
|
||||
"nodejs": "24.10.0",
|
||||
"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.50.1",
|
||||
"git": "2.51.2",
|
||||
},
|
||||
"shell": {
|
||||
"init_hook": [
|
||||
|
||||
@@ -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,8 +83,8 @@
|
||||
"eslint-plugin-promise": "6.6.0",
|
||||
"eslint-plugin-react": "7.37.5",
|
||||
"execa": "5.1.1",
|
||||
"fs-extra": "11.3.2",
|
||||
"glob": "11.0.3",
|
||||
"fs-extra": "11.3.3",
|
||||
"glob": "11.1.0",
|
||||
"gulp": "4.0.2",
|
||||
"husky": "9.1.7",
|
||||
"lerna": "3.22.1",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -31,9 +31,14 @@ cliUtils.printArray = function(logFunction, rows) {
|
||||
const line = [];
|
||||
for (let col = 0; col < colWidths.length; col++) {
|
||||
const item = rows[row][col];
|
||||
const width = colWidths[col];
|
||||
const dir = colAligns[col] === ALIGN_LEFT ? stringPadding.RIGHT : stringPadding.LEFT;
|
||||
line.push(stringPadding(item, width, ' ', dir));
|
||||
const isLastCol = col === colWidths.length - 1;
|
||||
if (isLastCol) {
|
||||
line.push(item ? item.toString() : '');
|
||||
} else {
|
||||
const width = colWidths[col];
|
||||
const dir = colAligns[col] === ALIGN_LEFT ? stringPadding.RIGHT : stringPadding.LEFT;
|
||||
line.push(stringPadding(item, width, ' ', dir));
|
||||
}
|
||||
}
|
||||
logFunction(line.join(' '));
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
19
packages/app-cli/app/command-clear.ts
Normal file
19
packages/app-cli/app/command-clear.ts
Normal 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;
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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_) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -45,6 +45,10 @@ describe('HtmlToMd', () => {
|
||||
htmlToMdOptions.preserveColorStyles = true;
|
||||
}
|
||||
|
||||
if (htmlFilename.indexOf('table_with') === 0 || htmlFilename.indexOf('table_default') === 0) {
|
||||
htmlToMdOptions.preserveTableStyles = true;
|
||||
}
|
||||
|
||||
const html = await readFile(htmlPath, 'utf8');
|
||||
let expectedMd = await readFile(mdPath, 'utf8');
|
||||
|
||||
@@ -96,4 +100,34 @@ describe('HtmlToMd', () => {
|
||||
expect(htmlToMd.parse('> 1 _2_ 3.pdf', { disableEscapeContent: false })).toBe('\\> 1 \\_2_ 3.pdf');
|
||||
});
|
||||
|
||||
it('should support tightLists option', async () => {
|
||||
const htmlToMd = new HtmlToMd();
|
||||
const html = '<ul><li><p><strong>Item 1</strong></p></li><li><p><strong>Item 2</strong></p></li><li><p><strong>Item 3</strong></p></li></ul>';
|
||||
|
||||
// Without tightLists, paragraphs inside list items produce extra blank lines
|
||||
const looseResult = htmlToMd.parse(html, { tightLists: false });
|
||||
expect(looseResult).toContain('\n \n');
|
||||
|
||||
// With tightLists, list items are compact without blank lines
|
||||
const tightResult = htmlToMd.parse(html, { tightLists: true });
|
||||
expect(tightResult).toBe('- **Item 1**\n- **Item 2**\n- **Item 3**');
|
||||
});
|
||||
|
||||
it('should support collapseMultipleBlankLines option', async () => {
|
||||
const htmlToMd = new HtmlToMd();
|
||||
const html = '<p>First</p><br><br><br><p>Second</p>';
|
||||
|
||||
// Without collapseMultipleBlankLines, multiple blank lines are preserved
|
||||
const looseResult = htmlToMd.parse(html, { collapseMultipleBlankLines: false });
|
||||
expect(looseResult).toContain('\n\n \n');
|
||||
|
||||
// With collapseMultipleBlankLines, multiple blank lines are collapsed into one
|
||||
const collapsedResult = htmlToMd.parse(html, { collapseMultipleBlankLines: true });
|
||||
expect(collapsedResult).not.toContain('\n\n\n');
|
||||
expect(collapsedResult).not.toContain('\n\n \n');
|
||||
|
||||
// Verify that a single blank line is preserved (not fully removed)
|
||||
expect(collapsedResult).toContain('\n\n');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
7
packages/app-cli/tests/enex_to_md/list_with_br.html
Normal file
7
packages/app-cli/tests/enex_to_md/list_with_br.html
Normal file
@@ -0,0 +1,7 @@
|
||||
<ul>
|
||||
<li>First line<br/>Second line</li>
|
||||
<li>Normal item</li>
|
||||
<li>With sub-list<ul>
|
||||
<li>Sub-list<br/>Paragraph<br/>Also another line</li>
|
||||
</ul></li>
|
||||
</ul>
|
||||
8
packages/app-cli/tests/enex_to_md/list_with_br.md
Normal file
8
packages/app-cli/tests/enex_to_md/list_with_br.md
Normal file
@@ -0,0 +1,8 @@
|
||||
- First line
|
||||
Second line
|
||||
|
||||
- Normal item
|
||||
- With sub-list
|
||||
- Sub-list
|
||||
Paragraph
|
||||
Also another line
|
||||
@@ -0,0 +1 @@
|
||||
<a href="#section" style="text-decoration: underline">Section Link</a>
|
||||
@@ -0,0 +1 @@
|
||||
[Section Link](#section)
|
||||
@@ -0,0 +1,18 @@
|
||||
<table style="border-collapse: collapse; width: 100%;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 50%;">Name</th>
|
||||
<th style="width: 50%;">Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width: 50%;">Cell A</td>
|
||||
<td style="width: 50%;">Cell B</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="width: 50%;">Cell C</td>
|
||||
<td style="width: 50%;">Cell D</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -0,0 +1,4 @@
|
||||
| Name | Value |
|
||||
| --- | --- |
|
||||
| Cell A | Cell B |
|
||||
| Cell C | Cell D |
|
||||
@@ -0,0 +1,18 @@
|
||||
<table bgcolor="#f0f0f0" cellpadding="8">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Cell A</td>
|
||||
<td>Cell B</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Cell C</td>
|
||||
<td>Cell D</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -0,0 +1 @@
|
||||
<div class="joplin-table-wrapper"><table bgcolor="#f0f0f0" cellpadding="8"><thead><tr><th>Name</th><th>Value</th></tr></thead><tbody><tr><td>Cell A</td><td>Cell B</td></tr><tr><td>Cell C</td><td>Cell D</td></tr></tbody></table></div>
|
||||
@@ -0,0 +1,18 @@
|
||||
<table style="border-collapse: collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="background-color: #e03e2d">Red cell</td>
|
||||
<td style="padding: 10px 15px">Padded cell</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="border-color: #2dc26b; border-style: solid">Green border</td>
|
||||
<td>Normal cell</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -0,0 +1 @@
|
||||
<div class="joplin-table-wrapper"><table style="border-collapse: collapse"><thead><tr><th>Name</th><th>Value</th></tr></thead><tbody><tr><td style="background-color: #e03e2d">Red cell</td><td style="padding: 10px 15px">Padded cell</td></tr><tr><td style="border-color: #2dc26b; border-style: solid">Green border</td><td>Normal cell</td></tr></tbody></table></div>
|
||||
@@ -10,6 +10,7 @@ import Folder from '@joplin/lib/models/Folder';
|
||||
import { expectNotThrow, setupDatabaseAndSynchronizer, switchClient, expectThrow, createTempDir, supportDir, mockMobilePlatform } from '@joplin/lib/testing/test-utils';
|
||||
import { newPluginScript } from '../../testUtils';
|
||||
import { join } from 'path';
|
||||
import { PluginManifest } from '@joplin/lib/services/plugins/utils/types';
|
||||
|
||||
const testPluginDir = `${supportDir}/plugins`;
|
||||
|
||||
@@ -472,4 +473,18 @@ describe('services_PluginService', () => {
|
||||
await fs.remove(testDir);
|
||||
}
|
||||
});
|
||||
|
||||
it('should report a missing app_min_version field specifically', () => {
|
||||
const service = newPluginService();
|
||||
const manifest = {
|
||||
manifest_version: 1,
|
||||
id: 'test.plugin',
|
||||
name: 'Test Plugin',
|
||||
version: '1.0.0',
|
||||
// Missing app_min_version
|
||||
};
|
||||
|
||||
const error = service.describeIncompatibility(manifest as unknown as PluginManifest);
|
||||
expect(error).toContain('Invalid plugin manifest: Missing required field: app_min_version');
|
||||
});
|
||||
});
|
||||
|
||||
BIN
packages/app-cli/tests/support/onenote/truncated.zip
Normal file
BIN
packages/app-cli/tests/support/onenote/truncated.zip
Normal file
Binary file not shown.
@@ -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>;
|
||||
|
||||
@@ -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 } from 'electron';
|
||||
import { BrowserWindow, Tray, WebContents, screen, App, nativeTheme, Menu, session as electronSession, Session } 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;
|
||||
|
||||
@@ -70,13 +67,15 @@ export default class ElectronAppWrapper {
|
||||
private updaterService_: AutoUpdaterService = null;
|
||||
private customProtocolHandlers_: CustomProtocolHandlers|null = null;
|
||||
private updatePollInterval_: ReturnType<typeof setTimeout>|null = null;
|
||||
private joplinSession_: Session|null = null;
|
||||
|
||||
private profileLocker_: FileLocker|null = null;
|
||||
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 +87,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 +113,8 @@ export default class ElectronAppWrapper {
|
||||
return !!this.ipcServer_;
|
||||
}
|
||||
|
||||
public ipcLoggerFilePath() {
|
||||
return this.ipcLoggerFilePath_;
|
||||
public mainProcessLogFilePath() {
|
||||
return this.mainProcessLoggerFilePath_;
|
||||
}
|
||||
|
||||
public windowById(joplinId: string) {
|
||||
@@ -176,6 +167,10 @@ export default class ElectronAppWrapper {
|
||||
public async handleAppFailure(errorMessage: string, canIgnore: boolean, isTesting?: boolean) {
|
||||
await bridge().captureException(new Error(errorMessage));
|
||||
|
||||
if (this.win_ && this.win_.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const buttons = [];
|
||||
buttons.push(_('Quit'));
|
||||
const exitIndex = 0;
|
||||
@@ -199,7 +194,7 @@ export default class ElectronAppWrapper {
|
||||
//
|
||||
// Also only run this if not testing (crashing the renderer breaks automated
|
||||
// tests).
|
||||
if (this.win_ && !this.win_.webContents.isCrashed() && !isTesting) {
|
||||
if (this.win_ && !this.win_.isDestroyed() && !this.win_.webContents.isCrashed() && !isTesting) {
|
||||
this.win_.webContents.forcefullyCrashRenderer();
|
||||
}
|
||||
} else if (response === exitIndex) {
|
||||
@@ -207,13 +202,46 @@ export default class ElectronAppWrapper {
|
||||
}
|
||||
}
|
||||
|
||||
private createJoplinSession_() {
|
||||
const sessionPath = path.join(this.profilePath_, 'internal');
|
||||
const joplinSession = electronSession.fromPath(sessionPath, { cache: false });
|
||||
|
||||
// One-time migration: copy existing dictionary words from the old Electron userData location into the new session.
|
||||
const migrationFlagPath = path.join(this.profilePath_, 'spell-checker-migration-done');
|
||||
if (!fs.existsSync(migrationFlagPath)) {
|
||||
try {
|
||||
const wordsToMigrate = new Set<string>();
|
||||
|
||||
const oldElectronDictPath = path.join(this.electronApp_.getPath('userData'), 'Custom Dictionary.txt');
|
||||
if (fs.existsSync(oldElectronDictPath)) {
|
||||
const content = fs.readFileSync(oldElectronDictPath, 'utf8');
|
||||
const words = content.split('\n')
|
||||
.map((w: string) => w.trim())
|
||||
.filter((w: string) => w.length > 0 && !/^checksum_v1\s*=/.test(w));
|
||||
|
||||
for (const word of words) {
|
||||
wordsToMigrate.add(word);
|
||||
}
|
||||
}
|
||||
|
||||
for (const word of wordsToMigrate) {
|
||||
joplinSession.addWordToSpellCheckerDictionary(word);
|
||||
}
|
||||
|
||||
fs.writeFileSync(migrationFlagPath, '', 'utf8');
|
||||
} catch (error) {
|
||||
console.warn('Failed to migrate spell-check dictionary:', error);
|
||||
}
|
||||
}
|
||||
return joplinSession;
|
||||
}
|
||||
|
||||
public createWindow() {
|
||||
// Set to true to view errors if the application does not start
|
||||
const debugEarlyBugs = this.env_ === 'dev' || this.isDebugMode_;
|
||||
|
||||
const windowStateKeeper = require('electron-window-state');
|
||||
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const stateOptions: any = {
|
||||
defaultWidth: Math.round(0.8 * screen.getPrimaryDisplay().workArea.width),
|
||||
@@ -239,6 +267,7 @@ export default class ElectronAppWrapper {
|
||||
// this needs to be a non-transparent color:
|
||||
backgroundColor: nativeTheme.shouldUseDarkColors ? '#333' : '#fff',
|
||||
webPreferences: {
|
||||
session: this.joplinSession_,
|
||||
nodeIntegration: true,
|
||||
contextIsolation: false,
|
||||
spellcheck: true,
|
||||
@@ -348,7 +377,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);
|
||||
}
|
||||
@@ -410,12 +439,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_;
|
||||
@@ -439,21 +471,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;
|
||||
}
|
||||
@@ -469,8 +507,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_;
|
||||
@@ -518,8 +579,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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -544,8 +605,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;
|
||||
}
|
||||
|
||||
@@ -574,6 +634,7 @@ export default class ElectronAppWrapper {
|
||||
}
|
||||
|
||||
public quit() {
|
||||
this.appLogger_.info('[appClose] quit() called');
|
||||
this.onExit();
|
||||
this.electronApp_.quit();
|
||||
}
|
||||
@@ -582,6 +643,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 {
|
||||
@@ -631,8 +693,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);
|
||||
@@ -640,7 +701,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()) {
|
||||
@@ -650,7 +711,7 @@ export default class ElectronAppWrapper {
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Cannot create tray', error);
|
||||
this.appLogger_.error('Cannot create tray', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -797,7 +858,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;
|
||||
}
|
||||
|
||||
@@ -845,8 +906,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;
|
||||
@@ -856,8 +916,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);
|
||||
}
|
||||
}
|
||||
@@ -872,14 +931,18 @@ export default class ElectronAppWrapper {
|
||||
|
||||
await this.fixLinuxAccessibility_();
|
||||
|
||||
this.customProtocolHandlers_ = handleCustomProtocols();
|
||||
// Session must be created before handleCustomProtocols() so both use the same object.
|
||||
this.joplinSession_ = this.createJoplinSession_();
|
||||
this.customProtocolHandlers_ = handleCustomProtocols(this.joplinSession_);
|
||||
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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -43,7 +43,7 @@ const electronContextMenu = require('./services/electron-context-menu');
|
||||
// Commands that are not tied to any particular component.
|
||||
// The runtime for these commands can be loaded when the app starts.
|
||||
|
||||
import PerFolderSortOrderService from './services/sortOrder/PerFolderSortOrderService';
|
||||
import PerFolderSortOrderService from '@joplin/lib/services/sortOrder/PerFolderSortOrderService';
|
||||
import ShareService from '@joplin/lib/services/share/ShareService';
|
||||
import checkForUpdates from './checkForUpdates';
|
||||
import { AppState } from './app.reducer';
|
||||
@@ -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;
|
||||
@@ -640,16 +639,19 @@ class Application extends BaseApplication {
|
||||
void AlarmService.updateAllNotifications();
|
||||
RevisionService.instance().runInBackground();
|
||||
} else {
|
||||
// eslint-disable-next-line promise/prefer-await-to-then -- Old code before rule was applied
|
||||
void reg.scheduleSync(1000).then(() => {
|
||||
// Wait for the first sync before updating the notifications, since synchronisation
|
||||
// might change the notifications.
|
||||
void AlarmService.updateAllNotifications();
|
||||
setTimeout(() => {
|
||||
// Schedule sync with a delay of 0 and wrap with the desired timeout, as shim.setTimeout may not fire on first run or after an upgrade
|
||||
// eslint-disable-next-line promise/prefer-await-to-then -- Old code before rule was applied
|
||||
void reg.scheduleSync(0).then(() => {
|
||||
// Wait for the first sync before updating the notifications, since synchronisation
|
||||
// might change the notifications.
|
||||
void AlarmService.updateAllNotifications();
|
||||
|
||||
void DecryptionWorker.instance().scheduleStart();
|
||||
void DecryptionWorker.instance().scheduleStart();
|
||||
|
||||
RevisionService.instance().runInBackground();
|
||||
});
|
||||
RevisionService.instance().runInBackground();
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
this.startRotatingLogMaintenance(Setting.value('profileDir'));
|
||||
@@ -730,6 +732,10 @@ class Application extends BaseApplication {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
ipcRenderer.on('secondary-window-closing', (_event, windowId: string) => {
|
||||
this.dispatch({ type: 'WINDOW_CLOSE', windowId });
|
||||
});
|
||||
});
|
||||
|
||||
addTask('app/initPluginService', () => this.initPluginService());
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -583,6 +577,11 @@ export class Bridge {
|
||||
execPath: process.env.PORTABLE_EXECUTABLE_FILE,
|
||||
};
|
||||
app.relaunch(options);
|
||||
} else if (process.env.APPIMAGE && !this.altInstanceId_) {
|
||||
app.relaunch({
|
||||
execPath: process.env.APPIMAGE,
|
||||
args: ['--appimage-extract-and-run'],
|
||||
});
|
||||
} else if (this.altInstanceId_) {
|
||||
// Couldn't get it to work using relaunch() - it would just "close" the app, but it
|
||||
// would still be open in the tray except unusable. Or maybe it reopens it quickly but
|
||||
|
||||
@@ -4,7 +4,7 @@ import { _ } from '@joplin/lib/locale';
|
||||
import bridge from './services/bridge';
|
||||
import KvStore from '@joplin/lib/services/KvStore';
|
||||
import * as ArrayUtils from '@joplin/lib/ArrayUtils';
|
||||
import { CheckForUpdateOptions, extractVersionInfo, GitHubRelease } from './utils/checkForUpdatesUtils';
|
||||
import { CheckForUpdateOptions, extractVersionInfo, GitHubRelease, handleReleaseResponseError } from './utils/checkForUpdatesUtils';
|
||||
import { PackageInfo } from '@joplin/lib/versionInfo';
|
||||
import { compareVersions } from 'compare-versions';
|
||||
const packageInfo: PackageInfo = require('./packageInfo.js');
|
||||
@@ -29,7 +29,8 @@ async function fetchLatestReleases() {
|
||||
|
||||
if (!response.ok) {
|
||||
const responseText = await response.text();
|
||||
throw new Error(`Cannot get latest release info: ${responseText.substr(0, 500)}`);
|
||||
logger.error(`Cannot get latest release info (${response.status}): ${responseText.substr(0, 500)}`);
|
||||
handleReleaseResponseError(response.status, responseText);
|
||||
}
|
||||
|
||||
return (await response.json()) as GitHubRelease[];
|
||||
@@ -48,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> {
|
||||
|
||||
@@ -19,7 +19,7 @@ import shouldShowMissingPasswordWarning from '@joplin/lib/components/shared/conf
|
||||
import MacOSMissingPasswordHelpLink from './controls/MissingPasswordHelpLink';
|
||||
const { KeymapConfigScreen } = require('../KeymapConfig/KeymapConfigScreen');
|
||||
import SettingComponent, { UpdateSettingValueEvent } from './controls/SettingComponent';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import shim, { MessageBoxType } from '@joplin/lib/shim';
|
||||
|
||||
|
||||
interface Font {
|
||||
@@ -145,8 +145,16 @@ class ConfigScreenComponent extends React.Component<any, any> {
|
||||
screenName = section.name;
|
||||
|
||||
if (this.hasChanges()) {
|
||||
const ok = await shim.showConfirmationDialog(_('This will open a new screen. Save your current changes?'));
|
||||
if (ok) {
|
||||
const answer = await shim.showMessageBox(
|
||||
_('This will open a new screen. Save your current changes?'),
|
||||
{
|
||||
type: MessageBoxType.Confirm,
|
||||
buttons: [_('Save changes'), _('Discard changes')],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
},
|
||||
);
|
||||
if (answer === 0) {
|
||||
await shared.saveSettings(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -10,18 +10,25 @@ import { MasterKeyEntity } from '@joplin/lib/services/e2ee/types';
|
||||
import { getEncryptionEnabled, masterKeyEnabled, SyncInfo } from '@joplin/lib/services/synchronizer/syncInfoUtils';
|
||||
import { getDefaultMasterKey, getMasterPasswordStatusMessage, masterPasswordIsValid, toggleAndSetupEncryption } from '@joplin/lib/services/e2ee/utils';
|
||||
import Button, { ButtonLevel } from '../Button/Button';
|
||||
import { useCallback, useId, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { AppState } from '../../app.reducer';
|
||||
import { AppState, AppStateDialogName } from '../../app.reducer';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import { PublicPrivateKeyPair } from '@joplin/lib/services/e2ee/ppk/ppk';
|
||||
import ToggleAdvancedSettingsButton from '../ConfigScreen/controls/ToggleAdvancedSettingsButton';
|
||||
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
|
||||
themeId: any;
|
||||
dispatch: Dispatch;
|
||||
masterKeys: MasterKeyEntity[];
|
||||
passwords: Record<string, string>;
|
||||
notLoadedMasterKeys: string[];
|
||||
@@ -30,10 +37,17 @@ interface Props {
|
||||
activeMasterKeyId: string;
|
||||
masterPassword: string;
|
||||
ppk: PublicPrivateKeyPair;
|
||||
masterPasswordDialogOpen: boolean;
|
||||
}
|
||||
|
||||
const EncryptionConfigScreen = (props: 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(() => {
|
||||
return themeStyle(props.themeId);
|
||||
@@ -44,6 +58,41 @@ const EncryptionConfigScreen = (props: Props) => {
|
||||
const { showDisabledMasterKeys, toggleShowDisabledMasterKeys } = useToggleShowDisabledMasterKeys();
|
||||
const needMasterPassword = useNeedMasterPassword(passwordChecks, props.masterKeys);
|
||||
|
||||
useEffect(() => {
|
||||
const wasOpen = wasMasterPasswordDialogOpen.current;
|
||||
wasMasterPasswordDialogOpen.current = props.masterPasswordDialogOpen;
|
||||
|
||||
if (shouldCancelPendingEnableAfterMasterPasswordDialog({
|
||||
pendingEnableEncryption,
|
||||
wasMasterPasswordDialogOpen: wasOpen,
|
||||
masterPasswordDialogOpen: props.masterPasswordDialogOpen,
|
||||
masterPassword: props.masterPassword,
|
||||
})) {
|
||||
setPendingEnableEncryption(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shouldResumeEnableAfterMasterPasswordDialog({
|
||||
pendingEnableEncryption,
|
||||
wasMasterPasswordDialogOpen: wasOpen,
|
||||
masterPasswordDialogOpen: props.masterPasswordDialogOpen,
|
||||
masterPassword: props.masterPassword,
|
||||
})) return;
|
||||
|
||||
const masterKey = getDefaultMasterKey();
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
await toggleAndSetupEncryption(EncryptionService.instance(), true, masterKey, props.masterPassword);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
await dialogs.alert(message);
|
||||
} finally {
|
||||
setPendingEnableEncryption(false);
|
||||
}
|
||||
})();
|
||||
}, [pendingEnableEncryption, props.masterPasswordDialogOpen, props.masterPassword]);
|
||||
|
||||
const onUpgradeMasterKey = useCallback(async (mk: MasterKeyEntity) => {
|
||||
const password = determineKeyPassword(mk.id, masterPasswordKeys, props.masterPassword, props.passwords);
|
||||
const result = await upgradeMasterKey(mk, password);
|
||||
@@ -194,14 +243,32 @@ 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?'));
|
||||
if (!answer) return;
|
||||
} else {
|
||||
const msg = enableEncryptionConfirmationMessages(masterKey, hasMasterPassword);
|
||||
newPassword = await dialogs.prompt(msg.join('\n\n'), '', '', { type: 'password' });
|
||||
if (shouldOpenMasterPasswordDialogForEnable({
|
||||
hasMasterPassword,
|
||||
masterPasswordDialogOpen: props.masterPasswordDialogOpen,
|
||||
})) {
|
||||
setPendingEnableEncryption(true);
|
||||
props.dispatch({
|
||||
type: 'DIALOG_OPEN',
|
||||
name: AppStateDialogName.MasterPassword,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -216,7 +283,64 @@ const EncryptionConfigScreen = (props: Props) => {
|
||||
} catch (error) {
|
||||
await dialogs.alert(error.message);
|
||||
}
|
||||
}, [props.masterPassword]);
|
||||
}, [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>;
|
||||
@@ -398,6 +522,7 @@ const EncryptionConfigScreen = (props: Props) => {
|
||||
{renderMasterKeySection(props.masterKeys.filter(mk => !masterKeyEnabled(mk)), false)}
|
||||
{renderNonExistingMasterKeysSection()}
|
||||
{renderAdvancedSection()}
|
||||
{renderEnableEncryptionDialog()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -415,6 +540,7 @@ const mapStateToProps = (state: AppState) => {
|
||||
notLoadedMasterKeys: state.notLoadedMasterKeys,
|
||||
masterPassword: state.settings['encryption.masterPassword'],
|
||||
ppk: syncInfo.ppk,
|
||||
masterPasswordDialogOpen: !!state.dialogs.find(dialog => dialog.name === AppStateDialogName.MasterPassword),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { shouldCancelPendingEnableAfterMasterPasswordDialog, shouldOpenMasterPasswordDialogForEnable, shouldResumeEnableAfterMasterPasswordDialog } from './enableFlow';
|
||||
|
||||
describe('enableFlow', () => {
|
||||
test('opens the master password dialog when enabling encryption without a stored master password', () => {
|
||||
expect(shouldOpenMasterPasswordDialogForEnable({
|
||||
hasMasterPassword: false,
|
||||
masterPasswordDialogOpen: false,
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
test('does not reopen the master password dialog if it is already open', () => {
|
||||
expect(shouldOpenMasterPasswordDialogForEnable({
|
||||
hasMasterPassword: false,
|
||||
masterPasswordDialogOpen: true,
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
test('does not open the master password dialog when a master password already exists', () => {
|
||||
expect(shouldOpenMasterPasswordDialogForEnable({
|
||||
hasMasterPassword: true,
|
||||
masterPasswordDialogOpen: false,
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
test('resumes enabling encryption after the dialog closes with a saved password', () => {
|
||||
expect(shouldResumeEnableAfterMasterPasswordDialog({
|
||||
pendingEnableEncryption: true,
|
||||
wasMasterPasswordDialogOpen: true,
|
||||
masterPasswordDialogOpen: false,
|
||||
masterPassword: 'new-password',
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
test('cancels the pending enable flow if the dialog closes without a password', () => {
|
||||
expect(shouldCancelPendingEnableAfterMasterPasswordDialog({
|
||||
pendingEnableEncryption: true,
|
||||
wasMasterPasswordDialogOpen: true,
|
||||
masterPasswordDialogOpen: false,
|
||||
masterPassword: '',
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
test('does not resume while the dialog is still open', () => {
|
||||
expect(shouldResumeEnableAfterMasterPasswordDialog({
|
||||
pendingEnableEncryption: true,
|
||||
wasMasterPasswordDialogOpen: true,
|
||||
masterPasswordDialogOpen: true,
|
||||
masterPassword: 'new-password',
|
||||
})).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
interface OpenDialogInput {
|
||||
hasMasterPassword: boolean;
|
||||
masterPasswordDialogOpen: boolean;
|
||||
}
|
||||
|
||||
interface ResumeEnableInput {
|
||||
pendingEnableEncryption: boolean;
|
||||
wasMasterPasswordDialogOpen: boolean;
|
||||
masterPasswordDialogOpen: boolean;
|
||||
masterPassword: string;
|
||||
}
|
||||
|
||||
export const shouldOpenMasterPasswordDialogForEnable = ({ hasMasterPassword, masterPasswordDialogOpen }: OpenDialogInput) => {
|
||||
return !hasMasterPassword && !masterPasswordDialogOpen;
|
||||
};
|
||||
|
||||
export const shouldResumeEnableAfterMasterPasswordDialog = ({ pendingEnableEncryption, wasMasterPasswordDialogOpen, masterPasswordDialogOpen, masterPassword }: ResumeEnableInput) => {
|
||||
return pendingEnableEncryption && wasMasterPasswordDialogOpen && !masterPasswordDialogOpen && !!masterPassword;
|
||||
};
|
||||
|
||||
export const shouldCancelPendingEnableAfterMasterPasswordDialog = ({ pendingEnableEncryption, wasMasterPasswordDialogOpen, masterPasswordDialogOpen, masterPassword }: ResumeEnableInput) => {
|
||||
return pendingEnableEncryption && wasMasterPasswordDialogOpen && !masterPasswordDialogOpen && !masterPassword;
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -41,7 +41,7 @@ export default function(props: Props) {
|
||||
if (mode === Mode.Reset) return false;
|
||||
return true;
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [status]);
|
||||
}, [status, mode]);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
props.dispatch({
|
||||
@@ -90,10 +90,12 @@ export default function(props: Props) {
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [currentPassword, password1, onClose, mode]);
|
||||
|
||||
// Show the "Re-enter password" confirmation field
|
||||
const needToRepeatPassword = useMemo(() => {
|
||||
if (mode === Mode.Reset) return true;
|
||||
if (showCurrentPassword) return true;
|
||||
return !hasMasterPasswordEncryptedData;
|
||||
}, [hasMasterPasswordEncryptedData, mode]);
|
||||
}, [mode, showCurrentPassword, hasMasterPasswordEncryptedData]);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const onCurrentPasswordChange = useCallback((event: any) => {
|
||||
@@ -138,6 +140,7 @@ export default function(props: Props) {
|
||||
}, [currentPassword]);
|
||||
|
||||
function renderPasswordForm() {
|
||||
const passwordsMatch = password1 === password2;
|
||||
const renderCurrentPassword = () => {
|
||||
if (!showCurrentPassword) return null;
|
||||
|
||||
@@ -161,11 +164,11 @@ export default function(props: Props) {
|
||||
const renderResetMasterPasswordLink = () => {
|
||||
if (mode === Mode.Reset) return null;
|
||||
if (status === MasterPasswordStatus.Valid) return null;
|
||||
return <p><a href="#" onClick={onToggleMode}>Reset master password</a></p>;
|
||||
return <p><a href="#" onClick={onToggleMode}>{_('Reset master password')}</a></p>;
|
||||
};
|
||||
|
||||
if (showPasswordForm) {
|
||||
const enterPasswordLabel = [MasterPasswordStatus.Loaded, MasterPasswordStatus.Valid].includes(status) ? 'Enter new password' : 'Enter password';
|
||||
const enterPasswordLabel = [MasterPasswordStatus.Loaded, MasterPasswordStatus.Valid].includes(status) ? _('Enter new password') : _('Enter password');
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -176,22 +179,32 @@ export default function(props: Props) {
|
||||
value={password1}
|
||||
onChange={onPasswordChange1}
|
||||
/>
|
||||
|
||||
{needToRepeatPassword && (
|
||||
<LabelledPasswordInput
|
||||
labelText={_('Re-enter password')}
|
||||
value={password2}
|
||||
onChange={onPasswordChange2}
|
||||
/>
|
||||
<>
|
||||
<LabelledPasswordInput
|
||||
labelText={_('Re-enter password')}
|
||||
value={password2}
|
||||
onChange={onPasswordChange2}
|
||||
valid={password2 ? passwordsMatch : undefined}
|
||||
/>
|
||||
|
||||
{password2 && !passwordsMatch && (
|
||||
<p className="error-message">
|
||||
{_('Passwords do not match')}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p className="bold">Please make sure you remember your password. For security reasons, it is not possible to recover it if it is lost.</p>
|
||||
<p className="bold">{_('Please make sure you remember your password. It cannot be recovered if lost, and any data encrypted with it will become inaccessible.')}</p>
|
||||
{renderResetMasterPasswordLink()}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<p>
|
||||
<a onClick={onShowPasswordForm} href="#">Change master password</a>
|
||||
<a onClick={onShowPasswordForm} href="#">{_('Change master password')}</a>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -201,16 +214,16 @@ export default function(props: Props) {
|
||||
if (mode === Mode.Reset) {
|
||||
return (
|
||||
<div className="dialog-content">
|
||||
<p>Attention: After resetting your password it will no longer be possible to decrypt any data encrypted with your current password. All encrypted shared notebooks will also be unshared, so please ask the notebook owner to share it again with you.</p>
|
||||
<p>{_('Attention: After resetting your password it will no longer be possible to decrypt any data encrypted with your current password. All encrypted shared notebooks will also be unshared, so please ask the notebook owner to share it again with you.')}</p>
|
||||
{renderPasswordForm()}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className="dialog-content">
|
||||
<p>Your master password is used to protect sensitive information. In particular, it is used to encrypt your notes when end-to-end encryption (E2EE) is enabled, or to share and encrypt notes with someone who has E2EE enabled.</p>
|
||||
<p>{_('Your master password is used to protect sensitive information. In particular, it is used to encrypt your notes when end-to-end encryption (E2EE) is enabled, or to share and encrypt notes with someone who has E2EE enabled.')}</p>
|
||||
<p>
|
||||
<span>{'Master password status:'}</span> <span className="bold">{getMasterPasswordStatusMessage(status)}</span>
|
||||
<span>{_('Master password status:')}</span> <span className="bold">{getMasterPasswordStatusMessage(status)}</span>
|
||||
</p>
|
||||
{renderPasswordForm()}
|
||||
</div>
|
||||
|
||||
@@ -709,6 +709,7 @@ function useMenu(props: Props) {
|
||||
menuItemDic.textCut,
|
||||
menuItemDic.textPaste,
|
||||
menuItemDic.pasteAsText,
|
||||
menuItemDic.pasteAsMarkdown,
|
||||
menuItemDic.textSelectAll,
|
||||
separator(),
|
||||
menuItemDic.globalUndo,
|
||||
@@ -822,6 +823,12 @@ function useMenu(props: Props) {
|
||||
Setting.incValue('windowContentZoomFactor', -10);
|
||||
},
|
||||
accelerator: 'CommandOrControl+-',
|
||||
}, {
|
||||
type: 'separator',
|
||||
visible: shim.isMac(),
|
||||
}, {
|
||||
role: 'togglefullscreen',
|
||||
visible: shim.isMac(),
|
||||
}],
|
||||
},
|
||||
go: {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -6,12 +6,18 @@ describe('useContextMenu', () => {
|
||||
|
||||
it('should return type=image when cursor is inside markdown image', () => {
|
||||
const line = ``;
|
||||
expect(getResourceIdFromMarkup(line, 15)).toEqual({ resourceId, type: 'image' });
|
||||
const result = getResourceIdFromMarkup(line, 15);
|
||||
expect(result.resourceId).toBe(resourceId);
|
||||
expect(result.type).toBe('image');
|
||||
expect(line.substring(result.markupStart, result.markupEnd)).toBe(line);
|
||||
});
|
||||
|
||||
it('should return type=file when cursor is inside markdown link', () => {
|
||||
const line = `[document.pdf](:/${resourceId})`;
|
||||
expect(getResourceIdFromMarkup(line, 15)).toEqual({ resourceId, type: 'file' });
|
||||
const result = getResourceIdFromMarkup(line, 15);
|
||||
expect(result.resourceId).toBe(resourceId);
|
||||
expect(result.type).toBe('file');
|
||||
expect(line.substring(result.markupStart, result.markupEnd)).toBe(line);
|
||||
});
|
||||
|
||||
it('should return null when cursor is outside markup', () => {
|
||||
@@ -22,8 +28,13 @@ describe('useContextMenu', () => {
|
||||
|
||||
it('should correctly distinguish between image and file on same line', () => {
|
||||
const line = ` [file](:/${resourceId2})`;
|
||||
expect(getResourceIdFromMarkup(line, 10)).toEqual({ resourceId, type: 'image' });
|
||||
expect(getResourceIdFromMarkup(line, 48)).toEqual({ resourceId: resourceId2, type: 'file' });
|
||||
const imageResult = getResourceIdFromMarkup(line, 10);
|
||||
expect(imageResult.resourceId).toBe(resourceId);
|
||||
expect(imageResult.type).toBe('image');
|
||||
|
||||
const fileResult = getResourceIdFromMarkup(line, 48);
|
||||
expect(fileResult.resourceId).toBe(resourceId2);
|
||||
expect(fileResult.type).toBe('file');
|
||||
});
|
||||
|
||||
it('should return null for empty line', () => {
|
||||
|
||||
@@ -11,7 +11,7 @@ import type CodeMirrorControl from '@joplin/editor/CodeMirror/CodeMirrorControl'
|
||||
import bridge from '../../../../../services/bridge';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import Resource from '@joplin/lib/models/Resource';
|
||||
import { ContextMenuItemType, ContextMenuOptions, buildMenuItems, handleEditorContextMenuFilter } from '../../../utils/contextMenuUtils';
|
||||
import { ContextMenuItemType, ContextMenuOptions, buildMenuItems, handleEditorContextMenuFilter, resolveContextMenuItemType } from '../../../utils/contextMenuUtils';
|
||||
import { menuItems } from '../../../utils/contextMenu';
|
||||
import isItemId from '@joplin/lib/models/utils/isItemId';
|
||||
import { extractResourceUrls } from '@joplin/lib/urlUtils';
|
||||
@@ -22,6 +22,8 @@ export type ResourceMarkupType = 'image' | 'file';
|
||||
export interface ResourceMarkupInfo {
|
||||
resourceId: string;
|
||||
type: ResourceMarkupType;
|
||||
markupStart: number;
|
||||
markupEnd: number;
|
||||
}
|
||||
|
||||
// Extract resource ID from resource markup (images or file attachments) at a given cursor position within a line.
|
||||
@@ -74,7 +76,7 @@ export const getResourceIdFromMarkup = (lineContent: string, cursorPosInLine: nu
|
||||
}
|
||||
|
||||
if (markupEnd !== -1 && cursorPosInLine >= markupStart && cursorPosInLine <= markupEnd) {
|
||||
return { resourceId: resourceInfo.itemId, type: markupType };
|
||||
return { resourceId: resourceInfo.itemId, type: markupType, markupStart, markupEnd };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,6 +115,9 @@ const useContextMenu = (props: ContextMenuProps) => {
|
||||
// It might be buggy, refer to the below issue
|
||||
// https://github.com/laurent22/joplin/pull/3974#issuecomment-718936703
|
||||
useEffect(() => {
|
||||
const targetWindow = bridge().windowById(windowId);
|
||||
if (!targetWindow) return ()=> {};
|
||||
|
||||
const isAncestorOfCodeMirrorEditor = (elem: Element) => {
|
||||
for (; elem.parentElement; elem = elem.parentElement) {
|
||||
if (elem.classList.contains(props.editorClassName)) {
|
||||
@@ -161,30 +166,24 @@ const useContextMenu = (props: ContextMenuProps) => {
|
||||
return clickedElement?.closest(`.${imageClassName}`) as HTMLElement | null;
|
||||
};
|
||||
|
||||
// Get resource info from markup at click position (not cursor position)
|
||||
const getResourceInfoAtClickPos = (params: ContextMenuParams): ResourceMarkupInfo | null => {
|
||||
if (!editorRef.current) return null;
|
||||
|
||||
const editor = editorRef.current.editor;
|
||||
if (!editor) return null;
|
||||
|
||||
const zoom = Setting.value('windowContentZoomFactor');
|
||||
const x = convertFromScreenCoordinates(zoom, params.x);
|
||||
const y = convertFromScreenCoordinates(zoom, params.y);
|
||||
|
||||
const clickPos = editor.posAtCoords({ x, y });
|
||||
if (clickPos === null) return null;
|
||||
|
||||
const line = editor.state.doc.lineAt(clickPos);
|
||||
return getResourceIdFromMarkup(line.text, clickPos - line.from);
|
||||
const appendEditMenuItems = (menu: typeof Menu.prototype) => {
|
||||
const hasSelectedText = editorRef.current && !!editorRef.current.getSelection();
|
||||
const isReadOnly = editorRef.current?.editor?.state.readOnly ?? false;
|
||||
menu.append(new MenuItem({ label: _('Cut'), enabled: hasSelectedText && !isReadOnly, click: () => props.editorCutText() }));
|
||||
menu.append(new MenuItem({ label: _('Copy'), enabled: hasSelectedText, click: () => props.editorCopyText() }));
|
||||
menu.append(new MenuItem({ label: _('Paste'), enabled: !isReadOnly, click: () => props.editorPaste() }));
|
||||
menu.append(new MenuItem({ label: _('Paste as Markdown'), enabled: !isReadOnly, click: () => CommandService.instance().execute('pasteAsMarkdown') }));
|
||||
};
|
||||
|
||||
const targetWindow = bridge().windowById(windowId);
|
||||
|
||||
const showResourceContextMenu = async (resourceId: string, type: ResourceMarkupType) => {
|
||||
const menu = new Menu();
|
||||
|
||||
// Add resource-specific options first
|
||||
const baseType = type === 'image' ? ContextMenuItemType.Image : ContextMenuItemType.Resource;
|
||||
const itemType = await resolveContextMenuItemType(baseType, resourceId);
|
||||
const isReadOnly = editorRef.current?.editor?.state.readOnly ?? false;
|
||||
const contextMenuOptions: ContextMenuOptions = {
|
||||
itemType: type === 'image' ? ContextMenuItemType.Image : ContextMenuItemType.Resource,
|
||||
itemType,
|
||||
resourceId,
|
||||
filename: null,
|
||||
mime: null,
|
||||
@@ -192,18 +191,34 @@ const useContextMenu = (props: ContextMenuProps) => {
|
||||
linkToOpen: null,
|
||||
textToCopy: null,
|
||||
htmlToCopy: null,
|
||||
insertContent: () => {},
|
||||
isReadOnly: true,
|
||||
insertContent: () => { editorRef.current?.insertText(''); },
|
||||
isReadOnly,
|
||||
fireEditorEvent: () => {},
|
||||
htmlToMd: null,
|
||||
mdToHtml: null,
|
||||
};
|
||||
|
||||
const resourceMenuItems = await buildMenuItems(menuItems(props.dispatch), contextMenuOptions);
|
||||
const resourceMenuItems = await buildMenuItems(menuItems(props.dispatch), contextMenuOptions, { excludeEditItems: true, excludePluginItems: true });
|
||||
for (const item of resourceMenuItems) {
|
||||
menu.append(item);
|
||||
}
|
||||
|
||||
// Add edit items
|
||||
menu.append(new MenuItem({ type: 'separator' }));
|
||||
appendEditMenuItems(menu);
|
||||
|
||||
// Add plugin items last
|
||||
const extraItems = await handleEditorContextMenuFilter({
|
||||
resourceId,
|
||||
itemType,
|
||||
});
|
||||
if (extraItems.length) {
|
||||
menu.append(new MenuItem({ type: 'separator' }));
|
||||
for (const item of extraItems) {
|
||||
menu.append(item);
|
||||
}
|
||||
}
|
||||
|
||||
menu.popup({ window: targetWindow });
|
||||
};
|
||||
|
||||
@@ -225,7 +240,25 @@ const useContextMenu = (props: ContextMenuProps) => {
|
||||
});
|
||||
};
|
||||
|
||||
interface ResourceContextInfo {
|
||||
resourceId: string;
|
||||
type: ResourceMarkupType;
|
||||
}
|
||||
|
||||
const getResourceInfoAtPos = (docPos: number): ResourceContextInfo | null => {
|
||||
const editor = editorRef.current?.editor;
|
||||
if (!editor) return null;
|
||||
|
||||
const line = editor.state.doc.lineAt(docPos);
|
||||
const info = getResourceIdFromMarkup(line.text, docPos - line.from);
|
||||
if (!info) return null;
|
||||
|
||||
return { resourceId: info.resourceId, type: info.type };
|
||||
};
|
||||
|
||||
const onContextMenu = async (event: Event, params: ContextMenuParams) => {
|
||||
let resourceInfo: ResourceContextInfo | null = null;
|
||||
|
||||
// Check if right-clicking on a rendered image first (images may not be "editable")
|
||||
const imageContainer = getClickedImageContainer(params);
|
||||
if (imageContainer && pointerInsideEditor(params, true)) {
|
||||
@@ -233,19 +266,40 @@ const useContextMenu = (props: ContextMenuProps) => {
|
||||
if (imgElement) {
|
||||
const resourceId = pathToId(imgElement.src);
|
||||
if (resourceId) {
|
||||
event.preventDefault();
|
||||
moveCursorToImageLine(imageContainer);
|
||||
await showResourceContextMenu(resourceId, 'image');
|
||||
return;
|
||||
const sourceFrom = imageContainer.dataset.sourceFrom;
|
||||
if (sourceFrom !== undefined) {
|
||||
const editor = editorRef.current?.editor;
|
||||
if (editor) {
|
||||
const pos = Math.min(Number(sourceFrom), editor.state.doc.length);
|
||||
resourceInfo = getResourceInfoAtPos(pos);
|
||||
}
|
||||
}
|
||||
// Fallback if we couldn't get markup info
|
||||
if (!resourceInfo) {
|
||||
resourceInfo = { resourceId, type: 'image' };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if right-clicking on resource markup text (images or file attachments)
|
||||
const markupResourceInfo = getResourceInfoAtClickPos(params);
|
||||
if (markupResourceInfo && pointerInsideEditor(params)) {
|
||||
if (!resourceInfo && pointerInsideEditor(params)) {
|
||||
const editor = editorRef.current?.editor;
|
||||
if (editor) {
|
||||
const zoom = Setting.value('windowContentZoomFactor');
|
||||
const x = convertFromScreenCoordinates(zoom, params.x);
|
||||
const y = convertFromScreenCoordinates(zoom, params.y);
|
||||
const clickPos = editor.posAtCoords({ x, y });
|
||||
if (clickPos !== null) {
|
||||
resourceInfo = getResourceInfoAtPos(clickPos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (resourceInfo) {
|
||||
event.preventDefault();
|
||||
await showResourceContextMenu(markupResourceInfo.resourceId, markupResourceInfo.type);
|
||||
await showResourceContextMenu(resourceInfo.resourceId, resourceInfo.type);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -256,38 +310,7 @@ const useContextMenu = (props: ContextMenuProps) => {
|
||||
event.preventDefault();
|
||||
|
||||
const menu = new Menu();
|
||||
|
||||
const hasSelectedText = editorRef.current && !!editorRef.current.getSelection() ;
|
||||
|
||||
menu.append(
|
||||
new MenuItem({
|
||||
label: _('Cut'),
|
||||
enabled: hasSelectedText,
|
||||
click: async () => {
|
||||
props.editorCutText();
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
menu.append(
|
||||
new MenuItem({
|
||||
label: _('Copy'),
|
||||
enabled: hasSelectedText,
|
||||
click: async () => {
|
||||
props.editorCopyText();
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
menu.append(
|
||||
new MenuItem({
|
||||
label: _('Paste'),
|
||||
enabled: true,
|
||||
click: async () => {
|
||||
props.editorPaste();
|
||||
},
|
||||
}),
|
||||
);
|
||||
appendEditMenuItems(menu);
|
||||
|
||||
const spellCheckerMenuItems = SpellCheckerService.instance().contextMenuItems(params.misspelledWord, params.dictionarySuggestions);
|
||||
|
||||
|
||||
@@ -221,7 +221,14 @@ const translateLE_ = (codeMirror: any, percent: number, l2e: boolean) => {
|
||||
linInterp = percent * lineCount - lineU;
|
||||
result = ePercentU + (ePercentL - ePercentU) * linInterp;
|
||||
} else {
|
||||
linInterp = Math.max(0, Math.min(1, (percent - ePercentU) / (ePercentL - ePercentU))) || 0;
|
||||
const rawLinInterp = (percent - ePercentU) / (ePercentL - ePercentU);
|
||||
if (ePercentL === ePercentU) {
|
||||
// Prevents the Viewer from jumping to the bottom of
|
||||
// the document when there is division by zero.
|
||||
linInterp = percent;
|
||||
} else {
|
||||
linInterp = Math.max(0, Math.min(1, rawLinInterp)) || 0;
|
||||
}
|
||||
result = (lineU + linInterp) / lineCount;
|
||||
}
|
||||
return Math.max(0, Math.min(1, result));
|
||||
|
||||
@@ -338,7 +338,7 @@ function CodeMirror(props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
|
||||
}, [editorPasteText, onEditorPaste]);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const loadScript = async (script: any) => {
|
||||
const loadScript = async (script: any, document: Document) => {
|
||||
return new Promise((resolve) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
let element: any = document.createElement('script');
|
||||
@@ -367,6 +367,7 @@ function CodeMirror(props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!editorRoot) return () => { };
|
||||
let cancelled = false;
|
||||
|
||||
async function loadScripts() {
|
||||
@@ -393,13 +394,14 @@ function CodeMirror(props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
|
||||
});
|
||||
}
|
||||
|
||||
const ownerDoc = editorRoot.ownerDocument;
|
||||
for (const s of scriptsToLoad) {
|
||||
if (document.getElementById(s.id)) {
|
||||
if (ownerDoc.getElementById(s.id)) {
|
||||
s.loaded = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
await loadScript(s);
|
||||
await loadScript(s, ownerDoc);
|
||||
if (cancelled) return;
|
||||
|
||||
s.loaded = true;
|
||||
@@ -411,7 +413,7 @@ function CodeMirror(props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [styles.editor.codeMirrorTheme]);
|
||||
}, [styles.editor.codeMirrorTheme, editorRoot]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editorRoot) return () => {};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import { useState, useEffect, useRef, forwardRef, useCallback, useImperativeHandle, useMemo, ForwardedRef, useContext } from 'react';
|
||||
import { useState, useEffect, useRef, forwardRef, useCallback, useImperativeHandle, ForwardedRef, useContext } from 'react';
|
||||
|
||||
import { EditorCommand, MarkupToHtmlOptions, NoteBodyEditorProps, NoteBodyEditorRef, OnChangeEvent } from '../../../utils/types';
|
||||
import { getResourcesFromPasteEvent } from '../../../utils/resourceHandling';
|
||||
@@ -12,11 +12,10 @@ import Note from '@joplin/lib/models/Note';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import bridge from '../../../../../services/bridge';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import { MarkupToHtml } from '@joplin/renderer';
|
||||
import { clipboard } from 'electron';
|
||||
import { reg } from '@joplin/lib/registry';
|
||||
import ErrorBoundary from '../../../../ErrorBoundary';
|
||||
import { EditorKeymap, EditorLanguageType, EditorSettings, SearchState, UserEventSource } from '@joplin/editor/types';
|
||||
import { SearchState, UserEventSource } from '@joplin/editor/types';
|
||||
import useStyles from '../utils/useStyles';
|
||||
import { EditorEvent, EditorEventType } from '@joplin/editor/events';
|
||||
import useScrollHandler from '../utils/useScrollHandler';
|
||||
@@ -33,6 +32,7 @@ import { WindowIdContext } from '../../../../NewWindowOrIFrame';
|
||||
import eventManager, { EventName, ResourceChangeEvent } from '@joplin/lib/eventManager';
|
||||
import useSyncEditorValue from './utils/useSyncEditorValue';
|
||||
import { getGlobalSettings } from '@joplin/renderer/types';
|
||||
import useEditorSettings from './utils/useEditorSettings';
|
||||
|
||||
const logger = Logger.create('CodeMirror6');
|
||||
const logDebug = (message: string) => logger.debug(message);
|
||||
@@ -338,46 +338,6 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
|
||||
void CommandService.instance().execute('focusElement', 'noteTitle');
|
||||
}, []);
|
||||
|
||||
const editorSettings = useMemo((): EditorSettings => {
|
||||
const isHTMLNote = props.contentMarkupLanguage === MarkupToHtml.MARKUP_LANGUAGE_HTML;
|
||||
|
||||
let keyboardMode = EditorKeymap.Default;
|
||||
if (props.keyboardMode === 'vim') {
|
||||
keyboardMode = EditorKeymap.Vim;
|
||||
} else if (props.keyboardMode === 'emacs') {
|
||||
keyboardMode = EditorKeymap.Emacs;
|
||||
}
|
||||
|
||||
return {
|
||||
language: isHTMLNote ? EditorLanguageType.Html : EditorLanguageType.Markdown,
|
||||
readOnly: props.disabled,
|
||||
markdownMarkEnabled: Setting.value('markdown.plugin.mark'),
|
||||
katexEnabled: Setting.value('markdown.plugin.katex'),
|
||||
inlineRenderingEnabled: Setting.value('editor.inlineRendering'),
|
||||
imageRenderingEnabled: Setting.value('editor.imageRendering'),
|
||||
highlightActiveLine: Setting.value('editor.highlightActiveLine'),
|
||||
themeData: {
|
||||
...styles.globalTheme,
|
||||
marginLeft: 0,
|
||||
marginRight: 0,
|
||||
monospaceFont: Setting.value('style.editor.monospaceFontFamily'),
|
||||
},
|
||||
automatchBraces: Setting.value('editor.autoMatchingBraces'),
|
||||
autocompleteMarkup: Setting.value('editor.autocompleteMarkup'),
|
||||
useExternalSearch: false,
|
||||
ignoreModifiers: true,
|
||||
spellcheckEnabled: Setting.value('editor.spellcheckBeta'),
|
||||
keymap: keyboardMode,
|
||||
preferMacShortcuts: shim.isMac(),
|
||||
indentWithTabs: true,
|
||||
tabMovesFocus: props.tabMovesFocus,
|
||||
editorLabel: _('Markdown editor'),
|
||||
};
|
||||
}, [
|
||||
props.contentMarkupLanguage, props.disabled, props.keyboardMode, styles.globalTheme,
|
||||
props.tabMovesFocus,
|
||||
]);
|
||||
|
||||
const initialCursorLocationRef = useRef(0);
|
||||
initialCursorLocationRef.current = props.initialCursorLocation.markdown ?? 0;
|
||||
|
||||
@@ -390,6 +350,14 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
|
||||
initialCursorLocationRef,
|
||||
});
|
||||
|
||||
const settings = useEditorSettings({
|
||||
baseTheme: styles.globalTheme,
|
||||
contentMarkupLanguage: props.contentMarkupLanguage,
|
||||
disabled: props.disabled,
|
||||
keyboardMode: props.keyboardMode,
|
||||
tabMovesFocus: props.tabMovesFocus,
|
||||
});
|
||||
|
||||
const renderEditor = () => {
|
||||
return (
|
||||
<div className='editor'>
|
||||
@@ -399,7 +367,7 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
|
||||
initialSelectionRef={initialCursorLocationRef}
|
||||
initialNoteId={props.noteId}
|
||||
ref={editorRef}
|
||||
settings={editorSettings}
|
||||
settings={settings}
|
||||
pluginStates={props.plugins}
|
||||
onPasteFile={null}
|
||||
onEvent={onEditorEvent}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { EditorKeymap, EditorLanguageType, EditorSettings, EditorTheme } from '@joplin/editor/types';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import { MarkupLanguage, MarkupToHtml } from '@joplin/renderer';
|
||||
import { useMemo } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { AppState } from '../../../../../../app.reducer';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
|
||||
interface EditorSettingsProps {
|
||||
contentMarkupLanguage: MarkupLanguage;
|
||||
keyboardMode: string;
|
||||
disabled: boolean;
|
||||
tabMovesFocus: boolean;
|
||||
baseTheme: EditorTheme;
|
||||
}
|
||||
|
||||
const useEditorSettings = (props: EditorSettingsProps) => {
|
||||
const stateToSettings = (state: AppState) => ({
|
||||
markdownMark: state.settings['markdown.plugin.mark'],
|
||||
markdownInsert: state.settings['markdown.plugin.insert'],
|
||||
katex: state.settings['markdown.plugin.katex'],
|
||||
inlineRendering: state.settings['editor.inlineRendering'],
|
||||
imageRendering: state.settings['editor.imageRendering'],
|
||||
highlightActiveLine: state.settings['editor.highlightActiveLine'],
|
||||
monospaceFont: state.settings['style.editor.monospaceFontFamily'],
|
||||
automatchBraces: state.settings['editor.autoMatchingBraces'],
|
||||
autocompleteMarkup: state.settings['editor.autocompleteMarkup'],
|
||||
spellcheckEnabled: state.settings['editor.spellcheckBeta'],
|
||||
});
|
||||
type SelectedSettings = ReturnType<typeof stateToSettings>;
|
||||
const settings = useSelector<AppState, SelectedSettings>(stateToSettings, isDeepStrictEqual);
|
||||
|
||||
return useMemo((): EditorSettings => {
|
||||
const isHTMLNote = props.contentMarkupLanguage === MarkupToHtml.MARKUP_LANGUAGE_HTML;
|
||||
|
||||
let keyboardMode = EditorKeymap.Default;
|
||||
if (props.keyboardMode === 'vim') {
|
||||
keyboardMode = EditorKeymap.Vim;
|
||||
} else if (props.keyboardMode === 'emacs') {
|
||||
keyboardMode = EditorKeymap.Emacs;
|
||||
}
|
||||
|
||||
return {
|
||||
language: isHTMLNote ? EditorLanguageType.Html : EditorLanguageType.Markdown,
|
||||
readOnly: props.disabled,
|
||||
markdownMarkEnabled: settings.markdownMark,
|
||||
markdownInsertEnabled: settings.markdownInsert,
|
||||
katexEnabled: settings.katex,
|
||||
inlineRenderingEnabled: settings.inlineRendering,
|
||||
imageRenderingEnabled: settings.imageRendering,
|
||||
highlightActiveLine: settings.highlightActiveLine,
|
||||
themeData: {
|
||||
...props.baseTheme,
|
||||
marginLeft: 0,
|
||||
marginRight: 0,
|
||||
monospaceFont: settings.monospaceFont,
|
||||
},
|
||||
automatchBraces: settings.automatchBraces,
|
||||
autocompleteMarkup: settings.autocompleteMarkup,
|
||||
useExternalSearch: false,
|
||||
ignoreModifiers: true,
|
||||
spellcheckEnabled: settings.spellcheckEnabled,
|
||||
keymap: keyboardMode,
|
||||
preferMacShortcuts: shim.isMac(),
|
||||
indentWithTabs: true,
|
||||
tabMovesFocus: props.tabMovesFocus,
|
||||
editorLabel: _('Markdown editor'),
|
||||
};
|
||||
}, [
|
||||
props.contentMarkupLanguage, props.disabled, props.keyboardMode, props.baseTheme,
|
||||
props.tabMovesFocus, settings,
|
||||
]);
|
||||
};
|
||||
|
||||
export default useEditorSettings;
|
||||
@@ -705,6 +705,15 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const containerWindow = editorContainerDom.defaultView as any;
|
||||
const isDefaultEnglishLocale = ['en_US', 'en_GB'].includes(language);
|
||||
|
||||
if (!isDefaultEnglishLocale) {
|
||||
await loadScript({
|
||||
id: `tinyMceLang_${language}`,
|
||||
src: `${bridge().vendorDir()}/lib/tinymce/langs/${language}.js`,
|
||||
}, editorContainerDom);
|
||||
}
|
||||
|
||||
const editors = await containerWindow.tinymce.init({
|
||||
selector: `#${editorContainer.id}`,
|
||||
|
||||
@@ -735,7 +744,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
|
||||
// Handle the first table row as table header.
|
||||
// https://www.tiny.cloud/docs/plugins/table/#table_header_type
|
||||
table_header_type: 'sectionCells',
|
||||
language_url: ['en_US', 'en_GB'].includes(language) ? undefined : `${bridge().vendorDir()}/lib/tinymce/langs/${language}`,
|
||||
language: isDefaultEnglishLocale ? undefined : language,
|
||||
toolbar: toolbar.join(' '),
|
||||
localization_function: _,
|
||||
// See https://www.tiny.cloud/docs/tinymce/latest/tinymce-and-csp/#content_security_policy
|
||||
@@ -887,6 +896,30 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
|
||||
editor.addShortcut('Meta+Shift+8', '', () => editor.execCommand('InsertUnorderedList'));
|
||||
editor.addShortcut('Meta+Shift+9', '', () => editor.execCommand('InsertJoplinChecklist'));
|
||||
|
||||
// Override ScrollIntoView to scroll to the cursor's character position
|
||||
// instead of the start of the paragraph.
|
||||
// See: https://github.com/laurent22/joplin/issues/14143
|
||||
editor.on('ScrollIntoView', (event) => {
|
||||
const sel = editor.getDoc().getSelection();
|
||||
if (!sel || sel.rangeCount === 0) return;
|
||||
|
||||
const rect = sel.getRangeAt(0).getBoundingClientRect();
|
||||
const win = editor.getWin();
|
||||
const viewHeight = win.innerHeight;
|
||||
|
||||
if (rect.top < 0) {
|
||||
win.scrollBy(0, rect.top);
|
||||
} else if (rect.bottom > viewHeight) {
|
||||
win.scrollBy(0, rect.bottom - viewHeight);
|
||||
} else if (rect.top === 0 && rect.height === 0) {
|
||||
// Handles edge case where rect is not rendered
|
||||
// See: https://stackoverflow.com/a/14384220/5757550
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
return;
|
||||
});
|
||||
|
||||
// TODO: remove event on unmount?
|
||||
editor.on('drop', (event) => {
|
||||
// Prevent the message "Dropped file type is not supported" from showing up.
|
||||
@@ -1326,13 +1359,35 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const onSetAttrib = (event: EditorEvent<any>) => {
|
||||
// Dispatch onChange when a link is edited
|
||||
// Dispatch onChange when a link or table-related formatting is edited
|
||||
const target = Array.isArray(event.attrElm) ? event.attrElm[0] : event.attrElm;
|
||||
if (!target) return;
|
||||
|
||||
if (target.nodeName === 'A') {
|
||||
if (event.attrName === 'title' || event.attrName === 'href' || event.attrName === 'rel') {
|
||||
onChangeHandler();
|
||||
}
|
||||
}
|
||||
|
||||
if (['TABLE', 'TR', 'TD', 'TH'].includes(target.nodeName)) {
|
||||
const attributeName = (event.attrName ?? '').toLowerCase();
|
||||
if (
|
||||
attributeName === 'style' ||
|
||||
attributeName === 'class' ||
|
||||
attributeName === 'bgcolor' ||
|
||||
attributeName === 'bordercolor' ||
|
||||
attributeName === 'background' ||
|
||||
attributeName === 'cellpadding' ||
|
||||
attributeName === 'cellspacing'
|
||||
) {
|
||||
onChangeHandler();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Table plugin fires this on structure/style changes from dialogs.
|
||||
const onTableModified = () => {
|
||||
onChangeHandler();
|
||||
};
|
||||
|
||||
// Keypress means that a printable key (letter, digit, etc.) has been
|
||||
@@ -1438,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,
|
||||
@@ -1453,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() {
|
||||
@@ -1481,6 +1560,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
|
||||
editor.on(TinyMceEditorEvents.Redo, onChangeHandler);
|
||||
editor.on(TinyMceEditorEvents.ExecCommand, onExecCommand);
|
||||
editor.on(TinyMceEditorEvents.SetAttrib, onSetAttrib);
|
||||
editor.on('TableModified', onTableModified);
|
||||
|
||||
return () => {
|
||||
try {
|
||||
@@ -1497,6 +1577,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
|
||||
editor.off(TinyMceEditorEvents.Redo, onChangeHandler);
|
||||
editor.off(TinyMceEditorEvents.ExecCommand, onExecCommand);
|
||||
editor.off(TinyMceEditorEvents.SetAttrib, onSetAttrib);
|
||||
editor.off('TableModified', onTableModified);
|
||||
} catch (error) {
|
||||
console.warn('Error removing events', error);
|
||||
}
|
||||
|
||||
@@ -35,8 +35,10 @@ export default function(editor: Editor, plugins: PluginStates, dispatch: Dispatc
|
||||
useEffect(() => {
|
||||
if (!editor) return () => {};
|
||||
|
||||
const contextMenuItems = menuItems(dispatch);
|
||||
const targetWindow = bridge().windowById(windowId);
|
||||
if (!targetWindow) return () => {};
|
||||
|
||||
const contextMenuItems = menuItems(dispatch);
|
||||
|
||||
const makeMainMenuItems = async (element: Element) => {
|
||||
let itemType: ContextMenuItemType = ContextMenuItemType.None;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as focusElementNoteBody from './focusElementNoteBody';
|
||||
import * as focusElementNoteTitle from './focusElementNoteTitle';
|
||||
import * as focusElementNoteViewer from './focusElementNoteViewer';
|
||||
import * as focusElementToolbar from './focusElementToolbar';
|
||||
import * as pasteAsMarkdown from './pasteAsMarkdown';
|
||||
import * as pasteAsText from './pasteAsText';
|
||||
import * as showLocalSearch from './showLocalSearch';
|
||||
import * as showRevisions from './showRevisions';
|
||||
@@ -12,6 +13,7 @@ const index: any[] = [
|
||||
focusElementNoteTitle,
|
||||
focusElementNoteViewer,
|
||||
focusElementToolbar,
|
||||
pasteAsMarkdown,
|
||||
pasteAsText,
|
||||
showLocalSearch,
|
||||
showRevisions,
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { CommandRuntime, CommandDeclaration } from '@joplin/lib/services/CommandService';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import HtmlToMd from '@joplin/lib/HtmlToMd';
|
||||
import { processImagesInPastedHtml } from '../utils/resourceHandling';
|
||||
|
||||
const { clipboard } = require('electron');
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'pasteAsMarkdown',
|
||||
label: () => _('Paste as Markdown'),
|
||||
};
|
||||
|
||||
let htmlToMd_: HtmlToMd | null = null;
|
||||
|
||||
const htmlToMd = () => {
|
||||
if (!htmlToMd_) {
|
||||
htmlToMd_ = new HtmlToMd();
|
||||
}
|
||||
return htmlToMd_;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Props passed from NoteEditor component
|
||||
export const runtime = (comp: any): CommandRuntime => {
|
||||
return {
|
||||
execute: async () => {
|
||||
let html = clipboard.readHTML();
|
||||
if (html) {
|
||||
// Download images and convert them to Joplin resources
|
||||
html = await processImagesInPastedHtml(html, { useInternalUrls: true });
|
||||
const markdown = htmlToMd().parse(html, { tightLists: true, collapseMultipleBlankLines: true });
|
||||
comp.editorRef.current.execCommand({ name: 'insertText', value: markdown });
|
||||
} else {
|
||||
// Fall back to plain text if no HTML is available
|
||||
const text = clipboard.readText();
|
||||
if (text) {
|
||||
comp.editorRef.current.execCommand({ name: 'insertText', value: text });
|
||||
}
|
||||
}
|
||||
},
|
||||
enabledCondition: 'oneNoteSelected && markdownEditorVisible',
|
||||
};
|
||||
};
|
||||
@@ -6,10 +6,12 @@ const baseContext: Record<string, any> = {
|
||||
modalDialogVisible: false,
|
||||
gotoAnythingVisible: false,
|
||||
markdownEditorPaneVisible: true,
|
||||
markdownViewerPaneVisible: false,
|
||||
oneNoteSelected: true,
|
||||
noteIsMarkdown: true,
|
||||
noteIsReadOnly: false,
|
||||
richTextEditorVisible: false,
|
||||
hasActivePluginEditor: false,
|
||||
};
|
||||
|
||||
describe('editorCommandDeclarations', () => {
|
||||
@@ -98,9 +100,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) => {
|
||||
|
||||
@@ -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 || hasActivePluginEditor)'
|
||||
: allowInViewerAndReadOnlyMode
|
||||
? '(markdownEditorPaneVisible || richTextEditorVisible || markdownViewerPaneVisible || hasActivePluginEditor)'
|
||||
: '(markdownEditorPaneVisible || richTextEditorVisible || hasActivePluginEditor)';
|
||||
|
||||
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(' && ');
|
||||
|
||||
@@ -103,10 +103,17 @@ export function menuItems(dispatch: Function): ContextMenuItems {
|
||||
}
|
||||
},
|
||||
isActive: (itemType: ContextMenuItemType, options: ContextMenuOptions) => (
|
||||
(!options.textToCopy && (itemType === ContextMenuItemType.Image || itemType === ContextMenuItemType.Resource))
|
||||
(!options.textToCopy && (itemType === ContextMenuItemType.Image || itemType === ContextMenuItemType.Resource || itemType === ContextMenuItemType.NoteLink))
|
||||
|| (!!options.linkToOpen && itemType === ContextMenuItemType.Link)
|
||||
),
|
||||
},
|
||||
openNoteInNewWindow: {
|
||||
label: _('Open in new window'),
|
||||
onAction: async (options: ContextMenuOptions) => {
|
||||
await CommandService.instance().execute('openNoteInNewWindow', options.resourceId);
|
||||
},
|
||||
isActive: (itemType: ContextMenuItemType) => itemType === ContextMenuItemType.NoteLink,
|
||||
},
|
||||
saveAs: {
|
||||
label: _('Save as...'),
|
||||
onAction: async (options: ContextMenuOptions) => {
|
||||
|
||||
@@ -6,6 +6,8 @@ import { ContextMenuItemType, EditContextMenuFilterObject } from '@joplin/lib/se
|
||||
import eventManager from '@joplin/lib/eventManager';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import { type MenuItem as MenuItemType } from 'electron';
|
||||
import BaseItem from '@joplin/lib/models/BaseItem';
|
||||
import { ModelType } from '@joplin/lib/BaseModel';
|
||||
|
||||
const MenuItem = bridge().MenuItem;
|
||||
const logger = Logger.create('contextMenuUtils');
|
||||
@@ -13,6 +15,19 @@ const logger = Logger.create('contextMenuUtils');
|
||||
// Re-export for backward compatibility
|
||||
export { ContextMenuItemType };
|
||||
|
||||
// Resolves whether a resource-type item is actually a note link.
|
||||
// Falls back to Resource on error or if the item is not found.
|
||||
export const resolveContextMenuItemType = async (itemType: ContextMenuItemType, resourceId: string): Promise<ContextMenuItemType> => {
|
||||
if (itemType !== ContextMenuItemType.Resource || !resourceId) return itemType;
|
||||
try {
|
||||
const item = await BaseItem.loadItemById(resourceId);
|
||||
if (item?.type_ === ModelType.Note) return ContextMenuItemType.NoteLink;
|
||||
} catch (error) {
|
||||
logger.warn('resolveContextMenuItemType: failed to load item, defaulting to Resource', error);
|
||||
}
|
||||
return ContextMenuItemType.Resource;
|
||||
};
|
||||
|
||||
export interface ContextMenuOptions {
|
||||
itemType: ContextMenuItemType;
|
||||
resourceId: string;
|
||||
@@ -182,39 +197,48 @@ export const handleEditorContextMenuFilter = async (context?: EditorContextMenuF
|
||||
return output;
|
||||
};
|
||||
|
||||
export const buildMenuItems = async (items: ContextMenuItems, options: ContextMenuOptions) => {
|
||||
export interface BuildMenuItemsOptions {
|
||||
excludeEditItems?: boolean;
|
||||
excludePluginItems?: boolean;
|
||||
}
|
||||
|
||||
export const buildMenuItems = async (items: ContextMenuItems, options: ContextMenuOptions, buildOptions?: BuildMenuItemsOptions) => {
|
||||
const editItemKeys = ['cut', 'copy', 'paste', 'pasteAsText', 'separator4'];
|
||||
const activeItems: ContextMenuItem[] = [];
|
||||
for (const itemKey in items) {
|
||||
if (buildOptions?.excludeEditItems && editItemKeys.includes(itemKey)) continue;
|
||||
const item = items[itemKey];
|
||||
if (item.isActive(options.itemType, options)) {
|
||||
activeItems.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
const extraItems = await handleEditorContextMenuFilter({
|
||||
resourceId: options.resourceId,
|
||||
itemType: options.itemType,
|
||||
textToCopy: options.textToCopy,
|
||||
});
|
||||
|
||||
if (extraItems.length) {
|
||||
activeItems.push({
|
||||
isActive: () => true,
|
||||
label: '',
|
||||
onAction: () => {},
|
||||
isSeparator: true,
|
||||
if (!buildOptions?.excludePluginItems) {
|
||||
const extraItems = await handleEditorContextMenuFilter({
|
||||
resourceId: options.resourceId,
|
||||
itemType: options.itemType,
|
||||
textToCopy: options.textToCopy,
|
||||
});
|
||||
}
|
||||
|
||||
for (const [, extraItem] of extraItems.entries()) {
|
||||
activeItems.push({
|
||||
isActive: () => true,
|
||||
label: extraItem.label,
|
||||
onAction: () => {
|
||||
extraItem.click();
|
||||
},
|
||||
isSeparator: extraItem.type === 'separator',
|
||||
});
|
||||
if (extraItems.length) {
|
||||
activeItems.push({
|
||||
isActive: () => true,
|
||||
label: '',
|
||||
onAction: () => {},
|
||||
isSeparator: true,
|
||||
});
|
||||
}
|
||||
|
||||
for (const [, extraItem] of extraItems.entries()) {
|
||||
activeItems.push({
|
||||
isActive: () => true,
|
||||
label: extraItem.label,
|
||||
onAction: () => {
|
||||
extraItem.click();
|
||||
},
|
||||
isSeparator: extraItem.type === 'separator',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const filteredItems = filterSeparators(activeItems, item => item.isSeparator);
|
||||
|
||||
@@ -13,6 +13,7 @@ export async function htmlToMarkdown(markupLanguage: number, html: string, origi
|
||||
newBody = htmlToMd.parse(html, {
|
||||
preserveImageTagsWithSize: true,
|
||||
preserveNestedTables: true,
|
||||
preserveTableStyles: true,
|
||||
preserveColorStyles: true,
|
||||
...parseOptions,
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { processPastedHtml } from './resourceHandling';
|
||||
import { processImagesInPastedHtml, processPastedHtml } 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';
|
||||
|
||||
const createTestMarkupConverters = () => {
|
||||
const markupToHtml: MarkupToHtmlHandler = async (markupLanguage, markup, options) => {
|
||||
@@ -63,4 +64,69 @@ describe('resourceHandling', () => {
|
||||
const html = `<img src="file://${encodeURI(Setting.value('resourceDir'))}/resource.png" alt="test"/>`;
|
||||
expect(await processPastedHtml(html, htmlToMd, markupToHtml)).toBe(html);
|
||||
});
|
||||
|
||||
it('should normalize HTML-encoded newlines in image alt attributes', async () => {
|
||||
// Word encodes newlines in alt text as HTML entities. These must be
|
||||
// normalized to spaces before Turndown processes the HTML, otherwise
|
||||
// node.outerHTML (returned verbatim for images with width/height) embeds
|
||||
// literal newlines that break Markdown raw HTML block parsing.
|
||||
const resourceSrc = `file://${encodeURI(Setting.value('resourceDir'))}/resource.png`;
|
||||
const testCases: [string, string][] = [
|
||||
// HTML entity newlines (Word clipboard format: = LF)
|
||||
[
|
||||
`<img src="${resourceSrc}" alt="A screenshot AI-generated content."/>`,
|
||||
`<img src="${resourceSrc}" alt="A screenshot AI-generated content."/>`,
|
||||
],
|
||||
// Literal newlines in the raw HTML attribute value
|
||||
[
|
||||
`<img src="${resourceSrc}" alt="hello\nworld"/>`,
|
||||
`<img src="${resourceSrc}" alt="hello world"/>`,
|
||||
],
|
||||
];
|
||||
|
||||
for (const [html, expected] of testCases) {
|
||||
expect(await processPastedHtml(html, null, null)).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it('should render Word-pasted images with newlines in alt as img elements, not broken text', async () => {
|
||||
// When Word pastes an image with width/height attributes and in the alt,
|
||||
// Turndown returns node.outerHTML verbatim (preserveImageTagsWithSize=true).
|
||||
// Without normalization, literal newlines inside the Markdown raw HTML block
|
||||
// would terminate the block early, causing the <img> to render as plain text.
|
||||
const { markupToHtml, htmlToMd } = createTestMarkupConverters();
|
||||
const resourceSrc = `file://${encodeURI(Setting.value('resourceDir'))}/resource.png`;
|
||||
|
||||
const testCases = [
|
||||
// Word-style: width/height present, alt has entities
|
||||
`<img width="625" height="284" src="${resourceSrc}" alt="A screenshot AI-generated content."/>`,
|
||||
// Multiple consecutive newline entities collapsed to single space
|
||||
`<img width="100" height="100" src="${resourceSrc}" alt="line1 line2"/>`,
|
||||
];
|
||||
|
||||
for (const html of testCases) {
|
||||
const result = await processPastedHtml(html, htmlToMd, markupToHtml);
|
||||
// The image must be rendered as an <img> element, not as escaped/broken text
|
||||
expect(result).toContain('<img');
|
||||
// The alt text after normalization must not contain literal newlines
|
||||
expect(result).not.toMatch(/alt="[^"]*\n/);
|
||||
}
|
||||
});
|
||||
|
||||
// Regression test: base64 branch was hardcoding file:// and ignoring useInternalUrls
|
||||
// 1x1 transparent PNG — smallest valid base64-encoded image for testing
|
||||
const minimalPng = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
|
||||
|
||||
test.each([
|
||||
{ useInternalUrls: true, expectMatch: /src=":\/[a-f0-9]+"/, expectAbsent: 'file://' },
|
||||
{ useInternalUrls: false, expectMatch: /src="file:\/\//, expectAbsent: 'data:' },
|
||||
])('should convert base64 image using resourceUrl (useInternalUrls=$useInternalUrls)', async ({ useInternalUrls, expectMatch, expectAbsent }) => {
|
||||
await setupDatabaseAndSynchronizer(1);
|
||||
await switchClient(1);
|
||||
const html = `<img src="data:image/png;base64,${minimalPng}"/>`;
|
||||
const result = await processImagesInPastedHtml(html, { useInternalUrls });
|
||||
expect(result).toMatch(expectMatch);
|
||||
expect(result).not.toContain(expectAbsent);
|
||||
expect(result).not.toContain('data:');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import shim from '@joplin/lib/shim';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import Resource from '@joplin/lib/models/Resource';
|
||||
import { ResourceEntity } from '@joplin/lib/services/database/types';
|
||||
import ResourceFetcher from '@joplin/lib/services/ResourceFetcher';
|
||||
import htmlUtils from '@joplin/lib/htmlUtils';
|
||||
import rendererHtmlUtils, { extractHtmlBody, removeWrappingParagraphAndTrailingEmptyElements } from '@joplin/renderer/htmlUtils';
|
||||
@@ -120,10 +121,21 @@ export async function getResourcesFromPasteEvent(event: any) {
|
||||
}
|
||||
|
||||
|
||||
const processImagesInPastedHtml = async (html: string) => {
|
||||
export interface ProcessImagesOptions {
|
||||
// When true, returns Joplin internal URLs (:/resourceId) instead of file:// URLs
|
||||
useInternalUrls?: boolean;
|
||||
}
|
||||
|
||||
export const processImagesInPastedHtml = async (html: string, options: ProcessImagesOptions = {}) => {
|
||||
const allImageUrls: string[] = [];
|
||||
const mappedResources: Record<string, string> = {};
|
||||
|
||||
const resourceUrl = (resource: ResourceEntity) => {
|
||||
return options.useInternalUrls
|
||||
? Resource.internalUrl(resource)
|
||||
: `file://${encodeURI(Resource.fullPath(resource))}`;
|
||||
};
|
||||
|
||||
htmlUtils.replaceImageUrls(html, (src: string) => {
|
||||
allImageUrls.push(src);
|
||||
});
|
||||
@@ -138,7 +150,7 @@ const processImagesInPastedHtml = async (html: string) => {
|
||||
await shim.fetchBlob(imageSrc, { path: filePath });
|
||||
const createdResource = await shim.createResourceFromPath(filePath);
|
||||
await shim.fsDriver().remove(filePath);
|
||||
mappedResources[imageSrc] = `file://${encodeURI(Resource.fullPath(createdResource))}`;
|
||||
mappedResources[imageSrc] = resourceUrl(createdResource);
|
||||
} catch (error) {
|
||||
logger.warn(`Error creating a resource for ${imageSrc}.`, error);
|
||||
mappedResources[imageSrc] = imageSrc;
|
||||
@@ -155,14 +167,49 @@ const processImagesInPastedHtml = async (html: string) => {
|
||||
const imageFilePath = path.normalize(fileUriToPath(imageSrc));
|
||||
const resourceDirPath = path.normalize(Setting.value('resourceDir'));
|
||||
|
||||
if (imageFilePath.startsWith(resourceDirPath)) {
|
||||
mappedResources[imageSrc] = imageSrc;
|
||||
// Use path.relative for robust containment check - startsWith can falsely match sibling paths
|
||||
const rel = path.relative(resourceDirPath, imageFilePath);
|
||||
const isInsideResourceDir = rel && !rel.startsWith('..') && !path.isAbsolute(rel);
|
||||
if (isInsideResourceDir) {
|
||||
if (options.useInternalUrls) {
|
||||
const resourceId = Resource.pathToId(imageFilePath);
|
||||
mappedResources[imageSrc] = `:/${resourceId}`;
|
||||
} else {
|
||||
mappedResources[imageSrc] = imageSrc;
|
||||
}
|
||||
} else {
|
||||
const createdResource = await shim.createResourceFromPath(imageFilePath);
|
||||
mappedResources[imageSrc] = `file://${encodeURI(Resource.fullPath(createdResource))}`;
|
||||
mappedResources[imageSrc] = resourceUrl(createdResource);
|
||||
}
|
||||
} else if (imageSrc.startsWith('data:')) {
|
||||
mappedResources[imageSrc] = imageSrc;
|
||||
// Word encodes base64 with MIME line breaks every ~76 chars.
|
||||
// Strip whitespace before decoding, then save as a Joplin resource
|
||||
// so Turndown's outerHTML (used for images with width/height) gets
|
||||
// a short URL instead of 200KB of base64.
|
||||
const cleanSrc = imageSrc.replace(/\s/g, '');
|
||||
const dataUrlMatch = cleanSrc.match(/^data:((?!image\/svg\+xml)[^;]+);base64,(.+)$/);
|
||||
if (dataUrlMatch) {
|
||||
const mimeType = dataUrlMatch[1];
|
||||
const base64Data = dataUrlMatch[2];
|
||||
const fileExt = mimeUtils.toFileExtension(mimeType) || 'bin';
|
||||
const filePath = `${Setting.value('tempDir')}/${md5(Date.now() + Math.random())}.${fileExt}`;
|
||||
try {
|
||||
await shim.fsDriver().writeFile(filePath, base64Data, 'base64');
|
||||
const createdResource = await shim.createResourceFromPath(filePath);
|
||||
mappedResources[imageSrc] = resourceUrl(createdResource);
|
||||
} catch (writeError) {
|
||||
writeError.message = `processPastedHtml: Failed to write or create resource from pasted image: ${writeError.message}`;
|
||||
throw writeError;
|
||||
} finally {
|
||||
try {
|
||||
await shim.fsDriver().remove(filePath);
|
||||
} catch (cleanupError) {
|
||||
logger.warn('processPastedHtml: Error removing temporary file.', cleanupError);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
mappedResources[imageSrc] = imageSrc;
|
||||
}
|
||||
} else {
|
||||
downloadImages.push(downloadImage(imageSrc));
|
||||
}
|
||||
@@ -188,6 +235,27 @@ export async function processPastedHtml(html: string, htmlToMd: HtmlToMarkdownHa
|
||||
|
||||
html = await processImagesInPastedHtml(html);
|
||||
|
||||
// Word encodes newlines in alt attributes as HTML entities ( 
 etc.).
|
||||
// These get decoded to literal newline characters by JSDOM when Turndown processes
|
||||
// the HTML. With preserveImageTagsWithSize=true, Turndown returns node.outerHTML
|
||||
// verbatim — embedding literal newlines inside an HTML attribute value, which
|
||||
// breaks the Markdown raw HTML block (a blank line ends the block, making the
|
||||
// parser treat the <img> as plain text). Normalize them to spaces here.
|
||||
html = html.replace(
|
||||
/(\balt\s*=\s*)(["'])([\s\S]*?)\2/gi,
|
||||
(_m, prefix, quote, altText) => {
|
||||
// Replace HTML-encoded newlines/control chars and literal ones with a space
|
||||
const normalized = altText
|
||||
.replace(/&#(?:10|13);|&#x(?:0*[aAdD]);/gi, ' ')
|
||||
// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional sanitisation of control chars
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/[\r\n\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ' ')
|
||||
.replace(/ {2,}/g, ' ')
|
||||
.trim();
|
||||
return `${prefix}${quote}${normalized}${quote}`;
|
||||
},
|
||||
);
|
||||
|
||||
// TinyMCE can accept any type of HTML, including HTML that may not be preserved once saved as
|
||||
// Markdown. For example the content may have a dark background which would be supported by
|
||||
// TinyMCE, but lost once the note is saved. So here we convert the HTML to Markdown then back
|
||||
|
||||
@@ -6,6 +6,7 @@ import PostMessageService from '@joplin/lib/services/PostMessageService';
|
||||
import ResourceFetcher from '@joplin/lib/services/ResourceFetcher';
|
||||
import { reg } from '@joplin/lib/registry';
|
||||
import bridge from '../../../services/bridge';
|
||||
import { resolveContextMenuItemType } from './contextMenuUtils';
|
||||
|
||||
export default function useMessageHandler(
|
||||
scrollWhenReadyRef: RefObject<ScrollOptions|null>,
|
||||
@@ -46,9 +47,11 @@ export default function useMessageHandler(
|
||||
if (s.length < 2) throw new Error(`Invalid message: ${msg}`);
|
||||
void ResourceFetcher.instance().markForDownload(s[1]);
|
||||
} else if (msg === 'contextMenu') {
|
||||
const resourceId = arg0.resourceId;
|
||||
const itemType = await resolveContextMenuItemType(arg0 && arg0.type, resourceId);
|
||||
const menu = await contextMenu({
|
||||
itemType: arg0 && arg0.type,
|
||||
resourceId: arg0.resourceId,
|
||||
itemType,
|
||||
resourceId: resourceId,
|
||||
filename: arg0.filename,
|
||||
mime: arg0.mime,
|
||||
linkToOpen: null,
|
||||
|
||||
@@ -13,6 +13,7 @@ const commandsWithDependencies = [
|
||||
require('../commands/focusElementNoteViewer'),
|
||||
require('../commands/focusElementToolbar'),
|
||||
require('../commands/pasteAsText'),
|
||||
require('../commands/pasteAsMarkdown'),
|
||||
];
|
||||
|
||||
type OnBodyChange = (event: OnChangeEvent)=> void;
|
||||
|
||||
@@ -30,6 +30,8 @@ import useFocusVisible from './utils/useFocusVisible';
|
||||
import { stateUtils } from '@joplin/lib/reducer';
|
||||
import { connect } from 'react-redux';
|
||||
import useOnNoteDoubleClick from './utils/useOnNoteDoubleClick';
|
||||
import useAutoScroll from './utils/useAutoScroll';
|
||||
import useRefocusOnDeletion from './utils/useRefocusOnDeletion';
|
||||
|
||||
const commands = {
|
||||
focusElementNoteList,
|
||||
@@ -73,6 +75,7 @@ const NoteList = (props: Props) => {
|
||||
|
||||
const { activeNoteId, setActiveNoteId } = useActiveDescendantId(props.selectedFolderId, props.selectedNoteIds);
|
||||
const focusNote = useFocusNote(listRef, props.notes, makeItemIndexVisible, setActiveNoteId);
|
||||
useRefocusOnDeletion(props.notes.length, props.selectedNoteIds, props.focusedField, props.selectedFolderId, focusNote);
|
||||
|
||||
const moveNote = useMoveNote(
|
||||
props.notesParentType,
|
||||
@@ -131,6 +134,10 @@ const NoteList = (props: Props) => {
|
||||
};
|
||||
}, [focusNote]);
|
||||
|
||||
const selectedNoteId = props.selectedNoteIds.length === 1 ? props.selectedNoteIds[0] : '';
|
||||
const targetIndex = props.notes.findIndex(note => note.id === selectedNoteId);
|
||||
useAutoScroll(selectedNoteId, props.selectedFolderId, targetIndex, makeItemIndexVisible);
|
||||
|
||||
const onItemContextMenu = useOnContextMenu(
|
||||
props.selectedNoteIds,
|
||||
props.selectedFolderId,
|
||||
|
||||
106
packages/app-desktop/gui/NoteList/utils/UseAutoScroll.test.ts
Normal file
106
packages/app-desktop/gui/NoteList/utils/UseAutoScroll.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import useAutoScroll from './useAutoScroll';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
type Props = {
|
||||
selectedNoteId: string;
|
||||
selectedFolderId: string;
|
||||
targetIndex: number;
|
||||
makeItemIndexVisible: (index: number)=> void;
|
||||
};
|
||||
|
||||
describe('useAutoScroll', () => {
|
||||
|
||||
test('scrolls to the note when a new note is selected', () => {
|
||||
const makeItemIndexVisible = jest.fn();
|
||||
|
||||
renderHook(() => useAutoScroll('note-1', 'folder-1', 5, makeItemIndexVisible));
|
||||
|
||||
expect(makeItemIndexVisible).toHaveBeenCalledTimes(1);
|
||||
expect(makeItemIndexVisible).toHaveBeenCalledWith(5);
|
||||
});
|
||||
|
||||
test('does not scroll when the same note is already selected', () => {
|
||||
const makeItemIndexVisible = jest.fn();
|
||||
|
||||
const { rerender } = renderHook(() =>
|
||||
useAutoScroll('note-1', 'folder-1', 5, makeItemIndexVisible),
|
||||
);
|
||||
|
||||
makeItemIndexVisible.mockClear();
|
||||
rerender();
|
||||
|
||||
expect(makeItemIndexVisible).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('does not scroll for multi-selection or no selection', () => {
|
||||
const makeItemIndexVisible = jest.fn();
|
||||
|
||||
renderHook(() => useAutoScroll('', 'folder-1', -1, makeItemIndexVisible));
|
||||
|
||||
expect(makeItemIndexVisible).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('defers scroll until notes load after folder change', () => {
|
||||
const makeItemIndexVisible = jest.fn();
|
||||
|
||||
const { rerender } = renderHook(
|
||||
(props: Props) => useAutoScroll(
|
||||
props.selectedNoteId,
|
||||
props.selectedFolderId,
|
||||
props.targetIndex,
|
||||
props.makeItemIndexVisible,
|
||||
),
|
||||
{ initialProps: { selectedNoteId: 'note-1', selectedFolderId: 'folder-2', targetIndex: -1, makeItemIndexVisible } },
|
||||
);
|
||||
|
||||
expect(makeItemIndexVisible).not.toHaveBeenCalled();
|
||||
|
||||
rerender({ selectedNoteId: 'note-1', selectedFolderId: 'folder-2', targetIndex: 3, makeItemIndexVisible });
|
||||
|
||||
expect(makeItemIndexVisible).toHaveBeenCalledTimes(1);
|
||||
expect(makeItemIndexVisible).toHaveBeenCalledWith(3);
|
||||
});
|
||||
|
||||
test('scrolls again when the folder changes even if note ID is the same', () => {
|
||||
const makeItemIndexVisible = jest.fn();
|
||||
|
||||
const { rerender } = renderHook(
|
||||
(props: Props) => useAutoScroll(
|
||||
props.selectedNoteId,
|
||||
props.selectedFolderId,
|
||||
props.targetIndex,
|
||||
props.makeItemIndexVisible,
|
||||
),
|
||||
{ initialProps: { selectedNoteId: 'note-1', selectedFolderId: 'folder-1', targetIndex: 2, makeItemIndexVisible } },
|
||||
);
|
||||
|
||||
expect(makeItemIndexVisible).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender({ selectedNoteId: 'note-1', selectedFolderId: 'folder-2', targetIndex: 2, makeItemIndexVisible });
|
||||
|
||||
expect(makeItemIndexVisible).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('does not scroll again when targetIndex changes after the pending flag is cleared', () => {
|
||||
// Covers the case where a sort or filter changes targetIndex without a new selection.
|
||||
// Without this guard, arrow-key navigation would trigger a spurious second scroll.
|
||||
const makeItemIndexVisible = jest.fn();
|
||||
|
||||
const { rerender } = renderHook(
|
||||
(props: Props) => useAutoScroll(
|
||||
props.selectedNoteId,
|
||||
props.selectedFolderId,
|
||||
props.targetIndex,
|
||||
props.makeItemIndexVisible,
|
||||
),
|
||||
{ initialProps: { selectedNoteId: 'note-1', selectedFolderId: 'folder-1', targetIndex: 5, makeItemIndexVisible } },
|
||||
);
|
||||
|
||||
expect(makeItemIndexVisible).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender({ selectedNoteId: 'note-1', selectedFolderId: 'folder-1', targetIndex: 7, makeItemIndexVisible });
|
||||
|
||||
expect(makeItemIndexVisible).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
});
|
||||
43
packages/app-desktop/gui/NoteList/utils/useAutoScroll.ts
Normal file
43
packages/app-desktop/gui/NoteList/utils/useAutoScroll.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useRef, useEffect } from 'react';
|
||||
|
||||
// Auto-scrolls the note list to the selected note when selection changes. Uses a pending flag
|
||||
// to handle cross-folder navigation where notes may not be loaded on the first render.
|
||||
const useAutoScroll = (
|
||||
selectedNoteId: string,
|
||||
selectedFolderId: string,
|
||||
targetIndex: number,
|
||||
makeItemIndexVisible: (index: number)=> void,
|
||||
) => {
|
||||
const lastNoteIdRef = useRef('');
|
||||
const lastFolderIdRef = useRef('');
|
||||
const scrollPendingRef = useRef(false); // true when scroll requested but notes not yet loaded
|
||||
|
||||
useEffect(() => {
|
||||
// No selection or multi-selection — reset tracking state.
|
||||
if (!selectedNoteId) {
|
||||
lastNoteIdRef.current = '';
|
||||
lastFolderIdRef.current = selectedFolderId;
|
||||
scrollPendingRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const isNewNote = selectedNoteId !== lastNoteIdRef.current;
|
||||
const isFolderChange = selectedFolderId !== lastFolderIdRef.current;
|
||||
|
||||
if (isNewNote || isFolderChange) {
|
||||
lastNoteIdRef.current = selectedNoteId;
|
||||
lastFolderIdRef.current = selectedFolderId;
|
||||
scrollPendingRef.current = true;
|
||||
}
|
||||
|
||||
// targetIndex is -1 until the new folder's notes load — re-runs automatically when they do.
|
||||
if (!scrollPendingRef.current || targetIndex === -1) return;
|
||||
|
||||
// makeItemIndexVisible has its own visibility guard and is a no-op when the note is
|
||||
// already visible — this covers arrow-key and click navigation without double-scrolling.
|
||||
makeItemIndexVisible(targetIndex);
|
||||
scrollPendingRef.current = false;
|
||||
}, [selectedNoteId, selectedFolderId, targetIndex, makeItemIndexVisible]);
|
||||
};
|
||||
|
||||
export default useAutoScroll;
|
||||
@@ -0,0 +1,41 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import useRefocusOnDeletion from './useRefocusOnDeletion';
|
||||
|
||||
describe('useRefocusOnDeletion', () => {
|
||||
it('should refocus when a note is deleted in the same folder', () => {
|
||||
const focusNote = jest.fn();
|
||||
const { rerender } = renderHook(
|
||||
({ noteCount }: { noteCount: number }) =>
|
||||
useRefocusOnDeletion(noteCount, ['note-1'], '', 'folder-1', focusNote),
|
||||
{ initialProps: { noteCount: 3 } },
|
||||
);
|
||||
rerender({ noteCount: 2 });
|
||||
expect(focusNote).toHaveBeenCalledWith('note-1');
|
||||
});
|
||||
|
||||
test.each([
|
||||
['note count increases', 2, 3, '', ['note-1']],
|
||||
['another field has focus', 3, 2, 'editor', ['note-1']],
|
||||
['multiple notes are selected', 3, 2, '', ['note-1', 'note-2']],
|
||||
])('should not refocus when %s', (_label, initialCount, newCount, focusedField, noteIds) => {
|
||||
const focusNote = jest.fn();
|
||||
const { rerender } = renderHook(
|
||||
({ noteCount }: { noteCount: number }) =>
|
||||
useRefocusOnDeletion(noteCount, noteIds, focusedField, 'folder-1', focusNote),
|
||||
{ initialProps: { noteCount: initialCount } },
|
||||
);
|
||||
rerender({ noteCount: newCount });
|
||||
expect(focusNote).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not refocus when switching to a folder with fewer notes', () => {
|
||||
const focusNote = jest.fn();
|
||||
const { rerender } = renderHook(
|
||||
({ noteCount, folderId }: { noteCount: number; folderId: string }) =>
|
||||
useRefocusOnDeletion(noteCount, ['note-1'], '', folderId, focusNote),
|
||||
{ initialProps: { noteCount: 3, folderId: 'folder-1' } },
|
||||
);
|
||||
rerender({ noteCount: 2, folderId: 'folder-2' });
|
||||
expect(focusNote).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useEffect } from 'react';
|
||||
import usePrevious from '@joplin/lib/hooks/usePrevious';
|
||||
const useRefocusOnDeletion = (
|
||||
noteCount: number,
|
||||
selectedNoteIds: string[],
|
||||
focusedField: string,
|
||||
selectedFolderId: string,
|
||||
focusNote: (noteId: string)=> void,
|
||||
) => {
|
||||
const previousNoteCount = usePrevious(noteCount, 0);
|
||||
const previousFolderId = usePrevious(selectedFolderId, '');
|
||||
useEffect(() => {
|
||||
const noteWasRemoved = noteCount < previousNoteCount;
|
||||
const folderDidNotChange = selectedFolderId === previousFolderId;
|
||||
if (noteWasRemoved && folderDidNotChange && selectedNoteIds.length === 1 && !focusedField) {
|
||||
focusNote(selectedNoteIds[0]);
|
||||
}
|
||||
}, [noteCount, previousNoteCount, selectedNoteIds, focusedField, selectedFolderId, previousFolderId, focusNote]);
|
||||
};
|
||||
export default useRefocusOnDeletion;
|
||||
@@ -6,7 +6,7 @@ import Button, { ButtonLevel, ButtonSize, buttonSizePx } from '../Button/Button'
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import { runtime as focusSearchRuntime } from './commands/focusSearch';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import { notesSortOrderNextField } from '../../services/sortOrder/notesSortOrderUtils';
|
||||
import { notesSortOrderNextField } from '@joplin/lib/services/sortOrder/notesSortOrderUtils';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { connect } from 'react-redux';
|
||||
import styled from 'styled-components';
|
||||
@@ -284,9 +284,11 @@ interface ConnectProps {
|
||||
const mapStateToProps = (state: AppState, ownProps: ConnectProps) => {
|
||||
const whenClauseContext = stateToWhenClauseContext(state, { windowId: ownProps.windowId });
|
||||
const windowState = stateUtils.windowStateById(state, ownProps.windowId);
|
||||
const hasFolderForNewNotes = whenClauseContext.selectedFolderIsValid
|
||||
&& windowState.selectedFolderId !== getTrashFolderId();
|
||||
|
||||
return {
|
||||
showNewNoteButtons: windowState.selectedFolderId !== getTrashFolderId(),
|
||||
showNewNoteButtons: hasFolderForNewNotes,
|
||||
newNoteButtonEnabled: CommandService.instance().isEnabled('newNote', whenClauseContext),
|
||||
newTodoButtonEnabled: CommandService.instance().isEnabled('newTodo', whenClauseContext),
|
||||
sortOrderButtonsVisible: state.settings['notes.sortOrder.buttonsVisible'],
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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]();
|
||||
};
|
||||
|
||||
@@ -4,8 +4,7 @@ import { ItemFlow, ListRenderer, NoteListColumns, OnChangeEvent, OnChangeHandler
|
||||
import { Size } from '@joplin/utils/types';
|
||||
import useRootElement from './utils/useRootElement';
|
||||
import useItemElement from './utils/useItemElement';
|
||||
import useItemEventHandlers from './utils/useItemEventHandlers';
|
||||
import { OnInputChange } from './utils/types';
|
||||
import { ItemEventHandlers, OnInputChange } from './utils/types';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import { NoteEntity } from '@joplin/lib/services/database/types';
|
||||
import useRenderedNote from './utils/useRenderedNote';
|
||||
@@ -72,7 +71,9 @@ const NoteListItem = (props: NoteItemProps, ref: LegacyRef<HTMLDivElement>) => {
|
||||
|
||||
const renderedNote = useRenderedNote(props.note, props.isSelected, props.isWatched, props.listRenderer, props.highlightedWords, props.index, props.columns);
|
||||
|
||||
const itemElement = useItemElement(
|
||||
const itemEventHandlers = useMemo((): ItemEventHandlers => ({ onInputChange, onClick: null }), [onInputChange]);
|
||||
|
||||
useItemElement(
|
||||
rootElement,
|
||||
noteId,
|
||||
renderedNote ? renderedNote.html : '',
|
||||
@@ -82,9 +83,9 @@ const NoteListItem = (props: NoteItemProps, ref: LegacyRef<HTMLDivElement>) => {
|
||||
props.onClick,
|
||||
props.onDoubleClick,
|
||||
props.flow,
|
||||
itemEventHandlers,
|
||||
);
|
||||
|
||||
useItemEventHandlers(rootElement, itemElement, onInputChange, null);
|
||||
|
||||
const className = useMemo(() => {
|
||||
return [
|
||||
|
||||
@@ -2,3 +2,8 @@ import * as React from 'react';
|
||||
|
||||
export type OnInputChange = (event: React.ChangeEvent<HTMLInputElement>)=> void;
|
||||
export type OnClick = (event: React.MouseEvent<HTMLElement>)=> void;
|
||||
|
||||
export type ItemEventHandlers = {
|
||||
onInputChange: OnInputChange;
|
||||
onClick: OnClick | null;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { ItemFlow } from '@joplin/lib/services/plugins/api/noteListType';
|
||||
import useItemElement from './useItemElement';
|
||||
import * as React from 'react';
|
||||
|
||||
const defaultProps = {
|
||||
noteId: 'note-1',
|
||||
noteHtml: '<span>Test content</span>',
|
||||
focusVisible: false,
|
||||
style: { height: '24px' } as React.CSSProperties,
|
||||
itemSize: { width: 200, height: 24 },
|
||||
onClick: jest.fn(),
|
||||
onDoubleClick: jest.fn(),
|
||||
flow: ItemFlow.TopToBottom,
|
||||
};
|
||||
|
||||
const defaultItemEventHandlers = { onInputChange: jest.fn(), onClick: null as import('./types').OnClick | null };
|
||||
|
||||
describe('useItemElement', () => {
|
||||
let rootElement: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
rootElement = document.createElement('div');
|
||||
document.body.appendChild(rootElement);
|
||||
defaultProps.onClick.mockClear();
|
||||
defaultProps.onDoubleClick.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rootElement.remove();
|
||||
});
|
||||
|
||||
test('returns a ref (no setState, avoids "Maximum update depth exceeded")', () => {
|
||||
const { result } = renderHook(
|
||||
() => useItemElement(rootElement, defaultProps.noteId, defaultProps.noteHtml, defaultProps.focusVisible, defaultProps.style, defaultProps.itemSize, defaultProps.onClick, defaultProps.onDoubleClick, defaultProps.flow, defaultItemEventHandlers),
|
||||
);
|
||||
|
||||
expect(result.current).toHaveProperty('current');
|
||||
});
|
||||
|
||||
test('assigns created element to ref.current and cleanup nulls ref and removes element', () => {
|
||||
const { result, unmount } = renderHook(
|
||||
() => useItemElement(rootElement, defaultProps.noteId, defaultProps.noteHtml, false, defaultProps.style, defaultProps.itemSize, defaultProps.onClick, defaultProps.onDoubleClick, defaultProps.flow, defaultItemEventHandlers),
|
||||
);
|
||||
|
||||
act(() => {});
|
||||
|
||||
const el = result.current.current;
|
||||
expect(el).toBeInstanceOf(HTMLDivElement);
|
||||
expect(rootElement.contains(el)).toBe(true);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(result.current.current).toBeNull();
|
||||
expect(rootElement.contains(el as Node)).toBe(false);
|
||||
});
|
||||
|
||||
test('does nothing when rootElement is null', () => {
|
||||
const { result } = renderHook(
|
||||
() => useItemElement(null, defaultProps.noteId, defaultProps.noteHtml, false, defaultProps.style, defaultProps.itemSize, defaultProps.onClick, defaultProps.onDoubleClick, defaultProps.flow, defaultItemEventHandlers),
|
||||
);
|
||||
|
||||
act(() => {});
|
||||
|
||||
expect(result.current.current).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,65 @@
|
||||
import * as React from 'react';
|
||||
import { Size } from '@joplin/utils/types';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { ItemFlow } from '@joplin/lib/services/plugins/api/noteListType';
|
||||
import { ItemEventHandlers } from './types';
|
||||
|
||||
const addItemEventListeners = (
|
||||
element: HTMLElement,
|
||||
listeners: ItemEventHandlers,
|
||||
onClick: React.MouseEventHandler<HTMLDivElement>,
|
||||
onDoubleClick: React.MouseEventHandler<HTMLDivElement>,
|
||||
): { cleanup: ()=> void } => {
|
||||
const processedInputs: HTMLInputElement[] = [];
|
||||
const processedButtons: HTMLButtonElement[] = [];
|
||||
|
||||
const inputs = element.getElementsByTagName('input');
|
||||
for (const input of inputs) {
|
||||
if (input.type === 'checkbox' || input.type === 'text') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- we're mixing React synthetic events with DOM events which ideally should not be done but it is fine in this particular case
|
||||
input.addEventListener('change', listeners.onInputChange as any);
|
||||
processedInputs.push(input);
|
||||
}
|
||||
}
|
||||
|
||||
const buttons = element.getElementsByTagName('button');
|
||||
if (listeners.onClick) {
|
||||
for (const button of buttons) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- we're mixing React synthetic events with DOM events which ideally should not be done but it is fine in this particular case
|
||||
button.addEventListener('click', listeners.onClick as any);
|
||||
processedButtons.push(button);
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- we're mixing React synthetic events with DOM events which ideally should not be done but it is fine in this particular case
|
||||
const clickHandler = (e: MouseEvent) => onClick(e as any);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- we're mixing React synthetic events with DOM events which ideally should not be done but it is fine in this particular case
|
||||
const dblclickHandler = (e: MouseEvent) => onDoubleClick(e as any);
|
||||
element.addEventListener('click', clickHandler);
|
||||
element.addEventListener('dblclick', dblclickHandler);
|
||||
|
||||
return {
|
||||
cleanup: () => {
|
||||
for (const input of processedInputs) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- we're mixing React synthetic events with DOM events which ideally should not be done but it is fine in this particular case
|
||||
input.removeEventListener('change', listeners.onInputChange as any);
|
||||
}
|
||||
if (listeners.onClick) {
|
||||
for (const button of processedButtons) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- we're mixing React synthetic events with DOM events which ideally should not be done but it is fine in this particular case
|
||||
button.removeEventListener('click', listeners.onClick as any);
|
||||
}
|
||||
}
|
||||
element.removeEventListener('click', clickHandler);
|
||||
element.removeEventListener('dblclick', dblclickHandler);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const useItemElement = (
|
||||
rootElement: HTMLDivElement, noteId: string, noteHtml: string, focusVisible: boolean, style: React.CSSProperties, itemSize: Size, onClick: React.MouseEventHandler<HTMLDivElement>, onDoubleClick: React.MouseEventHandler<HTMLDivElement>, flow: ItemFlow,
|
||||
rootElement: HTMLDivElement | null, noteId: string, noteHtml: string, focusVisible: boolean, style: React.CSSProperties, itemSize: Size, onClick: React.MouseEventHandler<HTMLDivElement>, onDoubleClick: React.MouseEventHandler<HTMLDivElement>, flow: ItemFlow, itemEventHandlers: ItemEventHandlers,
|
||||
) => {
|
||||
const [itemElement, setItemElement] = useState<HTMLDivElement>(null);
|
||||
const itemElement = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!rootElement) return () => {};
|
||||
@@ -21,29 +74,28 @@ const useItemElement = (
|
||||
if (flow === ItemFlow.LeftToRight) element.style.width = `${itemSize.width}px`;
|
||||
element.style.height = `${itemSize.height}px`;
|
||||
element.innerHTML = noteHtml;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- we're mixing React synthetic events with DOM events which ideally should not be done but it is fine in this particular case
|
||||
element.addEventListener('click', onClick as any);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- we're mixing React synthetic events with DOM events which ideally should not be done but it is fine in this particular case
|
||||
element.addEventListener('dblclick', onDoubleClick as any);
|
||||
|
||||
const { cleanup } = addItemEventListeners(element, itemEventHandlers, onClick, onDoubleClick);
|
||||
|
||||
rootElement.appendChild(element);
|
||||
|
||||
setItemElement(element);
|
||||
itemElement.current = element;
|
||||
|
||||
return () => {
|
||||
cleanup();
|
||||
itemElement.current = null;
|
||||
element.remove();
|
||||
};
|
||||
}, [rootElement, itemSize, noteHtml, noteId, style, onClick, onDoubleClick, flow]);
|
||||
}, [rootElement, itemSize, noteHtml, noteId, flow, style, onClick, onDoubleClick, itemEventHandlers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!itemElement) return;
|
||||
|
||||
const element = itemElement.current;
|
||||
if (!element) return;
|
||||
if (focusVisible) {
|
||||
itemElement.classList.add('-focus-visible');
|
||||
element.classList.add('-focus-visible');
|
||||
} else {
|
||||
itemElement.classList.remove('-focus-visible');
|
||||
element.classList.remove('-focus-visible');
|
||||
}
|
||||
}, [focusVisible, itemElement]);
|
||||
}, [focusVisible]);
|
||||
|
||||
return itemElement;
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user