You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2026-01-11 00:21:45 +02:00
Compare commits
117 Commits
join_serve
...
android-v3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b451a1a3ed | ||
|
|
4afac412ce | ||
|
|
b79bf11680 | ||
|
|
10d727f183 | ||
|
|
50e2dc7749 | ||
|
|
5108fe5b24 | ||
|
|
3536a68cfe | ||
|
|
d94d057f1d | ||
|
|
8ec11bddc2 | ||
|
|
4813c79b35 | ||
|
|
7778a68764 | ||
|
|
503e748ca8 | ||
|
|
b6297b609e | ||
|
|
31d37b30b0 | ||
|
|
0ccd7e474d | ||
|
|
046cfece32 | ||
|
|
0280bb80b9 | ||
|
|
8a61f4ec54 | ||
|
|
d7dd16aac1 | ||
|
|
e1ed573c33 | ||
|
|
b6c8347549 | ||
|
|
b150d6453d | ||
|
|
9feba9345d | ||
|
|
7fa3a3b545 | ||
|
|
fed2438bc3 | ||
|
|
31cb404854 | ||
|
|
dba3a3f68f | ||
|
|
14f8f51cd1 | ||
|
|
2240cf77b5 | ||
|
|
599f7a24ce | ||
|
|
f177563c4a | ||
|
|
a0bdc1fa9b | ||
|
|
f566e5c336 | ||
|
|
87d07eff4a | ||
|
|
7a31f1f156 | ||
|
|
090c1d9706 | ||
|
|
5e2b79557c | ||
|
|
74fa2a6eb9 | ||
|
|
791668455e | ||
|
|
91aedc5efa | ||
|
|
6b2d9ba5ec | ||
|
|
d8920840f2 | ||
|
|
bf571c5961 | ||
|
|
a7b22edbc4 | ||
|
|
f4904d8155 | ||
|
|
fab633bbb4 | ||
|
|
cda4073bfc | ||
|
|
903edb8fa2 | ||
|
|
f3409600e1 | ||
|
|
9f36b44842 | ||
|
|
6f41234db3 | ||
|
|
2feebf504e | ||
|
|
3312e96b0d | ||
|
|
af5108d702 | ||
|
|
0f4877f263 | ||
|
|
46c22fffb9 | ||
|
|
ae5bc1b849 | ||
|
|
907da6caa9 | ||
|
|
57a4a687d1 | ||
|
|
00aecd63d4 | ||
|
|
bd569b9d8d | ||
|
|
ad4a8aa76d | ||
|
|
c67dcebbbe | ||
|
|
0e135adbe2 | ||
|
|
43e83e7cee | ||
|
|
d1dcc6ced5 | ||
|
|
8425f195f8 | ||
|
|
055177f726 | ||
|
|
1674df2c0f | ||
|
|
29fa117d36 | ||
|
|
f08eaae7ed | ||
|
|
9573bb6af7 | ||
|
|
cb6bafcac6 | ||
|
|
d89aae5371 | ||
|
|
0b0ffe06d4 | ||
|
|
2ab720ff87 | ||
|
|
b9b07790d7 | ||
|
|
3dca34952b | ||
|
|
5be124b54a | ||
|
|
51dd0d3fdc | ||
|
|
7955f15298 | ||
|
|
fdf6091006 | ||
|
|
bb1c5792cc | ||
|
|
75544c943c | ||
|
|
db9967d4fd | ||
|
|
07a66ca62c | ||
|
|
3e3dc4392c | ||
|
|
57504a1795 | ||
|
|
9e9d2699b5 | ||
|
|
4a0d9220ba | ||
|
|
86a7771d5b | ||
|
|
d792a6b3a9 | ||
|
|
e8a083b7bd | ||
|
|
41ed6ab364 | ||
|
|
b587e9ad37 | ||
|
|
e3f9fafcdf | ||
|
|
c0ba743d70 | ||
|
|
523660006d | ||
|
|
aef9429f21 | ||
|
|
58e2bba1ed | ||
|
|
cee44bcdc3 | ||
|
|
9a120bc0d5 | ||
|
|
d1415a318c | ||
|
|
e626db3b8c | ||
|
|
053bd91984 | ||
|
|
c76059cf7f | ||
|
|
6d6bc78d53 | ||
|
|
8855495822 | ||
|
|
3491fea313 | ||
|
|
66f5e2fbc3 | ||
|
|
3640bf8ae7 | ||
|
|
977edf6e5d | ||
|
|
e8f067a0b2 | ||
|
|
f971e2aa4c | ||
|
|
b15b92d161 | ||
|
|
1c5f66b5a9 | ||
|
|
aaeb5db3c7 |
@@ -6,6 +6,7 @@ _releases/
|
||||
*.min.js
|
||||
**/commands/index.ts
|
||||
**/node_modules/
|
||||
**/abcjs-basic-min.js
|
||||
packages/generator-joplin/generators/app/templates/api/
|
||||
Assets/
|
||||
docs/
|
||||
@@ -96,7 +97,7 @@ packages/onenote-converter/renderer/pkg/*
|
||||
packages/app-cli/app/LinkSelector.js
|
||||
packages/app-cli/app/app.js
|
||||
packages/app-cli/app/base-command.js
|
||||
packages/app-cli/app/cli-integration-tests.js
|
||||
packages/app-cli/app/cli-integration-tests.test.js
|
||||
packages/app-cli/app/command-apidoc.js
|
||||
packages/app-cli/app/command-attach.js
|
||||
packages/app-cli/app/command-batch.js
|
||||
@@ -424,10 +425,11 @@ packages/app-desktop/gui/Sidebar/Sidebar.js
|
||||
packages/app-desktop/gui/Sidebar/commands/focusElementSideBar.js
|
||||
packages/app-desktop/gui/Sidebar/commands/index.js
|
||||
packages/app-desktop/gui/Sidebar/hooks/useFocusHandler.js
|
||||
packages/app-desktop/gui/Sidebar/hooks/useOnItemClick.js
|
||||
packages/app-desktop/gui/Sidebar/hooks/useOnRenderItem.js
|
||||
packages/app-desktop/gui/Sidebar/hooks/useOnRenderListWrapper.js
|
||||
packages/app-desktop/gui/Sidebar/hooks/useOnSidebarKeyDownHandler.js
|
||||
packages/app-desktop/gui/Sidebar/hooks/useSelectedSidebarIndex.js
|
||||
packages/app-desktop/gui/Sidebar/hooks/useSelectedSidebarIndexes.js
|
||||
packages/app-desktop/gui/Sidebar/hooks/useSidebarCommandHandler.js
|
||||
packages/app-desktop/gui/Sidebar/hooks/useSidebarListData.js
|
||||
packages/app-desktop/gui/Sidebar/hooks/utils/toggleHeader.js
|
||||
@@ -468,6 +470,7 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/editAlarm.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/exportPdf.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/gotoAnything.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/hideModalMessage.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/importFrom.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/index.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/linkToNote.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/moveToFolder.js
|
||||
@@ -510,6 +513,7 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleSideBar.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleVisiblePanes.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/types.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/appDialogs.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/showFolderPicker.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/usePrintToCallback.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/useSyncDialogState.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/useWindowCommands.js
|
||||
@@ -561,6 +565,7 @@ packages/app-desktop/integration-tests/util/evaluateWithRetry.js
|
||||
packages/app-desktop/integration-tests/util/extendedExpect.js
|
||||
packages/app-desktop/integration-tests/util/getImageSourceSize.js
|
||||
packages/app-desktop/integration-tests/util/getMainWindow.js
|
||||
packages/app-desktop/integration-tests/util/mockClipboard.js
|
||||
packages/app-desktop/integration-tests/util/retryOnFailure.js
|
||||
packages/app-desktop/integration-tests/util/setDarkMode.js
|
||||
packages/app-desktop/integration-tests/util/setFilePickerResponse.js
|
||||
@@ -849,6 +854,7 @@ packages/app-mobile/components/screens/NoteTagsDialog.js
|
||||
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/SearchScreen/SearchBar.js
|
||||
packages/app-mobile/components/screens/SearchScreen/SearchResults.test.js
|
||||
packages/app-mobile/components/screens/SearchScreen/SearchResults.js
|
||||
packages/app-mobile/components/screens/SearchScreen/index.js
|
||||
@@ -1005,6 +1011,7 @@ packages/editor/CodeMirror/CodeMirrorControl.js
|
||||
packages/editor/CodeMirror/configFromSettings.js
|
||||
packages/editor/CodeMirror/createEditor.test.js
|
||||
packages/editor/CodeMirror/createEditor.js
|
||||
packages/editor/CodeMirror/editorCommands/cutOrCopyText.js
|
||||
packages/editor/CodeMirror/editorCommands/duplicateLine.test.js
|
||||
packages/editor/CodeMirror/editorCommands/duplicateLine.js
|
||||
packages/editor/CodeMirror/editorCommands/editorCommands.js
|
||||
@@ -1047,6 +1054,7 @@ packages/editor/CodeMirror/extensions/rendering/addFormattingClasses.js
|
||||
packages/editor/CodeMirror/extensions/rendering/renderBlockImages.test.js
|
||||
packages/editor/CodeMirror/extensions/rendering/renderBlockImages.js
|
||||
packages/editor/CodeMirror/extensions/rendering/renderingExtension.js
|
||||
packages/editor/CodeMirror/extensions/rendering/replaceBackslashEscapes.js
|
||||
packages/editor/CodeMirror/extensions/rendering/replaceBulletLists.js
|
||||
packages/editor/CodeMirror/extensions/rendering/replaceCheckboxes.js
|
||||
packages/editor/CodeMirror/extensions/rendering/replaceDividers.js
|
||||
@@ -1113,10 +1121,12 @@ packages/editor/ProseMirror/plugins/detailsPlugin.js
|
||||
packages/editor/ProseMirror/plugins/imagePlugin.test.js
|
||||
packages/editor/ProseMirror/plugins/imagePlugin.js
|
||||
packages/editor/ProseMirror/plugins/inputRulesPlugin.js
|
||||
packages/editor/ProseMirror/plugins/joplinEditablePlugin/createEditorDialog.js
|
||||
packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.test.js
|
||||
packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.js
|
||||
packages/editor/ProseMirror/plugins/joplinEditablePlugin/postProcessRenderedHtml.js
|
||||
packages/editor/ProseMirror/plugins/joplinEditablePlugin/showCreateEditablePrompt.test.js
|
||||
packages/editor/ProseMirror/plugins/joplinEditablePlugin/showCreateEditablePrompt.js
|
||||
packages/editor/ProseMirror/plugins/joplinEditablePlugin/utils/createEditorDialog.js
|
||||
packages/editor/ProseMirror/plugins/joplinEditablePlugin/utils/postProcessRenderedHtml.js
|
||||
packages/editor/ProseMirror/plugins/joplinEditorApiPlugin.js
|
||||
packages/editor/ProseMirror/plugins/keymapPlugin.js
|
||||
packages/editor/ProseMirror/plugins/linkTooltipPlugin.test.js
|
||||
@@ -1131,6 +1141,7 @@ packages/editor/ProseMirror/schema.js
|
||||
packages/editor/ProseMirror/styles.js
|
||||
packages/editor/ProseMirror/testing/createTestEditor.js
|
||||
packages/editor/ProseMirror/testing/createTestEditorWithSerializer.js
|
||||
packages/editor/ProseMirror/testing/mockEditorApi.js
|
||||
packages/editor/ProseMirror/types.js
|
||||
packages/editor/ProseMirror/utils/SelectableNodeView.js
|
||||
packages/editor/ProseMirror/utils/UndoStackSynchronizer.js
|
||||
@@ -1144,6 +1155,7 @@ packages/editor/ProseMirror/utils/dom/showModal.js
|
||||
packages/editor/ProseMirror/utils/extractSelectedLinesTo.test.js
|
||||
packages/editor/ProseMirror/utils/extractSelectedLinesTo.js
|
||||
packages/editor/ProseMirror/utils/forEachHeading.js
|
||||
packages/editor/ProseMirror/utils/insertRenderedMarkdown.js
|
||||
packages/editor/ProseMirror/utils/jumpToHash.js
|
||||
packages/editor/ProseMirror/utils/makeLinksClickableInElement.js
|
||||
packages/editor/ProseMirror/utils/postprocessEditorOutput.test.js
|
||||
@@ -1361,6 +1373,7 @@ packages/lib/models/utils/getCanBeCollapsedFolderIds.js
|
||||
packages/lib/models/utils/getCollator.js
|
||||
packages/lib/models/utils/getConflictFolderId.js
|
||||
packages/lib/models/utils/isItemId.js
|
||||
packages/lib/models/utils/isJoplinServerVariant.js
|
||||
packages/lib/models/utils/itemCanBeEncrypted.js
|
||||
packages/lib/models/utils/onFolderDrop.test.js
|
||||
packages/lib/models/utils/onFolderDrop.js
|
||||
@@ -1396,6 +1409,7 @@ packages/lib/services/KeymapService_keysRegExp.js
|
||||
packages/lib/services/KvStore.js
|
||||
packages/lib/services/MigrationService.js
|
||||
packages/lib/services/NavService.js
|
||||
packages/lib/services/NotePositionService.js
|
||||
packages/lib/services/PostMessageService.js
|
||||
packages/lib/services/ReportService.test.js
|
||||
packages/lib/services/ReportService.js
|
||||
@@ -1411,6 +1425,7 @@ packages/lib/services/UndoRedoService.js
|
||||
packages/lib/services/WhenClause.test.js
|
||||
packages/lib/services/WhenClause.js
|
||||
packages/lib/services/commands/MenuUtils.js
|
||||
packages/lib/services/commands/ToolbarButtonUtils.test.js
|
||||
packages/lib/services/commands/ToolbarButtonUtils.js
|
||||
packages/lib/services/commands/commandsToMarkdownTable.js
|
||||
packages/lib/services/commands/focusEditorIfEditorCommand.js
|
||||
@@ -1779,6 +1794,7 @@ packages/renderer/MdToHtml/createEventHandlingAttrs.js
|
||||
packages/renderer/MdToHtml/linkReplacement.test.js
|
||||
packages/renderer/MdToHtml/linkReplacement.js
|
||||
packages/renderer/MdToHtml/renderMedia.js
|
||||
packages/renderer/MdToHtml/rules/abc.js
|
||||
packages/renderer/MdToHtml/rules/checkbox.js
|
||||
packages/renderer/MdToHtml/rules/code_inline.js
|
||||
packages/renderer/MdToHtml/rules/fence.js
|
||||
@@ -1821,14 +1837,18 @@ packages/tools/fuzzer/Client.js
|
||||
packages/tools/fuzzer/ClientPool.js
|
||||
packages/tools/fuzzer/Server.js
|
||||
packages/tools/fuzzer/constants.js
|
||||
packages/tools/fuzzer/doRandomAction.js
|
||||
packages/tools/fuzzer/model/FolderRecord.js
|
||||
packages/tools/fuzzer/sync-fuzzer.js
|
||||
packages/tools/fuzzer/types.js
|
||||
packages/tools/fuzzer/utils/ProgressBar.js
|
||||
packages/tools/fuzzer/utils/SeededRandom.js
|
||||
packages/tools/fuzzer/utils/getNumberProperty.js
|
||||
packages/tools/fuzzer/utils/getProperty.js
|
||||
packages/tools/fuzzer/utils/getStringProperty.js
|
||||
packages/tools/fuzzer/utils/logDiffDebug.js
|
||||
packages/tools/fuzzer/utils/openDebugSession.js
|
||||
packages/tools/fuzzer/utils/randomString.js
|
||||
packages/tools/fuzzer/utils/retryWithCount.js
|
||||
packages/tools/generate-database-types.js
|
||||
packages/tools/generate-images.js
|
||||
|
||||
13
.github/workflows/build-android.yml
vendored
13
.github/workflows/build-android.yml
vendored
@@ -21,19 +21,24 @@ jobs:
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '18'
|
||||
node-version: '24'
|
||||
cache: 'yarn'
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install Yarn
|
||||
run: |
|
||||
corepack enable
|
||||
|
||||
- name: Install
|
||||
run: yarn install
|
||||
env:
|
||||
SKIP_ONENOTE_CONVERTER_BUILD: 1
|
||||
|
||||
- name: Free disk space
|
||||
run: |
|
||||
sudo rm -rf /usr/share/dotnet || true
|
||||
sudo rm -rf /opt/ghc || true
|
||||
|
||||
- name: Assemble Android Release
|
||||
run: |
|
||||
|
||||
6
.github/workflows/build-macos-m1.yml
vendored
6
.github/workflows/build-macos-m1.yml
vendored
@@ -9,11 +9,9 @@ jobs:
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
- uses: olegtarasov/get-tag@v2.1.4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
# We need to pin the version to 18.15, because 18.16+ fails with this error:
|
||||
# https://github.com/facebook/react-native/issues/36440
|
||||
node-version: '18.20.8'
|
||||
node-version: '24'
|
||||
cache: 'yarn'
|
||||
|
||||
- name: Install Yarn
|
||||
|
||||
4
.github/workflows/github-actions-main.yml
vendored
4
.github/workflows/github-actions-main.yml
vendored
@@ -147,9 +147,9 @@ jobs:
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '18'
|
||||
node-version: '24'
|
||||
|
||||
- name: Free disk space
|
||||
if: runner.os == 'Linux'
|
||||
|
||||
@@ -51,9 +51,9 @@ runs:
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
if: ${{ runner.os != 'Windows' }}
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '18.20.8'
|
||||
node-version: '24'
|
||||
# Disable the cache on ARM runners. For now, we don't run "yarn install" on these
|
||||
# environments and this breaks actions/setup-node.
|
||||
# See https://github.com/laurent22/joplin/commit/47d0d3eb9e89153a609fb5441344da10904c6308#commitcomment-159577783.
|
||||
|
||||
27
.gitignore
vendored
27
.gitignore
vendored
@@ -69,7 +69,7 @@ docs/**/*.mustache
|
||||
packages/app-cli/app/LinkSelector.js
|
||||
packages/app-cli/app/app.js
|
||||
packages/app-cli/app/base-command.js
|
||||
packages/app-cli/app/cli-integration-tests.js
|
||||
packages/app-cli/app/cli-integration-tests.test.js
|
||||
packages/app-cli/app/command-apidoc.js
|
||||
packages/app-cli/app/command-attach.js
|
||||
packages/app-cli/app/command-batch.js
|
||||
@@ -397,10 +397,11 @@ packages/app-desktop/gui/Sidebar/Sidebar.js
|
||||
packages/app-desktop/gui/Sidebar/commands/focusElementSideBar.js
|
||||
packages/app-desktop/gui/Sidebar/commands/index.js
|
||||
packages/app-desktop/gui/Sidebar/hooks/useFocusHandler.js
|
||||
packages/app-desktop/gui/Sidebar/hooks/useOnItemClick.js
|
||||
packages/app-desktop/gui/Sidebar/hooks/useOnRenderItem.js
|
||||
packages/app-desktop/gui/Sidebar/hooks/useOnRenderListWrapper.js
|
||||
packages/app-desktop/gui/Sidebar/hooks/useOnSidebarKeyDownHandler.js
|
||||
packages/app-desktop/gui/Sidebar/hooks/useSelectedSidebarIndex.js
|
||||
packages/app-desktop/gui/Sidebar/hooks/useSelectedSidebarIndexes.js
|
||||
packages/app-desktop/gui/Sidebar/hooks/useSidebarCommandHandler.js
|
||||
packages/app-desktop/gui/Sidebar/hooks/useSidebarListData.js
|
||||
packages/app-desktop/gui/Sidebar/hooks/utils/toggleHeader.js
|
||||
@@ -441,6 +442,7 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/editAlarm.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/exportPdf.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/gotoAnything.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/hideModalMessage.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/importFrom.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/index.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/linkToNote.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/moveToFolder.js
|
||||
@@ -483,6 +485,7 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleSideBar.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleVisiblePanes.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/types.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/appDialogs.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/showFolderPicker.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/usePrintToCallback.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/useSyncDialogState.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/useWindowCommands.js
|
||||
@@ -534,6 +537,7 @@ packages/app-desktop/integration-tests/util/evaluateWithRetry.js
|
||||
packages/app-desktop/integration-tests/util/extendedExpect.js
|
||||
packages/app-desktop/integration-tests/util/getImageSourceSize.js
|
||||
packages/app-desktop/integration-tests/util/getMainWindow.js
|
||||
packages/app-desktop/integration-tests/util/mockClipboard.js
|
||||
packages/app-desktop/integration-tests/util/retryOnFailure.js
|
||||
packages/app-desktop/integration-tests/util/setDarkMode.js
|
||||
packages/app-desktop/integration-tests/util/setFilePickerResponse.js
|
||||
@@ -822,6 +826,7 @@ packages/app-mobile/components/screens/NoteTagsDialog.js
|
||||
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/SearchScreen/SearchBar.js
|
||||
packages/app-mobile/components/screens/SearchScreen/SearchResults.test.js
|
||||
packages/app-mobile/components/screens/SearchScreen/SearchResults.js
|
||||
packages/app-mobile/components/screens/SearchScreen/index.js
|
||||
@@ -978,6 +983,7 @@ packages/editor/CodeMirror/CodeMirrorControl.js
|
||||
packages/editor/CodeMirror/configFromSettings.js
|
||||
packages/editor/CodeMirror/createEditor.test.js
|
||||
packages/editor/CodeMirror/createEditor.js
|
||||
packages/editor/CodeMirror/editorCommands/cutOrCopyText.js
|
||||
packages/editor/CodeMirror/editorCommands/duplicateLine.test.js
|
||||
packages/editor/CodeMirror/editorCommands/duplicateLine.js
|
||||
packages/editor/CodeMirror/editorCommands/editorCommands.js
|
||||
@@ -1020,6 +1026,7 @@ packages/editor/CodeMirror/extensions/rendering/addFormattingClasses.js
|
||||
packages/editor/CodeMirror/extensions/rendering/renderBlockImages.test.js
|
||||
packages/editor/CodeMirror/extensions/rendering/renderBlockImages.js
|
||||
packages/editor/CodeMirror/extensions/rendering/renderingExtension.js
|
||||
packages/editor/CodeMirror/extensions/rendering/replaceBackslashEscapes.js
|
||||
packages/editor/CodeMirror/extensions/rendering/replaceBulletLists.js
|
||||
packages/editor/CodeMirror/extensions/rendering/replaceCheckboxes.js
|
||||
packages/editor/CodeMirror/extensions/rendering/replaceDividers.js
|
||||
@@ -1086,10 +1093,12 @@ packages/editor/ProseMirror/plugins/detailsPlugin.js
|
||||
packages/editor/ProseMirror/plugins/imagePlugin.test.js
|
||||
packages/editor/ProseMirror/plugins/imagePlugin.js
|
||||
packages/editor/ProseMirror/plugins/inputRulesPlugin.js
|
||||
packages/editor/ProseMirror/plugins/joplinEditablePlugin/createEditorDialog.js
|
||||
packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.test.js
|
||||
packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.js
|
||||
packages/editor/ProseMirror/plugins/joplinEditablePlugin/postProcessRenderedHtml.js
|
||||
packages/editor/ProseMirror/plugins/joplinEditablePlugin/showCreateEditablePrompt.test.js
|
||||
packages/editor/ProseMirror/plugins/joplinEditablePlugin/showCreateEditablePrompt.js
|
||||
packages/editor/ProseMirror/plugins/joplinEditablePlugin/utils/createEditorDialog.js
|
||||
packages/editor/ProseMirror/plugins/joplinEditablePlugin/utils/postProcessRenderedHtml.js
|
||||
packages/editor/ProseMirror/plugins/joplinEditorApiPlugin.js
|
||||
packages/editor/ProseMirror/plugins/keymapPlugin.js
|
||||
packages/editor/ProseMirror/plugins/linkTooltipPlugin.test.js
|
||||
@@ -1104,6 +1113,7 @@ packages/editor/ProseMirror/schema.js
|
||||
packages/editor/ProseMirror/styles.js
|
||||
packages/editor/ProseMirror/testing/createTestEditor.js
|
||||
packages/editor/ProseMirror/testing/createTestEditorWithSerializer.js
|
||||
packages/editor/ProseMirror/testing/mockEditorApi.js
|
||||
packages/editor/ProseMirror/types.js
|
||||
packages/editor/ProseMirror/utils/SelectableNodeView.js
|
||||
packages/editor/ProseMirror/utils/UndoStackSynchronizer.js
|
||||
@@ -1117,6 +1127,7 @@ packages/editor/ProseMirror/utils/dom/showModal.js
|
||||
packages/editor/ProseMirror/utils/extractSelectedLinesTo.test.js
|
||||
packages/editor/ProseMirror/utils/extractSelectedLinesTo.js
|
||||
packages/editor/ProseMirror/utils/forEachHeading.js
|
||||
packages/editor/ProseMirror/utils/insertRenderedMarkdown.js
|
||||
packages/editor/ProseMirror/utils/jumpToHash.js
|
||||
packages/editor/ProseMirror/utils/makeLinksClickableInElement.js
|
||||
packages/editor/ProseMirror/utils/postprocessEditorOutput.test.js
|
||||
@@ -1334,6 +1345,7 @@ packages/lib/models/utils/getCanBeCollapsedFolderIds.js
|
||||
packages/lib/models/utils/getCollator.js
|
||||
packages/lib/models/utils/getConflictFolderId.js
|
||||
packages/lib/models/utils/isItemId.js
|
||||
packages/lib/models/utils/isJoplinServerVariant.js
|
||||
packages/lib/models/utils/itemCanBeEncrypted.js
|
||||
packages/lib/models/utils/onFolderDrop.test.js
|
||||
packages/lib/models/utils/onFolderDrop.js
|
||||
@@ -1369,6 +1381,7 @@ packages/lib/services/KeymapService_keysRegExp.js
|
||||
packages/lib/services/KvStore.js
|
||||
packages/lib/services/MigrationService.js
|
||||
packages/lib/services/NavService.js
|
||||
packages/lib/services/NotePositionService.js
|
||||
packages/lib/services/PostMessageService.js
|
||||
packages/lib/services/ReportService.test.js
|
||||
packages/lib/services/ReportService.js
|
||||
@@ -1384,6 +1397,7 @@ packages/lib/services/UndoRedoService.js
|
||||
packages/lib/services/WhenClause.test.js
|
||||
packages/lib/services/WhenClause.js
|
||||
packages/lib/services/commands/MenuUtils.js
|
||||
packages/lib/services/commands/ToolbarButtonUtils.test.js
|
||||
packages/lib/services/commands/ToolbarButtonUtils.js
|
||||
packages/lib/services/commands/commandsToMarkdownTable.js
|
||||
packages/lib/services/commands/focusEditorIfEditorCommand.js
|
||||
@@ -1752,6 +1766,7 @@ packages/renderer/MdToHtml/createEventHandlingAttrs.js
|
||||
packages/renderer/MdToHtml/linkReplacement.test.js
|
||||
packages/renderer/MdToHtml/linkReplacement.js
|
||||
packages/renderer/MdToHtml/renderMedia.js
|
||||
packages/renderer/MdToHtml/rules/abc.js
|
||||
packages/renderer/MdToHtml/rules/checkbox.js
|
||||
packages/renderer/MdToHtml/rules/code_inline.js
|
||||
packages/renderer/MdToHtml/rules/fence.js
|
||||
@@ -1794,14 +1809,18 @@ packages/tools/fuzzer/Client.js
|
||||
packages/tools/fuzzer/ClientPool.js
|
||||
packages/tools/fuzzer/Server.js
|
||||
packages/tools/fuzzer/constants.js
|
||||
packages/tools/fuzzer/doRandomAction.js
|
||||
packages/tools/fuzzer/model/FolderRecord.js
|
||||
packages/tools/fuzzer/sync-fuzzer.js
|
||||
packages/tools/fuzzer/types.js
|
||||
packages/tools/fuzzer/utils/ProgressBar.js
|
||||
packages/tools/fuzzer/utils/SeededRandom.js
|
||||
packages/tools/fuzzer/utils/getNumberProperty.js
|
||||
packages/tools/fuzzer/utils/getProperty.js
|
||||
packages/tools/fuzzer/utils/getStringProperty.js
|
||||
packages/tools/fuzzer/utils/logDiffDebug.js
|
||||
packages/tools/fuzzer/utils/openDebugSession.js
|
||||
packages/tools/fuzzer/utils/randomString.js
|
||||
packages/tools/fuzzer/utils/retryWithCount.js
|
||||
packages/tools/generate-database-types.js
|
||||
packages/tools/generate-images.js
|
||||
|
||||
BIN
Assets/WebsiteAssets/images/md_plugins/abc/PeacherineRag.png
Normal file
BIN
Assets/WebsiteAssets/images/md_plugins/abc/PeacherineRag.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
BIN
Assets/WebsiteAssets/images/md_plugins/abc/Tablature.png
Normal file
BIN
Assets/WebsiteAssets/images/md_plugins/abc/Tablature.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
@@ -2,7 +2,7 @@
|
||||
# Build stage
|
||||
# =============================================================================
|
||||
|
||||
FROM node:18 AS builder
|
||||
FROM node:24 AS builder
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y \
|
||||
@@ -58,7 +58,7 @@ RUN --mount=type=cache,target=/build/.yarn/cache --mount=type=cache,target=/buil
|
||||
# from a smaller base image.
|
||||
# =============================================================================
|
||||
|
||||
FROM node:18-slim
|
||||
FROM node:24-slim
|
||||
|
||||
ARG user=joplin
|
||||
RUN useradd --create-home --shell /bin/bash $user
|
||||
|
||||
@@ -67,6 +67,45 @@ showHelp() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Accepts two versions in symver (a.b.c).
|
||||
# Echos -1 if the first version is less than the second,
|
||||
# 0 if they're equal,
|
||||
# 1 if the first version is greater than second.
|
||||
compareVersions() {
|
||||
V_MAJOR1=$(echo "$1"|cut -d. -f1)
|
||||
V_MAJOR2=$(echo "$2"|cut -d. -f1)
|
||||
|
||||
if [[ $V_MAJOR1 -lt $V_MAJOR2 ]] ; then
|
||||
echo -1
|
||||
return
|
||||
elif [[ $V_MAJOR1 -gt $V_MAJOR2 ]] ; then
|
||||
echo 1
|
||||
return
|
||||
fi
|
||||
|
||||
V_MINOR1=$(echo "$1"|cut -d. -f2)
|
||||
V_MINOR2=$(echo "$2"|cut -d. -f2)
|
||||
|
||||
if [[ $V_MINOR1 -lt $V_MINOR2 ]] ; then
|
||||
echo -1
|
||||
return
|
||||
elif [[ $V_MINOR1 -gt $V_MINOR2 ]] ; then
|
||||
echo 1
|
||||
return
|
||||
fi
|
||||
|
||||
V_PATCH1=$(echo "$1"|cut -d. -f3)
|
||||
V_PATCH2=$(echo "$2"|cut -d. -f3)
|
||||
|
||||
if [[ $V_PATCH1 -lt $V_PATCH2 ]] ; then
|
||||
echo -1
|
||||
elif [[ $V_PATCH1 -gt $V_PATCH2 ]] ; then
|
||||
echo 1
|
||||
else
|
||||
echo 0
|
||||
fi
|
||||
}
|
||||
|
||||
#-----------------------------------------------------
|
||||
# Setup Download Helper: DL
|
||||
#-----------------------------------------------------
|
||||
@@ -258,6 +297,15 @@ fi
|
||||
if [[ $DESKTOP =~ .*gnome.*|.*kde.*|.*xfce.*|.*mate.*|.*lxqt.*|.*unity.*|.*x-cinnamon.*|.*deepin.*|.*pantheon.*|.*lxde.*|.*i3.*|.*sway.* ]] || [[ `command -v update-desktop-database` ]]; then
|
||||
DATA_HOME=${XDG_DATA_HOME:-~/.local/share}
|
||||
DESKTOP_FILE_LOCATION="$DATA_HOME/applications"
|
||||
|
||||
# Only later versions of Joplin default to Wayland
|
||||
IS_WAYLAND_BY_DEFAULT=$(compareVersions "$RELEASE_VERSION" "3.5.6")
|
||||
# Joplin has a different startup WM class on Wayland and X11:
|
||||
STARTUP_WM_CLASS=Joplin
|
||||
if [[ $XDG_SESSION_TYPE != "x11" && $IS_WAYLAND_BY_DEFAULT == "1" ]]; then
|
||||
STARTUP_WM_CLASS=@joplin/app-desktop
|
||||
fi
|
||||
|
||||
# Only delete the desktop file if it will be replaced
|
||||
rm -f "$DESKTOP_FILE_LOCATION/appimagekit-joplin.desktop"
|
||||
|
||||
@@ -272,7 +320,9 @@ Name=Joplin
|
||||
Comment=Joplin for Desktop
|
||||
Exec=env APPIMAGELAUNCHER_DISABLE=TRUE "${INSTALL_DIR}/Joplin.AppImage" ${SANDBOXPARAM} %u
|
||||
Icon=joplin
|
||||
StartupWMClass=Joplin
|
||||
# This will be different between Wayland and X11. On Wayland, the startup
|
||||
# WM class is "@joplin/app-desktop". On X11, it's "Joplin".
|
||||
StartupWMClass=${STARTUP_WM_CLASS}
|
||||
Type=Application
|
||||
Categories=Office;
|
||||
MimeType=x-scheme-handler/joplin;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- DONATELINKS -->
|
||||
[](https://www.paypal.com/donate/?business=E8JMYD2LQ8MMA&no_recurring=0&item_name=I+rely+on+donations+to+maintain+and+improve+the+Joplin+open+source+project.+Thank+you+for+your+help+-+it+makes+a+difference%21¤cy_code=EUR) [](https://github.com/sponsors/laurent22/) [](https://www.patreon.com/joplin) [](https://joplinapp.org/donate/#donations)
|
||||
[](https://www.paypal.com/donate/?hosted_button_id=WQCERTSSLCC7U) [](https://github.com/sponsors/laurent22/) [](https://www.patreon.com/joplin) [](https://joplinapp.org/donate/#donations)
|
||||
<!-- DONATELINKS -->
|
||||
|
||||
<img width="64" src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/LinuxIcons/256x256.png" align="left" style="margin-right:15px"/>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"version": "latest",
|
||||
"excluded_platforms": ["aarch64-darwin", "x86_64-darwin"],
|
||||
},
|
||||
"git": "2.48.1",
|
||||
"git": "2.50.0",
|
||||
},
|
||||
"shell": {
|
||||
"init_hook": [
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
services:
|
||||
|
||||
postgresql-master:
|
||||
image: 'bitnamilegacy/postgresql:17.4.0'
|
||||
image: 'bitnamilegacy/postgresql:17.5.0'
|
||||
ports:
|
||||
- '5432:5432'
|
||||
environment:
|
||||
@@ -36,7 +36,7 @@ services:
|
||||
- POSTGRESQL_EXTRA_FLAGS=-c work_mem=100000 -c log_statement=all
|
||||
|
||||
postgresql-slave:
|
||||
image: 'bitnamilegacy/postgresql:17.4.0'
|
||||
image: 'bitnamilegacy/postgresql:17.5.0'
|
||||
ports:
|
||||
- '5433:5432'
|
||||
depends_on:
|
||||
|
||||
270
packages/app-cli/app/cli-integration-tests.test.ts
Normal file
270
packages/app-cli/app/cli-integration-tests.test.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import * as fs from 'fs-extra';
|
||||
import Logger, { TargetType } from '@joplin/utils/Logger';
|
||||
import { dirname } from '@joplin/lib/path-utils';
|
||||
const { DatabaseDriverNode } = require('@joplin/lib/database-driver-node.js');
|
||||
import JoplinDatabase from '@joplin/lib/JoplinDatabase';
|
||||
import BaseModel from '@joplin/lib/BaseModel';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { node } from 'execa';
|
||||
import { splitCommandString } from '@joplin/utils';
|
||||
const nodeSqlite = require('sqlite3');
|
||||
const { loadKeychainServiceAndSettings } = require('@joplin/lib/services/SettingUtils');
|
||||
const { default: shimInitCli } = require('./utils/shimInitCli');
|
||||
|
||||
const baseDir = `${dirname(__dirname)}/tests/cli-integration`;
|
||||
const joplinAppPath = `${__dirname}/main.js`;
|
||||
|
||||
shimInitCli({ nodeSqlite, appVersion: () => require('../package.json').version, keytar: null });
|
||||
require('@joplin/lib/testing/test-utils');
|
||||
|
||||
|
||||
interface Client {
|
||||
id: number;
|
||||
profileDir: string;
|
||||
}
|
||||
|
||||
function createClient(id: number): Client {
|
||||
return {
|
||||
id: id,
|
||||
profileDir: `${baseDir}/client${id}`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
async function execCommand(client: Client, command: string) {
|
||||
const result = await node(
|
||||
joplinAppPath,
|
||||
['--update-geolocation-disabled', '--env', 'dev', '--profile', client.profileDir, ...splitCommandString(command)],
|
||||
);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(`Command failed: ${command}:\nstderr: ${result.stderr}\nstdout: ${result.stdout}`);
|
||||
}
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
async function clearDatabase(db: JoplinDatabase) {
|
||||
await db.transactionExecBatch(['DELETE FROM folders', 'DELETE FROM notes', 'DELETE FROM tags', 'DELETE FROM note_tags', 'DELETE FROM resources', 'DELETE FROM deleted_items']);
|
||||
}
|
||||
|
||||
|
||||
describe('cli-integration-tests', () => {
|
||||
let client: Client;
|
||||
let db: JoplinDatabase;
|
||||
|
||||
beforeAll(async () => {
|
||||
await fs.remove(baseDir);
|
||||
await fs.mkdir(baseDir);
|
||||
|
||||
client = createClient(1);
|
||||
// Initialize the database by running a client command and exiting.
|
||||
await execCommand(client, 'version');
|
||||
|
||||
const dbLogger = new Logger();
|
||||
dbLogger.addTarget(TargetType.Console);
|
||||
dbLogger.setLevel(Logger.LEVEL_WARN);
|
||||
|
||||
db = new JoplinDatabase(new DatabaseDriverNode());
|
||||
db.setLogger(dbLogger);
|
||||
|
||||
await db.open({ name: `${client.profileDir}/database.sqlite` });
|
||||
BaseModel.setDb(db);
|
||||
Setting.setConstant('rootProfileDir', client.profileDir);
|
||||
Setting.setConstant('profileDir', client.profileDir);
|
||||
await loadKeychainServiceAndSettings([]);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await clearDatabase(db);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'version',
|
||||
'help',
|
||||
])('should run command %j without crashing', async (command) => {
|
||||
await execCommand(client, command);
|
||||
});
|
||||
|
||||
it('should support the \'ls\' command', async () => {
|
||||
await execCommand(client, 'mkbook nb1');
|
||||
await execCommand(client, 'mknote note1');
|
||||
await execCommand(client, 'mknote note2');
|
||||
const r = await execCommand(client, 'ls');
|
||||
|
||||
expect(r.indexOf('note1') >= 0).toBe(true);
|
||||
expect(r.indexOf('note2') >= 0).toBe(true);
|
||||
});
|
||||
|
||||
it('should support the \'mv\' command', async () => {
|
||||
await execCommand(client, 'mkbook nb2');
|
||||
await execCommand(client, 'mkbook nb1');
|
||||
await execCommand(client, 'mknote n1');
|
||||
await execCommand(client, 'mv n1 nb2');
|
||||
|
||||
const f1 = await Folder.loadByTitle('nb1');
|
||||
const f2 = await Folder.loadByTitle('nb2');
|
||||
let notes1 = await Note.previews(f1.id);
|
||||
let notes2 = await Note.previews(f2.id);
|
||||
|
||||
expect(notes1.length).toBe(0);
|
||||
expect(notes2.length).toBe(1);
|
||||
|
||||
await execCommand(client, 'mknote note1');
|
||||
await execCommand(client, 'mknote note2');
|
||||
await execCommand(client, 'mknote note3');
|
||||
await execCommand(client, 'mknote blabla');
|
||||
|
||||
notes1 = await Note.previews(f1.id);
|
||||
notes2 = await Note.previews(f2.id);
|
||||
|
||||
expect(notes1.length).toBe(4);
|
||||
expect(notes2.length).toBe(1);
|
||||
|
||||
await execCommand(client, 'mv \'note*\' nb2');
|
||||
|
||||
notes2 = await Note.previews(f2.id);
|
||||
notes1 = await Note.previews(f1.id);
|
||||
|
||||
expect(notes1.length).toBe(1);
|
||||
expect(notes2.length).toBe(4);
|
||||
});
|
||||
|
||||
it('should support the \'use\' command', async () => {
|
||||
await execCommand(client, 'mkbook nb1');
|
||||
await execCommand(client, 'mkbook nb2');
|
||||
await execCommand(client, 'mknote n1');
|
||||
await execCommand(client, 'mknote n2');
|
||||
|
||||
const f1 = await Folder.loadByTitle('nb1');
|
||||
const f2 = await Folder.loadByTitle('nb2');
|
||||
let notes1 = await Note.previews(f1.id);
|
||||
let notes2 = await Note.previews(f2.id);
|
||||
|
||||
expect(notes1.length).toBe(0);
|
||||
expect(notes2.length).toBe(2);
|
||||
|
||||
await execCommand(client, 'use nb1');
|
||||
await execCommand(client, 'mknote note2');
|
||||
await execCommand(client, 'mknote note3');
|
||||
|
||||
notes1 = await Note.previews(f1.id);
|
||||
notes2 = await Note.previews(f2.id);
|
||||
|
||||
expect(notes1.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should support creating and removing folders', async () => {
|
||||
await execCommand(client, 'mkbook nb1');
|
||||
|
||||
let folders = await Folder.all();
|
||||
expect(folders.length).toBe(1);
|
||||
expect(folders[0].title).toBe('nb1');
|
||||
|
||||
await execCommand(client, 'mkbook nb1');
|
||||
|
||||
folders = await Folder.all();
|
||||
expect(folders.length).toBe(2);
|
||||
expect(folders[0].title).toBe('nb1');
|
||||
expect(folders[1].title).toBe('nb1');
|
||||
|
||||
await execCommand(client, 'rmbook -p -f nb1');
|
||||
|
||||
folders = await Folder.all();
|
||||
expect(folders.length).toBe(1);
|
||||
|
||||
await execCommand(client, 'rmbook -p -f nb1');
|
||||
|
||||
folders = await Folder.all();
|
||||
expect(folders.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should support creating and removing notes', async () => {
|
||||
await execCommand(client, 'mkbook nb1');
|
||||
await execCommand(client, 'mknote n1');
|
||||
|
||||
let notes = await Note.all();
|
||||
expect(notes.length).toBe(1);
|
||||
expect(notes[0].title).toBe('n1');
|
||||
|
||||
await execCommand(client, 'rmnote -p -f n1');
|
||||
notes = await Note.all();
|
||||
expect(notes.length).toBe(0);
|
||||
|
||||
await execCommand(client, 'mknote n1');
|
||||
await execCommand(client, 'mknote n2');
|
||||
|
||||
notes = await Note.all();
|
||||
expect(notes.length).toBe(2);
|
||||
|
||||
// Should fail to delete a non-existent note
|
||||
let failed = false;
|
||||
try {
|
||||
await execCommand(client, 'rmnote -f \'blabla*\'');
|
||||
} catch (error) {
|
||||
failed = true;
|
||||
}
|
||||
expect(failed).toBe(true);
|
||||
|
||||
notes = await Note.all();
|
||||
expect(notes.length).toBe(2);
|
||||
|
||||
await execCommand(client, 'rmnote -f -p \'n*\'');
|
||||
|
||||
notes = await Note.all();
|
||||
expect(notes.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should support listing the contents of notes', async () => {
|
||||
await execCommand(client, 'mkbook nb1');
|
||||
await execCommand(client, 'mknote mynote');
|
||||
|
||||
const folder = await Folder.loadByTitle('nb1');
|
||||
const note = await Note.loadFolderNoteByField(folder.id, 'title', 'mynote');
|
||||
|
||||
let r = await execCommand(client, 'cat mynote');
|
||||
expect(r).toContain('mynote');
|
||||
expect(r).not.toContain(note.id);
|
||||
|
||||
r = await execCommand(client, 'cat -v mynote');
|
||||
expect(r).toContain(note.id);
|
||||
});
|
||||
|
||||
it('should support changing settings with config', async () => {
|
||||
await execCommand(client, 'config editor vim');
|
||||
await Setting.reset();
|
||||
await Setting.load();
|
||||
expect(Setting.value('editor')).toBe('vim');
|
||||
|
||||
await execCommand(client, 'config editor subl');
|
||||
await Setting.reset();
|
||||
await Setting.load();
|
||||
expect(Setting.value('editor')).toBe('subl');
|
||||
|
||||
const r = await execCommand(client, 'config');
|
||||
expect(r.indexOf('editor') >= 0).toBe(true);
|
||||
expect(r.indexOf('subl') >= 0).toBe(true);
|
||||
});
|
||||
|
||||
it('should support copying folders with cp', async () => {
|
||||
await execCommand(client, 'mkbook nb2');
|
||||
await execCommand(client, 'mkbook nb1');
|
||||
await execCommand(client, 'mknote n1');
|
||||
|
||||
await execCommand(client, 'cp n1');
|
||||
|
||||
const f1 = await Folder.loadByTitle('nb1');
|
||||
const f2 = await Folder.loadByTitle('nb2');
|
||||
let notes = await Note.previews(f1.id);
|
||||
|
||||
expect(notes.length).toBe(2);
|
||||
|
||||
await execCommand(client, 'cp n1 nb2');
|
||||
const notesF1 = await Note.previews(f1.id);
|
||||
expect(notesF1.length).toBe(2);
|
||||
notes = await Note.previews(f2.id);
|
||||
expect(notes.length).toBe(1);
|
||||
expect(notes[0].title).toBe(notesF1[0].title);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,300 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import * as fs from 'fs-extra';
|
||||
import Logger, { TargetType } from '@joplin/utils/Logger';
|
||||
import { dirname } from '@joplin/lib/path-utils';
|
||||
const { DatabaseDriverNode } = require('@joplin/lib/database-driver-node.js');
|
||||
import JoplinDatabase from '@joplin/lib/JoplinDatabase';
|
||||
import BaseModel from '@joplin/lib/BaseModel';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
const { sprintf } = require('sprintf-js');
|
||||
const exec = require('child_process').exec;
|
||||
const nodeSqlite = require('sqlite3');
|
||||
const { loadKeychainServiceAndSettings } = require('@joplin/lib/services/SettingUtils');
|
||||
const { default: shimInitCli } = require('./utils/shimInitCli');
|
||||
|
||||
const baseDir = `${dirname(__dirname)}/tests/cli-integration`;
|
||||
const joplinAppPath = `${__dirname}/main.js`;
|
||||
|
||||
shimInitCli({ nodeSqlite, appVersion: () => require('../package.json').version, keytar: null });
|
||||
require('@joplin/lib/testing/test-utils');
|
||||
|
||||
const logger = new Logger();
|
||||
logger.addTarget(TargetType.Console);
|
||||
logger.setLevel(Logger.LEVEL_ERROR);
|
||||
|
||||
const dbLogger = new Logger();
|
||||
dbLogger.addTarget(TargetType.Console);
|
||||
dbLogger.setLevel(Logger.LEVEL_INFO);
|
||||
|
||||
const db = new JoplinDatabase(new DatabaseDriverNode());
|
||||
db.setLogger(dbLogger);
|
||||
|
||||
interface Client {
|
||||
id: number;
|
||||
profileDir: string;
|
||||
}
|
||||
|
||||
function createClient(id: number): Client {
|
||||
return {
|
||||
id: id,
|
||||
profileDir: `${baseDir}/client${id}`,
|
||||
};
|
||||
}
|
||||
|
||||
const client = createClient(1);
|
||||
|
||||
function execCommand(client: Client, command: string) {
|
||||
const exePath = `node ${joplinAppPath}`;
|
||||
const cmd = `${exePath} --update-geolocation-disabled --env dev --profile ${client.profileDir} ${command}`;
|
||||
logger.info(`${client.id}: ${command}`);
|
||||
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
exec(cmd, (error: string, stdout: string, stderr: string) => {
|
||||
if (error) {
|
||||
logger.error(stderr);
|
||||
reject(error);
|
||||
} else {
|
||||
resolve(stdout.trim());
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function assertTrue(v: unknown) {
|
||||
if (!v) throw new Error(sprintf('Expected "true", got "%s"."', v));
|
||||
process.stdout.write('.');
|
||||
}
|
||||
|
||||
function assertFalse(v: unknown) {
|
||||
if (v) throw new Error(sprintf('Expected "false", got "%s"."', v));
|
||||
process.stdout.write('.');
|
||||
}
|
||||
|
||||
function assertEquals(expected: unknown, real: unknown) {
|
||||
if (expected !== real) throw new Error(sprintf('Expecting "%s", got "%s"', expected, real));
|
||||
process.stdout.write('.');
|
||||
}
|
||||
|
||||
async function clearDatabase() {
|
||||
await db.transactionExecBatch(['DELETE FROM folders', 'DELETE FROM notes', 'DELETE FROM tags', 'DELETE FROM note_tags', 'DELETE FROM resources', 'DELETE FROM deleted_items']);
|
||||
}
|
||||
|
||||
const testUnits: Record<string, ()=> Promise<void>> = {};
|
||||
|
||||
testUnits.testFolders = async () => {
|
||||
await execCommand(client, 'mkbook nb1');
|
||||
|
||||
let folders = await Folder.all();
|
||||
assertEquals(1, folders.length);
|
||||
assertEquals('nb1', folders[0].title);
|
||||
|
||||
await execCommand(client, 'mkbook nb1');
|
||||
|
||||
folders = await Folder.all();
|
||||
assertEquals(2, folders.length);
|
||||
assertEquals('nb1', folders[0].title);
|
||||
assertEquals('nb1', folders[1].title);
|
||||
|
||||
await execCommand(client, 'rmbook -p -f nb1');
|
||||
|
||||
folders = await Folder.all();
|
||||
assertEquals(1, folders.length);
|
||||
|
||||
await execCommand(client, 'rmbook -p -f nb1');
|
||||
|
||||
folders = await Folder.all();
|
||||
assertEquals(0, folders.length);
|
||||
};
|
||||
|
||||
testUnits.testNotes = async () => {
|
||||
await execCommand(client, 'mkbook nb1');
|
||||
await execCommand(client, 'mknote n1');
|
||||
|
||||
let notes = await Note.all();
|
||||
assertEquals(1, notes.length);
|
||||
assertEquals('n1', notes[0].title);
|
||||
|
||||
await execCommand(client, 'rmnote -p -f n1');
|
||||
notes = await Note.all();
|
||||
assertEquals(0, notes.length);
|
||||
|
||||
await execCommand(client, 'mknote n1');
|
||||
await execCommand(client, 'mknote n2');
|
||||
|
||||
notes = await Note.all();
|
||||
assertEquals(2, notes.length);
|
||||
|
||||
// Should fail to delete a non-existent note
|
||||
let failed = false;
|
||||
try {
|
||||
await execCommand(client, 'rmnote -f \'blabla*\'');
|
||||
} catch (error) {
|
||||
failed = true;
|
||||
}
|
||||
assertEquals(failed, true);
|
||||
|
||||
notes = await Note.all();
|
||||
assertEquals(2, notes.length);
|
||||
|
||||
await execCommand(client, 'rmnote -f -p \'n*\'');
|
||||
|
||||
notes = await Note.all();
|
||||
assertEquals(0, notes.length);
|
||||
};
|
||||
|
||||
testUnits.testCat = async () => {
|
||||
await execCommand(client, 'mkbook nb1');
|
||||
await execCommand(client, 'mknote mynote');
|
||||
|
||||
const folder = await Folder.loadByTitle('nb1');
|
||||
const note = await Note.loadFolderNoteByField(folder.id, 'title', 'mynote');
|
||||
|
||||
let r = await execCommand(client, 'cat mynote');
|
||||
assertTrue(r.indexOf('mynote') >= 0);
|
||||
assertFalse(r.indexOf(note.id) >= 0);
|
||||
|
||||
r = await execCommand(client, 'cat -v mynote');
|
||||
assertTrue(r.indexOf(note.id) >= 0);
|
||||
};
|
||||
|
||||
testUnits.testConfig = async () => {
|
||||
await execCommand(client, 'config editor vim');
|
||||
await Setting.reset();
|
||||
await Setting.load();
|
||||
assertEquals('vim', Setting.value('editor'));
|
||||
|
||||
await execCommand(client, 'config editor subl');
|
||||
await Setting.reset();
|
||||
await Setting.load();
|
||||
assertEquals('subl', Setting.value('editor'));
|
||||
|
||||
const r = await execCommand(client, 'config');
|
||||
assertTrue(r.indexOf('editor') >= 0);
|
||||
assertTrue(r.indexOf('subl') >= 0);
|
||||
};
|
||||
|
||||
testUnits.testCp = async () => {
|
||||
await execCommand(client, 'mkbook nb2');
|
||||
await execCommand(client, 'mkbook nb1');
|
||||
await execCommand(client, 'mknote n1');
|
||||
|
||||
await execCommand(client, 'cp n1');
|
||||
|
||||
const f1 = await Folder.loadByTitle('nb1');
|
||||
const f2 = await Folder.loadByTitle('nb2');
|
||||
let notes = await Note.previews(f1.id);
|
||||
|
||||
assertEquals(2, notes.length);
|
||||
|
||||
await execCommand(client, 'cp n1 nb2');
|
||||
const notesF1 = await Note.previews(f1.id);
|
||||
assertEquals(2, notesF1.length);
|
||||
notes = await Note.previews(f2.id);
|
||||
assertEquals(1, notes.length);
|
||||
assertEquals(notesF1[0].title, notes[0].title);
|
||||
};
|
||||
|
||||
testUnits.testLs = async () => {
|
||||
await execCommand(client, 'mkbook nb1');
|
||||
await execCommand(client, 'mknote note1');
|
||||
await execCommand(client, 'mknote note2');
|
||||
const r = await execCommand(client, 'ls');
|
||||
|
||||
assertTrue(r.indexOf('note1') >= 0);
|
||||
assertTrue(r.indexOf('note2') >= 0);
|
||||
};
|
||||
|
||||
testUnits.testMv = async () => {
|
||||
await execCommand(client, 'mkbook nb2');
|
||||
await execCommand(client, 'mkbook nb1');
|
||||
await execCommand(client, 'mknote n1');
|
||||
await execCommand(client, 'mv n1 nb2');
|
||||
|
||||
const f1 = await Folder.loadByTitle('nb1');
|
||||
const f2 = await Folder.loadByTitle('nb2');
|
||||
let notes1 = await Note.previews(f1.id);
|
||||
let notes2 = await Note.previews(f2.id);
|
||||
|
||||
assertEquals(0, notes1.length);
|
||||
assertEquals(1, notes2.length);
|
||||
|
||||
await execCommand(client, 'mknote note1');
|
||||
await execCommand(client, 'mknote note2');
|
||||
await execCommand(client, 'mknote note3');
|
||||
await execCommand(client, 'mknote blabla');
|
||||
|
||||
notes1 = await Note.previews(f1.id);
|
||||
notes2 = await Note.previews(f2.id);
|
||||
|
||||
assertEquals(4, notes1.length);
|
||||
assertEquals(1, notes2.length);
|
||||
|
||||
await execCommand(client, 'mv \'note*\' nb2');
|
||||
|
||||
notes2 = await Note.previews(f2.id);
|
||||
notes1 = await Note.previews(f1.id);
|
||||
|
||||
assertEquals(1, notes1.length);
|
||||
assertEquals(4, notes2.length);
|
||||
};
|
||||
|
||||
testUnits.testUse = async () => {
|
||||
await execCommand(client, 'mkbook nb1');
|
||||
await execCommand(client, 'mkbook nb2');
|
||||
await execCommand(client, 'mknote n1');
|
||||
await execCommand(client, 'mknote n2');
|
||||
|
||||
const f1 = await Folder.loadByTitle('nb1');
|
||||
const f2 = await Folder.loadByTitle('nb2');
|
||||
let notes1 = await Note.previews(f1.id);
|
||||
let notes2 = await Note.previews(f2.id);
|
||||
|
||||
assertEquals(0, notes1.length);
|
||||
assertEquals(2, notes2.length);
|
||||
|
||||
await execCommand(client, 'use nb1');
|
||||
await execCommand(client, 'mknote note2');
|
||||
await execCommand(client, 'mknote note3');
|
||||
|
||||
notes1 = await Note.previews(f1.id);
|
||||
notes2 = await Note.previews(f2.id);
|
||||
|
||||
assertEquals(2, notes1.length);
|
||||
assertEquals(2, notes2.length);
|
||||
};
|
||||
|
||||
async function main() {
|
||||
await fs.remove(baseDir);
|
||||
|
||||
logger.info(await execCommand(client, 'version'));
|
||||
|
||||
await db.open({ name: `${client.profileDir}/database.sqlite` });
|
||||
BaseModel.setDb(db);
|
||||
Setting.setConstant('rootProfileDir', client.profileDir);
|
||||
Setting.setConstant('profileDir', client.profileDir);
|
||||
await loadKeychainServiceAndSettings([]);
|
||||
|
||||
let onlyThisTest = 'testMv';
|
||||
onlyThisTest = '';
|
||||
|
||||
for (const n in testUnits) {
|
||||
if (!testUnits.hasOwnProperty(n)) continue;
|
||||
if (onlyThisTest && n !== onlyThisTest) continue;
|
||||
|
||||
await clearDatabase();
|
||||
const testName = n.substr(4).toLowerCase();
|
||||
process.stdout.write(`${testName}: `);
|
||||
await testUnits[n]();
|
||||
console.info('');
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.info('');
|
||||
logger.error(error);
|
||||
});
|
||||
@@ -12,7 +12,7 @@ class Command extends BaseCommand {
|
||||
}
|
||||
|
||||
public override async action() {
|
||||
this.stdout(versionInfo(require('./package.json'), {}).message);
|
||||
this.stdout(versionInfo(require('../package.json'), {}).message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const { afterEachCleanUp } = require('@joplin/lib/testing/test-utils.js');
|
||||
const { shimInit } = require('@joplin/lib/shim-init-node.js');
|
||||
const { default: shimInitCli } = require('./app/utils/shimInitCli');
|
||||
const shim = require('@joplin/lib/shim').default;
|
||||
const sharp = require('sharp');
|
||||
const nodeSqlite = require('sqlite3');
|
||||
@@ -13,7 +13,7 @@ try {
|
||||
keytar = null;
|
||||
}
|
||||
|
||||
shimInit({ sharp, keytar, nodeSqlite });
|
||||
shimInitCli({ sharp, nodeSqlite, appVersion: () => require('./package.json').version, keytar });
|
||||
|
||||
global.afterEach(async () => {
|
||||
await afterEachCleanUp();
|
||||
|
||||
@@ -52,7 +52,7 @@ describe('MarkupToHtml', () => {
|
||||
pluginAssets: [],
|
||||
};
|
||||
|
||||
expect(await service.render(MarkupLanguage.Html, testString, {}, {})).toMatchObject(expectedOutput);
|
||||
expect(await service.render(MarkupLanguage.Markdown, testString, {}, {})).toMatchObject(expectedOutput);
|
||||
expect(await service.render(MarkupLanguage.Html, testString, {}, { })).toMatchObject(expectedOutput);
|
||||
expect(await service.render(MarkupLanguage.Markdown, testString, {}, { })).toMatchObject(expectedOutput);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@ import { defaultWindowId } from '@joplin/lib/reducer';
|
||||
import { msleep, Second } from '@joplin/utils/time';
|
||||
import determineBaseAppDirs from '@joplin/lib/determineBaseAppDirs';
|
||||
import getAppName from '@joplin/lib/getAppName';
|
||||
import { execCommand } from '@joplin/utils';
|
||||
|
||||
interface RendererProcessQuitReply {
|
||||
canClose: boolean;
|
||||
@@ -810,6 +811,33 @@ export default class ElectronAppWrapper {
|
||||
return this.customProtocolHandler_;
|
||||
}
|
||||
|
||||
private async fixLinuxAccessibility_() {
|
||||
if (this.electronApp().accessibilitySupportEnabled) return;
|
||||
|
||||
const isOrcaRunning = async () => {
|
||||
if (!shim.isLinux()) return false;
|
||||
try {
|
||||
const matchingProcesses = await execCommand(['ps', '--no-headers', '-C', 'orca'], { quiet: true });
|
||||
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);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// 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.electronApp().setAccessibilitySupportEnabled(true);
|
||||
}
|
||||
}
|
||||
|
||||
public async start() {
|
||||
// Since we are doing other async things before creating the window, we might miss
|
||||
// the "ready" event. So we use the function below to make sure that the app is ready.
|
||||
@@ -818,6 +846,8 @@ export default class ElectronAppWrapper {
|
||||
const alreadyRunning = await this.ensureSingleInstance();
|
||||
if (alreadyRunning) return;
|
||||
|
||||
await this.fixLinuxAccessibility_();
|
||||
|
||||
this.customProtocolHandler_ = handleCustomProtocols();
|
||||
this.createWindow();
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import Setting from '@joplin/lib/models/Setting';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
const { friendlySafeFilename } = require('@joplin/lib/path-utils');
|
||||
import time from '@joplin/lib/time';
|
||||
import { BrowserWindow } from 'electron';
|
||||
import { BrowserWindow, BrowserWindowConstructorOptions } from 'electron';
|
||||
const md5 = require('md5');
|
||||
const url = require('url');
|
||||
|
||||
@@ -62,8 +62,10 @@ export default class InteropServiceHelper {
|
||||
|
||||
htmlFile = await this.exportNoteToHtmlFile(noteId, exportOptions);
|
||||
|
||||
const windowOptions = {
|
||||
show: false,
|
||||
const windowOptions: BrowserWindowConstructorOptions = {
|
||||
// Work around a printing issue: As of Electron 39, if the window is initially hidden, printing crashes the app.
|
||||
// This only seems to be necessary on Linux.
|
||||
show: shim.isLinux(),
|
||||
};
|
||||
|
||||
win = bridge().newBrowserWindow(windowOptions);
|
||||
@@ -120,6 +122,9 @@ export default class InteropServiceHelper {
|
||||
//
|
||||
// 2025-05-03: Windows and MacOS also need the window.print() workaround.
|
||||
// See https://github.com/electron/electron/pull/46937.
|
||||
//
|
||||
// 2025-10-30: window.print() now causes a crash on Linux -- switch back to the
|
||||
// other method.
|
||||
|
||||
const applyWorkaround = true;
|
||||
if (applyWorkaround) {
|
||||
|
||||
@@ -52,7 +52,7 @@ describe('app.reducer', () => {
|
||||
...createAppDefaultState({}),
|
||||
backgroundWindows: {
|
||||
testWindow: {
|
||||
...createAppDefaultWindowState(null),
|
||||
...createAppDefaultWindowState(),
|
||||
windowId: 'testWindow',
|
||||
|
||||
visibleDialogs: {
|
||||
|
||||
@@ -30,17 +30,6 @@ export interface NoteIdToScrollPercent {
|
||||
[noteId: string]: number;
|
||||
}
|
||||
|
||||
type RichTextEditorSelectionBookmark = unknown;
|
||||
|
||||
export interface EditorCursorLocations {
|
||||
readonly richText?: RichTextEditorSelectionBookmark;
|
||||
readonly markdown?: number;
|
||||
}
|
||||
|
||||
export interface NoteIdToEditorCursorLocations {
|
||||
[noteId: string]: EditorCursorLocations;
|
||||
}
|
||||
|
||||
export interface VisibleDialogs {
|
||||
[dialogKey: string]: boolean;
|
||||
}
|
||||
@@ -53,9 +42,6 @@ export interface AppWindowState extends WindowState {
|
||||
devToolsVisible: boolean;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
watchedResources: any;
|
||||
|
||||
lastEditorScrollPercents: NoteIdToScrollPercent;
|
||||
lastEditorCursorLocations: NoteIdToEditorCursorLocations;
|
||||
}
|
||||
|
||||
interface BackgroundWindowStates {
|
||||
@@ -79,7 +65,7 @@ export interface AppState extends State, AppWindowState {
|
||||
isResettingLayout: boolean;
|
||||
}
|
||||
|
||||
export const createAppDefaultWindowState = (globalState: AppState|null): AppWindowState => {
|
||||
export const createAppDefaultWindowState = (): AppWindowState => {
|
||||
return {
|
||||
...defaultWindowState,
|
||||
visibleDialogs: {},
|
||||
@@ -88,12 +74,6 @@ export const createAppDefaultWindowState = (globalState: AppState|null): AppWind
|
||||
editorCodeView: true,
|
||||
devToolsVisible: false,
|
||||
watchedResources: {},
|
||||
|
||||
// Maintain the scroll and cursor location for secondary windows separate from the
|
||||
// main window. This prevents scrolling in a secondary window from changing/resetting
|
||||
// the default scroll position in the main window:
|
||||
lastEditorCursorLocations: globalState?.lastEditorCursorLocations ?? {},
|
||||
lastEditorScrollPercents: globalState?.lastEditorScrollPercents ?? {},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -101,7 +81,7 @@ export const createAppDefaultWindowState = (globalState: AppState|null): AppWind
|
||||
export function createAppDefaultState(resourceEditWatcherDefaultState: any): AppState {
|
||||
return {
|
||||
...defaultState,
|
||||
...createAppDefaultWindowState(null),
|
||||
...createAppDefaultWindowState(),
|
||||
route: {
|
||||
type: 'NAV_GO',
|
||||
routeName: 'Main',
|
||||
@@ -307,28 +287,6 @@ export default function(state: AppState, action: any) {
|
||||
}
|
||||
break;
|
||||
|
||||
case 'EDITOR_SCROLL_PERCENT_SET':
|
||||
|
||||
{
|
||||
newState = { ...state };
|
||||
const newPercents = { ...newState.lastEditorScrollPercents };
|
||||
newPercents[action.noteId] = action.percent;
|
||||
newState.lastEditorScrollPercents = newPercents;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'EDITOR_CURSOR_POSITION_SET':
|
||||
{
|
||||
newState = { ...state };
|
||||
const newCursorLocations = { ...newState.lastEditorCursorLocations };
|
||||
newCursorLocations[action.noteId] = {
|
||||
...(newCursorLocations[action.noteId] ?? {}),
|
||||
...action.location,
|
||||
};
|
||||
newState.lastEditorCursorLocations = newCursorLocations;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'NOTE_DEVTOOLS_TOGGLE':
|
||||
newState = { ...state };
|
||||
newState.devToolsVisible = !newState.devToolsVisible;
|
||||
|
||||
@@ -280,6 +280,16 @@ class Application extends BaseApplication {
|
||||
Setting.setValue('plugins.states', pluginSettings);
|
||||
}
|
||||
|
||||
// As of Joplin 3.5.7, the ABC rendering is part of the app so we automatically disable the plugin
|
||||
pluginSettings = {
|
||||
...pluginSettings,
|
||||
['org.joplinapp.plugins.AbcSheetMusic']: {
|
||||
enabled: false,
|
||||
deleted: false,
|
||||
hasBeenUpdated: false,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
if (await shim.fsDriver().exists(Setting.value('pluginDir'))) {
|
||||
await service.loadAndRunPlugins(Setting.value('pluginDir'), pluginSettings);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { stateUtils } from '@joplin/lib/reducer';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import { AppState, createAppDefaultWindowState } from '../app.reducer';
|
||||
import { createAppDefaultWindowState } from '../app.reducer';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
@@ -25,7 +25,7 @@ export const runtime = (): CommandRuntime => {
|
||||
folderId: note.parent_id,
|
||||
windowId: `window-${noteId}-${idCounter++}`,
|
||||
defaultAppWindowState: {
|
||||
...createAppDefaultWindowState(context.state as AppState),
|
||||
...createAppDefaultWindowState(),
|
||||
noteVisiblePanes: Setting.value('noteVisiblePanes'),
|
||||
editorCodeView: Setting.value('editor.codeView'),
|
||||
},
|
||||
|
||||
@@ -261,7 +261,10 @@ class ConfigScreenComponent extends React.Component<any, any> {
|
||||
if (settings['sync.target'] === SyncTargetRegistry.nameToId('joplinServerSaml')) {
|
||||
const server = settings['sync.11.path'] as string;
|
||||
|
||||
const goToSamlLogin = () => {
|
||||
const goToSamlLogin = async () => {
|
||||
// Save settings to allow SAML auth with the correct URL.
|
||||
await shared.saveSettings(this);
|
||||
|
||||
this.props.dispatch({
|
||||
type: 'NAV_GO',
|
||||
routeName: 'JoplinServerSamlLogin',
|
||||
|
||||
@@ -106,7 +106,7 @@ const JoplinCloudScreenComponent = (props: Props) => {
|
||||
<span className={state.className}>{state.errorMessage}</span>
|
||||
) : null}
|
||||
</p>
|
||||
{state.active === 'LINK_USED' ? <div id="loading-animation" /> : null}
|
||||
{state.active === 'LINK_USED' ? <div className="loading-animation" /> : null}
|
||||
<JoplinCloudSignUpCallToAction />
|
||||
</div>
|
||||
<ButtonBar onCancelClick={() => props.dispatch({ type: 'NAV_BACK' })} />
|
||||
|
||||
@@ -9,7 +9,6 @@ import { PluginStates, utils as pluginUtils } from '@joplin/lib/services/plugins
|
||||
import shim from '@joplin/lib/shim';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import versionInfo, { PackageInfo } from '@joplin/lib/versionInfo';
|
||||
import makeDiscourseDebugUrl from '@joplin/lib/makeDiscourseDebugUrl';
|
||||
import { ImportModule } from '@joplin/lib/services/interop/Module';
|
||||
import InteropServiceHelper from '../InteropServiceHelper';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
@@ -29,6 +28,8 @@ import { EventName } from '@joplin/lib/eventManager';
|
||||
import { ipcRenderer } from 'electron';
|
||||
import NavService from '@joplin/lib/services/NavService';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import { ImportCommandOptions } from './WindowCommandsAndDialogs/commands/importFrom';
|
||||
import { FileSystemItem } from '@joplin/lib/services/interop/types';
|
||||
|
||||
const logger = Logger.create('MenuBar');
|
||||
|
||||
@@ -304,83 +305,16 @@ function useMenu(props: Props) {
|
||||
void CommandService.instance().execute(commandName);
|
||||
}, []);
|
||||
|
||||
const onImportModuleClick = useCallback(async (module: ImportModule, moduleSource: string) => {
|
||||
let path = null;
|
||||
|
||||
if (moduleSource === 'file') {
|
||||
path = await bridge().showOpenDialog({
|
||||
filters: [{ name: module.description, extensions: module.fileExtensions }],
|
||||
});
|
||||
} else {
|
||||
path = await bridge().showOpenDialog({
|
||||
properties: ['openDirectory', 'createDirectory'],
|
||||
});
|
||||
}
|
||||
|
||||
if (!path || (Array.isArray(path) && !path.length)) return;
|
||||
|
||||
if (Array.isArray(path)) path = path[0];
|
||||
|
||||
const modalMessage = _('Importing from "%s" as "%s" format. Please wait...', path, module.format);
|
||||
|
||||
void CommandService.instance().execute('showModalMessage', modalMessage);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const errors: any[] = [];
|
||||
|
||||
const importOptions = {
|
||||
path,
|
||||
format: module.format,
|
||||
outputFormat: module.outputFormat,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
onProgress: (status: any) => {
|
||||
const statusStrings: string[] = Object.keys(status).map((key: string) => {
|
||||
return `${key}: ${status[key]}`;
|
||||
});
|
||||
|
||||
void CommandService.instance().execute('showModalMessage', `${modalMessage}\n\n${statusStrings.join('\n')}`);
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
onError: (error: any) => {
|
||||
errors.push(error);
|
||||
console.warn(error);
|
||||
},
|
||||
const onImportModuleClick = useCallback(async (module: ImportModule, moduleSource: FileSystemItem) => {
|
||||
const options: ImportCommandOptions = {
|
||||
destinationFolderId: !module.isNoteArchive && moduleSource === 'file' ? props.selectedFolderId : null,
|
||||
sourcePath: undefined, // Show a file picker
|
||||
sourceType: moduleSource,
|
||||
importFormat: module.format,
|
||||
outputFormat: module.outputFormat,
|
||||
};
|
||||
|
||||
const service = InteropService.instance();
|
||||
try {
|
||||
const result = await service.import(importOptions);
|
||||
// eslint-disable-next-line no-console
|
||||
console.info('Import result: ', result);
|
||||
} catch (error) {
|
||||
bridge().showErrorMessageBox(error.message);
|
||||
}
|
||||
|
||||
void CommandService.instance().execute('hideModalMessage');
|
||||
|
||||
if (errors.length) {
|
||||
const response = bridge().showErrorMessageBox('There was some errors importing the notes - check the console for more details.\n\nPlease consider sending a bug report to the forum!', {
|
||||
buttons: [_('Close'), _('Send bug report')],
|
||||
});
|
||||
|
||||
props.dispatch({ type: 'NOTE_DEVTOOLS_SET', value: true });
|
||||
|
||||
if (response === 1) {
|
||||
const url = makeDiscourseDebugUrl(
|
||||
`Error importing notes from format: ${module.format}`,
|
||||
`- Input format: ${module.format}\n- Output format: ${module.outputFormat}`,
|
||||
errors,
|
||||
packageInfo,
|
||||
PluginService.instance(),
|
||||
props.pluginSettings,
|
||||
);
|
||||
|
||||
void bridge().openExternal(url);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [props.selectedFolderId, props.pluginSettings]);
|
||||
await CommandService.instance().execute('importFrom', options);
|
||||
}, [props.selectedFolderId]);
|
||||
|
||||
const onMenuItemClickRef = useRef(null);
|
||||
onMenuItemClickRef.current = onMenuItemClick;
|
||||
|
||||
@@ -22,12 +22,6 @@ interface MultiNoteActionsProps {
|
||||
function styles_(props: MultiNoteActionsProps) {
|
||||
return buildStyle('MultiNoteActions', props.themeId, (theme: ThemeStyle) => {
|
||||
return {
|
||||
root: {
|
||||
display: 'inline-flex',
|
||||
justifyContent: 'center',
|
||||
paddingTop: theme.marginTop,
|
||||
width: '100%',
|
||||
},
|
||||
itemList: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
@@ -90,7 +84,7 @@ export default function MultiNoteActions(props: MultiNoteActionsProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={styles.root}>
|
||||
<div style={styles.root} className='multi-note-actions'>
|
||||
<div style={styles.itemList}>{itemComps}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -13,7 +13,7 @@ import { _ } from '@joplin/lib/locale';
|
||||
import bridge from '../../../../../services/bridge';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import { MarkupToHtml } from '@joplin/renderer';
|
||||
const { clipboard } = require('electron');
|
||||
import { clipboard } from 'electron';
|
||||
import { reg } from '@joplin/lib/registry';
|
||||
import ErrorBoundary from '../../../../ErrorBoundary';
|
||||
import { EditorKeymap, EditorLanguageType, EditorSettings, SearchState, UserEventSource } from '@joplin/editor/types';
|
||||
@@ -32,6 +32,7 @@ import useRefocusOnVisiblePaneChange from './utils/useRefocusOnVisiblePaneChange
|
||||
import { WindowIdContext } from '../../../../NewWindowOrIFrame';
|
||||
import eventManager, { EventName, ResourceChangeEvent } from '@joplin/lib/eventManager';
|
||||
import useSyncEditorValue from './utils/useSyncEditorValue';
|
||||
import { getGlobalSettings } from '@joplin/renderer/types';
|
||||
|
||||
const logger = Logger.create('CodeMirror6');
|
||||
const logDebug = (message: string) => logger.debug(message);
|
||||
@@ -93,41 +94,13 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
|
||||
|
||||
const editorCutText = useCallback(() => {
|
||||
if (editorRef.current) {
|
||||
const selections = editorRef.current.getSelections();
|
||||
if (selections.length > 0 && selections[0]) {
|
||||
clipboard.writeText(selections[0]);
|
||||
// Easy way to wipe out just the first selection
|
||||
selections[0] = '';
|
||||
editorRef.current.replaceSelections(selections);
|
||||
} else {
|
||||
const cursor = editorRef.current.getCursor();
|
||||
const line = editorRef.current.getLine(cursor.line);
|
||||
clipboard.writeText(`${line}\n`);
|
||||
const startLine = editorRef.current.getCursor('head');
|
||||
startLine.ch = 0;
|
||||
const endLine = {
|
||||
line: startLine.line + 1,
|
||||
ch: 0,
|
||||
};
|
||||
editorRef.current.replaceRange('', startLine, endLine);
|
||||
}
|
||||
editorRef.current.cutText(text => clipboard.writeText(text));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const editorCopyText = useCallback(() => {
|
||||
if (editorRef.current) {
|
||||
const selections = editorRef.current.getSelections();
|
||||
|
||||
// Handle the case when there is a selection - copy the selection to the clipboard
|
||||
// When there is no selection, the selection array contains an empty string.
|
||||
if (selections.length > 0 && selections[0]) {
|
||||
clipboard.writeText(selections[0]);
|
||||
} else {
|
||||
// This is the case when there is no selection - copy the current line to the clipboard
|
||||
const cursor = editorRef.current.getCursor();
|
||||
const line = editorRef.current.getLine(cursor.line);
|
||||
clipboard.writeText(line);
|
||||
}
|
||||
editorRef.current.copyText(text => clipboard.writeText(text));
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -248,6 +221,7 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
|
||||
useCustomPdfViewer: props.useCustomPdfViewer,
|
||||
noteId: props.noteId,
|
||||
vendorDir: bridge().vendorDir(),
|
||||
globalSettings: getGlobalSettings(Setting),
|
||||
}));
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
@@ -5,6 +5,8 @@ import { MarkupToHtmlHandler } from '../../../utils/types';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import enableTextAreaTab, { TextAreaTabHandler } from './enableTextAreaTab';
|
||||
import { MarkupToHtml } from '@joplin/renderer';
|
||||
import { getGlobalSettings } from '@joplin/renderer/types';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
|
||||
interface Props {
|
||||
editor: Editor;
|
||||
@@ -90,7 +92,7 @@ function openEditDialog(
|
||||
onSubmit: async (dialogApi: any) => {
|
||||
const newSource = newBlockSource(dialogApi.getData().languageInput, dialogApi.getData().codeTextArea, source);
|
||||
const md = `${newSource.openCharacters}${newSource.content}${newSource.closeCharacters}`;
|
||||
const result = await markupToHtml.current(MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN, md, { bodyOnly: true });
|
||||
const result = await markupToHtml.current(MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN, md, { bodyOnly: true, globalSettings: getGlobalSettings(Setting) });
|
||||
|
||||
// markupToHtml will return the complete editable HTML, but we only
|
||||
// want to update the inner HTML, so as not to break additional props that
|
||||
|
||||
@@ -18,7 +18,7 @@ import { NoteEditorProps, FormNote, OnChangeEvent, AllAssetsOptions, NoteBodyEdi
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import Button, { ButtonLevel } from '../Button/Button';
|
||||
import eventManager, { EventName } from '@joplin/lib/eventManager';
|
||||
import { AppState, EditorCursorLocations } from '../../app.reducer';
|
||||
import { AppState } from '../../app.reducer';
|
||||
import ToolbarButtonUtils, { ToolbarButtonInfo } from '@joplin/lib/services/commands/ToolbarButtonUtils';
|
||||
import { _, _n } from '@joplin/lib/locale';
|
||||
import NoteTitleBar from './NoteTitle/NoteTitleBar';
|
||||
@@ -58,6 +58,7 @@ import useVisiblePluginEditorViewIds from '@joplin/lib/hooks/plugins/useVisibleP
|
||||
import useConnectToEditorPlugin from './utils/useConnectToEditorPlugin';
|
||||
import getResourceBaseUrl from './utils/getResourceBaseUrl';
|
||||
import useInitialCursorLocation from './utils/useInitialCursorLocation';
|
||||
import NotePositionService, { EditorCursorLocations } from '@joplin/lib/services/NotePositionService';
|
||||
|
||||
const debounce = require('debounce');
|
||||
|
||||
@@ -333,7 +334,6 @@ function NoteEditorContent(props: NoteEditorProps) {
|
||||
const { scrollWhenReadyRef, clearScrollWhenReady } = useScrollWhenReadyOptions({
|
||||
noteId: formNote.id,
|
||||
selectedNoteHash: props.selectedNoteHash,
|
||||
lastEditorScrollPercents: props.lastEditorScrollPercents,
|
||||
editorRef,
|
||||
editorName: props.bodyEditor,
|
||||
});
|
||||
@@ -401,23 +401,14 @@ function NoteEditorContent(props: NoteEditorProps) {
|
||||
}, [setShowRevisions]);
|
||||
|
||||
const onScroll = useCallback((event: { percent: number }) => {
|
||||
props.dispatch({
|
||||
type: 'EDITOR_SCROLL_PERCENT_SET',
|
||||
// In callbacks of setTimeout()/setInterval(), props/state cannot be used
|
||||
// to refer the current value, since they would be one or more generations old.
|
||||
// For the purpose, useRef value should be used.
|
||||
noteId: formNoteRef.current.id,
|
||||
percent: event.percent,
|
||||
});
|
||||
}, [props.dispatch]);
|
||||
const noteId = formNoteRef.current.id;
|
||||
NotePositionService.instance().updateScrollPosition(noteId, windowId, event.percent);
|
||||
}, [windowId]);
|
||||
|
||||
const onCursorMotion = useCallback((location: EditorCursorLocations) => {
|
||||
props.dispatch({
|
||||
type: 'EDITOR_CURSOR_POSITION_SET',
|
||||
noteId: formNoteRef.current.id,
|
||||
location,
|
||||
});
|
||||
}, [props.dispatch]);
|
||||
const noteId = formNoteRef.current.id;
|
||||
NotePositionService.instance().updateCursorPosition(noteId, windowId, location);
|
||||
}, [windowId]);
|
||||
|
||||
function renderNoNotes(rootStyle: React.CSSProperties) {
|
||||
const emptyDivStyle = {
|
||||
@@ -430,7 +421,7 @@ function NoteEditorContent(props: NoteEditorProps) {
|
||||
|
||||
const searchMarkers = useSearchMarkers(showLocalSearch, localSearchMarkerOptions, props.searches, props.selectedSearchId, props.highlightedWords);
|
||||
const initialCursorLocation = useInitialCursorLocation({
|
||||
lastEditorCursorLocations: props.lastEditorCursorLocations, noteId: props.noteId,
|
||||
noteId: props.noteId,
|
||||
});
|
||||
|
||||
const markupLanguage = formNote.markup_language;
|
||||
@@ -743,8 +734,6 @@ const mapStateToProps = (state: AppState, ownProps: ConnectProps) => {
|
||||
watchedNoteFiles: state.watchedNoteFiles,
|
||||
notesParentType: windowState.notesParentType,
|
||||
selectedNoteTags: windowState.selectedNoteTags,
|
||||
lastEditorScrollPercents: state.lastEditorScrollPercents,
|
||||
lastEditorCursorLocations: state.lastEditorCursorLocations,
|
||||
selectedNoteHash: windowState.selectedNoteHash,
|
||||
searches: state.searches,
|
||||
selectedSearchId: windowState.selectedSearchId,
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { LinkRenderingType } from '@joplin/renderer/MdToHtml';
|
||||
import { MarkupToHtmlOptions } from './types';
|
||||
import { getGlobalSettings, ResourceInfos } from '@joplin/renderer/types';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
|
||||
export default (override: MarkupToHtmlOptions = null): MarkupToHtmlOptions => {
|
||||
interface OptionOverride {
|
||||
bodyOnly: boolean;
|
||||
resourceInfos?: ResourceInfos;
|
||||
allowedFilePrefixes?: string[];
|
||||
}
|
||||
|
||||
export default (override: OptionOverride = null): MarkupToHtmlOptions => {
|
||||
return {
|
||||
plugins: {
|
||||
checkbox: {
|
||||
@@ -12,6 +20,7 @@ export default (override: MarkupToHtmlOptions = null): MarkupToHtmlOptions => {
|
||||
},
|
||||
},
|
||||
replaceResourceInternalToExternalLinks: true,
|
||||
globalSettings: getGlobalSettings(Setting),
|
||||
...override,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -98,6 +98,10 @@ export async function getResourcesFromPasteEvent(event: any) {
|
||||
const formatType = format.split('/')[0];
|
||||
|
||||
if (formatType === 'image') {
|
||||
// writeImageToFile can process only image/jpeg, image/jpg or image/png mime types
|
||||
if (['image/png', 'image/jpg', 'image/jpeg'].indexOf(format) < 0) {
|
||||
continue;
|
||||
}
|
||||
if (event) event.preventDefault();
|
||||
|
||||
const image = clipboard.readImage();
|
||||
|
||||
@@ -14,7 +14,7 @@ import { ScrollbarSize } from '@joplin/lib/models/settings/builtInMetadata';
|
||||
import { RefObject, SetStateAction } from 'react';
|
||||
import * as React from 'react';
|
||||
import { ResourceEntity, ResourceLocalStateEntity } from '@joplin/lib/services/database/types';
|
||||
import { EditorCursorLocations, NoteIdToEditorCursorLocations, NoteIdToScrollPercent } from '../../../app.reducer';
|
||||
import { EditorCursorLocations } from '@joplin/lib/services/NotePositionService';
|
||||
|
||||
export interface AllAssetsOptions {
|
||||
contentMaxWidthTarget?: string;
|
||||
@@ -41,8 +41,6 @@ export interface NoteEditorProps {
|
||||
notesParentType: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
selectedNoteTags: any[];
|
||||
lastEditorScrollPercents: NoteIdToScrollPercent;
|
||||
lastEditorCursorLocations: NoteIdToEditorCursorLocations;
|
||||
selectedNoteHash: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
searches: any[];
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { useMemo } from 'react';
|
||||
import { EditorCursorLocations, NoteIdToEditorCursorLocations } from '../../../app.reducer';
|
||||
import { useContext, useMemo } from 'react';
|
||||
import { WindowIdContext } from '../../NewWindowOrIFrame';
|
||||
import NotePositionService from '@joplin/lib/services/NotePositionService';
|
||||
|
||||
interface Props {
|
||||
lastEditorCursorLocations: NoteIdToEditorCursorLocations;
|
||||
noteId: string;
|
||||
}
|
||||
|
||||
const useInitialCursorLocation = ({ noteId, lastEditorCursorLocations }: Props) => {
|
||||
const lastCursorLocation = lastEditorCursorLocations[noteId];
|
||||
const useInitialCursorLocation = ({ noteId }: Props) => {
|
||||
const windowId = useContext(WindowIdContext);
|
||||
|
||||
return useMemo((): EditorCursorLocations => {
|
||||
return lastCursorLocation ?? { };
|
||||
}, [lastCursorLocation]);
|
||||
return useMemo(() => {
|
||||
return NotePositionService.instance().getCursorPosition(noteId, windowId);
|
||||
}, [noteId, windowId]);
|
||||
};
|
||||
|
||||
export default useInitialCursorLocation;
|
||||
|
||||
@@ -1,42 +1,43 @@
|
||||
import { RefObject, useCallback, useRef } from 'react';
|
||||
import { RefObject, useCallback, useContext, useRef } from 'react';
|
||||
import { NoteBodyEditorRef, ScrollOptions, ScrollOptionTypes } from './types';
|
||||
import usePrevious from '@joplin/lib/hooks/usePrevious';
|
||||
import type { NoteIdToScrollPercent } from '../../../app.reducer';
|
||||
import NotePositionService from '@joplin/lib/services/NotePositionService';
|
||||
import useNowEffect from '@joplin/lib/hooks/useNowEffect';
|
||||
import { WindowIdContext } from '../../NewWindowOrIFrame';
|
||||
|
||||
interface Props {
|
||||
noteId: string;
|
||||
editorName: string;
|
||||
selectedNoteHash: string;
|
||||
lastEditorScrollPercents: NoteIdToScrollPercent;
|
||||
editorRef: RefObject<NoteBodyEditorRef>;
|
||||
}
|
||||
|
||||
const useScrollWhenReadyOptions = ({ noteId, editorName, selectedNoteHash, lastEditorScrollPercents, editorRef }: Props) => {
|
||||
const useScrollWhenReadyOptions = ({ noteId, editorName, selectedNoteHash, editorRef }: Props) => {
|
||||
const scrollWhenReadyRef = useRef<ScrollOptions|null>(null);
|
||||
|
||||
const previousNoteId = usePrevious(noteId);
|
||||
const noteIdChanged = noteId !== previousNoteId;
|
||||
const previousEditor = usePrevious(editorName);
|
||||
const editorChanged = editorName !== previousEditor;
|
||||
const lastScrollPercentsRef = useRef<NoteIdToScrollPercent>(null);
|
||||
lastScrollPercentsRef.current = lastEditorScrollPercents;
|
||||
const windowId = useContext(WindowIdContext);
|
||||
|
||||
|
||||
// This needs to be a nowEffect to prevent race conditions
|
||||
useNowEffect(() => {
|
||||
const editorChanged = editorName !== previousEditor;
|
||||
const noteIdChanged = noteId !== previousNoteId;
|
||||
if (!editorChanged && !noteIdChanged) return () => {};
|
||||
|
||||
const lastScrollPercent = NotePositionService.instance().getScrollPercent(noteId, windowId) || 0;
|
||||
scrollWhenReadyRef.current = {
|
||||
type: selectedNoteHash ? ScrollOptionTypes.Hash : ScrollOptionTypes.Percent,
|
||||
value: selectedNoteHash ? selectedNoteHash : lastScrollPercent,
|
||||
};
|
||||
|
||||
if (editorRef.current) {
|
||||
editorRef.current.resetScroll();
|
||||
}
|
||||
|
||||
const lastScrollPercent = lastScrollPercentsRef.current[noteId] || 0;
|
||||
scrollWhenReadyRef.current = {
|
||||
type: selectedNoteHash ? ScrollOptionTypes.Hash : ScrollOptionTypes.Percent,
|
||||
value: selectedNoteHash ? selectedNoteHash : lastScrollPercent,
|
||||
};
|
||||
return () => {};
|
||||
}, [editorChanged, noteIdChanged, noteId, selectedNoteHash, editorRef]);
|
||||
}, [editorName, previousEditor, noteId, previousNoteId, selectedNoteHash, editorRef, windowId]);
|
||||
|
||||
const clearScrollWhenReady = useCallback(() => {
|
||||
scrollWhenReadyRef.current = null;
|
||||
|
||||
@@ -25,6 +25,8 @@ import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
|
||||
import { ScrollbarSize } from '@joplin/lib/models/settings/builtInMetadata';
|
||||
import { focus } from '@joplin/lib/utils/focusHandler';
|
||||
import useDeleteHistoryClick from '@joplin/lib/components/shared/NoteRevisionViewer/useDeleteHistoryClick';
|
||||
import { getGlobalSettings } from '@joplin/renderer/types';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
@@ -72,6 +74,7 @@ const useNoteContent = (
|
||||
const result = await markupToHtml(markupLanguage, noteBody, {
|
||||
resources: await shared.attachedResources(noteBody),
|
||||
whiteBackgroundNoteRendering: markupLanguage === MarkupLanguage.Html,
|
||||
globalSettings: getGlobalSettings(Setting),
|
||||
});
|
||||
|
||||
viewerRef.current.setHtml(result.html, {
|
||||
|
||||
@@ -20,6 +20,7 @@ import { reg } from '@joplin/lib/registry';
|
||||
import useAsyncEffect, { AsyncEffectEvent } from '@joplin/lib/hooks/useAsyncEffect';
|
||||
import { ChangeEvent, Dropdown, DropdownOptions, DropdownVariant } from '../Dropdown/Dropdown';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import { SettingsRecord } from '@joplin/lib/models/Setting';
|
||||
|
||||
const logger = Logger.create('ShareFolderDialog');
|
||||
|
||||
@@ -421,10 +422,14 @@ function ShareFolderDialog(props: Props) {
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: State) => {
|
||||
const getCanUseSharePermissions = (settings: Partial<SettingsRecord>) => {
|
||||
return [9, 10, 11].includes(settings['sync.target']) && !!settings['sync.10.canUseSharePermissions'];
|
||||
};
|
||||
|
||||
return {
|
||||
shares: state.shareService.shares,
|
||||
shareUsers: state.shareService.shareUsers,
|
||||
canUseSharePermissions: state.settings['sync.target'] === 10 && state.settings['sync.10.canUseSharePermissions'],
|
||||
canUseSharePermissions: getCanUseSharePermissions(state.settings),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useMemo, useRef, useState } from 'react';
|
||||
import ItemList from '../ItemList';
|
||||
import useElementHeight from '../hooks/useElementHeight';
|
||||
import useSidebarListData from './hooks/useSidebarListData';
|
||||
import useSelectedSidebarIndex from './hooks/useSelectedSidebarIndex';
|
||||
import useSelectedSidebarIndexes from './hooks/useSelectedSidebarIndexes';
|
||||
import useOnSidebarKeyDownHandler from './hooks/useOnSidebarKeyDownHandler';
|
||||
import useFocusHandler from './hooks/useFocusHandler';
|
||||
import useOnRenderItem from './hooks/useOnRenderItem';
|
||||
@@ -26,7 +26,9 @@ interface Props {
|
||||
tags: TagsWithNoteCountEntity[];
|
||||
folders: FolderEntity[];
|
||||
notesParentType: string;
|
||||
selectedTagIds: string[];
|
||||
selectedTagId: string;
|
||||
selectedFolderIds: string[];
|
||||
selectedFolderId: string;
|
||||
selectedSmartFilterId: string;
|
||||
collapsedFolderIds: string[];
|
||||
@@ -37,7 +39,7 @@ interface Props {
|
||||
|
||||
const FolderAndTagList: React.FC<Props> = props => {
|
||||
const listItems = useSidebarListData(props);
|
||||
const { selectedIndex, updateSelectedIndex } = useSelectedSidebarIndex({
|
||||
const { selectedIndex, selectedIndexes, updateSelectedIndex } = useSelectedSidebarIndexes({
|
||||
...props,
|
||||
listItems: listItems,
|
||||
});
|
||||
@@ -50,6 +52,7 @@ const FolderAndTagList: React.FC<Props> = props => {
|
||||
const onRenderItem = useOnRenderItem({
|
||||
...props,
|
||||
selectedIndex,
|
||||
selectedIndexes,
|
||||
listItems,
|
||||
containerRef: listContainerRef,
|
||||
});
|
||||
@@ -58,6 +61,7 @@ const FolderAndTagList: React.FC<Props> = props => {
|
||||
dispatch: props.dispatch,
|
||||
listItems: listItems,
|
||||
selectedIndex,
|
||||
selectedIndexes,
|
||||
updateSelectedIndex,
|
||||
collapsedFolderIds: props.collapsedFolderIds,
|
||||
});
|
||||
@@ -107,6 +111,8 @@ const mapStateToProps = (state: AppState) => {
|
||||
tags: state.tags,
|
||||
folders: state.folders,
|
||||
notesParentType: mainWindowState.notesParentType,
|
||||
selectedFolderIds: mainWindowState.selectedFolderIds,
|
||||
selectedTagIds: mainWindowState.selectedTagIds,
|
||||
selectedFolderId: mainWindowState.selectedFolderId,
|
||||
selectedTagId: mainWindowState.selectedTagId,
|
||||
collapsedFolderIds: state.collapsedFolderIds,
|
||||
|
||||
65
packages/app-desktop/gui/Sidebar/hooks/useOnItemClick.ts
Normal file
65
packages/app-desktop/gui/Sidebar/hooks/useOnItemClick.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { MouseEvent } from 'react';
|
||||
import { ModelType } from '@joplin/lib/BaseModel';
|
||||
import { RefObject, useCallback } from 'react';
|
||||
import { Dispatch } from 'redux';
|
||||
import { ListItem, ListItemType } from '../types';
|
||||
import shim from '@joplin/lib/shim';
|
||||
|
||||
export interface ItemClickEvent {
|
||||
id: string;
|
||||
type: ModelType;
|
||||
event: MouseEvent;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
itemsRef: RefObject<ListItem[]>;
|
||||
selectedIndexesRef: RefObject<number[]>;
|
||||
dispatch: Dispatch;
|
||||
}
|
||||
|
||||
const listItemToId = (item: ListItem) => {
|
||||
if (item.kind === ListItemType.Tag) return item.tag.id;
|
||||
if (item.kind === ListItemType.Folder) return item.folder.id;
|
||||
return null;
|
||||
};
|
||||
|
||||
const useOnItemClick = ({ dispatch, selectedIndexesRef, itemsRef }: Props) => {
|
||||
return useCallback(({ id, type, event }: ItemClickEvent) => {
|
||||
const action = type === ModelType.Folder ? 'FOLDER_SELECT' : 'TAG_SELECT';
|
||||
const selectedIndexes = selectedIndexesRef.current;
|
||||
const findItemIndex = () => itemsRef.current.findIndex(item => listItemToId(item) === id);
|
||||
|
||||
if (event.shiftKey && selectedIndexes.length > 0) {
|
||||
const index = findItemIndex();
|
||||
if (index === -1) throw new Error(`No item found with ID: ${id}`);
|
||||
|
||||
const lastAddedIndex = selectedIndexes[selectedIndexes.length - 1];
|
||||
const indexStart = Math.min(index, lastAddedIndex);
|
||||
const indexStop = Math.max(index, lastAddedIndex);
|
||||
const itemIds = itemsRef.current.slice(indexStart, indexStop + 1)
|
||||
.map(listItemToId)
|
||||
.filter(id => !!id);
|
||||
|
||||
dispatch({
|
||||
type: `${action}_ADD`,
|
||||
ids: itemIds,
|
||||
});
|
||||
} else if (shim.isMac() ? event.metaKey : event.ctrlKey) {
|
||||
const index = findItemIndex();
|
||||
// Don't allow unselecting all items: Keep at least one item selected
|
||||
const canDeselect = selectedIndexes.length > 1;
|
||||
const actionType = canDeselect && selectedIndexes.includes(index) ? 'REMOVE' : 'ADD';
|
||||
dispatch({
|
||||
type: `${action}_${actionType}`,
|
||||
id: id,
|
||||
});
|
||||
} else {
|
||||
dispatch({
|
||||
type: action,
|
||||
id: id,
|
||||
});
|
||||
}
|
||||
}, [dispatch, selectedIndexesRef, itemsRef]);
|
||||
};
|
||||
|
||||
export default useOnItemClick;
|
||||
@@ -1,16 +1,15 @@
|
||||
import * as React from 'react';
|
||||
import { DragEventHandler, MouseEventHandler, useCallback, useMemo, useRef } from 'react';
|
||||
import { ItemClickListener, ItemDragListener, ListItem, ListItemType } from '../types';
|
||||
import TagItem, { TagLinkClickEvent } from '../listItemComponents/TagItem';
|
||||
import TagItem from '../listItemComponents/TagItem';
|
||||
import { Dispatch } from 'redux';
|
||||
import { clipboard } from 'electron';
|
||||
import type { MenuItem as MenuItemType } from 'electron';
|
||||
import { getTrashFolderId } from '@joplin/lib/services/trash';
|
||||
import BaseModel, { ModelType } from '@joplin/lib/BaseModel';
|
||||
import Tag from '@joplin/lib/models/Tag';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { substrWithEllipsis } from '@joplin/lib/string-utils';
|
||||
import { AppState } from '../../../app.reducer';
|
||||
import { store } from '@joplin/lib/reducer';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
import bridge from '../../../services/bridge';
|
||||
import MenuUtils from '@joplin/lib/services/commands/MenuUtils';
|
||||
@@ -18,7 +17,6 @@ import CommandService from '@joplin/lib/services/CommandService';
|
||||
import { FolderEntity } from '@joplin/lib/services/database/types';
|
||||
import InteropService from '@joplin/lib/services/interop/InteropService';
|
||||
import InteropServiceHelper from '../../../InteropServiceHelper';
|
||||
import stateToWhenClauseContext from '@joplin/lib/services/commands/stateToWhenClauseContext';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import PerFolderSortOrderService from '../../../services/sortOrder/PerFolderSortOrderService';
|
||||
import { getFolderCallbackUrl, getTagCallbackUrl } from '@joplin/lib/callbackUrlUtils';
|
||||
@@ -29,12 +27,13 @@ import Logger from '@joplin/utils/Logger';
|
||||
import onFolderDrop from '@joplin/lib/models/utils/onFolderDrop';
|
||||
import HeaderItem from '../listItemComponents/HeaderItem';
|
||||
import AllNotesItem from '../listItemComponents/AllNotesItem';
|
||||
import ListItemWrapper from '../listItemComponents/ListItemWrapper';
|
||||
import ListItemWrapper, { ItemSelectionState } from '../listItemComponents/ListItemWrapper';
|
||||
import { focus } from '@joplin/lib/utils/focusHandler';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import useOnItemClick from './useOnItemClick';
|
||||
|
||||
const Menu = bridge().Menu;
|
||||
const MenuItem = bridge().MenuItem;
|
||||
const MenuItem: typeof MenuItemType = bridge().MenuItem;
|
||||
|
||||
const logger = Logger.create('useOnRenderItem');
|
||||
|
||||
@@ -47,6 +46,7 @@ interface Props {
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
|
||||
selectedIndex: number;
|
||||
selectedIndexes: number[];
|
||||
listItems: ListItem[];
|
||||
}
|
||||
|
||||
@@ -65,6 +65,11 @@ const focusListItem = (item: HTMLElement|null) => {
|
||||
|
||||
const noFocusListItem = () => {};
|
||||
|
||||
const folderCommandToMenuItem = (commandId: string, folderIds: string|string[]) => {
|
||||
const options = Array.isArray(folderIds) ? { commandFolderIds: folderIds } : { commandFolderId: folderIds };
|
||||
return new MenuItem(menuUtils.commandToStatefulMenuItem(commandId, folderIds, options));
|
||||
};
|
||||
|
||||
const useOnRenderItem = (props: Props) => {
|
||||
|
||||
const pluginsRef = useRef<PluginStates>(null);
|
||||
@@ -72,13 +77,6 @@ const useOnRenderItem = (props: Props) => {
|
||||
const foldersRef = useRef<FolderEntity[]>(null);
|
||||
foldersRef.current = props.folders;
|
||||
|
||||
const tagItem_click = useCallback(({ tag }: TagLinkClickEvent) => {
|
||||
props.dispatch({
|
||||
type: 'TAG_SELECT',
|
||||
id: tag ? tag.id : null,
|
||||
});
|
||||
}, [props.dispatch]);
|
||||
|
||||
const onTagDrop_: DragEventHandler<HTMLElement> = useCallback(async event => {
|
||||
const tagId = event.currentTarget.getAttribute('data-tag-id');
|
||||
const dt = event.dataTransfer;
|
||||
@@ -94,6 +92,24 @@ const useOnRenderItem = (props: Props) => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const selectedIndexesRef = useRef(props.selectedIndexes);
|
||||
selectedIndexesRef.current = props.selectedIndexes;
|
||||
const itemsRef = useRef(props.listItems);
|
||||
itemsRef.current = props.listItems;
|
||||
const getSelectedIds = useCallback(() => {
|
||||
return selectedIndexesRef.current.map(index => {
|
||||
const item = itemsRef.current[index];
|
||||
if (item.kind === ListItemType.Folder) {
|
||||
return item.folder.id;
|
||||
} else if (item.kind === ListItemType.Tag) {
|
||||
return item.tag.id;
|
||||
}
|
||||
return null;
|
||||
}).filter(id => !!id);
|
||||
}, []);
|
||||
|
||||
const onItemClick = useOnItemClick({ dispatch: props.dispatch, selectedIndexesRef, itemsRef });
|
||||
|
||||
const onItemContextMenu: ItemContextMenuListener = useCallback(async event => {
|
||||
const itemId = event.currentTarget.getAttribute('data-id');
|
||||
if (itemId === Folder.conflictFolderId()) return;
|
||||
@@ -101,14 +117,22 @@ const useOnRenderItem = (props: Props) => {
|
||||
const itemType = Number(event.currentTarget.getAttribute('data-type'));
|
||||
if (!itemId || !itemType) throw new Error('No data on element');
|
||||
|
||||
const state: AppState = store().getState();
|
||||
let itemIds = [itemId];
|
||||
const itemIndex = Number(event.currentTarget.getAttribute('data-index'));
|
||||
if (selectedIndexesRef.current.includes(itemIndex)) {
|
||||
itemIds = getSelectedIds();
|
||||
}
|
||||
|
||||
let deleteMessage = '';
|
||||
const deleteButtonLabel = _('Remove');
|
||||
|
||||
if (itemType === BaseModel.TYPE_TAG) {
|
||||
const tag = await Tag.load(itemId);
|
||||
deleteMessage = _('Remove tag "%s" from all notes?', substrWithEllipsis(tag.title, 0, 32));
|
||||
if (itemIds.length === 1) {
|
||||
const tag = await Tag.load(itemId);
|
||||
deleteMessage = _('Remove tag "%s" from all notes?', substrWithEllipsis(tag.title, 0, 32));
|
||||
} else {
|
||||
deleteMessage = _('Remove %d tags from all notes? This cannot be undone.', itemIds.length);
|
||||
}
|
||||
} else if (itemType === BaseModel.TYPE_SEARCH) {
|
||||
deleteMessage = _('Remove this search from the sidebar?');
|
||||
}
|
||||
@@ -131,16 +155,13 @@ const useOnRenderItem = (props: Props) => {
|
||||
const isDeleted = item ? !!item.deleted_time : false;
|
||||
|
||||
if (!isDeleted) {
|
||||
if (itemType === BaseModel.TYPE_FOLDER && !item.encryption_applied) {
|
||||
menu.append(
|
||||
new MenuItem(menuUtils.commandToStatefulMenuItem('newFolder', itemId)),
|
||||
);
|
||||
const isDecryptedFolder = itemType === BaseModel.TYPE_FOLDER && !item.encryption_applied;
|
||||
if (isDecryptedFolder && itemIds.length === 1) {
|
||||
menu.append(folderCommandToMenuItem('newFolder', itemId));
|
||||
}
|
||||
|
||||
if (itemType === BaseModel.TYPE_FOLDER) {
|
||||
menu.append(
|
||||
new MenuItem(menuUtils.commandToStatefulMenuItem('deleteFolder', itemId)),
|
||||
);
|
||||
menu.append(folderCommandToMenuItem('deleteFolder', itemIds));
|
||||
} else {
|
||||
menu.append(
|
||||
new MenuItem({
|
||||
@@ -153,7 +174,9 @@ const useOnRenderItem = (props: Props) => {
|
||||
if (!ok) return;
|
||||
|
||||
if (itemType === BaseModel.TYPE_TAG) {
|
||||
await Tag.untagAll(itemId);
|
||||
for (const itemId of itemIds) {
|
||||
await Tag.untagAll(itemId);
|
||||
}
|
||||
} else if (itemType === BaseModel.TYPE_SEARCH) {
|
||||
props.dispatch({
|
||||
type: 'SEARCH_DELETE',
|
||||
@@ -165,15 +188,18 @@ const useOnRenderItem = (props: Props) => {
|
||||
);
|
||||
}
|
||||
|
||||
if (itemType === BaseModel.TYPE_FOLDER && !item.encryption_applied) {
|
||||
if (isDecryptedFolder) {
|
||||
const whenClause = CommandService.instance().currentWhenClauseContext({ commandFolderIds: itemIds });
|
||||
menu.append(new MenuItem({
|
||||
...menuUtils.commandToStatefulMenuItem('moveToFolder', [itemId]),
|
||||
// By default, enabled is based on the selected folder. However, the right-click
|
||||
// menu can be shown for unselected folders.
|
||||
enabled: true,
|
||||
...menuUtils.commandToStatefulMenuItem('moveToFolder', itemIds),
|
||||
// By default, moveToFolder's enabled condition is based on the selected notes. However, the right-click
|
||||
// menu item applies to folders. For now, use a custom condition:
|
||||
enabled: !whenClause.foldersIncludeReadOnly,
|
||||
}));
|
||||
}
|
||||
|
||||
menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem('openFolderDialog', { folderId: itemId })));
|
||||
if (isDecryptedFolder && itemIds.length === 1) {
|
||||
menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem('openFolderDialog', { folderId: itemId }, { commandFolderId: itemId })));
|
||||
|
||||
menu.append(new MenuItem({ type: 'separator' }));
|
||||
|
||||
@@ -188,25 +214,17 @@ const useOnRenderItem = (props: Props) => {
|
||||
new MenuItem({
|
||||
label: module.fullLabel(),
|
||||
click: async () => {
|
||||
await InteropServiceHelper.export(props.dispatch, module, { sourceFolderIds: [itemId], plugins: pluginsRef.current });
|
||||
await InteropServiceHelper.export(props.dispatch, module, { sourceFolderIds: itemIds, plugins: pluginsRef.current });
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// We don't display the "Share notebook" menu item for sub-notebooks
|
||||
// that are within a shared notebook. If user wants to do this,
|
||||
// they'd have to move the notebook out of the shared notebook
|
||||
// first.
|
||||
const whenClause = stateToWhenClauseContext(state, { commandFolderId: itemId });
|
||||
|
||||
if (CommandService.instance().isEnabled('showShareFolderDialog', whenClause)) {
|
||||
menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem('showShareFolderDialog', itemId)));
|
||||
}
|
||||
|
||||
if (CommandService.instance().isEnabled('leaveSharedFolder', whenClause)) {
|
||||
menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem('leaveSharedFolder', itemId)));
|
||||
}
|
||||
// Only show the share/leave share actions for top-level folders
|
||||
const shareFolderItem = folderCommandToMenuItem('showShareFolderDialog', itemId);
|
||||
if (shareFolderItem.enabled) menu.append(shareFolderItem);
|
||||
const leaveSharedFolderItem = folderCommandToMenuItem('leaveSharedFolder', itemId);
|
||||
if (leaveSharedFolderItem.enabled) menu.append(leaveSharedFolderItem);
|
||||
|
||||
menu.append(
|
||||
new MenuItem({
|
||||
@@ -216,14 +234,14 @@ const useOnRenderItem = (props: Props) => {
|
||||
);
|
||||
if (Setting.value('notes.perFolderSortOrderEnabled')) {
|
||||
menu.append(new MenuItem({
|
||||
...menuUtils.commandToStatefulMenuItem('togglePerFolderSortOrder', itemId),
|
||||
...menuUtils.commandToStatefulMenuItem('togglePerFolderSortOrder', itemId, { commandFolderId: itemId }),
|
||||
type: 'checkbox',
|
||||
checked: PerFolderSortOrderService.isSet(itemId),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if (itemType === BaseModel.TYPE_FOLDER) {
|
||||
if (itemType === BaseModel.TYPE_FOLDER && itemIds.length === 1) {
|
||||
menu.append(
|
||||
new MenuItem({
|
||||
label: _('Copy external link'),
|
||||
@@ -234,7 +252,7 @@ const useOnRenderItem = (props: Props) => {
|
||||
);
|
||||
}
|
||||
|
||||
if (itemType === BaseModel.TYPE_TAG) {
|
||||
if (itemType === BaseModel.TYPE_TAG && itemIds.length === 1) {
|
||||
menu.append(new MenuItem(
|
||||
menuUtils.commandToStatefulMenuItem('renameTag', itemId),
|
||||
));
|
||||
@@ -253,24 +271,22 @@ const useOnRenderItem = (props: Props) => {
|
||||
for (const view of pluginViews) {
|
||||
const location = view.location;
|
||||
|
||||
if (itemType === ModelType.Tag && location === MenuItemLocation.TagContextMenu ||
|
||||
itemType === ModelType.Folder && location === MenuItemLocation.FolderContextMenu
|
||||
) {
|
||||
if (itemType === ModelType.Tag && location === MenuItemLocation.TagContextMenu) {
|
||||
menu.append(
|
||||
new MenuItem(menuUtils.commandToStatefulMenuItem(view.commandName, itemId)),
|
||||
);
|
||||
} else if (itemType === ModelType.Folder && location === MenuItemLocation.FolderContextMenu) {
|
||||
menu.append(folderCommandToMenuItem(view.commandName, itemId));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (itemType === BaseModel.TYPE_FOLDER) {
|
||||
menu.append(
|
||||
new MenuItem(menuUtils.commandToStatefulMenuItem('restoreFolder', itemId)),
|
||||
);
|
||||
menu.append(folderCommandToMenuItem('restoreFolder', itemIds));
|
||||
}
|
||||
}
|
||||
|
||||
menu.popup({ window: bridge().activeWindow() });
|
||||
}, [props.dispatch, pluginsRef]);
|
||||
}, [props.dispatch, pluginsRef, getSelectedIds]);
|
||||
|
||||
|
||||
|
||||
@@ -278,10 +294,16 @@ const useOnRenderItem = (props: Props) => {
|
||||
const folderId = event.currentTarget.getAttribute('data-folder-id');
|
||||
if (!folderId) return;
|
||||
|
||||
let itemIds = [folderId];
|
||||
const itemIndex = Number(event.currentTarget.getAttribute('data-index'));
|
||||
if (selectedIndexesRef.current.includes(itemIndex)) {
|
||||
itemIds = getSelectedIds();
|
||||
}
|
||||
|
||||
event.dataTransfer.setDragImage(new Image(), 1, 1);
|
||||
event.dataTransfer.clearData();
|
||||
event.dataTransfer.setData('text/x-jop-folder-ids', JSON.stringify([folderId]));
|
||||
}, []);
|
||||
event.dataTransfer.setData('text/x-jop-folder-ids', JSON.stringify(itemIds));
|
||||
}, [getSelectedIds]);
|
||||
|
||||
const onFolderDragOver_: ItemDragListener = useCallback(event => {
|
||||
if (event.dataTransfer.types.indexOf('text/x-jop-note-ids') >= 0) event.preventDefault();
|
||||
@@ -323,13 +345,6 @@ const useOnRenderItem = (props: Props) => {
|
||||
});
|
||||
}, [props.dispatch]);
|
||||
|
||||
const folderItem_click = useCallback((folderId: string) => {
|
||||
props.dispatch({
|
||||
type: 'FOLDER_SELECT',
|
||||
id: folderId ? folderId : null,
|
||||
});
|
||||
}, [props.dispatch]);
|
||||
|
||||
// If at least one of the folder has an icon, then we display icons for all
|
||||
// folders (those without one will get the default icon). This is so that
|
||||
// visual alignment is correct for all folders, otherwise the folder tree
|
||||
@@ -338,22 +353,26 @@ const useOnRenderItem = (props: Props) => {
|
||||
return Folder.shouldShowFolderIcons(props.folders);
|
||||
}, [props.folders]);
|
||||
|
||||
const selectedIndexRef = useRef(props.selectedIndex);
|
||||
selectedIndexRef.current = props.selectedIndex;
|
||||
|
||||
const itemCount = props.listItems.length;
|
||||
return useCallback((item: ListItem, index: number) => {
|
||||
const selected = props.selectedIndex === index;
|
||||
const primarySelected = props.selectedIndex === index;
|
||||
const selected = primarySelected || props.selectedIndexes.includes(index);
|
||||
const selectionState: ItemSelectionState = {
|
||||
primarySelected,
|
||||
selected,
|
||||
multipleItemsSelected: props.selectedIndexes.length > 1,
|
||||
};
|
||||
|
||||
const focusInList = document.hasFocus() && props.containerRef.current?.contains(document.activeElement);
|
||||
const anchorRef = (focusInList && selected) ? focusListItem : noFocusListItem;
|
||||
const anchorRef = (focusInList && primarySelected) ? focusListItem : noFocusListItem;
|
||||
|
||||
if (item.kind === ListItemType.Tag) {
|
||||
const tag = item.tag;
|
||||
return <TagItem
|
||||
key={item.key}
|
||||
anchorRef={anchorRef}
|
||||
selected={selected}
|
||||
onClick={tagItem_click}
|
||||
selectionState={selectionState}
|
||||
onClick={onItemClick}
|
||||
onTagDrop={onTagDrop_}
|
||||
onContextMenu={onItemContextMenu}
|
||||
label={item.label}
|
||||
@@ -383,7 +402,7 @@ const useOnRenderItem = (props: Props) => {
|
||||
return <FolderItem
|
||||
key={item.key}
|
||||
anchorRef={anchorRef}
|
||||
selected={selected}
|
||||
selectionState={selectionState}
|
||||
folderId={folder.id}
|
||||
folderTitle={item.label}
|
||||
folderIcon={Folder.unserializeIcon(folder.icon)}
|
||||
@@ -395,7 +414,7 @@ const useOnRenderItem = (props: Props) => {
|
||||
onFolderDragOver_={onFolderDragOver_}
|
||||
onFolderDrop_={onFolderDrop_}
|
||||
itemContextMenu={onItemContextMenu}
|
||||
folderItem_click={folderItem_click}
|
||||
folderItem_click={onItemClick}
|
||||
onFolderToggleClick_={onFolderToggleClick_}
|
||||
shareId={folder.share_id}
|
||||
parentId={folder.parent_id}
|
||||
@@ -408,7 +427,7 @@ const useOnRenderItem = (props: Props) => {
|
||||
key={item.id}
|
||||
anchorRef={anchorRef}
|
||||
item={item}
|
||||
isSelected={selected}
|
||||
selectionState={selectionState}
|
||||
onDrop={item.supportsFolderDrop ? onFolderDrop_ : null}
|
||||
index={index}
|
||||
itemCount={itemCount}
|
||||
@@ -417,7 +436,7 @@ const useOnRenderItem = (props: Props) => {
|
||||
return <AllNotesItem
|
||||
key={item.key}
|
||||
anchorRef={anchorRef}
|
||||
selected={selected}
|
||||
selectionState={selectionState}
|
||||
item={item}
|
||||
index={index}
|
||||
itemCount={itemCount}
|
||||
@@ -428,7 +447,7 @@ const useOnRenderItem = (props: Props) => {
|
||||
key={item.key}
|
||||
containerRef={anchorRef}
|
||||
depth={1}
|
||||
selected={selected}
|
||||
selectionState={selectionState}
|
||||
itemIndex={index}
|
||||
itemCount={itemCount}
|
||||
highlightOnHover={false}
|
||||
@@ -442,7 +461,7 @@ const useOnRenderItem = (props: Props) => {
|
||||
return exhaustivenessCheck;
|
||||
}
|
||||
}, [
|
||||
folderItem_click,
|
||||
onItemClick,
|
||||
onFolderDragOver_,
|
||||
onFolderDragStart_,
|
||||
onFolderDrop_,
|
||||
@@ -452,8 +471,8 @@ const useOnRenderItem = (props: Props) => {
|
||||
props.collapsedFolderIds,
|
||||
props.folders,
|
||||
showFolderIcons,
|
||||
tagItem_click,
|
||||
props.selectedIndex,
|
||||
props.selectedIndexes,
|
||||
props.containerRef,
|
||||
itemCount,
|
||||
]);
|
||||
|
||||
@@ -9,6 +9,7 @@ interface Props {
|
||||
listItems: ListItem[];
|
||||
collapsedFolderIds: string[];
|
||||
selectedIndex: number;
|
||||
selectedIndexes: number[];
|
||||
updateSelectedIndex: SetSelectedIndexCallback;
|
||||
}
|
||||
|
||||
@@ -68,7 +69,7 @@ const findNextTypeAheadMatch = (selectedIndex: number, query: string, listItems:
|
||||
};
|
||||
|
||||
const useOnSidebarKeyDownHandler = (props: Props) => {
|
||||
const { updateSelectedIndex, listItems, selectedIndex, collapsedFolderIds, dispatch } = props;
|
||||
const { updateSelectedIndex, listItems, selectedIndex, selectedIndexes, collapsedFolderIds, dispatch } = props;
|
||||
|
||||
return useCallback<KeyboardEventHandler<HTMLElement>>((event) => {
|
||||
const selectedItem = listItems[selectedIndex];
|
||||
@@ -104,12 +105,15 @@ const useOnSidebarKeyDownHandler = (props: Props) => {
|
||||
event.preventDefault();
|
||||
} else if (event.code === 'Home') {
|
||||
event.preventDefault();
|
||||
updateSelectedIndex(0);
|
||||
updateSelectedIndex(0, { extend: false });
|
||||
indexChange = 0;
|
||||
} else if (event.code === 'End') {
|
||||
event.preventDefault();
|
||||
updateSelectedIndex(listItems.length - 1);
|
||||
updateSelectedIndex(listItems.length - 1, { extend: false });
|
||||
indexChange = 0;
|
||||
} else if (event.code === 'Escape' && selectedIndexes.length > 1) {
|
||||
event.preventDefault();
|
||||
updateSelectedIndex(selectedIndex, { extend: false });
|
||||
} else if (event.code === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
void CommandService.instance().execute('focusElement', 'noteList');
|
||||
@@ -122,9 +126,9 @@ const useOnSidebarKeyDownHandler = (props: Props) => {
|
||||
|
||||
if (indexChange !== 0) {
|
||||
event.preventDefault();
|
||||
updateSelectedIndex(selectedIndex + indexChange);
|
||||
updateSelectedIndex(selectedIndex + indexChange, { extend: event.shiftKey });
|
||||
}
|
||||
}, [selectedIndex, collapsedFolderIds, listItems, updateSelectedIndex, dispatch]);
|
||||
}, [selectedIndex, selectedIndexes, collapsedFolderIds, listItems, updateSelectedIndex, dispatch]);
|
||||
};
|
||||
|
||||
export default useOnSidebarKeyDownHandler;
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { ListItem, ListItemType } from '../types';
|
||||
import { isFolderSelected, isTagSelected } from '@joplin/lib/components/shared/side-menu-shared';
|
||||
import { Dispatch } from 'redux';
|
||||
const { ALL_NOTES_FILTER_ID } = require('@joplin/lib/reserved-ids');
|
||||
|
||||
|
||||
interface Props {
|
||||
dispatch: Dispatch;
|
||||
listItems: ListItem[];
|
||||
|
||||
notesParentType: string;
|
||||
selectedTagId: string;
|
||||
selectedFolderId: string;
|
||||
selectedSmartFilterId: string;
|
||||
}
|
||||
|
||||
const useSelectedSidebarIndex = (props: Props) => {
|
||||
const appStateSelectedIndex = useMemo(() => {
|
||||
for (let i = 0; i < props.listItems.length; i++) {
|
||||
const listItem = props.listItems[i];
|
||||
|
||||
let selected = false;
|
||||
if (listItem.kind === ListItemType.AllNotes) {
|
||||
selected = props.selectedSmartFilterId === ALL_NOTES_FILTER_ID && props.notesParentType === 'SmartFilter';
|
||||
} else if (listItem.kind === ListItemType.Header || listItem.kind === ListItemType.Spacer) {
|
||||
selected = false;
|
||||
} else if (listItem.kind === ListItemType.Folder) {
|
||||
selected = isFolderSelected(listItem.folder, { selectedFolderId: props.selectedFolderId, notesParentType: props.notesParentType });
|
||||
} else if (listItem.kind === ListItemType.Tag) {
|
||||
selected = isTagSelected(listItem.tag, { selectedTagId: props.selectedTagId, notesParentType: props.notesParentType });
|
||||
} else {
|
||||
const exhaustivenessCheck: never = listItem;
|
||||
return exhaustivenessCheck;
|
||||
}
|
||||
|
||||
if (selected) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}, [props.listItems, props.selectedFolderId, props.selectedTagId, props.selectedSmartFilterId, props.notesParentType]);
|
||||
|
||||
// Not all list items correspond with selectable Joplin folders/tags, but we want to
|
||||
// be able to select them anyway. This is handled with selectedIndexOverride.
|
||||
//
|
||||
// When selectedIndexOverride >= 0, it corresponds to the index of a selected item with no
|
||||
// specific note parent item (e.g. a header).
|
||||
const [selectedIndexOverride, setSelectedIndexOverride] = useState(-1);
|
||||
useEffect(() => {
|
||||
setSelectedIndexOverride(-1);
|
||||
}, [appStateSelectedIndex]);
|
||||
|
||||
const updateSelectedIndex = useCallback((newIndex: number) => {
|
||||
if (newIndex < 0) {
|
||||
newIndex = 0;
|
||||
} else if (newIndex >= props.listItems.length) {
|
||||
newIndex = props.listItems.length - 1;
|
||||
}
|
||||
|
||||
const newItem = props.listItems[newIndex];
|
||||
let newOverrideIndex = -1;
|
||||
if (newItem.kind === ListItemType.AllNotes) {
|
||||
props.dispatch({
|
||||
type: 'SMART_FILTER_SELECT',
|
||||
id: ALL_NOTES_FILTER_ID,
|
||||
});
|
||||
} else if (newItem.kind === ListItemType.Folder) {
|
||||
props.dispatch({
|
||||
type: 'FOLDER_SELECT',
|
||||
id: newItem.folder.id,
|
||||
});
|
||||
} else if (newItem.kind === ListItemType.Tag) {
|
||||
props.dispatch({
|
||||
type: 'TAG_SELECT',
|
||||
id: newItem.tag.id,
|
||||
});
|
||||
} else {
|
||||
newOverrideIndex = newIndex;
|
||||
}
|
||||
setSelectedIndexOverride(newOverrideIndex);
|
||||
}, [props.listItems, props.dispatch]);
|
||||
|
||||
const selectedIndex = selectedIndexOverride === -1 ? appStateSelectedIndex : selectedIndexOverride;
|
||||
return { selectedIndex, updateSelectedIndex };
|
||||
};
|
||||
|
||||
export default useSelectedSidebarIndex;
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { ListItem, ListItemType } from '../types';
|
||||
import { isFolderSelected, isTagSelected } from '@joplin/lib/components/shared/side-menu-shared';
|
||||
import { Dispatch } from 'redux';
|
||||
const { ALL_NOTES_FILTER_ID } = require('@joplin/lib/reserved-ids');
|
||||
|
||||
type UpdateSelectedIndexOptions = { extend: boolean };
|
||||
|
||||
interface Props {
|
||||
dispatch: Dispatch;
|
||||
listItems: ListItem[];
|
||||
|
||||
notesParentType: string;
|
||||
selectedTagId: string;
|
||||
selectedTagIds: string[];
|
||||
selectedFolderId: string;
|
||||
selectedFolderIds: string[];
|
||||
selectedSmartFilterId: string;
|
||||
}
|
||||
|
||||
const useSelectedSidebarIndexes = (props: Props) => {
|
||||
const isIndexInSelection = useCallback((index: number) => {
|
||||
const listItem = props.listItems[index];
|
||||
|
||||
let selected = false;
|
||||
if (listItem.kind === ListItemType.AllNotes) {
|
||||
selected = props.selectedSmartFilterId === ALL_NOTES_FILTER_ID && props.notesParentType === 'SmartFilter';
|
||||
} else if (listItem.kind === ListItemType.Header || listItem.kind === ListItemType.Spacer) {
|
||||
selected = false;
|
||||
} else if (listItem.kind === ListItemType.Folder) {
|
||||
selected = isFolderSelected(listItem.folder, {
|
||||
selectedFolderIds: props.selectedFolderIds,
|
||||
notesParentType: props.notesParentType,
|
||||
});
|
||||
} else if (listItem.kind === ListItemType.Tag) {
|
||||
selected = isTagSelected(listItem.tag, { selectedTagIds: props.selectedTagIds, notesParentType: props.notesParentType });
|
||||
} else {
|
||||
const exhaustivenessCheck: never = listItem;
|
||||
return exhaustivenessCheck;
|
||||
}
|
||||
|
||||
return selected;
|
||||
}, [props.listItems, props.selectedFolderIds, props.selectedTagIds, props.selectedSmartFilterId, props.notesParentType]);
|
||||
|
||||
const isIndexPrimarySelected = useCallback((index: number) => {
|
||||
const listItem = props.listItems[index];
|
||||
|
||||
if (listItem.kind === ListItemType.Folder) {
|
||||
return isFolderSelected(listItem.folder, {
|
||||
selectedFolderIds: [props.selectedFolderId],
|
||||
notesParentType: props.notesParentType,
|
||||
});
|
||||
} else if (listItem.kind === ListItemType.Tag) {
|
||||
return isTagSelected(listItem.tag, { selectedTagIds: [props.selectedTagId], notesParentType: props.notesParentType });
|
||||
} else {
|
||||
return isIndexInSelection(index);
|
||||
}
|
||||
}, [props.listItems, isIndexInSelection, props.selectedFolderId, props.selectedTagId, props.notesParentType]);
|
||||
|
||||
const appStateSelectedIndexes = useMemo(() => {
|
||||
const selectedIndexes = [];
|
||||
for (let i = 0; i < props.listItems.length; i++) {
|
||||
if (isIndexInSelection(i)) {
|
||||
selectedIndexes.push(i);
|
||||
}
|
||||
}
|
||||
return selectedIndexes;
|
||||
}, [props.listItems, isIndexInSelection]);
|
||||
|
||||
const appStateSelectedIndex = useMemo(() => {
|
||||
return props.listItems.findIndex((_item, index) => isIndexPrimarySelected(index));
|
||||
}, [props.listItems, isIndexPrimarySelected]);
|
||||
|
||||
// The main index of all selected indexes. This is where the focus will go.
|
||||
// Ignored if not included in appStateSelectedIndexes.
|
||||
const [primarySelectedIndex, setPrimarySelectedIndex] = useState(0);
|
||||
|
||||
// Not all list items correspond with selectable Joplin folders/tags, but we want to
|
||||
// be able to select them anyway. This is handled with selectedIndexOverride.
|
||||
//
|
||||
// When selectedIndexOverride >= 0, it corresponds to the index of a selected item with no
|
||||
// specific note parent item (e.g. a header).
|
||||
const [selectedIndexOverride, setSelectedIndexOverride] = useState(-1);
|
||||
useEffect(() => {
|
||||
setSelectedIndexOverride(-1);
|
||||
setPrimarySelectedIndex(appStateSelectedIndex);
|
||||
}, [appStateSelectedIndex]);
|
||||
|
||||
const updateSelectedIndex = useCallback((newIndex: number, options: UpdateSelectedIndexOptions) => {
|
||||
if (newIndex < 0) {
|
||||
newIndex = 0;
|
||||
} else if (newIndex >= props.listItems.length) {
|
||||
newIndex = props.listItems.length - 1;
|
||||
}
|
||||
|
||||
const newItem = props.listItems[newIndex];
|
||||
let newOverrideIndex = -1;
|
||||
if (newItem.kind === ListItemType.AllNotes) {
|
||||
props.dispatch({
|
||||
type: 'SMART_FILTER_SELECT',
|
||||
id: ALL_NOTES_FILTER_ID,
|
||||
});
|
||||
} else if (newItem.kind === ListItemType.Folder) {
|
||||
props.dispatch({
|
||||
type: options.extend ? 'FOLDER_SELECT_ADD' : 'FOLDER_SELECT',
|
||||
id: newItem.folder.id,
|
||||
});
|
||||
} else if (newItem.kind === ListItemType.Tag) {
|
||||
props.dispatch({
|
||||
type: options.extend ? 'TAG_SELECT_ADD' : 'TAG_SELECT',
|
||||
id: newItem.tag.id,
|
||||
});
|
||||
} else {
|
||||
newOverrideIndex = newIndex;
|
||||
}
|
||||
setSelectedIndexOverride(newOverrideIndex);
|
||||
setPrimarySelectedIndex(newIndex);
|
||||
}, [props.listItems, props.dispatch]);
|
||||
|
||||
const selectedIndexes = useMemo(() => {
|
||||
return selectedIndexOverride === -1 ? appStateSelectedIndexes : [selectedIndexOverride];
|
||||
}, [appStateSelectedIndexes, selectedIndexOverride]);
|
||||
const selectedIndex = selectedIndexes.includes(primarySelectedIndex) ? primarySelectedIndex : (selectedIndexes[0] ?? -1);
|
||||
|
||||
return {
|
||||
selectedIndex,
|
||||
selectedIndexes,
|
||||
updateSelectedIndex,
|
||||
};
|
||||
};
|
||||
|
||||
export default useSelectedSidebarIndexes;
|
||||
@@ -9,7 +9,7 @@ import CommandService from '@joplin/lib/services/CommandService';
|
||||
import PerFolderSortOrderService from '../../../services/sortOrder/PerFolderSortOrderService';
|
||||
import { connect } from 'react-redux';
|
||||
import EmptyExpandLink from './EmptyExpandLink';
|
||||
import ListItemWrapper, { ListItemRef } from './ListItemWrapper';
|
||||
import ListItemWrapper, { ItemSelectionState, ListItemRef } from './ListItemWrapper';
|
||||
import { ListItem } from '../types';
|
||||
const { ALL_NOTES_FILTER_ID } = require('@joplin/lib/reserved-ids');
|
||||
|
||||
@@ -19,7 +19,7 @@ const MenuItem = bridge().MenuItem;
|
||||
interface Props {
|
||||
dispatch: Dispatch;
|
||||
anchorRef: ListItemRef;
|
||||
selected: boolean;
|
||||
selectionState: ItemSelectionState;
|
||||
item: ListItem;
|
||||
index: number;
|
||||
itemCount: number;
|
||||
@@ -53,7 +53,7 @@ const AllNotesItem: React.FC<Props> = props => {
|
||||
<ListItemWrapper
|
||||
containerRef={props.anchorRef}
|
||||
key="allNotesHeader"
|
||||
selected={props.selected}
|
||||
selectionState={props.selectionState}
|
||||
depth={props.item.depth}
|
||||
className={'list-item-container list-item-depth-0 all-notes'}
|
||||
highlightOnHover={true}
|
||||
@@ -65,7 +65,7 @@ const AllNotesItem: React.FC<Props> = props => {
|
||||
<StyledListItemAnchor
|
||||
className="list-item"
|
||||
isSpecialItem={true}
|
||||
selected={props.selected}
|
||||
selected={props.selectionState.selected}
|
||||
onClick={onAllNotesClick_}
|
||||
onContextMenu={toggleAllNotesContextMenu}
|
||||
>
|
||||
|
||||
@@ -10,8 +10,9 @@ import Folder from '@joplin/lib/models/Folder';
|
||||
import { ModelType } from '@joplin/lib/BaseModel';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import NoteCount from './NoteCount';
|
||||
import ListItemWrapper, { ListItemRef } from './ListItemWrapper';
|
||||
import ListItemWrapper, { ItemSelectionState, ListItemRef } from './ListItemWrapper';
|
||||
import { useId } from 'react';
|
||||
import { ItemClickEvent } from '../hooks/useOnItemClick';
|
||||
|
||||
const renderFolderIcon = (folderIcon: FolderIcon) => {
|
||||
if (!folderIcon) {
|
||||
@@ -42,17 +43,17 @@ interface FolderItemProps {
|
||||
onFolderDragOver_: ItemDragListener;
|
||||
onFolderDrop_: ItemDragListener;
|
||||
itemContextMenu: ItemContextMenuListener;
|
||||
folderItem_click: (folderId: string)=> void;
|
||||
folderItem_click: (event: ItemClickEvent)=> void;
|
||||
onFolderToggleClick_: ItemClickListener;
|
||||
shareId: string;
|
||||
selected: boolean;
|
||||
selectionState: ItemSelectionState;
|
||||
|
||||
index: number;
|
||||
itemCount: number;
|
||||
}
|
||||
|
||||
function FolderItem(props: FolderItemProps) {
|
||||
const { hasChildren, showFolderIcon, isExpanded, parentId, depth, selected, folderId, folderTitle, folderIcon, noteCount, onFolderDragStart_, onFolderDragOver_, onFolderDrop_, itemContextMenu, folderItem_click, onFolderToggleClick_, shareId } = props;
|
||||
const { hasChildren, showFolderIcon, isExpanded, parentId, depth, selectionState, folderId, folderTitle, folderIcon, noteCount, onFolderDragStart_, onFolderDragOver_, onFolderDrop_, itemContextMenu, folderItem_click, onFolderToggleClick_, shareId } = props;
|
||||
|
||||
const shareTitle = _('Shared');
|
||||
const shareIcon = shareId && !parentId ? <StyledShareIcon aria-label={shareTitle} title={shareTitle} className="fas fa-share-alt"/> : null;
|
||||
@@ -73,11 +74,11 @@ function FolderItem(props: FolderItemProps) {
|
||||
containerRef={props.anchorRef}
|
||||
// Folders are contained within the "Notebooks" section (which has depth 0):
|
||||
depth={depth + 1}
|
||||
selected={selected}
|
||||
selectionState={selectionState}
|
||||
itemIndex={props.index}
|
||||
itemCount={props.itemCount}
|
||||
expanded={hasChildren ? props.isExpanded : undefined}
|
||||
className={`list-item-container list-item-depth-${depth} ${selected ? 'selected' : ''}`}
|
||||
className={`list-item-container list-item-depth-${depth} ${selectionState.selected ? 'selected' : ''}`}
|
||||
highlightOnHover={true}
|
||||
onDragStart={onFolderDragStart_}
|
||||
onDragOver={onFolderDragOver_}
|
||||
@@ -95,13 +96,15 @@ function FolderItem(props: FolderItemProps) {
|
||||
className="list-item"
|
||||
id={titleId}
|
||||
isConflictFolder={folderId === Folder.conflictFolderId()}
|
||||
selected={selected}
|
||||
selected={selectionState.selected}
|
||||
shareId={shareId}
|
||||
data-folder-id={folderId}
|
||||
onDoubleClick={onFolderToggleClick_}
|
||||
|
||||
onClick={() => {
|
||||
folderItem_click(folderId);
|
||||
onClick={(event: React.MouseEvent) => {
|
||||
folderItem_click({
|
||||
id: folderId, type: ModelType.Folder, event,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{doRenderFolderIcon()}<StyledSpanFix className="title">{folderTitle}</StyledSpanFix>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { HeaderId, HeaderListItem } from '../types';
|
||||
import bridge from '../../../services/bridge';
|
||||
import MenuUtils from '@joplin/lib/services/commands/MenuUtils';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import ListItemWrapper, { ListItemRef } from './ListItemWrapper';
|
||||
import ListItemWrapper, { ItemSelectionState, ListItemRef } from './ListItemWrapper';
|
||||
|
||||
const Menu = bridge().Menu;
|
||||
const MenuItem = bridge().MenuItem;
|
||||
@@ -15,7 +15,7 @@ const menuUtils = new MenuUtils(CommandService.instance());
|
||||
interface Props {
|
||||
anchorRef: ListItemRef;
|
||||
item: HeaderListItem;
|
||||
isSelected: boolean;
|
||||
selectionState: ItemSelectionState;
|
||||
onDrop: React.DragEventHandler|null;
|
||||
index: number;
|
||||
itemCount: number;
|
||||
@@ -47,7 +47,7 @@ const HeaderItem: React.FC<Props> = props => {
|
||||
return (
|
||||
<ListItemWrapper
|
||||
containerRef={props.anchorRef}
|
||||
selected={props.isSelected}
|
||||
selectionState={props.selectionState}
|
||||
itemIndex={props.index}
|
||||
itemCount={props.itemCount}
|
||||
expanded={props.item.expanded}
|
||||
|
||||
@@ -4,9 +4,18 @@ import { useMemo } from 'react';
|
||||
|
||||
export type ListItemRef = React.Ref<HTMLDivElement>;
|
||||
|
||||
export interface ItemSelectionState {
|
||||
selected: boolean;
|
||||
// The item with primary selection is used for actions that support only one folder.
|
||||
// Only one item can have primary selection.
|
||||
primarySelected: boolean;
|
||||
|
||||
multipleItemsSelected: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
containerRef: ListItemRef;
|
||||
selected: boolean;
|
||||
selectionState: ItemSelectionState;
|
||||
itemIndex: number;
|
||||
itemCount: number;
|
||||
expanded?: boolean|undefined;
|
||||
@@ -35,15 +44,17 @@ const ListItemWrapper: React.FC<Props> = props => {
|
||||
} as React.CSSProperties;
|
||||
}, [props.depth]);
|
||||
|
||||
const { selected, primarySelected, multipleItemsSelected } = props.selectionState;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={props.containerRef}
|
||||
aria-posinset={props.itemIndex + 1}
|
||||
aria-setsize={props.itemCount}
|
||||
aria-selected={props.selected}
|
||||
aria-selected={selected}
|
||||
aria-expanded={props.expanded}
|
||||
aria-level={props.depth}
|
||||
tabIndex={props.selected ? 0 : -1}
|
||||
tabIndex={primarySelected ? 0 : -1}
|
||||
|
||||
onContextMenu={props.onContextMenu}
|
||||
onDrag={props.onDrag}
|
||||
@@ -53,10 +64,17 @@ const ListItemWrapper: React.FC<Props> = props => {
|
||||
draggable={props.draggable}
|
||||
|
||||
role='treeitem'
|
||||
className={`list-item-wrapper ${props.highlightOnHover ? '-highlight-on-hover' : ''} ${props.selected ? '-selected' : ''} ${props.className ?? ''}`}
|
||||
className={[
|
||||
'list-item-wrapper',
|
||||
props.highlightOnHover ? '-highlight-on-hover' : '',
|
||||
selected ? '-selected' : '',
|
||||
primarySelected && multipleItemsSelected ? '-selected-primary' : '',
|
||||
props.className ?? '',
|
||||
].join(' ')}
|
||||
style={style}
|
||||
data-folder-id={props['data-folder-id']}
|
||||
data-id={props['data-id']}
|
||||
data-index={props.itemIndex}
|
||||
data-tag-id={props['data-tag-id']}
|
||||
data-type={props['data-type']}
|
||||
aria-labelledby={props['aria-labelledby']}
|
||||
|
||||
@@ -3,28 +3,27 @@ import * as React from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { StyledListItemAnchor, StyledSpanFix } from '../styles';
|
||||
import { TagsWithNoteCountEntity } from '@joplin/lib/services/database/types';
|
||||
import BaseModel from '@joplin/lib/BaseModel';
|
||||
import BaseModel, { ModelType } from '@joplin/lib/BaseModel';
|
||||
import NoteCount from './NoteCount';
|
||||
import EmptyExpandLink from './EmptyExpandLink';
|
||||
import ListItemWrapper, { ListItemRef } from './ListItemWrapper';
|
||||
|
||||
export type TagLinkClickEvent = { tag: TagsWithNoteCountEntity|undefined };
|
||||
import ListItemWrapper, { ItemSelectionState, ListItemRef } from './ListItemWrapper';
|
||||
import { ItemClickEvent } from '../hooks/useOnItemClick';
|
||||
|
||||
interface Props {
|
||||
anchorRef: ListItemRef;
|
||||
selected: boolean;
|
||||
selectionState: ItemSelectionState;
|
||||
tag: TagsWithNoteCountEntity;
|
||||
label: string;
|
||||
onTagDrop: React.DragEventHandler<HTMLElement>;
|
||||
onContextMenu: React.MouseEventHandler<HTMLElement>;
|
||||
onClick: (event: TagLinkClickEvent)=> void;
|
||||
onClick: (event: ItemClickEvent)=> void;
|
||||
|
||||
itemCount: number;
|
||||
index: number;
|
||||
}
|
||||
|
||||
const TagItem = (props: Props) => {
|
||||
const { tag, selected } = props;
|
||||
const { tag, selectionState } = props;
|
||||
|
||||
let noteCount = null;
|
||||
if (Setting.value('showNoteCounts')) {
|
||||
@@ -32,30 +31,31 @@ const TagItem = (props: Props) => {
|
||||
noteCount = <NoteCount count={count}/>;
|
||||
}
|
||||
|
||||
const onClickHandler = useCallback(() => {
|
||||
props.onClick({ tag });
|
||||
const onClickHandler: React.MouseEventHandler<HTMLElement> = useCallback((event) => {
|
||||
props.onClick({ id: tag.id, type: ModelType.Tag, event });
|
||||
}, [props.onClick, tag]);
|
||||
|
||||
return (
|
||||
<ListItemWrapper
|
||||
containerRef={props.anchorRef}
|
||||
selected={selected}
|
||||
selectionState={selectionState}
|
||||
depth={1}
|
||||
className={`list-item-container ${selected ? 'selected' : ''}`}
|
||||
className={`list-item-container ${selectionState.selected ? 'selected' : ''}`}
|
||||
highlightOnHover={true}
|
||||
onDrop={props.onTagDrop}
|
||||
onContextMenu={props.onContextMenu}
|
||||
data-id={tag.id}
|
||||
data-tag-id={tag.id}
|
||||
aria-selected={selected}
|
||||
data-type={ModelType.Tag}
|
||||
itemIndex={props.index}
|
||||
itemCount={props.itemCount}
|
||||
>
|
||||
<EmptyExpandLink/>
|
||||
<StyledListItemAnchor
|
||||
className="list-item"
|
||||
selected={selected}
|
||||
selected={selectionState.selected}
|
||||
data-id={tag.id}
|
||||
data-type={BaseModel.TYPE_TAG}
|
||||
onContextMenu={props.onContextMenu}
|
||||
onClick={onClickHandler}
|
||||
>
|
||||
<StyledSpanFix className="tag-label">{props.label}</StyledSpanFix>
|
||||
|
||||
@@ -22,7 +22,30 @@
|
||||
background: var(--joplin-selected-color2);
|
||||
}
|
||||
|
||||
&.-highlight-on-hover:hover {
|
||||
// When multiple items are selected, show an outline (similar to the focus outline) to indicate
|
||||
// which folder has the primary selection.
|
||||
&.-selected-primary {
|
||||
--outline-color: var(--joplin-focus-outline-color-dimmed);
|
||||
outline: 1px solid var(--outline-color);
|
||||
|
||||
// Also adjust the background color: This makes it clearer which item has primary focus,
|
||||
// especially when using a dimmed outline.
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--outline-color) 12%,
|
||||
var(--joplin-selected-color2) 92%
|
||||
);
|
||||
|
||||
// For accessibility, use a different style when actually focused. This makes it easier to
|
||||
// tell where the keyboard focus is.
|
||||
&:focus {
|
||||
--outline-color: var(--joplin-focus-outline-color);
|
||||
}
|
||||
}
|
||||
|
||||
// Don't highlight selected items on hover -- doing so makes it
|
||||
// difficult to tell whether the hovered item is selected or not.
|
||||
&.-highlight-on-hover:not(.-selected):hover {
|
||||
background-color: var(--joplin-background-color-hover2);
|
||||
}
|
||||
}
|
||||
@@ -57,8 +57,10 @@ export interface SpacerListItem extends ToplevelListItem {
|
||||
|
||||
export type ListItem = HeaderListItem|AllNotesListItem|TagListItem|FolderListItem|SpacerListItem;
|
||||
|
||||
|
||||
export type SetSelectedIndexCallback = (newIndex: number)=> void;
|
||||
interface SetSelectedIndexOptions {
|
||||
extend: boolean;
|
||||
}
|
||||
export type SetSelectedIndexCallback = (newIndex: number, options: SetSelectedIndexOptions)=> void;
|
||||
|
||||
|
||||
export type ItemDragListener = DragEventHandler<HTMLElement>;
|
||||
|
||||
@@ -40,12 +40,18 @@ async function exportDebugReportClick() {
|
||||
}
|
||||
|
||||
function StatusScreen(props: Props) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [report, setReport] = useState<ReportSection[]>([]);
|
||||
|
||||
async function refreshScreen() {
|
||||
const service = new ReportService();
|
||||
const r = await service.status(Setting.value('sync.target'));
|
||||
setReport(r);
|
||||
setLoading(true);
|
||||
try {
|
||||
const service = new ReportService();
|
||||
const r = await service.status(Setting.value('sync.target'));
|
||||
setReport(r);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -208,6 +214,7 @@ function StatusScreen(props: Props) {
|
||||
<div style={style}>
|
||||
<div style={containerStyle}>
|
||||
{renderTools()}
|
||||
{loading && <p><span className='loading-animation'/> {_('Loading...')}</p>}
|
||||
{body}
|
||||
</div>
|
||||
<ButtonBar
|
||||
|
||||
@@ -14,7 +14,7 @@ const ModalMessageOverlay: React.FC<Props> = ({ message }) => {
|
||||
|
||||
return <Dialog contentFillsScreen={true}>
|
||||
<div className="modal-message">
|
||||
<div id="loading-animation" />
|
||||
<div className="loading-animation" />
|
||||
<div className="text" role="status">
|
||||
{lines}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import bridge from '../../../services/bridge';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
import { getTrashFolderId } from '@joplin/lib/services/trash';
|
||||
const { substrWithEllipsis } = require('@joplin/lib/string-utils');
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
@@ -11,22 +12,37 @@ export const declaration: CommandDeclaration = {
|
||||
|
||||
export const runtime = (): CommandRuntime => {
|
||||
return {
|
||||
execute: async (context: CommandContext, folderId: string = null) => {
|
||||
if (folderId === null) folderId = context.state.selectedFolderId;
|
||||
|
||||
const folder = await Folder.load(folderId);
|
||||
if (!folder) throw new Error(`No such folder: ${folderId}`);
|
||||
|
||||
let deleteMessage = _('Move notebook "%s" to the trash?\n\nAll notes and sub-notebooks within this notebook will also be moved to the trash.', substrWithEllipsis(folder.title, 0, 32));
|
||||
if (folderId === context.state.settings['sync.10.inboxId']) {
|
||||
deleteMessage = _('Delete the Inbox notebook?\n\nIf you delete the inbox notebook, any email that\'s recently been sent to it may be lost.');
|
||||
execute: async (context: CommandContext, folderIds: string|string[] = null) => {
|
||||
if (folderIds === null) {
|
||||
folderIds = context.state.selectedFolderIds;
|
||||
}
|
||||
if (!Array.isArray(folderIds)) {
|
||||
folderIds = [folderIds];
|
||||
}
|
||||
|
||||
const ok = bridge().showConfirmMessageBox(deleteMessage);
|
||||
folderIds = folderIds.filter(id => id !== getTrashFolderId());
|
||||
if (folderIds.length === 0) {
|
||||
throw new Error('Nothing to do: At least one valid folder must be specified.');
|
||||
}
|
||||
|
||||
const folders = await Folder.loadItemsByIdsOrFail(folderIds);
|
||||
|
||||
const deleteMessage = [];
|
||||
if (folders.length === 1) {
|
||||
deleteMessage.push(_('Move notebook "%s" to the trash?\n\nAll notes and sub-notebooks within this notebook will also be moved to the trash.', substrWithEllipsis(folders[0].title, 0, 32)));
|
||||
} else {
|
||||
deleteMessage.push(_('Move %d notebooks to the trash?\n\nAll notes and sub-notebooks within these notebooks will also be moved to the trash.', folders.length));
|
||||
}
|
||||
|
||||
if (folders.some(folder => folder.id === context.state.settings['sync.10.inboxId'])) {
|
||||
deleteMessage.push(_('Delete the Inbox notebook?\n\nIf you delete the inbox notebook, any email that\'s recently been sent to it may be lost.'));
|
||||
}
|
||||
|
||||
const ok = bridge().showConfirmMessageBox(deleteMessage.join('\n\n'));
|
||||
if (!ok) return;
|
||||
|
||||
await Folder.delete(folderId, { toTrash: true, sourceDescription: 'deleteFolder command' });
|
||||
await Folder.batchDelete(folderIds, { toTrash: true, sourceDescription: 'deleteFolder command' });
|
||||
},
|
||||
enabledCondition: '!folderIsReadOnly',
|
||||
enabledCondition: '!foldersIncludeReadOnly',
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import CommandService, { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
|
||||
import InteropService from '@joplin/lib/services/interop/InteropService';
|
||||
import { FileSystemItem, ImportModuleOutputFormat, ModuleType } from '@joplin/lib/services/interop/types';
|
||||
import bridge from '../../../services/bridge';
|
||||
import { WindowControl } from '../utils/useWindowControl';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import makeDiscourseDebugUrl from '@joplin/lib/makeDiscourseDebugUrl';
|
||||
import PluginService from '@joplin/lib/services/plugins/PluginService';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { PackageInfo } from '@joplin/lib/versionInfo';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import { ImportModule } from '@joplin/lib/services/interop/Module';
|
||||
const packageInfo: PackageInfo = require('../../../packageInfo.js');
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'importFrom',
|
||||
label: () => _('Import...'),
|
||||
};
|
||||
|
||||
export interface ImportCommandOptions {
|
||||
sourcePath: string|undefined;
|
||||
sourceType: FileSystemItem;
|
||||
destinationFolderId: string|null;
|
||||
importFormat: string;
|
||||
outputFormat: ImportModuleOutputFormat;
|
||||
}
|
||||
|
||||
const findImportModule = async (commandOptions: ImportCommandOptions|null, control: WindowControl) => {
|
||||
if (commandOptions) {
|
||||
const module = InteropService.instance().findModuleByFormat(
|
||||
ModuleType.Importer, commandOptions.importFormat, commandOptions.sourceType, commandOptions.outputFormat);
|
||||
if (module) {
|
||||
return module as ImportModule;
|
||||
}
|
||||
}
|
||||
|
||||
const importModules = InteropService.instance().modules().filter(module => module.type === ModuleType.Importer) as ImportModule[];
|
||||
return await control.showPrompt({
|
||||
label: _('Select the type of file to be imported:'),
|
||||
value: '',
|
||||
suggestions: importModules.map(module => {
|
||||
const label = module.fullLabel();
|
||||
return {
|
||||
key: `${module.type}--${label}`,
|
||||
value: module,
|
||||
label: module.fullLabel(),
|
||||
};
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const promptForSourcePath = async (module: ImportModule, sourceType: FileSystemItem|undefined) => {
|
||||
if (!sourceType) {
|
||||
if (!module.sources.includes(FileSystemItem.Directory)) {
|
||||
sourceType = FileSystemItem.File;
|
||||
}
|
||||
if (!module.sources.includes(FileSystemItem.File)) {
|
||||
sourceType = FileSystemItem.Directory;
|
||||
}
|
||||
}
|
||||
if (sourceType === FileSystemItem.File) {
|
||||
return await bridge().showOpenDialog({
|
||||
filters: [{ name: module.description, extensions: module.fileExtensions }],
|
||||
});
|
||||
} else if (sourceType === FileSystemItem.Directory) {
|
||||
return await bridge().showOpenDialog({
|
||||
properties: ['openDirectory', 'createDirectory'],
|
||||
});
|
||||
} else {
|
||||
return await bridge().showOpenDialog({
|
||||
properties: ['openDirectory', 'openFile'],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const runtime = (control: WindowControl): CommandRuntime => {
|
||||
return {
|
||||
// Since this can be run from "go to anything", partialOptions needs to support being null or empty.
|
||||
execute: async (context: CommandContext, options: ImportCommandOptions|undefined) => {
|
||||
const importModule = await findImportModule(options, control);
|
||||
if (!importModule) return null; // E.g. if cancelled
|
||||
|
||||
let sourcePath = options?.sourcePath ?? await promptForSourcePath(importModule, options?.sourceType);
|
||||
if (Array.isArray(sourcePath)) {
|
||||
sourcePath = sourcePath[0];
|
||||
}
|
||||
// Handle the case where the directory picker action was cancelled
|
||||
if (!sourcePath) return null;
|
||||
|
||||
if (!options) {
|
||||
const isDirectory = await shim.fsDriver().isDirectory(sourcePath);
|
||||
const importsMultipleNotes = importModule.isNoteArchive || isDirectory;
|
||||
|
||||
const destinationFolderId = importsMultipleNotes ? null : context.state.selectedFolderId;
|
||||
const importFormat = importModule.format;
|
||||
const outputFormat = importModule.outputFormat;
|
||||
options = {
|
||||
sourcePath,
|
||||
destinationFolderId,
|
||||
importFormat,
|
||||
outputFormat,
|
||||
sourceType: isDirectory ? FileSystemItem.Directory : FileSystemItem.File,
|
||||
};
|
||||
}
|
||||
|
||||
const modalMessage = _('Importing from "%s" as "%s" format. Please wait...', sourcePath, options.importFormat);
|
||||
void CommandService.instance().execute('showModalMessage', modalMessage);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const errors: any[] = [];
|
||||
|
||||
const importOptions = {
|
||||
path: sourcePath,
|
||||
format: options.importFormat,
|
||||
outputFormat: options.outputFormat,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
onProgress: (status: any) => {
|
||||
const statusStrings: string[] = Object.keys(status).map((key: string) => {
|
||||
return `${key}: ${status[key]}`;
|
||||
});
|
||||
|
||||
void CommandService.instance().execute('showModalMessage', `${modalMessage}\n\n${statusStrings.join('\n')}`);
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
onError: (error: any) => {
|
||||
errors.push(error);
|
||||
console.warn(error);
|
||||
},
|
||||
destinationFolderId: options.destinationFolderId,
|
||||
};
|
||||
|
||||
const service = InteropService.instance();
|
||||
try {
|
||||
const result = await service.import(importOptions);
|
||||
// eslint-disable-next-line no-console
|
||||
console.info('Import result: ', result);
|
||||
} catch (error) {
|
||||
bridge().showErrorMessageBox(error.message);
|
||||
}
|
||||
|
||||
void CommandService.instance().execute('hideModalMessage');
|
||||
|
||||
if (errors.length) {
|
||||
const response = bridge().showErrorMessageBox('There were some errors importing the notes - check the console for more details.\n\nPlease consider sending a bug report to the forum!', {
|
||||
buttons: [_('Close'), _('Send bug report')],
|
||||
});
|
||||
|
||||
context.dispatch({ type: 'NOTE_DEVTOOLS_SET', value: true });
|
||||
|
||||
if (response === 1) {
|
||||
const url = makeDiscourseDebugUrl(
|
||||
`Error importing notes from format: ${options.importFormat}`,
|
||||
`- Input format: ${options.importFormat}\n- Output format: ${options.outputFormat}`,
|
||||
errors,
|
||||
packageInfo,
|
||||
PluginService.instance(),
|
||||
Setting.value('plugins.states'),
|
||||
);
|
||||
|
||||
void bridge().openExternal(url);
|
||||
}
|
||||
}
|
||||
},
|
||||
enabledCondition: '',
|
||||
};
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import * as editAlarm from './editAlarm';
|
||||
import * as exportPdf from './exportPdf';
|
||||
import * as gotoAnything from './gotoAnything';
|
||||
import * as hideModalMessage from './hideModalMessage';
|
||||
import * as importFrom from './importFrom';
|
||||
import * as linkToNote from './linkToNote';
|
||||
import * as moveToFolder from './moveToFolder';
|
||||
import * as newFolder from './newFolder';
|
||||
@@ -55,6 +56,7 @@ const index: any[] = [
|
||||
exportPdf,
|
||||
gotoAnything,
|
||||
hideModalMessage,
|
||||
importFrom,
|
||||
linkToNote,
|
||||
moveToFolder,
|
||||
newFolder,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import Folder, { FolderEntityWithChildren } from '@joplin/lib/models/Folder';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import BaseItem from '@joplin/lib/models/BaseItem';
|
||||
import { ModelType } from '@joplin/lib/BaseModel';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import showFolderPicker from '../utils/showFolderPicker';
|
||||
|
||||
const logger = Logger.create('commands/moveToFolder');
|
||||
|
||||
@@ -31,71 +32,37 @@ export const runtime = (comp: any): CommandRuntime => {
|
||||
}
|
||||
}
|
||||
|
||||
const folders = await Folder.sortFolderTree();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const startFolders: any[] = [];
|
||||
const maxDepth = 15;
|
||||
|
||||
// It's okay for folders (but not notes) to have no parent folder:
|
||||
if (allAreFolders) {
|
||||
startFolders.push({
|
||||
key: '',
|
||||
value: '',
|
||||
label: _('None'),
|
||||
indentDepth: 0,
|
||||
});
|
||||
}
|
||||
|
||||
const addOptions = (folders: FolderEntityWithChildren[], depth: number) => {
|
||||
for (let i = 0; i < folders.length; i++) {
|
||||
const folder = folders[i];
|
||||
|
||||
// Disallow making a folder a subfolder of itself.
|
||||
if (itemIdToType.has(folder.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
startFolders.push({ key: folder.id, value: folder.id, label: folder.title, indentDepth: depth });
|
||||
if (folder.children) addOptions(folder.children, (depth + 1) < maxDepth ? depth + 1 : maxDepth);
|
||||
}
|
||||
};
|
||||
|
||||
addOptions(folders, 0);
|
||||
|
||||
comp.setState({
|
||||
promptOptions: {
|
||||
label: _('Move to notebook:'),
|
||||
inputType: 'dropdown',
|
||||
value: '',
|
||||
autocomplete: startFolders,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
onClose: async (answer: any) => {
|
||||
if (answer) {
|
||||
try {
|
||||
const targetFolderId = answer.value;
|
||||
for (const id of itemIds) {
|
||||
if (id === targetFolderId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const itemType = itemIdToType.get(id);
|
||||
if (itemType === ModelType.Note) {
|
||||
await Note.moveToFolder(id, targetFolderId);
|
||||
} else if (itemType === ModelType.Folder) {
|
||||
await Folder.moveToFolder(id, targetFolderId);
|
||||
} else {
|
||||
throw new Error(`Cannot move item with type ${itemType}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error moving items', error);
|
||||
void shim.showMessageBox(`Error: ${error}`);
|
||||
}
|
||||
}
|
||||
comp.setState({ promptOptions: null });
|
||||
},
|
||||
},
|
||||
const targetFolderId = await showFolderPicker(comp, {
|
||||
label: _('Move to notebook:'),
|
||||
// It's okay for folders (but not notes) to have no parent folder:
|
||||
allowSelectNone: allAreFolders,
|
||||
// Don't allow setting a folder as its own parent
|
||||
showFolder: (folder) => !itemIdToType.has(folder.id),
|
||||
});
|
||||
|
||||
// It's important to allow the case where targetFolderId is the empty string,
|
||||
// since that corresponds to the toplevel notebook.
|
||||
if (targetFolderId !== null) {
|
||||
try {
|
||||
for (const id of itemIds) {
|
||||
if (id === targetFolderId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const itemType = itemIdToType.get(id);
|
||||
if (itemType === ModelType.Note) {
|
||||
await Note.moveToFolder(id, targetFolderId);
|
||||
} else if (itemType === ModelType.Folder) {
|
||||
await Folder.moveToFolder(id, targetFolderId);
|
||||
} else {
|
||||
throw new Error(`Cannot move item with type ${itemType}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error moving items', error);
|
||||
void shim.showMessageBox(`Error: ${error}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
enabledCondition: 'someNotesSelected && !noteIsReadOnly',
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { utils, CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
|
||||
export const newNoteEnabledConditions = 'oneFolderSelected && !inConflictFolder && !folderIsReadOnly && !folderIsTrash';
|
||||
|
||||
@@ -14,7 +14,7 @@ export const declaration: CommandDeclaration = {
|
||||
export const runtime = (): CommandRuntime => {
|
||||
return {
|
||||
execute: async (_context: CommandContext, body = '', isTodo = false) => {
|
||||
const folderId = Setting.value('activeFolderId');
|
||||
const folderId = await Folder.getValidActiveFolder();
|
||||
if (!folderId) return;
|
||||
|
||||
const defaultValues = Note.previewFieldsWithDefaultValues({ includeTimestamps: false });
|
||||
|
||||
@@ -12,13 +12,15 @@ export const declaration: CommandDeclaration = {
|
||||
|
||||
export const runtime = (): CommandRuntime => {
|
||||
return {
|
||||
execute: async (context: CommandContext, folderId: string = null) => {
|
||||
if (folderId === null) folderId = context.state.selectedFolderId;
|
||||
execute: async (context: CommandContext, folderIds: string|string[] = null) => {
|
||||
if (folderIds === null) folderIds = context.state.selectedFolderIds;
|
||||
if (!Array.isArray(folderIds)) {
|
||||
folderIds = [folderIds];
|
||||
}
|
||||
|
||||
const folder = await Folder.load(folderId);
|
||||
if (!folder) throw new Error(`No such folder: ${folderId}`);
|
||||
await restoreItems(ModelType.Folder, [folder]);
|
||||
const folders = await Folder.loadItemsByIdsOrFail(folderIds);
|
||||
await restoreItems(ModelType.Folder, folders);
|
||||
},
|
||||
enabledCondition: 'folderIsDeleted',
|
||||
enabledCondition: 'foldersAreDeleted',
|
||||
};
|
||||
};
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface DialogState {
|
||||
description?: string;
|
||||
label?: string;
|
||||
value?: string;
|
||||
autocomplete?: unknown;
|
||||
onClose?: (answer: unknown, buttonType: unknown)=> void;
|
||||
}|null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import Folder, { FolderEntityWithChildren } from '@joplin/lib/models/Folder';
|
||||
import { WindowControl } from './useWindowControl';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { FolderEntity } from '@joplin/lib/services/database/types';
|
||||
|
||||
interface FolderEntry {
|
||||
key: string;
|
||||
value: string;
|
||||
label: string;
|
||||
indentDepth: number;
|
||||
}
|
||||
|
||||
interface Options {
|
||||
label: string;
|
||||
allowSelectNone: boolean;
|
||||
showFolder: (entity: FolderEntity)=> boolean;
|
||||
}
|
||||
|
||||
const showFolderPicker = async (control: WindowControl, { label, allowSelectNone, showFolder }: Options) => {
|
||||
const folders = await Folder.sortFolderTree();
|
||||
const startFolders: FolderEntry[] = [];
|
||||
const maxDepth = 15;
|
||||
|
||||
if (allowSelectNone) {
|
||||
startFolders.push({
|
||||
key: '',
|
||||
value: '',
|
||||
label: _('None'),
|
||||
indentDepth: 0,
|
||||
});
|
||||
}
|
||||
|
||||
const addOptions = (folders: FolderEntityWithChildren[], depth: number) => {
|
||||
for (let i = 0; i < folders.length; i++) {
|
||||
const folder = folders[i];
|
||||
|
||||
if (!showFolder(folder)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
startFolders.push({ key: folder.id, value: folder.id, label: folder.title, indentDepth: depth });
|
||||
if (folder.children) addOptions(folder.children, (depth + 1) < maxDepth ? depth + 1 : maxDepth);
|
||||
}
|
||||
};
|
||||
|
||||
addOptions(folders, 0);
|
||||
|
||||
const folderId = await control.showPrompt({
|
||||
label,
|
||||
value: '',
|
||||
suggestions: startFolders,
|
||||
});
|
||||
return folderId;
|
||||
};
|
||||
|
||||
export default showFolderPicker;
|
||||
@@ -5,8 +5,22 @@ import { PrintCallback } from './usePrintToCallback';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import announceForAccessibility from '../../utils/announceForAccessibility';
|
||||
|
||||
interface PromptSuggestion<T> {
|
||||
key: string;
|
||||
value: T;
|
||||
label: string;
|
||||
indentDepth?: number;
|
||||
}
|
||||
|
||||
interface PromptOptions<T> {
|
||||
label: string;
|
||||
value: string;
|
||||
suggestions: PromptSuggestion<T>[];
|
||||
}
|
||||
|
||||
export interface WindowControl {
|
||||
setState: (update: Partial<DialogState>)=> void;
|
||||
showPrompt: <T>(options: PromptOptions<T>)=> Promise<T>;
|
||||
printTo: PrintCallback;
|
||||
announcePanelVisibility(panelName: string, visible: boolean): void;
|
||||
}
|
||||
@@ -19,7 +33,7 @@ const useWindowControl = (setDialogState: OnSetDialogState, onPrint: PrintCallba
|
||||
onPrintRef.current = onPrint;
|
||||
|
||||
return useMemo((): WindowControl => {
|
||||
return {
|
||||
const control: WindowControl = {
|
||||
setState: (newPartialState: Partial<DialogState>) => {
|
||||
setDialogState(oldState => ({
|
||||
...oldState,
|
||||
@@ -32,7 +46,29 @@ const useWindowControl = (setDialogState: OnSetDialogState, onPrint: PrintCallba
|
||||
visible ? _('Panel "%s" is visible', panelName) : _('Panel %s is hidden', panelName),
|
||||
);
|
||||
},
|
||||
showPrompt: <T> (options: PromptOptions<T>) => {
|
||||
return new Promise<T>((resolve) => {
|
||||
control.setState({
|
||||
promptOptions: {
|
||||
label: options.label,
|
||||
inputType: 'dropdown',
|
||||
value: options.value,
|
||||
autocomplete: options.suggestions,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Partially refactored code before rule was applied
|
||||
onClose: async (answer: any) => {
|
||||
if (answer) {
|
||||
resolve(answer.value);
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
control.setState({ promptOptions: null });
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
return control;
|
||||
}, [setDialogState]);
|
||||
};
|
||||
|
||||
|
||||
@@ -79,6 +79,9 @@ export default function useMarkupToHtml(deps: HookDependencies) {
|
||||
|
||||
return resourceFullPath(resources[id].item, resourceBaseUrl) + urlParameters;
|
||||
},
|
||||
globalSettings: {
|
||||
'markdown.plugin.abc.options': Setting.value('markdown.plugin.abc.options'),
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
|
||||
@@ -18,3 +18,4 @@
|
||||
@use './joplin-cloud-sign-up.scss';
|
||||
@use './popup-notification-list.scss';
|
||||
@use './popup-notification-item.scss';
|
||||
@use './multi-note-actions.scss';
|
||||
|
||||
8
packages/app-desktop/gui/styles/multi-note-actions.scss
Normal file
8
packages/app-desktop/gui/styles/multi-note-actions.scss
Normal file
@@ -0,0 +1,8 @@
|
||||
|
||||
.multi-note-actions {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
padding-top: var(--joplin-margin-top);
|
||||
width: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
@@ -38,7 +38,7 @@ describe('NoteListUtils', () => {
|
||||
const mockStore = {
|
||||
getState: () => {
|
||||
return {
|
||||
...createAppDefaultWindowState(null),
|
||||
...createAppDefaultWindowState(),
|
||||
settings: {},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -212,5 +212,28 @@ test.describe('main', () => {
|
||||
|
||||
await electronApp.close();
|
||||
});
|
||||
|
||||
test('should import an HTML directory', async ({ mainWindow, electronApp }) => {
|
||||
const mainScreen = await new MainScreen(mainWindow).setup();
|
||||
await mainScreen.waitFor();
|
||||
|
||||
await mainScreen.importHtmlDirectory(electronApp, join(__dirname, 'resources', 'html-import'));
|
||||
const importedFolder = mainScreen.sidebar.container.getByText('html-import');
|
||||
await importedFolder.click();
|
||||
|
||||
const importedNote1 = await mainScreen.noteList.getNoteItemByTitle('test-html-file-with-image');
|
||||
await expect(importedNote1).toBeAttached();
|
||||
const importedNote2 = await mainScreen.noteList.getNoteItemByTitle('test-html-file-2');
|
||||
await expect(importedNote2).toBeAttached();
|
||||
});
|
||||
|
||||
test('should import a single HTML file', async ({ mainWindow, electronApp }) => {
|
||||
const mainScreen = await new MainScreen(mainWindow).setup();
|
||||
await mainScreen.waitFor();
|
||||
|
||||
await mainScreen.importHtmlFile(electronApp, join(__dirname, 'resources', 'html-import', 'test-html-file-with-image.html'));
|
||||
const importedNote = await mainScreen.noteList.getNoteItemByTitle('test-html-file-with-image');
|
||||
await expect(importedNote).toBeAttached();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import setFilePickerResponse from './util/setFilePickerResponse';
|
||||
import activateMainMenuItem from './util/activateMainMenuItem';
|
||||
import setSettingValue from './util/setSettingValue';
|
||||
import { toForwardSlashes } from '@joplin/utils/path';
|
||||
import mockClipboard from './util/mockClipboard';
|
||||
|
||||
|
||||
test.describe('markdownEditor', () => {
|
||||
@@ -337,5 +338,48 @@ test.describe('markdownEditor', () => {
|
||||
// Should show the legacy editor
|
||||
await expect(mainWindow.locator('.rli-editor .CodeMirror5')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should support the textCopy command', async ({ electronApp, mainWindow }) => {
|
||||
const mainScreen = await new MainScreen(mainWindow).setup();
|
||||
await mainScreen.waitFor();
|
||||
|
||||
await mainScreen.createNewNote('Test copy');
|
||||
const noteEditor = mainScreen.noteEditor;
|
||||
await noteEditor.focusCodeMirrorEditor();
|
||||
await mainWindow.keyboard.type('Test content.');
|
||||
|
||||
const { expectClipboardToMatch } = await mockClipboard(electronApp, 'original');
|
||||
|
||||
await mainScreen.goToAnything.runCommand(electronApp, 'textCopy');
|
||||
await expectClipboardToMatch('Test content.\n');
|
||||
});
|
||||
|
||||
test('should support the textCut and textPaste commands', async ({ electronApp, mainWindow }) => {
|
||||
const mainScreen = await new MainScreen(mainWindow).setup();
|
||||
await mainScreen.waitFor();
|
||||
|
||||
await mainScreen.createNewNote('Test paste');
|
||||
const { expectClipboardToMatch } = await mockClipboard(electronApp, 'test!');
|
||||
await expectClipboardToMatch('test!');
|
||||
|
||||
// Should paste text using the textPaste command
|
||||
const goToAnything = mainScreen.goToAnything;
|
||||
await goToAnything.runCommand(electronApp, 'textPaste');
|
||||
const noteEditor = mainScreen.noteEditor;
|
||||
await noteEditor.expectToHaveText('test!');
|
||||
|
||||
// Should cut text using the textCut command
|
||||
await mainScreen.createNewNote('Test cut');
|
||||
await noteEditor.focusCodeMirrorEditor();
|
||||
await mainWindow.keyboard.type('Test (new content!)');
|
||||
|
||||
await goToAnything.runCommand(electronApp, 'textCut');
|
||||
await noteEditor.expectToHaveText('\n');
|
||||
await expectClipboardToMatch('Test (new content!)\n');
|
||||
|
||||
// Should paste the content again with textPaste
|
||||
await goToAnything.runCommand(electronApp, 'textPaste');
|
||||
await noteEditor.expectToHaveText(/^Test \(new content!\)[\n]+/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -68,9 +68,17 @@ export default class MainScreen {
|
||||
await searchBar.fill(text);
|
||||
}
|
||||
|
||||
public async importHtmlDirectory(electronApp: ElectronApplication, path: string) {
|
||||
private async importFromModule_(electronApp: ElectronApplication, moduleName: string, path: string) {
|
||||
await setFilePickerResponse(electronApp, [path]);
|
||||
await activateMainMenuItem(electronApp, 'HTML - HTML document (Directory)', 'Import');
|
||||
await activateMainMenuItem(electronApp, moduleName, 'Import');
|
||||
}
|
||||
|
||||
public async importHtmlDirectory(electronApp: ElectronApplication, path: string) {
|
||||
return this.importFromModule_(electronApp, 'HTML - HTML document (Directory)', path);
|
||||
}
|
||||
|
||||
public async importHtmlFile(electronApp: ElectronApplication, path: string) {
|
||||
return this.importFromModule_(electronApp, 'HTML - HTML document (File)', path);
|
||||
}
|
||||
|
||||
public async pluginPanelLocator(pluginId: string) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import activateMainMenuItem from '../util/activateMainMenuItem';
|
||||
import type MainScreen from './MainScreen';
|
||||
import { ElectronApplication, Locator, Page } from '@playwright/test';
|
||||
import expect from '../util/extendedExpect';
|
||||
|
||||
export default class Sidebar {
|
||||
public readonly container: Locator;
|
||||
@@ -42,4 +43,14 @@ export default class Sidebar {
|
||||
await this.sortByDate(electronApp);
|
||||
await this.sortByTitle(electronApp);
|
||||
}
|
||||
|
||||
// Checks the indentation level of each folder. Useful for determining whether folders are subfolders.
|
||||
public async expectToHaveDepths(folderToDepth: [Locator, number][]) {
|
||||
for (let i = 0; i < folderToDepth.length; i++) {
|
||||
const [folder, depth] = folderToDepth[i];
|
||||
await expect(
|
||||
folder, { message: `Folder ${i} should have depth ${depth}.` },
|
||||
).toHaveJSProperty('ariaLevel', String(depth));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<h1>Test HTML file 2!</h1>
|
||||
</body>
|
||||
</html>
|
||||
@@ -81,10 +81,12 @@ test.describe('sidebar', () => {
|
||||
await folderDHeader.dragTo(folderCHeader);
|
||||
|
||||
// Folders should have correct initial levels
|
||||
await expect(folderAHeader).toHaveJSProperty('ariaLevel', '2');
|
||||
await expect(folderBHeader).toHaveJSProperty('ariaLevel', '3');
|
||||
await expect(folderCHeader).toHaveJSProperty('ariaLevel', '3');
|
||||
await expect(folderDHeader).toHaveJSProperty('ariaLevel', '4');
|
||||
await sidebar.expectToHaveDepths([
|
||||
[folderAHeader, 2],
|
||||
[folderBHeader, 3],
|
||||
[folderCHeader, 3],
|
||||
[folderDHeader, 4],
|
||||
]);
|
||||
|
||||
await sidebar.forceUpdateSorting(electronApp);
|
||||
await folderBHeader.click();
|
||||
@@ -186,4 +188,87 @@ test.describe('sidebar', () => {
|
||||
await testFolderA.dblclick();
|
||||
await expect(testFolderB).toBeVisible();
|
||||
});
|
||||
|
||||
test('should be possible to select, then deselect, multiple folders with cmd-click', async ({ mainWindow, electronApp }) => {
|
||||
const mainScreen = await new MainScreen(mainWindow).setup();
|
||||
const sidebar = mainScreen.sidebar;
|
||||
|
||||
const folderA = await sidebar.createNewFolder('Folder A');
|
||||
const folderB = await sidebar.createNewFolder('Folder B');
|
||||
const folderC = await sidebar.createNewFolder('Folder C');
|
||||
const folderD = await sidebar.createNewFolder('Folder D');
|
||||
|
||||
await sidebar.forceUpdateSorting(electronApp);
|
||||
|
||||
await folderA.click();
|
||||
await folderB.click({ modifiers: ['ControlOrMeta'] });
|
||||
await folderC.click({ modifiers: ['ControlOrMeta'] });
|
||||
|
||||
await expect(folderA).toBeSelected();
|
||||
await expect(folderB).toBeSelected();
|
||||
await expect(folderC).toBeSelected();
|
||||
await expect(folderD).toHaveJSProperty('ariaSelected', 'false');
|
||||
|
||||
// Should be able to deselect up to two folders
|
||||
await folderA.click({ modifiers: ['ControlOrMeta'] });
|
||||
await expect(folderA).toHaveJSProperty('ariaSelected', 'false');
|
||||
await folderB.click({ modifiers: ['ControlOrMeta'] });
|
||||
await expect(folderB).toHaveJSProperty('ariaSelected', 'false');
|
||||
// Should not be possible to deselect the last folder
|
||||
await folderC.click({ modifiers: ['ControlOrMeta'] });
|
||||
await expect(folderC).toBeSelected();
|
||||
});
|
||||
|
||||
test('should be possible to move multiple folders at once with drag and drop', async ({ mainWindow, electronApp }) => {
|
||||
const mainScreen = await new MainScreen(mainWindow).setup();
|
||||
const sidebar = mainScreen.sidebar;
|
||||
|
||||
const folderA = await sidebar.createNewFolder('Folder A');
|
||||
const folderB = await sidebar.createNewFolder('Folder B');
|
||||
const folderC = await sidebar.createNewFolder('Folder C');
|
||||
const folderD = await sidebar.createNewFolder('Folder D');
|
||||
|
||||
await sidebar.forceUpdateSorting(electronApp);
|
||||
|
||||
await folderB.click();
|
||||
await folderC.click({ modifiers: ['ControlOrMeta'] });
|
||||
|
||||
await expect(folderB).toBeSelected();
|
||||
await expect(folderC).toBeSelected();
|
||||
|
||||
await folderB.dragTo(folderA);
|
||||
|
||||
// Should have made folder B **and folder C** subfolders of testFolderA
|
||||
await sidebar.expectToHaveDepths([
|
||||
[folderA, 2],
|
||||
[folderB, 3],
|
||||
[folderC, 3],
|
||||
[folderD, 2],
|
||||
]);
|
||||
});
|
||||
|
||||
test('should not move selected folders when dragging an unselected folder', async ({ mainWindow, electronApp }) => {
|
||||
const mainScreen = await new MainScreen(mainWindow).setup();
|
||||
const sidebar = mainScreen.sidebar;
|
||||
|
||||
const testFolderA = await sidebar.createNewFolder('Folder A');
|
||||
const testFolderB = await sidebar.createNewFolder('Folder B');
|
||||
const testFolderC = await sidebar.createNewFolder('Folder C');
|
||||
|
||||
await sidebar.forceUpdateSorting(electronApp);
|
||||
|
||||
await testFolderB.click();
|
||||
await testFolderC.click({ modifiers: ['ControlOrMeta'] });
|
||||
|
||||
await expect(testFolderB).toBeSelected();
|
||||
await expect(testFolderC).toBeSelected();
|
||||
|
||||
await testFolderA.dragTo(testFolderB);
|
||||
|
||||
await sidebar.expectToHaveDepths([
|
||||
[testFolderB, 2],
|
||||
[testFolderA, 3],
|
||||
[testFolderC, 2],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,6 +54,26 @@ const extendedExpect = expect.extend({
|
||||
name: assertionName,
|
||||
};
|
||||
},
|
||||
|
||||
async toBeSelected(locator: Locator) {
|
||||
let pass = true;
|
||||
|
||||
const assertionName = 'toBeSelected';
|
||||
let resultMessage = () => `${assertionName}: Passed`;
|
||||
|
||||
try {
|
||||
await extendedExpect(locator).toHaveJSProperty('ariaSelected', 'true');
|
||||
} catch (error) {
|
||||
pass = false;
|
||||
resultMessage = () => error.toString();
|
||||
}
|
||||
|
||||
return {
|
||||
pass,
|
||||
message: () => `${assertionName}: ${resultMessage()}`,
|
||||
name: assertionName,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default extendedExpect;
|
||||
|
||||
29
packages/app-desktop/integration-tests/util/mockClipboard.ts
Normal file
29
packages/app-desktop/integration-tests/util/mockClipboard.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { ElectronApplication } from '@playwright/test';
|
||||
import { expect } from './test';
|
||||
import getMainWindow from './getMainWindow';
|
||||
|
||||
// Currently only supports mocking reading/writing text
|
||||
const mockClipboard = async (electronApp: ElectronApplication, clipboardText: string) => {
|
||||
const mainWindow = await getMainWindow(electronApp);
|
||||
await mainWindow.evaluate(async (clipboardText) => {
|
||||
const { clipboard } = require('electron');
|
||||
clipboard.writeText = (text: string) => {
|
||||
clipboardText = text;
|
||||
};
|
||||
clipboard.readText = () => {
|
||||
return clipboardText;
|
||||
};
|
||||
}, clipboardText);
|
||||
|
||||
return {
|
||||
expectClipboardToMatch: async (text: string) => {
|
||||
await expect.poll(async () => {
|
||||
return await mainWindow.evaluate(() => {
|
||||
return require('electron').clipboard.readText();
|
||||
});
|
||||
}).toBe(text);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default mockClipboard;
|
||||
@@ -132,10 +132,12 @@ a {
|
||||
margin: 40px 20px;
|
||||
}
|
||||
|
||||
#loading-animation {
|
||||
.loading-animation {
|
||||
margin-right: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
border: 5px solid lightgrey;
|
||||
border-top: 4px solid white;
|
||||
border-radius: 50%;
|
||||
@@ -144,6 +146,10 @@ a {
|
||||
animation-duration: 1.2s;
|
||||
animation-iteration-count: infinite;
|
||||
animation-timing-function: linear;
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
animation-name: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/app-desktop",
|
||||
"version": "3.5.6",
|
||||
"version": "3.5.9",
|
||||
"description": "Joplin for Desktop",
|
||||
"main": "main.bundle.js",
|
||||
"private": true,
|
||||
@@ -119,7 +119,8 @@
|
||||
"category": "Office",
|
||||
"desktop": {
|
||||
"Icon": "joplin",
|
||||
"MimeType": "x-scheme-handler/joplin;"
|
||||
"MimeType": "x-scheme-handler/joplin;",
|
||||
"StartupWMClass": "@joplin/app-desktop"
|
||||
},
|
||||
"target": [
|
||||
"AppImage",
|
||||
@@ -144,7 +145,7 @@
|
||||
"@joplin/renderer": "~3.5",
|
||||
"@joplin/tools": "~3.5",
|
||||
"@joplin/utils": "~3.5",
|
||||
"@playwright/test": "1.53.2",
|
||||
"@playwright/test": "1.54.2",
|
||||
"@sentry/electron": "4.24.0",
|
||||
"@testing-library/react-hooks": "8.0.1",
|
||||
"@types/jest": "29.5.14",
|
||||
@@ -159,9 +160,8 @@
|
||||
"codemirror": "5.65.9",
|
||||
"color": "3.2.1",
|
||||
"compare-versions": "6.1.1",
|
||||
"countable": "3.0.1",
|
||||
"debounce": "1.2.1",
|
||||
"electron": "37.7.0",
|
||||
"electron": "39.2.3",
|
||||
"electron-builder": "24.13.3",
|
||||
"electron-updater": "6.6.2",
|
||||
"electron-window-state": "5.0.3",
|
||||
@@ -179,7 +179,7 @@
|
||||
"md5": "2.3.0",
|
||||
"moment": "2.30.1",
|
||||
"mustache": "4.2.0",
|
||||
"nan": "2.22.2",
|
||||
"nan": "2.23.0",
|
||||
"node-notifier": "10.0.1",
|
||||
"node-rsa": "1.1.1",
|
||||
"pdfjs-dist": "3.11.174",
|
||||
|
||||
@@ -25,7 +25,7 @@ async function main() {
|
||||
// wrong one. However it means it will have to be manually upgraded for each
|
||||
// new Electron release. Some ABI map there:
|
||||
// https://github.com/electron/node-abi/tree/master/test
|
||||
const forceAbiArgs = '--force-abi 138';
|
||||
const forceAbiArgs = '--force-abi 142';
|
||||
|
||||
if (isWindows()) {
|
||||
// Cannot run this in parallel, or the 64-bit version might end up
|
||||
|
||||
@@ -89,8 +89,8 @@ android {
|
||||
applicationId "net.cozic.joplin"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 2097780
|
||||
versionName "3.5.0"
|
||||
versionCode 2097781
|
||||
versionName "3.5.1"
|
||||
ndk {
|
||||
abiFilters "armeabi-v7a", "x86", "arm64-v8a", "x86_64"
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import goToNote, { GotoNoteOptions } from './util/goToNote';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
|
||||
const logger = Logger.create('newNoteCommand');
|
||||
|
||||
@@ -13,7 +13,7 @@ export const declaration: CommandDeclaration = {
|
||||
export const runtime = (): CommandRuntime => {
|
||||
return {
|
||||
execute: async (_context: CommandContext, body = '', todo = false, options: GotoNoteOptions = null) => {
|
||||
const folderId = Setting.value('activeFolderId');
|
||||
const folderId = await Folder.getValidActiveFolder();
|
||||
if (!folderId) {
|
||||
logger.warn('Not creating new note -- no active folder ID.');
|
||||
return;
|
||||
|
||||
@@ -73,6 +73,7 @@ const useSettingButtonInfo = (setSettingsVisible: SetSettingsVisible) => {
|
||||
name: 'showToolbarSettings',
|
||||
tooltip: _('Settings'),
|
||||
iconName: 'material cogs',
|
||||
visible: true,
|
||||
enabled: true,
|
||||
onClick: () => setSettingsVisible(true),
|
||||
title: '',
|
||||
|
||||
@@ -20,6 +20,9 @@ const builtInCommandNames = [
|
||||
EditorCommandType.ToggleBulletedList,
|
||||
EditorCommandType.ToggleCheckList,
|
||||
'-',
|
||||
`editor.${EditorCommandType.InsertTable}`,
|
||||
`editor.${EditorCommandType.InsertCodeBlock}`,
|
||||
'-',
|
||||
EditorCommandType.IndentLess,
|
||||
EditorCommandType.IndentMore,
|
||||
`editor.${EditorCommandType.SwapLineDown}`,
|
||||
|
||||
@@ -122,6 +122,7 @@ const useStyles = (theme: Theme) => {
|
||||
backgroundColor: theme.backgroundColor4,
|
||||
color: theme.color4,
|
||||
margin: 2,
|
||||
width: 90, // Reduce the min width for mobile screens in portrait
|
||||
},
|
||||
buttonText: buttonTextStyle,
|
||||
activeButtonText: {
|
||||
@@ -343,7 +344,7 @@ export const SearchPanel = (props: SearchPanelProps) => {
|
||||
);
|
||||
|
||||
const simpleLayout = (
|
||||
<View style={{ flexDirection: 'row' }}>
|
||||
<View style={{ flexDirection: 'row', flexShrink: 1 }}>
|
||||
{ closeButton }
|
||||
{ searchTextInput }
|
||||
{ showDetailsButton }
|
||||
@@ -353,7 +354,7 @@ export const SearchPanel = (props: SearchPanelProps) => {
|
||||
);
|
||||
|
||||
const advancedLayout = (
|
||||
<View style={{ flexDirection: 'column' }}>
|
||||
<View style={{ flexDirection: 'column', flexShrink: 1 }}>
|
||||
<View style={{ flexDirection: 'row' }}>
|
||||
{ closeButton }
|
||||
{ labeledSearchInput }
|
||||
|
||||
@@ -9,16 +9,31 @@ const markdownEditorOnlyCommands = [
|
||||
EditorCommandType.SwapLineDown,
|
||||
].map(command => `editor.${command}`);
|
||||
|
||||
export const enabledCondition = (commandName: string) => {
|
||||
const output = [
|
||||
'!noteIsReadOnly',
|
||||
];
|
||||
|
||||
|
||||
const richTextEditorOnlyCommands = [
|
||||
EditorCommandType.InsertTable,
|
||||
EditorCommandType.InsertCodeBlock,
|
||||
].map(command => `editor.${command}`);
|
||||
|
||||
export const visibleCondition = (commandName: string) => {
|
||||
const output = [];
|
||||
|
||||
if (markdownEditorOnlyCommands.includes(commandName)) {
|
||||
output.push('!richTextEditorVisible');
|
||||
}
|
||||
|
||||
return output.filter(c => !!c).join(' && ');
|
||||
if (richTextEditorOnlyCommands.includes(commandName)) {
|
||||
output.push('!markdownEditorPaneVisible');
|
||||
}
|
||||
|
||||
return output.join(' && ');
|
||||
};
|
||||
|
||||
export const enabledCondition = (commandName: string) => {
|
||||
return [
|
||||
visibleCondition(commandName), '!noteIsReadOnly',
|
||||
].filter(c => !!c).join('&&');
|
||||
};
|
||||
|
||||
const headerDeclarations = () => {
|
||||
@@ -98,6 +113,16 @@ const declarations: CommandDeclaration[] = [
|
||||
label: () => _('Task list'),
|
||||
iconName: 'material format-list-checks',
|
||||
},
|
||||
{
|
||||
name: `editor.${EditorCommandType.InsertTable}`,
|
||||
label: () => _('Table'),
|
||||
iconName: 'material table',
|
||||
},
|
||||
{
|
||||
name: `editor.${EditorCommandType.InsertCodeBlock}`,
|
||||
label: () => _('Block code'),
|
||||
iconName: 'material code-tags',
|
||||
},
|
||||
{
|
||||
name: EditorCommandType.IndentLess,
|
||||
label: () => _('Decrease indent level'),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import CommandService, { CommandContext, CommandDeclaration } from '@joplin/lib/services/CommandService';
|
||||
import { EditorControl } from '@joplin/editor/types';
|
||||
import useNowEffect from '@joplin/lib/hooks/useNowEffect';
|
||||
import commandDeclarations, { enabledCondition } from '../commandDeclarations';
|
||||
import commandDeclarations, { enabledCondition, visibleCondition } from '../commandDeclarations';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
|
||||
const logger = Logger.create('useEditorCommandHandler');
|
||||
@@ -30,6 +30,7 @@ const commandRuntime = (declaration: CommandDeclaration, editor: EditorControl)
|
||||
return await editor.execCommand(commandName, ...args);
|
||||
},
|
||||
enabledCondition: enabledCondition(declaration.name),
|
||||
visibleCondition: visibleCondition(declaration.name),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -168,10 +168,10 @@ const NoteItemComponent: React.FC<Props> = memo(props => {
|
||||
};
|
||||
return (
|
||||
<MultiTouchableOpacity
|
||||
{...pressableProps}
|
||||
containerProps={{
|
||||
style: [selectionWrapperStyle, opacityStyle, styles.listItem],
|
||||
}}
|
||||
pressableProps={pressableProps}
|
||||
onPress={onPress}
|
||||
beforePressable={todoCheckbox}
|
||||
>
|
||||
|
||||
@@ -668,6 +668,10 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
|
||||
{pluginPanelsComp}
|
||||
{betaIconComp}
|
||||
{togglePluginEditorButton}
|
||||
{selectAllButtonComp}
|
||||
{searchButtonComp}
|
||||
{deleteButtonComp}
|
||||
{customDeleteButtonComp}
|
||||
</>;
|
||||
|
||||
const titleComp = createTitleComponent(hideableRightComponents);
|
||||
@@ -706,10 +710,6 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
|
||||
this.props.showSaveButton === true,
|
||||
)}
|
||||
{titleComp}
|
||||
{selectAllButtonComp}
|
||||
{searchButtonComp}
|
||||
{deleteButtonComp}
|
||||
{customDeleteButtonComp}
|
||||
{restoreButtonComp}
|
||||
{duplicateButtonComp}
|
||||
{sortButtonComp}
|
||||
|
||||
@@ -2,20 +2,19 @@ import * as React from 'react';
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import { Animated, StyleSheet, Pressable, ViewProps, PressableProps } from 'react-native';
|
||||
|
||||
interface Props {
|
||||
interface Props extends PressableProps {
|
||||
// Nodes that need to change opacity but shouldn't be included in the main touchable
|
||||
beforePressable: React.ReactNode;
|
||||
// Children of the main pressable
|
||||
children: React.ReactNode;
|
||||
onPress: ()=> void;
|
||||
|
||||
pressableProps?: PressableProps;
|
||||
containerProps?: ViewProps;
|
||||
}
|
||||
|
||||
// A TouchableOpacity that can contain multiple pressable items still within the region that
|
||||
// changes opacity
|
||||
const MultiTouchableOpacity: React.FC<Props> = props => {
|
||||
const MultiTouchableOpacity: React.FC<Props> = ({ beforePressable, children, onPress, containerProps = {}, ...pressableProps }) => {
|
||||
// See https://blog.logrocket.com/react-native-touchable-vs-pressable-components/
|
||||
// for more about animating Pressable buttons.
|
||||
const fadeAnim = useRef(new Animated.Value(1)).current;
|
||||
@@ -41,12 +40,12 @@ const MultiTouchableOpacity: React.FC<Props> = props => {
|
||||
const button = (
|
||||
<Pressable
|
||||
accessibilityRole='button'
|
||||
{...props.pressableProps}
|
||||
onPress={props.onPress}
|
||||
{...pressableProps}
|
||||
onPress={onPress}
|
||||
onPressIn={onPressIn}
|
||||
onPressOut={onPressOut}
|
||||
>
|
||||
{props.children}
|
||||
{children}
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
@@ -56,10 +55,9 @@ const MultiTouchableOpacity: React.FC<Props> = props => {
|
||||
});
|
||||
}, [fadeAnim]);
|
||||
|
||||
const containerProps = props.containerProps ?? {};
|
||||
return (
|
||||
<Animated.View {...containerProps} style={[styles.container, props.containerProps.style]}>
|
||||
{props.beforePressable}
|
||||
<Animated.View {...containerProps} style={[styles.container, containerProps.style]}>
|
||||
{beforePressable}
|
||||
{button}
|
||||
</Animated.View>
|
||||
);
|
||||
|
||||
@@ -166,19 +166,21 @@ describe('screens/Note', () => {
|
||||
|
||||
it('should show the currently selected note', async () => {
|
||||
await openNewNote({ title: 'Test note (title)', body: '# Testing...' });
|
||||
render(<WrappedNoteScreen />);
|
||||
const { unmount } = render(<WrappedNoteScreen />);
|
||||
|
||||
const titleInput = await screen.findByDisplayValue('Test note (title)');
|
||||
expect(titleInput).toBeVisible();
|
||||
|
||||
const renderedNote = await getNoteViewerDom();
|
||||
expect(renderedNote.querySelector('h1')).toMatchObject({ textContent: 'Testing...' });
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('changing the note title input should update the note\'s title', async () => {
|
||||
const noteId = await openNewNote({ title: 'Change me!', body: 'Unchanged body' });
|
||||
|
||||
render(<WrappedNoteScreen />);
|
||||
const { unmount } = render(<WrappedNoteScreen />);
|
||||
|
||||
const titleInput = await screen.findByDisplayValue('Change me!');
|
||||
|
||||
@@ -205,6 +207,8 @@ describe('screens/Note', () => {
|
||||
await waitForNoteToMatch(noteId, { title: expectedTitle });
|
||||
}
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('changing the note body in the editor should update the note\'s body', async () => {
|
||||
@@ -237,7 +241,7 @@ describe('screens/Note', () => {
|
||||
|
||||
it('pressing "delete" should move the note to the trash', async () => {
|
||||
const noteId = await openNewNote({ title: 'To be deleted', body: '...' });
|
||||
render(<WrappedNoteScreen />);
|
||||
const { unmount } = render(<WrappedNoteScreen />);
|
||||
|
||||
await openNoteActionsMenu();
|
||||
const deleteButton = await screen.findByText('Delete');
|
||||
@@ -246,11 +250,13 @@ describe('screens/Note', () => {
|
||||
await waitFor(async () => {
|
||||
expect((await Note.load(noteId)).deleted_time).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('pressing "delete permanently" should permanently delete a note', async () => {
|
||||
const noteId = await openNewNote({ title: 'To be deleted', body: '...', deleted_time: Date.now() });
|
||||
render(<WrappedNoteScreen />);
|
||||
const { unmount } = render(<WrappedNoteScreen />);
|
||||
|
||||
// Permanently delete note shows a confirmation dialog -- mock it.
|
||||
const deleteId = 0;
|
||||
@@ -264,6 +270,8 @@ describe('screens/Note', () => {
|
||||
expect(await Note.load(noteId)).toBeUndefined();
|
||||
});
|
||||
expect(shim.showMessageBox).toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('delete should be disabled in a read-only note', async () => {
|
||||
@@ -284,7 +292,7 @@ describe('screens/Note', () => {
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
render(<WrappedNoteScreen />);
|
||||
const { unmount } = render(<WrappedNoteScreen />);
|
||||
|
||||
const titleInput = await screen.findByDisplayValue('Title: Read-only note');
|
||||
expect(titleInput).toBeVisible();
|
||||
@@ -295,6 +303,7 @@ describe('screens/Note', () => {
|
||||
expect(deleteButton).toHaveProp('disabled', true);
|
||||
|
||||
act(() => cleanup());
|
||||
unmount();
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -317,7 +326,7 @@ describe('screens/Note', () => {
|
||||
|
||||
await openExistingNote(note.id);
|
||||
|
||||
render(<WrappedNoteScreen />);
|
||||
const { unmount } = render(<WrappedNoteScreen />);
|
||||
|
||||
// Note should render
|
||||
const titleInput = await screen.findByDisplayValue('Note 1');
|
||||
@@ -339,16 +348,20 @@ describe('screens/Note', () => {
|
||||
throw new Error(`Should not be testing downloadMode: ${downloadMode}.`);
|
||||
}
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('the toggleVisiblePanes command should start and stop editing', async () => {
|
||||
await openNewNote({ title: 'To be edited', body: '...' });
|
||||
render(<WrappedNoteScreen />);
|
||||
const { unmount } = render(<WrappedNoteScreen />);
|
||||
|
||||
await expectToBeEditing(false);
|
||||
await runEditorCommand('toggleVisiblePanes');
|
||||
await expectToBeEditing(true);
|
||||
await runEditorCommand('toggleVisiblePanes');
|
||||
await expectToBeEditing(false);
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import * as React from 'react';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { StyleSheet, TextInput, View } from 'react-native';
|
||||
import { themeStyle } from '../../global-style';
|
||||
import IconButton from '../../IconButton';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
value: string;
|
||||
autoFocus: boolean;
|
||||
placeholder?: string;
|
||||
onChangeText: (text: string)=> void;
|
||||
onClearButtonPress: ()=> void;
|
||||
onSubmitEditing?: ()=> void;
|
||||
}
|
||||
|
||||
const useStyles = (themeId: number) => {
|
||||
return useMemo(() => {
|
||||
const theme = themeStyle(themeId);
|
||||
return StyleSheet.create({
|
||||
searchContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: theme.dividerColor,
|
||||
},
|
||||
searchTextInput: {
|
||||
...theme.lineInput,
|
||||
paddingLeft: theme.marginLeft,
|
||||
flex: 1,
|
||||
backgroundColor: theme.backgroundColor,
|
||||
color: theme.color,
|
||||
},
|
||||
clearIcon: {
|
||||
...theme.icon,
|
||||
color: theme.colorFaded,
|
||||
paddingRight: theme.marginRight,
|
||||
backgroundColor: theme.backgroundColor,
|
||||
},
|
||||
});
|
||||
}, [themeId]);
|
||||
};
|
||||
|
||||
const SearchBar: React.FC<Props> = ({ themeId, value, autoFocus, placeholder, onChangeText, onClearButtonPress, onSubmitEditing }) => {
|
||||
const theme = themeStyle(themeId);
|
||||
const styles = useStyles(themeId);
|
||||
|
||||
return (
|
||||
<View style={styles.searchContainer}>
|
||||
<TextInput
|
||||
style={styles.searchTextInput}
|
||||
autoFocus={autoFocus}
|
||||
underlineColorAndroid="#ffffff00"
|
||||
onChangeText={onChangeText}
|
||||
onSubmitEditing={onSubmitEditing}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={theme.colorFaded}
|
||||
value={value}
|
||||
selectionColor={theme.textSelectionColor}
|
||||
keyboardAppearance={theme.keyboardAppearance}
|
||||
/>
|
||||
<IconButton
|
||||
themeId={themeId}
|
||||
iconStyle={styles.clearIcon}
|
||||
iconName='ionicon close-circle'
|
||||
onPress={onClearButtonPress}
|
||||
description={_('Clear')}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchBar;
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { StyleSheet, View, TextInput } from 'react-native';
|
||||
import { StyleSheet, View } from 'react-native';
|
||||
import { connect } from 'react-redux';
|
||||
import ScreenHeader from '../../ScreenHeader';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
@@ -8,10 +8,10 @@ import { ThemeStyle, themeStyle } from '../../global-style';
|
||||
import { AppState } from '../../../utils/types';
|
||||
import { Dispatch } from 'redux';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import IconButton from '../../IconButton';
|
||||
import SearchResults from './SearchResults';
|
||||
import AccessibleView from '../../accessibility/AccessibleView';
|
||||
import { ComplexTerm } from '@joplin/lib/services/search/SearchEngine';
|
||||
import SearchBar from './SearchBar';
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
@@ -29,25 +29,6 @@ const useStyles = (theme: ThemeStyle, visible: boolean) => {
|
||||
body: {
|
||||
flex: 1,
|
||||
},
|
||||
searchContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: theme.dividerColor,
|
||||
},
|
||||
searchTextInput: {
|
||||
...theme.lineInput,
|
||||
paddingLeft: theme.marginLeft,
|
||||
flex: 1,
|
||||
backgroundColor: theme.backgroundColor,
|
||||
color: theme.color,
|
||||
},
|
||||
clearIcon: {
|
||||
...theme.icon,
|
||||
color: theme.colorFaded,
|
||||
paddingRight: theme.marginRight,
|
||||
backgroundColor: theme.backgroundColor,
|
||||
},
|
||||
rootStyle: visible ? theme.rootStyle : theme.hiddenRootStyle,
|
||||
});
|
||||
}, [theme, visible]);
|
||||
@@ -118,26 +99,14 @@ const SearchScreenComponent: React.FC<Props> = props => {
|
||||
showSearchButton={false}
|
||||
/>
|
||||
<View style={styles.body}>
|
||||
<View style={styles.searchContainer}>
|
||||
<TextInput
|
||||
style={styles.searchTextInput}
|
||||
autoFocus={props.visible}
|
||||
underlineColorAndroid="#ffffff00"
|
||||
onChangeText={setQuery}
|
||||
onSubmitEditing={onOverridePause}
|
||||
value={query}
|
||||
selectionColor={theme.textSelectionColor}
|
||||
keyboardAppearance={theme.keyboardAppearance}
|
||||
/>
|
||||
<IconButton
|
||||
themeId={props.themeId}
|
||||
iconStyle={styles.clearIcon}
|
||||
iconName='ionicon close-circle'
|
||||
onPress={clearButton_press}
|
||||
description={_('Clear')}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<SearchBar
|
||||
themeId={props.themeId}
|
||||
autoFocus={props.visible}
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
onSubmitEditing={onOverridePause}
|
||||
onClearButtonPress={clearButton_press}
|
||||
/>
|
||||
<SearchResults
|
||||
query={query}
|
||||
paused={paused}
|
||||
|
||||
@@ -10,8 +10,12 @@ import { AppState } from '../../utils/types';
|
||||
import { TagEntity } from '@joplin/lib/services/database/types';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Dispatch } from 'redux';
|
||||
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
|
||||
import useQueuedAsyncEffect from '@joplin/lib/hooks/useQueuedAsyncEffect';
|
||||
import { getCollator, getCollatorLocale } from '@joplin/lib/models/utils/getCollator';
|
||||
import SearchBar from './SearchScreen/SearchBar';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
|
||||
const logger = Logger.create('tags');
|
||||
|
||||
interface Props {
|
||||
dispatch: Dispatch;
|
||||
@@ -46,6 +50,8 @@ const useStyles = (themeId: number) => {
|
||||
|
||||
const TagsScreenComponent: React.FC<Props> = props => {
|
||||
const [tags, setTags] = useState<TagEntity[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
const styles = useStyles(props.themeId);
|
||||
const collatorLocale = getCollatorLocale();
|
||||
const collator = useMemo(() => {
|
||||
@@ -54,12 +60,45 @@ const TagsScreenComponent: React.FC<Props> = props => {
|
||||
|
||||
type TagItemPressEvent = { id: string };
|
||||
|
||||
useAsyncEffect(async () => {
|
||||
const tags = await Tag.allWithNotes();
|
||||
tags.sort((a, b) => {
|
||||
return collator.compare(a.title, b.title);
|
||||
});
|
||||
setTags(tags);
|
||||
useQueuedAsyncEffect(async (event) => {
|
||||
try {
|
||||
let fetchedTags: TagEntity[];
|
||||
|
||||
if (searchQuery.trim()) {
|
||||
const searchPattern = `*${searchQuery.trim()}*`;
|
||||
fetchedTags = await Tag.searchAllWithNotes({
|
||||
titlePattern: searchPattern,
|
||||
});
|
||||
} else {
|
||||
fetchedTags = await Tag.allWithNotes();
|
||||
}
|
||||
|
||||
fetchedTags.sort((a, b) => {
|
||||
return collator.compare(a.title, b.title);
|
||||
});
|
||||
|
||||
if (!event.cancelled) {
|
||||
setTags(fetchedTags);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching tags', error);
|
||||
if (!event.cancelled) {
|
||||
setTags([]);
|
||||
}
|
||||
}
|
||||
}, [searchQuery, collator], { interval: 200 });
|
||||
|
||||
const onSearchButtonPress = useCallback(() => {
|
||||
setShowSearch(!showSearch);
|
||||
|
||||
// If the search button is pressed while the search bar is open, in addition to hiding the search bar, it should clear the search
|
||||
if (showSearch) {
|
||||
setSearchQuery('');
|
||||
}
|
||||
}, [showSearch]);
|
||||
|
||||
const clearButton_press = useCallback(() => {
|
||||
setSearchQuery('');
|
||||
}, []);
|
||||
|
||||
const onTagItemPress = useCallback((event: TagItemPressEvent) => {
|
||||
@@ -89,7 +128,21 @@ const TagsScreenComponent: React.FC<Props> = props => {
|
||||
|
||||
return (
|
||||
<View style={styles.rootStyle}>
|
||||
<ScreenHeader title={_('Tags')} showSearchButton={false} />
|
||||
<ScreenHeader
|
||||
title={_('Tags')}
|
||||
showSearchButton={true}
|
||||
onSearchButtonPress={onSearchButtonPress}
|
||||
/>
|
||||
{showSearch && (
|
||||
<SearchBar
|
||||
themeId={props.themeId}
|
||||
autoFocus={true}
|
||||
placeholder={_('Search tags')}
|
||||
value={searchQuery}
|
||||
onChangeText={setSearchQuery}
|
||||
onClearButtonPress={clearButton_press}
|
||||
/>
|
||||
)}
|
||||
<FlatList style={{ flex: 1 }} data={tags} renderItem={onRenderItem} keyExtractor={tag => tag.id} />
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -43,8 +43,7 @@ interface Props {
|
||||
folders: FolderEntity[];
|
||||
profileConfig: ProfileConfig;
|
||||
inboxJopId: string;
|
||||
selectedFolderId: string;
|
||||
selectedTagId: string;
|
||||
selectedFolderIds: string[];
|
||||
}
|
||||
|
||||
const syncIconRotationValue = new Animated.Value(0);
|
||||
@@ -564,7 +563,7 @@ const SideMenuContentComponent = (props: Props) => {
|
||||
hasChildren={hasChildren}
|
||||
depth={depth}
|
||||
collapsed={props.collapsedFolderIds.includes(folder.id)}
|
||||
selected={isFolderSelected(folder, { selectedFolderId: props.selectedFolderId, notesParentType: props.notesParentType })}
|
||||
selected={isFolderSelected(folder, { selectedFolderIds: props.selectedFolderIds, notesParentType: props.notesParentType })}
|
||||
styles={styles_}
|
||||
folder={folder}
|
||||
alwaysShowFolderIcons={alwaysShowFolderIcons}
|
||||
@@ -730,8 +729,7 @@ export default connect((state: AppState) => {
|
||||
folders: state.folders,
|
||||
syncStarted: state.syncStarted,
|
||||
syncReport: state.syncReport,
|
||||
selectedFolderId: state.selectedFolderId,
|
||||
selectedTagId: state.selectedTagId,
|
||||
selectedFolderIds: state.selectedFolderIds,
|
||||
notesParentType: state.notesParentType,
|
||||
locale: state.settings.locale,
|
||||
themeId: state.settings.theme,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { MarkupLanguage, MarkupToHtml } from '@joplin/renderer';
|
||||
import type { MarkupToHtmlConverter, RenderOptions, FsDriver as RendererFsDriver, ResourceInfos } from '@joplin/renderer/types';
|
||||
import type { MarkupToHtmlConverter, RenderOptions, RenderOptionsGlobalSettings, FsDriver as RendererFsDriver, ResourceInfos } from '@joplin/renderer/types';
|
||||
import makeResourceModel from './utils/makeResourceModel';
|
||||
import addPluginAssets from './utils/addPluginAssets';
|
||||
import { ExtraContentScriptSource, ForwardedJoplinSettings, MarkupRecord } from '../types';
|
||||
@@ -32,6 +32,7 @@ export interface RenderSettings {
|
||||
destroyEditPopupSyntax: string;
|
||||
|
||||
pluginSettings: Record<string, unknown>;
|
||||
globalSettings?: RenderOptionsGlobalSettings;
|
||||
requestPluginSetting: (pluginId: string, settingKey: string)=> void;
|
||||
readAssetBlob: (assetPath: string)=> Promise<Blob>;
|
||||
}
|
||||
@@ -135,6 +136,7 @@ export default class Renderer {
|
||||
splitted: settings.splitted,
|
||||
mapsToLine: settings.mapsToLine,
|
||||
whiteBackgroundNoteRendering: markup.language === MarkupLanguage.Html,
|
||||
globalSettings: settings.globalSettings,
|
||||
};
|
||||
|
||||
const pluginSettingsCacheKey = JSON.stringify(settings.pluginSettings);
|
||||
@@ -153,7 +155,7 @@ export default class Renderer {
|
||||
// Adding plugin assets can be slow -- run it asynchronously.
|
||||
if (settings.pluginAssetContainerSelector) {
|
||||
void (async () => {
|
||||
await addPluginAssets(result.pluginAssets, {
|
||||
const addedCount = await addPluginAssets(result.pluginAssets, {
|
||||
inlineAssets: this.setupOptions_.useTransferredFiles,
|
||||
readAssetBlob: settings.readAssetBlob,
|
||||
container: document.querySelector(settings.pluginAssetContainerSelector),
|
||||
@@ -161,7 +163,12 @@ export default class Renderer {
|
||||
});
|
||||
|
||||
// Some plugins require this event to be dispatched just after being added.
|
||||
document.dispatchEvent(new Event('joplin-noteDidUpdate'));
|
||||
// Avoid dispatching unless the plugins actually changed to avoid unnecessary
|
||||
// rerenders in the background (which can cause content to flicker in the Rich
|
||||
// Text Editor).
|
||||
if (addedCount) {
|
||||
document.dispatchEvent(new Event('joplin-noteDidUpdate'));
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ interface Options {
|
||||
// Note that this function keeps track of what's been added so as not to
|
||||
// add the same CSS files multiple times.
|
||||
const addPluginAssets = async (assets: RenderResultPluginAsset[], options: Options) => {
|
||||
if (!assets) return;
|
||||
if (!assets) return 0;
|
||||
|
||||
const pluginAssetsContainer = options.container;
|
||||
|
||||
@@ -78,6 +78,7 @@ const addPluginAssets = async (assets: RenderResultPluginAsset[], options: Optio
|
||||
|
||||
const processedAssetIds = [];
|
||||
|
||||
let addedCount = 0;
|
||||
for (let i = 0; i < assets.length; i++) {
|
||||
const asset = assets[i];
|
||||
|
||||
@@ -124,6 +125,7 @@ const addPluginAssets = async (assets: RenderResultPluginAsset[], options: Optio
|
||||
pluginAssetsContainer.appendChild(element);
|
||||
}
|
||||
|
||||
addedCount++;
|
||||
pluginAssetsAdded_[assetId] = {
|
||||
element,
|
||||
};
|
||||
@@ -150,12 +152,14 @@ const addPluginAssets = async (assets: RenderResultPluginAsset[], options: Optio
|
||||
} catch (error) {
|
||||
// We don't throw an exception but we log it since
|
||||
// it shouldn't happen
|
||||
console.warn('Tried to remove an asset but got an error', error);
|
||||
console.warn('Tried to remove an asset but got an error. On asset:', asset, error);
|
||||
}
|
||||
pluginAssetsAdded_[assetId] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return addedCount > 0;
|
||||
};
|
||||
|
||||
export default addPluginAssets;
|
||||
|
||||
@@ -228,6 +228,9 @@ const useWebViewSetup = (props: Props): Result => {
|
||||
return shim.fsDriver().fileAtPath(resolvedPath);
|
||||
},
|
||||
removeUnusedPluginAssets: options.removeUnusedPluginAssets,
|
||||
globalSettings: {
|
||||
'markdown.plugin.abc.options': Setting.value('markdown.plugin.abc.options'),
|
||||
},
|
||||
});
|
||||
|
||||
await transferResources(options.resources);
|
||||
|
||||
@@ -364,6 +364,7 @@
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/boost/boost_privacy.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/glog/glog_privacy.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/react-native-image-picker/RNImagePickerPrivacyInfo.bundle",
|
||||
);
|
||||
name = "[CP] Copy Pods Resources";
|
||||
@@ -396,6 +397,7 @@
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/boost_privacy.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/glog_privacy.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNImagePickerPrivacyInfo.bundle",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
|
||||
@@ -1514,9 +1514,9 @@ PODS:
|
||||
- Yoga
|
||||
- react-native-rsa-native (2.0.5):
|
||||
- React
|
||||
- react-native-saf-x (3.5.0):
|
||||
- react-native-saf-x (3.5.1):
|
||||
- React-Core
|
||||
- react-native-safe-area-context (5.4.1):
|
||||
- react-native-safe-area-context (5.5.2):
|
||||
- React-Core
|
||||
- react-native-sqlite-storage (6.0.1):
|
||||
- React-Core
|
||||
@@ -1874,7 +1874,7 @@ PODS:
|
||||
- React-Core
|
||||
- RNCPushNotificationIOS (1.11.0):
|
||||
- React-Core
|
||||
- RNDateTimePicker (8.4.2):
|
||||
- RNDateTimePicker (8.4.4):
|
||||
- React-Core
|
||||
- RNDeviceInfo (14.0.4):
|
||||
- React-Core
|
||||
@@ -1884,13 +1884,13 @@ PODS:
|
||||
- React-Core
|
||||
- RNFS (2.20.0):
|
||||
- React-Core
|
||||
- RNLocalize (3.4.2):
|
||||
- RNLocalize (3.5.2):
|
||||
- React-Core
|
||||
- RNQuickAction (0.3.13):
|
||||
- React
|
||||
- RNSecureRandom (1.0.1):
|
||||
- React
|
||||
- RNShare (12.1.0):
|
||||
- RNShare (12.1.2):
|
||||
- DoubleConversion
|
||||
- glog
|
||||
- hermes-engine
|
||||
@@ -1916,7 +1916,7 @@ PODS:
|
||||
- Yoga
|
||||
- RNSVG (15.13.0):
|
||||
- React-Core
|
||||
- RNVectorIcons (10.2.0):
|
||||
- RNVectorIcons (10.3.0):
|
||||
- DoubleConversion
|
||||
- glog
|
||||
- hermes-engine
|
||||
@@ -2290,7 +2290,7 @@ EXTERNAL SOURCES:
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90
|
||||
DoubleConversion: 76ab83afb40bddeeee456813d9c04f67f78771b5
|
||||
DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb
|
||||
EXAV: ae28256069c4cdde93d185c007d8f68d92902c2e
|
||||
EXConstants: 98bcf0f22b820f9b28f9fee55ff2daededadd2f8
|
||||
Expo: b527631da3b11e085809e877b845f9e6cdd68f9c
|
||||
@@ -2303,7 +2303,7 @@ SPEC CHECKSUMS:
|
||||
fast_float: 06eeec4fe712a76acc9376682e4808b05ce978b6
|
||||
FBLazyVector: 84b955f7b4da8b895faf5946f73748267347c975
|
||||
fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd
|
||||
glog: c5d68082e772fa1c511173d6b30a9de2c05a69a2
|
||||
glog: 5683914934d5b6e4240e497e0f4a3b42d1854183
|
||||
hermes-engine: 314be5250afa5692b57b4dd1705959e1973a8ebe
|
||||
JoplinCommonShareExtension: a8b60b02704d85a7305627912c0240e94af78db7
|
||||
JoplinRNShareExtension: e158a4b53ee0aa9cd3037a16221dc8adbd6f7860
|
||||
@@ -2348,8 +2348,8 @@ SPEC CHECKSUMS:
|
||||
react-native-netinfo: cec9c4e86083cb5b6aba0e0711f563e2fbbff187
|
||||
react-native-quick-crypto: b475b71e7fa4dbf3446be55e8ad4ef2c58ac4f7f
|
||||
react-native-rsa-native: a7931cdda1f73a8576a46d7f431378c5550f0c38
|
||||
react-native-saf-x: 8a349c8348f43ff7c14770da4b0d618d62593346
|
||||
react-native-safe-area-context: dde2052b903c11d677c320b599c3244021c34ce8
|
||||
react-native-saf-x: 404f0f9a29cc6bf21d88582e054c45a11b28c22b
|
||||
react-native-safe-area-context: 0f7bf11598f9a61b7ceac8dc3f59ef98697e99e1
|
||||
react-native-sqlite-storage: 0c84826214baaa498796c7e46a5ccc9a82e114ed
|
||||
react-native-version-info: f0b04e16111c4016749235ff6d9a757039189141
|
||||
react-native-webview: 0dceb35a9d050f5fa55f7fe2d8c4d1903651eb7d
|
||||
@@ -2387,17 +2387,17 @@ SPEC CHECKSUMS:
|
||||
rn-fetch-blob: 25612b6d6f6e980c6f17ed98ba2f58f5696a51ca
|
||||
RNCClipboard: f6679d470d0da2bce2a37b0af7b9e0bf369ecda5
|
||||
RNCPushNotificationIOS: 6c4ca3388c7434e4a662b92e4dfeeee858e6f440
|
||||
RNDateTimePicker: 392bdc0d6863b5de2fe9b957c82c25b6a038db29
|
||||
RNDateTimePicker: 7d93eacf4bdf56350e4b7efd5cfc47639185e10c
|
||||
RNDeviceInfo: d863506092aef7e7af3a1c350c913d867d795047
|
||||
RNExitApp: 4432b9b7cc5ccec9f91c94e507849891282befd4
|
||||
RNFileViewer: 4b5d83358214347e4ab2d4ca8d5c1c90d869e251
|
||||
RNFS: 89de7d7f4c0f6bafa05343c578f61118c8282ed8
|
||||
RNLocalize: 6a87f0490f1793d7a70042e4c55eb9a1ba6dd5b4
|
||||
RNLocalize: 3c4d0abd777a546fa77bdb6caef85a87fb9ea349
|
||||
RNQuickAction: c2c8f379e614428be0babe4d53a575739667744d
|
||||
RNSecureRandom: b64d263529492a6897e236a22a2c4249aa1b53dc
|
||||
RNShare: 9528acd4e374d3cb76b994b9e167d4a75cd8f452
|
||||
RNShare: 6496fc1ea6e8fce76b769513b6c2852f9c3ded82
|
||||
RNSVG: 295a96bc43f2baa5958d64aeec9847a1d8ca7a3d
|
||||
RNVectorIcons: d53917643fddb261b22bd6d889776f336893622b
|
||||
RNVectorIcons: e431ef1e6bef75d6ad0e33a83d376e6207962a9d
|
||||
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748
|
||||
Yoga: c758bfb934100bb4bf9cbaccb52557cee35e8bdf
|
||||
ZXingObjC: 8898711ab495761b2dbbdec76d90164a6d7e14c5
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"@joplin/renderer": "~3.5",
|
||||
"@joplin/utils": "~3.5",
|
||||
"@react-native-clipboard/clipboard": "1.16.3",
|
||||
"@react-native-community/datetimepicker": "8.4.3",
|
||||
"@react-native-community/datetimepicker": "8.4.4",
|
||||
"@react-native-community/geolocation": "3.4.0",
|
||||
"@react-native-community/netinfo": "11.4.1",
|
||||
"@react-native-community/push-notification-ios": "1.11.0",
|
||||
@@ -53,13 +53,13 @@
|
||||
"react": "19.0.0",
|
||||
"react-native": "0.79.2",
|
||||
"react-native-device-info": "14.0.4",
|
||||
"react-native-dropdownalert": "5.1.0",
|
||||
"react-native-dropdownalert": "5.2.0",
|
||||
"react-native-exit-app": "2.0.0",
|
||||
"react-native-file-viewer": "2.1.5",
|
||||
"react-native-fs": "2.20.0",
|
||||
"react-native-get-random-values": "1.11.0",
|
||||
"react-native-image-picker": "8.2.1",
|
||||
"react-native-localize": "3.4.2",
|
||||
"react-native-localize": "3.5.2",
|
||||
"react-native-modal-datetime-picker": "18.0.0",
|
||||
"react-native-paper": "5.14.5",
|
||||
"react-native-popup-menu": "0.17.0",
|
||||
@@ -72,7 +72,7 @@
|
||||
"react-native-sqlite-storage": "6.0.1",
|
||||
"react-native-svg": "15.13.0",
|
||||
"react-native-url-polyfill": "2.0.0",
|
||||
"react-native-vector-icons": "10.2.0",
|
||||
"react-native-vector-icons": "10.3.0",
|
||||
"react-native-version-info": "1.1.1",
|
||||
"react-native-webview": "13.15.0",
|
||||
"react-native-zip-archive": "7.0.2",
|
||||
@@ -109,13 +109,13 @@
|
||||
"@types/node": "18.19.130",
|
||||
"@types/react": "19.0.14",
|
||||
"@types/react-redux": "7.1.33",
|
||||
"@types/serviceworker": "0.0.149",
|
||||
"@types/serviceworker": "0.0.150",
|
||||
"@types/tar-stream": "3.1.4",
|
||||
"babel-jest": "29.7.0",
|
||||
"babel-loader": "9.1.3",
|
||||
"babel-plugin-module-resolver": "4.1.0",
|
||||
"babel-plugin-react-native-web": "0.20.0",
|
||||
"esbuild": "0.25.8",
|
||||
"esbuild": "0.25.9",
|
||||
"fast-deep-equal": "3.1.3",
|
||||
"fs-extra": "11.2.0",
|
||||
"gulp": "4.0.2",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = `KCgpID0+IHsKCWxldCBpbml0RG9uZV8gPSBmYWxzZTsKCgljb25zdCBnZXRMaWJyYXJ5ID0gKCkgPT4gewoJCXJldHVybiB3aW5kb3c/LkFCQ0pTOwoJfTsKCgljb25zdCBnZXRPcHRpb25zID0gKGVsZW1lbnQpID0+IHsKCQljb25zdCBvcHRpb25zID0gZWxlbWVudC5nZXRBdHRyaWJ1dGUoJ2RhdGEtYWJjLW9wdGlvbnMnKTsKCgkJaWYgKG9wdGlvbnMpIHsKCQkJdHJ5IHsKCQkJCXJldHVybiBKU09OLnBhcnNlKG9wdGlvbnMpOwoJCQl9IGNhdGNoIChlcnJvcikgewoJCQkJY29uc29sZS5lcnJvcignQ291bGQgbm90IHBhcnNlIEFCQyBvcHRpb25zOicsIG9wdGlvbnMsIGVycm9yKTsKCQkJfQoJCX0KCgkJcmV0dXJuIHt9OwoJfTsKCgljb25zdCBpbml0aWFsaXplID0gKCkgPT4gewoJCWlmIChpbml0RG9uZV8pIHJldHVybiB0cnVlOwoKCQljb25zdCBsaWIgPSBnZXRMaWJyYXJ5KCk7CgkJaWYgKCFsaWIpIHJldHVybiBmYWxzZTsKCgkJaW5pdERvbmVfID0gdHJ1ZTsKCgkJY29uc3QgZWxlbWVudHMgPSBkb2N1bWVudC5xdWVyeVNlbGVjdG9yQWxsKCcuam9wbGluLWVkaXRhYmxlID4gLmpvcGxpbi1hYmMtbm90YXRpb24tcmVuZGVyZWQnKTsKCgkJZm9yIChjb25zdCByZW5kZXJDb250YWluZXIgb2YgZWxlbWVudHMpIHsKCQkJY29uc3QgYmxvY2sgPSByZW5kZXJDb250YWluZXIucGFyZW50RWxlbWVudDsKCQkJY29uc3Qgc291cmNlRWxlbWVudCA9IGJsb2NrLnF1ZXJ5U2VsZWN0b3IoJy5qb3BsaW4tc291cmNlJyk7CgkJCWlmICghc291cmNlRWxlbWVudCkgY29udGludWU7CgoJCQljb25zdCBvcHRpb25zID0gZ2V0T3B0aW9ucyhzb3VyY2VFbGVtZW50KTsKCQkJbGliLnJlbmRlckFiYyhyZW5kZXJDb250YWluZXIsIHNvdXJjZUVsZW1lbnQudGV4dENvbnRlbnQsIHsgLi4ub3B0aW9ucyB9KTsKCQl9CgoJCXJldHVybiB0cnVlOwoJfTsKCglkb2N1bWVudC5hZGRFdmVudExpc3RlbmVyKCdqb3BsaW4tbm90ZURpZFVwZGF0ZScsICgpID0+IHsKCQlpbml0RG9uZV8gPSBmYWxzZTsKCQlpbml0aWFsaXplKCk7Cgl9KTsKCgljb25zdCBpbml0SUlEXyA9IHNldEludGVydmFsKCgpID0+IHsKCQlpZiAoaW5pdGlhbGl6ZSgpKSBjbGVhckludGVydmFsKGluaXRJSURfKTsKCX0sIDEwMCk7CgoJZG9jdW1lbnQuYWRkRXZlbnRMaXN0ZW5lcignRE9NQ29udGVudExvYWRlZCcsICgpID0+IHsKCQlpZiAoaW5pdGlhbGl6ZSgpKSBjbGVhckludGVydmFsKGluaXRJSURfKTsKCX0pOwp9KSgpOwoK`;
|
||||
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user