mirror of
https://github.com/laurent22/joplin.git
synced 2026-10-31 05:52:20 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87211d1996 | ||
|
|
26f7166200 |
No files matched your search
@@ -1,9 +0,0 @@
|
||||
# Environment variables injected by Yarn into every install/build-script run.
|
||||
# See the injectEnvironmentFiles setting: https://yarnpkg.com/configuration/yarnrc#injectEnvironmentFiles
|
||||
|
||||
# onnxruntime-node bundles the CPU runtime we use, but its postinstall also
|
||||
# downloads the CUDA/GPU execution provider from nuget.org on Linux x64. Joplin
|
||||
# only does CPU inference, so that download is unused - and it randomly times
|
||||
# out and fails the whole `yarn install`. Skip it. This is a no-op on
|
||||
# macOS/Windows, where onnxruntime-node downloads nothing anyway.
|
||||
ONNXRUNTIME_NODE_INSTALL=skip
|
||||
@@ -45,9 +45,7 @@ jobs:
|
||||
- uses: ./.github/workflows/shared/install-yarn
|
||||
|
||||
- name: Install
|
||||
run: |
|
||||
source "${GITHUB_WORKSPACE}/.github/scripts/retry.sh"
|
||||
retry yarn install
|
||||
run: yarn install
|
||||
env:
|
||||
SKIP_ONENOTE_CONVERTER_BUILD: 1
|
||||
|
||||
|
||||
@@ -53,8 +53,7 @@ jobs:
|
||||
run: |
|
||||
export npm_config_arch=arm64
|
||||
export npm_config_target_arch=arm64
|
||||
source "${GITHUB_WORKSPACE}/.github/scripts/retry.sh"
|
||||
retry yarn install
|
||||
yarn install
|
||||
cd packages/app-desktop
|
||||
npm pkg set 'build.mac.artifactName'='${productName}-${version}-${arch}.${ext}'
|
||||
|
||||
|
||||
@@ -178,8 +178,7 @@ jobs:
|
||||
echo "RUNNER_ARCH=$RUNNER_ARCH"
|
||||
echo "DOCKER_IMAGE_PLATFORM=$DOCKER_IMAGE_PLATFORM"
|
||||
|
||||
source "${GITHUB_WORKSPACE}/.github/scripts/retry.sh"
|
||||
retry yarn install
|
||||
yarn install
|
||||
yarn buildServerDocker --docker-file Dockerfile.server --platform $DOCKER_IMAGE_PLATFORM --tag-name server-v0.0.0 --repository joplin/server
|
||||
|
||||
# Basic test to ensure that the created build is valid. It should exit with
|
||||
|
||||
@@ -25,9 +25,7 @@ jobs:
|
||||
Install-WindowsDeps
|
||||
- name: Build
|
||||
if: runner.os != 'Windows'
|
||||
run: |
|
||||
source "${GITHUB_WORKSPACE}/.github/scripts/retry.sh"
|
||||
retry yarn install
|
||||
run: yarn install
|
||||
env:
|
||||
# The onenote-converter package uses Rust, which isn't installed on all CI
|
||||
# runners. Since the onenote-converter doesn't have UI tests, it can be excluded
|
||||
@@ -53,9 +51,7 @@ jobs:
|
||||
- name: Setup build environment
|
||||
uses: ./.github/workflows/shared/setup-build-environment
|
||||
- name: Build
|
||||
run: |
|
||||
source "${GITHUB_WORKSPACE}/.github/scripts/retry.sh"
|
||||
retry yarn install
|
||||
run: yarn install
|
||||
env:
|
||||
SKIP_ONENOTE_CONVERTER_BUILD: 1
|
||||
- name: Run mobile UI tests
|
||||
|
||||
+16
-49
@@ -71,8 +71,6 @@ docs/**/*.mustache
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
|
||||
packages/generator-joplin/generators/app/templates/script/publish/dist/
|
||||
|
||||
# AUTO-GENERATED - EXCLUDED TYPESCRIPT BUILD
|
||||
packages/app-cli/app/LinkSelector.js
|
||||
packages/app-cli/app/ResourceServer.js
|
||||
@@ -249,7 +247,6 @@ packages/app-desktop/gui/KeymapConfig/utils/useCommandStatus.js
|
||||
packages/app-desktop/gui/KeymapConfig/utils/useKeymap.js
|
||||
packages/app-desktop/gui/MainLayoutPane.js
|
||||
packages/app-desktop/gui/MainScreen.js
|
||||
packages/app-desktop/gui/MainScreen/handleCallbackUrl.js
|
||||
packages/app-desktop/gui/MasterPasswordDialog/Dialog.js
|
||||
packages/app-desktop/gui/MenuBar.js
|
||||
packages/app-desktop/gui/MultiNoteActions.js
|
||||
@@ -322,7 +319,6 @@ packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/useCardWheel
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/useCheckboxToggle.test.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/useCheckboxToggle.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteEditor.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteLockPanel/NoteLockPanel.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteTitle/NoteTitleBar.js
|
||||
packages/app-desktop/gui/NoteEditor/StatusBar.js
|
||||
packages/app-desktop/gui/NoteEditor/WarningBanner/BannerContent.js
|
||||
@@ -365,7 +361,6 @@ packages/app-desktop/gui/NoteEditor/utils/usePluginServiceRegistration.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/useResourceUnwatcher.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/useScheduleSaveCallbacks.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/useScrollWhenReadyOptions.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/useSearchMarkers.test.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/useSearchMarkers.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/useWindowCommandHandler.js
|
||||
packages/app-desktop/gui/NoteList/NoteList2.js
|
||||
@@ -413,7 +408,6 @@ packages/app-desktop/gui/NoteListItem/utils/useRenderedNote.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/useRootElement.test.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/useRootElement.js
|
||||
packages/app-desktop/gui/NoteListWrapper/NoteListWrapper.js
|
||||
packages/app-desktop/gui/NoteLockUnlockDialog/Dialog.js
|
||||
packages/app-desktop/gui/NotePropertiesDialog.js
|
||||
packages/app-desktop/gui/NoteRevisionViewer.js
|
||||
packages/app-desktop/gui/NoteSearchBar.js
|
||||
@@ -508,11 +502,10 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/WindowCommandsAndDialogs.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/addNoteToWhiteboard.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/addProfile.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/commandPalette.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/copyNoteExternalLink.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/deleteFolder.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/disableNoteEncryption.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/duplicateNote.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/editAlarm.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/enableNoteEncryption.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/exportPdf.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/globalRedo.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/globalUndo.js
|
||||
@@ -521,7 +514,6 @@ 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/lockEncryptedNotes.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/moveToFolder.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newFolder.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newNote.test.js
|
||||
@@ -703,7 +695,6 @@ packages/app-mobile/commands/util/goToNote.js
|
||||
packages/app-mobile/commands/util/showResource.js
|
||||
packages/app-mobile/components/BetaChip.js
|
||||
packages/app-mobile/components/BottomDrawer.js
|
||||
packages/app-mobile/components/BottomDrawerMenu.js
|
||||
packages/app-mobile/components/CameraView/ActionButtons.js
|
||||
packages/app-mobile/components/CameraView/Camera/index.jest.js
|
||||
packages/app-mobile/components/CameraView/Camera/index.js
|
||||
@@ -865,8 +856,6 @@ packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/utils/expo
|
||||
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/utils/exportDebugReport.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/utils/exportProfile.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/utils/makeImportExportCacheDirectory.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/NoteLockConfig.test.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/NoteLockConfig.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/SectionDescription.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/SectionHeader.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/SectionSelector/SectionTab.js
|
||||
@@ -1154,7 +1143,6 @@ packages/editor/CodeMirror/extensions/rendering/replaceLinks.test.js
|
||||
packages/editor/CodeMirror/extensions/rendering/replaceLinks.js
|
||||
packages/editor/CodeMirror/extensions/rendering/types.js
|
||||
packages/editor/CodeMirror/extensions/rendering/utils/makeBlockReplaceExtension.js
|
||||
packages/editor/CodeMirror/extensions/rendering/utils/makeInlineReplaceExtension.test.js
|
||||
packages/editor/CodeMirror/extensions/rendering/utils/makeInlineReplaceExtension.js
|
||||
packages/editor/CodeMirror/extensions/rendering/utils/nodeIntersectsSelection.js
|
||||
packages/editor/CodeMirror/extensions/searchExtension.test.js
|
||||
@@ -1302,6 +1290,9 @@ packages/generator-joplin/generators/app/templates/api/index.js
|
||||
packages/generator-joplin/generators/app/templates/api/noteListType.js
|
||||
packages/generator-joplin/generators/app/templates/api/types.js
|
||||
packages/generator-joplin/generators/app/templates/api_index.js
|
||||
packages/generator-joplin/generators/app/templates/script/publish/steps/verifyBuild.js
|
||||
packages/generator-joplin/generators/app/templates/script/publish/steps/verifyGitState.js
|
||||
packages/generator-joplin/generators/app/templates/script/publish/utils/logger.js
|
||||
packages/generator-joplin/generators/app/templates/src/index.js
|
||||
packages/generator-joplin/tools/updateCategories.js
|
||||
packages/htmlpack/index.test.js
|
||||
@@ -1403,7 +1394,6 @@ packages/lib/components/shared/config/plugins/useOnInstallHandler.js
|
||||
packages/lib/components/shared/config/shouldShowMissingPasswordWarning.test.js
|
||||
packages/lib/components/shared/config/shouldShowMissingPasswordWarning.js
|
||||
packages/lib/components/shared/dropbox-login-shared.js
|
||||
packages/lib/components/shared/note-screen-shared.test.js
|
||||
packages/lib/components/shared/note-screen-shared.js
|
||||
packages/lib/components/shared/reduxSharedMiddleware.js
|
||||
packages/lib/components/shared/side-menu-shared.test.js
|
||||
@@ -1479,7 +1469,6 @@ packages/lib/models/Alarm.js
|
||||
packages/lib/models/BaseItem.test.js
|
||||
packages/lib/models/BaseItem.js
|
||||
packages/lib/models/ConflictNoteState.js
|
||||
packages/lib/models/Folder.publishing.test.js
|
||||
packages/lib/models/Folder.sharing.test.js
|
||||
packages/lib/models/Folder.test.js
|
||||
packages/lib/models/Folder.js
|
||||
@@ -1597,13 +1586,13 @@ packages/lib/services/ai/SearchService.js
|
||||
packages/lib/services/ai/SqliteVec.test.js
|
||||
packages/lib/services/ai/aiSettingsTransition.test.js
|
||||
packages/lib/services/ai/aiSettingsTransition.js
|
||||
packages/lib/services/ai/applyNoteEdits.js
|
||||
packages/lib/services/ai/availability.test.js
|
||||
packages/lib/services/ai/availability.js
|
||||
packages/lib/services/ai/chunker.test.js
|
||||
packages/lib/services/ai/chunker.js
|
||||
packages/lib/services/ai/classification.js
|
||||
packages/lib/services/ai/noteChat.test.js
|
||||
packages/lib/services/ai/noteChat.tools.test.js
|
||||
packages/lib/services/ai/noteChat.js
|
||||
packages/lib/services/ai/providers/Anthropic.test.js
|
||||
packages/lib/services/ai/providers/Anthropic.js
|
||||
@@ -1614,26 +1603,6 @@ packages/lib/services/ai/providers/OpenAiCompatible.js
|
||||
packages/lib/services/ai/providers/TestProvider.js
|
||||
packages/lib/services/ai/testing/TestEmbeddingProvider.test.js
|
||||
packages/lib/services/ai/testing/TestEmbeddingProvider.js
|
||||
packages/lib/services/ai/tools/ToolIndex.js
|
||||
packages/lib/services/ai/tools/buildEditorTools.js
|
||||
packages/lib/services/ai/tools/global/createNote.js
|
||||
packages/lib/services/ai/tools/global/createNotebook.js
|
||||
packages/lib/services/ai/tools/global/deleteNote.js
|
||||
packages/lib/services/ai/tools/global/index.js
|
||||
packages/lib/services/ai/tools/global/listNotebooks.js
|
||||
packages/lib/services/ai/tools/global/listTags.js
|
||||
packages/lib/services/ai/tools/global/manageTags.js
|
||||
packages/lib/services/ai/tools/global/readImage.js
|
||||
packages/lib/services/ai/tools/global/readNote.js
|
||||
packages/lib/services/ai/tools/global/searchNotes.js
|
||||
packages/lib/services/ai/tools/global/semanticSearchNotes.js
|
||||
packages/lib/services/ai/tools/global/updateNote.js
|
||||
packages/lib/services/ai/tools/types.js
|
||||
packages/lib/services/ai/tools/utils/applyNoteEdits.test.js
|
||||
packages/lib/services/ai/tools/utils/applyNoteEdits.js
|
||||
packages/lib/services/ai/tools/utils/buildTool.js
|
||||
packages/lib/services/ai/tools/utils/serializeToolOutput.js
|
||||
packages/lib/services/ai/tools/utils/settings.js
|
||||
packages/lib/services/ai/types.js
|
||||
packages/lib/services/ai/utils/findFencedBlock.js
|
||||
packages/lib/services/commands/MenuUtils.js
|
||||
@@ -1645,13 +1614,6 @@ packages/lib/services/commands/isEditorCommand.js
|
||||
packages/lib/services/commands/propsHaveChanged.js
|
||||
packages/lib/services/commands/stateToWhenClauseContext.test.js
|
||||
packages/lib/services/commands/stateToWhenClauseContext.js
|
||||
packages/lib/services/conflict/diffNotes.test.js
|
||||
packages/lib/services/conflict/diffNotes.js
|
||||
packages/lib/services/conflict/isConflictResolutionEnabled.js
|
||||
packages/lib/services/conflict/loadConflictData.test.js
|
||||
packages/lib/services/conflict/loadConflictData.js
|
||||
packages/lib/services/conflict/wordDiff.test.js
|
||||
packages/lib/services/conflict/wordDiff.js
|
||||
packages/lib/services/contextkey/contextkey.js
|
||||
packages/lib/services/database/addMigrationFile.js
|
||||
packages/lib/services/database/isSqliteSyntaxError.js
|
||||
@@ -1704,7 +1666,6 @@ packages/lib/services/interop/InteropService_Exporter_Raw.js
|
||||
packages/lib/services/interop/InteropService_Importer_Base.js
|
||||
packages/lib/services/interop/InteropService_Importer_Custom.js
|
||||
packages/lib/services/interop/InteropService_Importer_EnexToHtml.js
|
||||
packages/lib/services/interop/InteropService_Importer_EnexToMd.test.js
|
||||
packages/lib/services/interop/InteropService_Importer_EnexToMd.js
|
||||
packages/lib/services/interop/InteropService_Importer_Jex.js
|
||||
packages/lib/services/interop/InteropService_Importer_Md.test.js
|
||||
@@ -1732,6 +1693,17 @@ packages/lib/services/keychain/KeychainServiceDriver.node.js
|
||||
packages/lib/services/keychain/KeychainServiceDriverBase.js
|
||||
packages/lib/services/mcp/McpServer.test.js
|
||||
packages/lib/services/mcp/McpServer.js
|
||||
packages/lib/services/mcp/registry.js
|
||||
packages/lib/services/mcp/tools/createNote.js
|
||||
packages/lib/services/mcp/tools/createNotebook.js
|
||||
packages/lib/services/mcp/tools/deleteNote.js
|
||||
packages/lib/services/mcp/tools/listNotebooks.js
|
||||
packages/lib/services/mcp/tools/listTags.js
|
||||
packages/lib/services/mcp/tools/manageTags.js
|
||||
packages/lib/services/mcp/tools/readNote.js
|
||||
packages/lib/services/mcp/tools/searchNotes.js
|
||||
packages/lib/services/mcp/tools/semanticSearchNotes.js
|
||||
packages/lib/services/mcp/tools/updateNote.js
|
||||
packages/lib/services/mcp/types.js
|
||||
packages/lib/services/noteList/checkboxPieCss.js
|
||||
packages/lib/services/noteList/defaultLeftToRightListRenderer.js
|
||||
@@ -1749,10 +1721,7 @@ packages/lib/services/noteLock/NoteLockService.test.js
|
||||
packages/lib/services/noteLock/NoteLockService.js
|
||||
packages/lib/services/noteLock/NoteLockSession.test.js
|
||||
packages/lib/services/noteLock/NoteLockSession.js
|
||||
packages/lib/services/noteLock/hasLockedNoteWhileSessionLocked.js
|
||||
packages/lib/services/noteLock/isNoteLockEnabled.js
|
||||
packages/lib/services/noteLock/setNoteLockState.test.js
|
||||
packages/lib/services/noteLock/setNoteLockState.js
|
||||
packages/lib/services/ocr/OcrDriverBase.js
|
||||
packages/lib/services/ocr/OcrService.test.js
|
||||
packages/lib/services/ocr/OcrService.js
|
||||
@@ -1868,7 +1837,6 @@ packages/lib/services/rest/utils/readonlyProperties.js
|
||||
packages/lib/services/rest/utils/requestFields.js
|
||||
packages/lib/services/rest/utils/requestPaginationOptions.js
|
||||
packages/lib/services/search/SearchEngine.resources.test.js
|
||||
packages/lib/services/search/SearchEngine.semantic.test.js
|
||||
packages/lib/services/search/SearchEngine.test.js
|
||||
packages/lib/services/search/SearchEngine.js
|
||||
packages/lib/services/search/SearchEngineUtils.test.js
|
||||
@@ -1949,7 +1917,6 @@ packages/lib/shim-init-node.js
|
||||
packages/lib/shim.js
|
||||
packages/lib/string-utils.test.js
|
||||
packages/lib/string-utils.js
|
||||
packages/lib/testing/ai/semanticSearch.js
|
||||
packages/lib/testing/dom-test-environment.js
|
||||
packages/lib/testing/plugins/createTestPlugin.js
|
||||
packages/lib/testing/share/makeMockShareInvitation.js
|
||||
|
||||
+16
-48
@@ -8,7 +8,6 @@ _releases/
|
||||
**/node_modules/
|
||||
**/abcjs-basic-min.js
|
||||
packages/generator-joplin/generators/app/templates/api/
|
||||
packages/generator-joplin/generators/app/templates/script/publish/dist/
|
||||
Assets/
|
||||
docs/
|
||||
highlight.pack.js
|
||||
@@ -274,7 +273,6 @@ packages/app-desktop/gui/KeymapConfig/utils/useCommandStatus.js
|
||||
packages/app-desktop/gui/KeymapConfig/utils/useKeymap.js
|
||||
packages/app-desktop/gui/MainLayoutPane.js
|
||||
packages/app-desktop/gui/MainScreen.js
|
||||
packages/app-desktop/gui/MainScreen/handleCallbackUrl.js
|
||||
packages/app-desktop/gui/MasterPasswordDialog/Dialog.js
|
||||
packages/app-desktop/gui/MenuBar.js
|
||||
packages/app-desktop/gui/MultiNoteActions.js
|
||||
@@ -347,7 +345,6 @@ packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/useCardWheel
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/useCheckboxToggle.test.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/useCheckboxToggle.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteEditor.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteLockPanel/NoteLockPanel.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteTitle/NoteTitleBar.js
|
||||
packages/app-desktop/gui/NoteEditor/StatusBar.js
|
||||
packages/app-desktop/gui/NoteEditor/WarningBanner/BannerContent.js
|
||||
@@ -390,7 +387,6 @@ packages/app-desktop/gui/NoteEditor/utils/usePluginServiceRegistration.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/useResourceUnwatcher.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/useScheduleSaveCallbacks.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/useScrollWhenReadyOptions.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/useSearchMarkers.test.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/useSearchMarkers.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/useWindowCommandHandler.js
|
||||
packages/app-desktop/gui/NoteList/NoteList2.js
|
||||
@@ -438,7 +434,6 @@ packages/app-desktop/gui/NoteListItem/utils/useRenderedNote.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/useRootElement.test.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/useRootElement.js
|
||||
packages/app-desktop/gui/NoteListWrapper/NoteListWrapper.js
|
||||
packages/app-desktop/gui/NoteLockUnlockDialog/Dialog.js
|
||||
packages/app-desktop/gui/NotePropertiesDialog.js
|
||||
packages/app-desktop/gui/NoteRevisionViewer.js
|
||||
packages/app-desktop/gui/NoteSearchBar.js
|
||||
@@ -533,11 +528,10 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/WindowCommandsAndDialogs.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/addNoteToWhiteboard.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/addProfile.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/commandPalette.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/copyNoteExternalLink.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/deleteFolder.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/disableNoteEncryption.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/duplicateNote.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/editAlarm.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/enableNoteEncryption.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/exportPdf.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/globalRedo.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/globalUndo.js
|
||||
@@ -546,7 +540,6 @@ 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/lockEncryptedNotes.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/moveToFolder.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newFolder.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newNote.test.js
|
||||
@@ -728,7 +721,6 @@ packages/app-mobile/commands/util/goToNote.js
|
||||
packages/app-mobile/commands/util/showResource.js
|
||||
packages/app-mobile/components/BetaChip.js
|
||||
packages/app-mobile/components/BottomDrawer.js
|
||||
packages/app-mobile/components/BottomDrawerMenu.js
|
||||
packages/app-mobile/components/CameraView/ActionButtons.js
|
||||
packages/app-mobile/components/CameraView/Camera/index.jest.js
|
||||
packages/app-mobile/components/CameraView/Camera/index.js
|
||||
@@ -890,8 +882,6 @@ packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/utils/expo
|
||||
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/utils/exportDebugReport.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/utils/exportProfile.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/utils/makeImportExportCacheDirectory.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/NoteLockConfig.test.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/NoteLockConfig.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/SectionDescription.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/SectionHeader.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/SectionSelector/SectionTab.js
|
||||
@@ -1179,7 +1169,6 @@ packages/editor/CodeMirror/extensions/rendering/replaceLinks.test.js
|
||||
packages/editor/CodeMirror/extensions/rendering/replaceLinks.js
|
||||
packages/editor/CodeMirror/extensions/rendering/types.js
|
||||
packages/editor/CodeMirror/extensions/rendering/utils/makeBlockReplaceExtension.js
|
||||
packages/editor/CodeMirror/extensions/rendering/utils/makeInlineReplaceExtension.test.js
|
||||
packages/editor/CodeMirror/extensions/rendering/utils/makeInlineReplaceExtension.js
|
||||
packages/editor/CodeMirror/extensions/rendering/utils/nodeIntersectsSelection.js
|
||||
packages/editor/CodeMirror/extensions/searchExtension.test.js
|
||||
@@ -1327,6 +1316,9 @@ packages/generator-joplin/generators/app/templates/api/index.js
|
||||
packages/generator-joplin/generators/app/templates/api/noteListType.js
|
||||
packages/generator-joplin/generators/app/templates/api/types.js
|
||||
packages/generator-joplin/generators/app/templates/api_index.js
|
||||
packages/generator-joplin/generators/app/templates/script/publish/steps/verifyBuild.js
|
||||
packages/generator-joplin/generators/app/templates/script/publish/steps/verifyGitState.js
|
||||
packages/generator-joplin/generators/app/templates/script/publish/utils/logger.js
|
||||
packages/generator-joplin/generators/app/templates/src/index.js
|
||||
packages/generator-joplin/tools/updateCategories.js
|
||||
packages/htmlpack/index.test.js
|
||||
@@ -1428,7 +1420,6 @@ packages/lib/components/shared/config/plugins/useOnInstallHandler.js
|
||||
packages/lib/components/shared/config/shouldShowMissingPasswordWarning.test.js
|
||||
packages/lib/components/shared/config/shouldShowMissingPasswordWarning.js
|
||||
packages/lib/components/shared/dropbox-login-shared.js
|
||||
packages/lib/components/shared/note-screen-shared.test.js
|
||||
packages/lib/components/shared/note-screen-shared.js
|
||||
packages/lib/components/shared/reduxSharedMiddleware.js
|
||||
packages/lib/components/shared/side-menu-shared.test.js
|
||||
@@ -1504,7 +1495,6 @@ packages/lib/models/Alarm.js
|
||||
packages/lib/models/BaseItem.test.js
|
||||
packages/lib/models/BaseItem.js
|
||||
packages/lib/models/ConflictNoteState.js
|
||||
packages/lib/models/Folder.publishing.test.js
|
||||
packages/lib/models/Folder.sharing.test.js
|
||||
packages/lib/models/Folder.test.js
|
||||
packages/lib/models/Folder.js
|
||||
@@ -1622,13 +1612,13 @@ packages/lib/services/ai/SearchService.js
|
||||
packages/lib/services/ai/SqliteVec.test.js
|
||||
packages/lib/services/ai/aiSettingsTransition.test.js
|
||||
packages/lib/services/ai/aiSettingsTransition.js
|
||||
packages/lib/services/ai/applyNoteEdits.js
|
||||
packages/lib/services/ai/availability.test.js
|
||||
packages/lib/services/ai/availability.js
|
||||
packages/lib/services/ai/chunker.test.js
|
||||
packages/lib/services/ai/chunker.js
|
||||
packages/lib/services/ai/classification.js
|
||||
packages/lib/services/ai/noteChat.test.js
|
||||
packages/lib/services/ai/noteChat.tools.test.js
|
||||
packages/lib/services/ai/noteChat.js
|
||||
packages/lib/services/ai/providers/Anthropic.test.js
|
||||
packages/lib/services/ai/providers/Anthropic.js
|
||||
@@ -1639,26 +1629,6 @@ packages/lib/services/ai/providers/OpenAiCompatible.js
|
||||
packages/lib/services/ai/providers/TestProvider.js
|
||||
packages/lib/services/ai/testing/TestEmbeddingProvider.test.js
|
||||
packages/lib/services/ai/testing/TestEmbeddingProvider.js
|
||||
packages/lib/services/ai/tools/ToolIndex.js
|
||||
packages/lib/services/ai/tools/buildEditorTools.js
|
||||
packages/lib/services/ai/tools/global/createNote.js
|
||||
packages/lib/services/ai/tools/global/createNotebook.js
|
||||
packages/lib/services/ai/tools/global/deleteNote.js
|
||||
packages/lib/services/ai/tools/global/index.js
|
||||
packages/lib/services/ai/tools/global/listNotebooks.js
|
||||
packages/lib/services/ai/tools/global/listTags.js
|
||||
packages/lib/services/ai/tools/global/manageTags.js
|
||||
packages/lib/services/ai/tools/global/readImage.js
|
||||
packages/lib/services/ai/tools/global/readNote.js
|
||||
packages/lib/services/ai/tools/global/searchNotes.js
|
||||
packages/lib/services/ai/tools/global/semanticSearchNotes.js
|
||||
packages/lib/services/ai/tools/global/updateNote.js
|
||||
packages/lib/services/ai/tools/types.js
|
||||
packages/lib/services/ai/tools/utils/applyNoteEdits.test.js
|
||||
packages/lib/services/ai/tools/utils/applyNoteEdits.js
|
||||
packages/lib/services/ai/tools/utils/buildTool.js
|
||||
packages/lib/services/ai/tools/utils/serializeToolOutput.js
|
||||
packages/lib/services/ai/tools/utils/settings.js
|
||||
packages/lib/services/ai/types.js
|
||||
packages/lib/services/ai/utils/findFencedBlock.js
|
||||
packages/lib/services/commands/MenuUtils.js
|
||||
@@ -1670,13 +1640,6 @@ packages/lib/services/commands/isEditorCommand.js
|
||||
packages/lib/services/commands/propsHaveChanged.js
|
||||
packages/lib/services/commands/stateToWhenClauseContext.test.js
|
||||
packages/lib/services/commands/stateToWhenClauseContext.js
|
||||
packages/lib/services/conflict/diffNotes.test.js
|
||||
packages/lib/services/conflict/diffNotes.js
|
||||
packages/lib/services/conflict/isConflictResolutionEnabled.js
|
||||
packages/lib/services/conflict/loadConflictData.test.js
|
||||
packages/lib/services/conflict/loadConflictData.js
|
||||
packages/lib/services/conflict/wordDiff.test.js
|
||||
packages/lib/services/conflict/wordDiff.js
|
||||
packages/lib/services/contextkey/contextkey.js
|
||||
packages/lib/services/database/addMigrationFile.js
|
||||
packages/lib/services/database/isSqliteSyntaxError.js
|
||||
@@ -1729,7 +1692,6 @@ packages/lib/services/interop/InteropService_Exporter_Raw.js
|
||||
packages/lib/services/interop/InteropService_Importer_Base.js
|
||||
packages/lib/services/interop/InteropService_Importer_Custom.js
|
||||
packages/lib/services/interop/InteropService_Importer_EnexToHtml.js
|
||||
packages/lib/services/interop/InteropService_Importer_EnexToMd.test.js
|
||||
packages/lib/services/interop/InteropService_Importer_EnexToMd.js
|
||||
packages/lib/services/interop/InteropService_Importer_Jex.js
|
||||
packages/lib/services/interop/InteropService_Importer_Md.test.js
|
||||
@@ -1757,6 +1719,17 @@ packages/lib/services/keychain/KeychainServiceDriver.node.js
|
||||
packages/lib/services/keychain/KeychainServiceDriverBase.js
|
||||
packages/lib/services/mcp/McpServer.test.js
|
||||
packages/lib/services/mcp/McpServer.js
|
||||
packages/lib/services/mcp/registry.js
|
||||
packages/lib/services/mcp/tools/createNote.js
|
||||
packages/lib/services/mcp/tools/createNotebook.js
|
||||
packages/lib/services/mcp/tools/deleteNote.js
|
||||
packages/lib/services/mcp/tools/listNotebooks.js
|
||||
packages/lib/services/mcp/tools/listTags.js
|
||||
packages/lib/services/mcp/tools/manageTags.js
|
||||
packages/lib/services/mcp/tools/readNote.js
|
||||
packages/lib/services/mcp/tools/searchNotes.js
|
||||
packages/lib/services/mcp/tools/semanticSearchNotes.js
|
||||
packages/lib/services/mcp/tools/updateNote.js
|
||||
packages/lib/services/mcp/types.js
|
||||
packages/lib/services/noteList/checkboxPieCss.js
|
||||
packages/lib/services/noteList/defaultLeftToRightListRenderer.js
|
||||
@@ -1774,10 +1747,7 @@ packages/lib/services/noteLock/NoteLockService.test.js
|
||||
packages/lib/services/noteLock/NoteLockService.js
|
||||
packages/lib/services/noteLock/NoteLockSession.test.js
|
||||
packages/lib/services/noteLock/NoteLockSession.js
|
||||
packages/lib/services/noteLock/hasLockedNoteWhileSessionLocked.js
|
||||
packages/lib/services/noteLock/isNoteLockEnabled.js
|
||||
packages/lib/services/noteLock/setNoteLockState.test.js
|
||||
packages/lib/services/noteLock/setNoteLockState.js
|
||||
packages/lib/services/ocr/OcrDriverBase.js
|
||||
packages/lib/services/ocr/OcrService.test.js
|
||||
packages/lib/services/ocr/OcrService.js
|
||||
@@ -1893,7 +1863,6 @@ packages/lib/services/rest/utils/readonlyProperties.js
|
||||
packages/lib/services/rest/utils/requestFields.js
|
||||
packages/lib/services/rest/utils/requestPaginationOptions.js
|
||||
packages/lib/services/search/SearchEngine.resources.test.js
|
||||
packages/lib/services/search/SearchEngine.semantic.test.js
|
||||
packages/lib/services/search/SearchEngine.test.js
|
||||
packages/lib/services/search/SearchEngine.js
|
||||
packages/lib/services/search/SearchEngineUtils.test.js
|
||||
@@ -1974,7 +1943,6 @@ packages/lib/shim-init-node.js
|
||||
packages/lib/shim.js
|
||||
packages/lib/string-utils.test.js
|
||||
packages/lib/string-utils.js
|
||||
packages/lib/testing/ai/semanticSearch.js
|
||||
packages/lib/testing/dom-test-environment.js
|
||||
packages/lib/testing/plugins/createTestPlugin.js
|
||||
packages/lib/testing/share/makeMockShareInvitation.js
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# This patch's goal is to work around an issue in the NSIS uninstaller on Windows:
|
||||
# - For future uninstallers, this patch backports an upstream commit that changes how
|
||||
# running copies of the app are found.
|
||||
# - See https://github.com/electron-userland/electron-builder/pull/8133
|
||||
# - If an existing uninstaller fails, gives an option to continue with the installation
|
||||
# despite the failure.
|
||||
# - Updates "uninstall failed" error messages to state that uninstallation failed (rather
|
||||
# than incorrectly stating that the issue was with closing the app).
|
||||
#
|
||||
# See https://github.com/laurent22/joplin/pull/11541
|
||||
diff --git a/templates/nsis/include/allowOnlyOneInstallerInstance.nsh b/templates/nsis/include/allowOnlyOneInstallerInstance.nsh
|
||||
index fe5d45c730f36c9fe8d8cfea12e242e501b67139..97b27fce6798e30e3e631221435f09b3579e77c3 100644
|
||||
--- a/templates/nsis/include/allowOnlyOneInstallerInstance.nsh
|
||||
+++ b/templates/nsis/include/allowOnlyOneInstallerInstance.nsh
|
||||
@@ -42,7 +42,7 @@
|
||||
${nsProcess::FindProcess} "${_FILE}" ${_ERR}
|
||||
!else
|
||||
# find process owned by current user
|
||||
- nsExec::Exec `%SYSTEMROOT%\System32\cmd.exe /c tasklist /FI "USERNAME eq %USERNAME%" /FI "IMAGENAME eq ${_FILE}" /FO csv | %SYSTEMROOT%\System32\find.exe "${_FILE}"`
|
||||
+ nsExec::Exec `"$SYSDIR\cmd.exe" /c tasklist /FI "USERNAME eq %USERNAME%" /FI "IMAGENAME eq ${_FILE}" /FO csv | "$SYSDIR\find.exe" "${_FILE}"`
|
||||
Pop ${_ERR}
|
||||
!endif
|
||||
!macroend
|
||||
@@ -73,7 +73,7 @@
|
||||
!ifdef INSTALL_MODE_PER_ALL_USERS
|
||||
nsExec::Exec `taskkill /im "${APP_EXECUTABLE_FILENAME}" /fi "PID ne $pid"`
|
||||
!else
|
||||
- nsExec::Exec `%SYSTEMROOT%\System32\cmd.exe /c taskkill /im "${APP_EXECUTABLE_FILENAME}" /fi "PID ne $pid" /fi "USERNAME eq %USERNAME%"`
|
||||
+ nsExec::Exec `"$SYSDIR\cmd.exe" /c taskkill /im "${APP_EXECUTABLE_FILENAME}" /fi "PID ne $pid" /fi "USERNAME eq %USERNAME%"`
|
||||
!endif
|
||||
# to ensure that files are not "in-use"
|
||||
Sleep 300
|
||||
@@ -91,7 +91,7 @@
|
||||
!ifdef INSTALL_MODE_PER_ALL_USERS
|
||||
nsExec::Exec `taskkill /f /im "${APP_EXECUTABLE_FILENAME}" /fi "PID ne $pid"`
|
||||
!else
|
||||
- nsExec::Exec `%SYSTEMROOT%\System32\cmd.exe /c taskkill /f /im "${APP_EXECUTABLE_FILENAME}" /fi "PID ne $pid" /fi "USERNAME eq %USERNAME%"`
|
||||
+ nsExec::Exec `"$SYSDIR\cmd.exe" /c taskkill /f /im "${APP_EXECUTABLE_FILENAME}" /fi "PID ne $pid" /fi "USERNAME eq %USERNAME%"`
|
||||
!endif
|
||||
!insertmacro FIND_PROCESS "${APP_EXECUTABLE_FILENAME}" $R0
|
||||
${If} $R0 == 0
|
||||
diff --git a/templates/nsis/include/installUtil.nsh b/templates/nsis/include/installUtil.nsh
|
||||
index 47367741632726ba0886ac516461dbe98b7aea58..675965762375925a505ca6d8bbb67507ef696c2e 100644
|
||||
--- a/templates/nsis/include/installUtil.nsh
|
||||
+++ b/templates/nsis/include/installUtil.nsh
|
||||
@@ -126,10 +126,11 @@ Function handleUninstallResult
|
||||
Return
|
||||
|
||||
${if} $R0 != 0
|
||||
- MessageBox MB_OK|MB_ICONEXCLAMATION "$(uninstallFailed): $R0"
|
||||
+ # MessageBox MB_OK|MB_ICONEXCLAMATION "$(uninstallFailed): $R0"
|
||||
DetailPrint `Uninstall was not successful. Uninstaller error code: $R0.`
|
||||
- SetErrorLevel 2
|
||||
- Quit
|
||||
+ DetailPrint `Continuing anyway. See https://github.com/laurent22/joplin/pull/11612.`
|
||||
+ # SetErrorLevel 2
|
||||
+ # Quit
|
||||
${endif}
|
||||
FunctionEnd
|
||||
|
||||
@@ -216,11 +217,13 @@ Function uninstallOldVersion
|
||||
IntOp $R5 $R5 + 1
|
||||
|
||||
${if} $R5 > 5
|
||||
- MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "$(appCannotBeClosed)" /SD IDCANCEL IDRETRY OneMoreAttempt
|
||||
- Return
|
||||
+ MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "$(appCannotBeUninstalled)" /SD IDCANCEL IDRETRY ContinueWithoutUninstall
|
||||
+ Abort ; Exit early
|
||||
+ ContinueWithoutUninstall:
|
||||
+ Return
|
||||
${endIf}
|
||||
|
||||
- OneMoreAttempt:
|
||||
+# OneMoreAttempt: ; Commented out because unused
|
||||
ExecWait '"$uninstallerFileNameTemp" /S /KEEP_APP_DATA $0 _?=$installationDir' $R0
|
||||
ifErrors TryInPlace CheckResult
|
||||
|
||||
diff --git a/templates/nsis/messages.yml b/templates/nsis/messages.yml
|
||||
index a1c2847fa48d79f835b30b48e999ccaf3c818657..6884c18d1e77dbd6be114401d23cf5caf3e0dd94 100644
|
||||
--- a/templates/nsis/messages.yml
|
||||
+++ b/templates/nsis/messages.yml
|
||||
@@ -235,3 +235,8 @@ uninstallFailed:
|
||||
sv: Det gick inte att avinstallera gamla programfiler. Försök att köra installationsprogrammet igen.
|
||||
uk: Не вдалось видалити старі файли застосунку. Будь ласка, спробуйте запустити встановлювач знов.
|
||||
zh_TW: 無法俺安裝舊的應用程式檔案。 請嘗試再次執行安裝程式。
|
||||
+
|
||||
+
|
||||
+appCannotBeUninstalled:
|
||||
+ en: "The old version of ${PRODUCT_NAME} could not be removed. \nClick Retry to skip this step."
|
||||
+
|
||||
@@ -1,48 +0,0 @@
|
||||
# Patched to work around a Windows install issue: On Windows, if an existing uninstaller fails,
|
||||
# this change gives an option to continue with the installation despite the failure
|
||||
# (https://github.com/laurent22/joplin/pull/11541).
|
||||
diff --git a/templates/nsis/include/installUtil.nsh b/templates/nsis/include/installUtil.nsh
|
||||
index 47367741632726ba0886ac516461dbe98b7aea58..c40a0907688cd8b1b8d09047cb0b380e42110ea1 100644
|
||||
--- a/templates/nsis/include/installUtil.nsh
|
||||
+++ b/templates/nsis/include/installUtil.nsh
|
||||
@@ -128,8 +128,9 @@ Function handleUninstallResult
|
||||
${if} $R0 != 0
|
||||
- MessageBox MB_OK|MB_ICONEXCLAMATION "$(uninstallFailed): $R0"
|
||||
+ # MessageBox MB_OK|MB_ICONEXCLAMATION "$(uninstallFailed): $R0"
|
||||
DetailPrint `Uninstall was not successful. Uninstaller error code: $R0.`
|
||||
- SetErrorLevel 2
|
||||
- Quit
|
||||
+ DetailPrint `Continuing anyway. See https://github.com/laurent22/joplin/pull/11612.`
|
||||
+ # SetErrorLevel 2
|
||||
+ # Quit
|
||||
${endif}
|
||||
FunctionEnd
|
||||
|
||||
@@ -216,11 +217,13 @@ Function uninstallOldVersion
|
||||
IntOp $R5 $R5 + 1
|
||||
|
||||
${if} $R5 > 5
|
||||
- MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "$(appCannotBeClosed)" /SD IDCANCEL IDRETRY OneMoreAttempt
|
||||
- Return
|
||||
+ MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "$(appCannotBeUninstalled)" /SD IDCANCEL IDRETRY ContinueWithoutUninstall
|
||||
+ Abort ; Exit early
|
||||
+ ContinueWithoutUninstall:
|
||||
+ Return
|
||||
${endIf}
|
||||
|
||||
- OneMoreAttempt:
|
||||
+# OneMoreAttempt: ; OneMoreAttempt is unused
|
||||
ExecWait '"$uninstallerFileNameTemp" /S /KEEP_APP_DATA $0 _?=$installationDir' $R0
|
||||
ifErrors TryInPlace CheckResult
|
||||
|
||||
diff --git a/templates/nsis/messages.yml b/templates/nsis/messages.yml
|
||||
index 982e1b09e424c4f07ce7c1152fe5ce8b703f5f57..20dfa5743d18f8775ce88658014092cf977ad055 100644
|
||||
--- a/templates/nsis/messages.yml
|
||||
+++ b/templates/nsis/messages.yml
|
||||
@@ -238,3 +238,5 @@ uninstallFailed:
|
||||
appClosing:
|
||||
en: "Closing running ${PRODUCT_NAME}..."
|
||||
cs: "Ukončuji aplikaci ${PRODUCT_NAME}..."
|
||||
+appCannotBeUninstalled:
|
||||
+ en: "The old version of ${PRODUCT_NAME} could not be removed. \nClick Retry to skip this step."
|
||||
\ No newline at end of file
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 208 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 575 KiB |
+1
-3
@@ -102,15 +102,13 @@ WORKDIR /home/$user/packages/server
|
||||
ENTRYPOINT ["tini", "--"]
|
||||
CMD ["yarn", "start-prod"]
|
||||
|
||||
ARG IMAGE_NAME="Joplin Server"
|
||||
ENV APP_NAME=${IMAGE_NAME}
|
||||
|
||||
# Build-time metadata
|
||||
# https://github.com/opencontainers/image-spec/blob/master/annotations.md
|
||||
ARG BUILD_DATE
|
||||
ARG REVISION
|
||||
ARG VERSION
|
||||
ARG SOURCE
|
||||
ARG IMAGE_NAME="Joplin Server"
|
||||
LABEL org.opencontainers.image.created="$BUILD_DATE" \
|
||||
org.opencontainers.image.title="$IMAGE_NAME" \
|
||||
org.opencontainers.image.description="Docker image for Joplin Server" \
|
||||
|
||||
@@ -172,6 +172,24 @@ elif [[ $ARCHITECTURE =~ .*i386.*|.*i686.* ]]; then
|
||||
fi
|
||||
|
||||
#-----------------------------------------------------
|
||||
print "Checking dependencies..."
|
||||
## Check if libfuse2 is present.
|
||||
if [[ $(command -v ldconfig) ]]; then
|
||||
LIBFUSE=$(ldconfig -p | grep "libfuse.so.2" || echo '')
|
||||
fi
|
||||
if [[ $LIBFUSE == "" ]]; then
|
||||
LIBFUSE=$(find /lib /usr/lib /lib64 /usr/lib64 /usr/local/lib -name "libfuse.so.2" 2>/dev/null | grep "libfuse.so.2" || echo '')
|
||||
fi
|
||||
if [[ $LIBFUSE == "" ]]; then
|
||||
print "${COLOR_RED}Error: Can't get libfuse2 on system, please install libfuse2${COLOR_RESET}"
|
||||
print "See https://joplinapp.org/help/faq/#desktop-application-will-not-launch-on-linux and https://github.com/AppImage/AppImageKit/wiki/FUSE for more information"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
#-----------------------------------------------------
|
||||
# Download Joplin
|
||||
#-----------------------------------------------------
|
||||
|
||||
# Get the latest version to download
|
||||
if [[ "$INCLUDE_PRE_RELEASE" == true ]]; then
|
||||
RELEASE_VERSION=$($DL - "https://api.github.com/repos/laurent22/joplin/releases" | grep -Po '"tag_name": ?"v\K.*?(?=")' | sort -rV | head -1)
|
||||
@@ -179,27 +197,6 @@ else
|
||||
RELEASE_VERSION=$($DL - "https://api.github.com/repos/laurent22/joplin/releases/latest" | grep -Po '"tag_name": ?"v\K.*?(?=")')
|
||||
fi
|
||||
|
||||
#-----------------------------------------------------
|
||||
print "Checking dependencies..."
|
||||
## Check for libfuse2 for Joplin versions lesser than 3.7.10, which transitioned to a new AppImage runtime without libfuse2 dependency
|
||||
if [[ $(compareVersions "$RELEASE_VERSION" "3.7.10") -lt 0 ]]; then
|
||||
if [[ $(command -v ldconfig) ]]; then
|
||||
LIBFUSE=$(ldconfig -p | grep "libfuse.so.2" || echo '')
|
||||
fi
|
||||
if [[ $LIBFUSE == "" ]]; then
|
||||
LIBFUSE=$(find /lib /usr/lib /lib64 /usr/lib64 /usr/local/lib -name "libfuse.so.2" 2>/dev/null | grep "libfuse.so.2" || echo '')
|
||||
fi
|
||||
if [[ $LIBFUSE == "" ]]; then
|
||||
print "${COLOR_RED}Error: Can't get libfuse2 on system, please install libfuse2${COLOR_RESET}"
|
||||
print "See https://joplinapp.org/help/faq/#desktop-application-will-not-launch-on-linux and https://github.com/AppImage/AppImageKit/wiki/FUSE for more information"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
#-----------------------------------------------------
|
||||
# Download Joplin
|
||||
#-----------------------------------------------------
|
||||
|
||||
# Check if it's in the latest version
|
||||
if [[ -e "${INSTALL_DIR}/VERSION" ]]; then
|
||||
CURRENT_VERSION=$(< "${INSTALL_DIR}/VERSION")
|
||||
|
||||
@@ -31,7 +31,7 @@ Please see the [donation page](https://github.com/laurent22/joplin/blob/dev/read
|
||||
# Sponsors
|
||||
|
||||
<!-- SPONSORS-ORG -->
|
||||
<a href="https://seirei.ne.jp"><img title="Serei Network" width="256" src="https://joplinapp.org/images/sponsors/SeireiNetwork.png"/></a> <a href="https://citricsheep.com"><img title="Citric Sheep" width="256" src="https://joplinapp.org/images/sponsors/CitricSheep.png"/></a> <a href="https://sorted.travel/?utm_source=joplinapp"><img title="Sorted Travel" width="256" src="https://joplinapp.org/images/sponsors/SortedTravel.png"/></a> <a href="https://celebian.com"><img title="Celebian" width="256" src="https://joplinapp.org/images/sponsors/Celebian.png"/></a> <a href="https://bestkru.com"><img title="BestKru" width="256" src="https://joplinapp.org/images/sponsors/BestKru.png"/></a> <a href="https://route4me.com"><img title="Route4Me" width="256" src="https://joplinapp.org/images/sponsors/Route4Me.png"/></a> <a href="https://socialboom.io/"><img title="Buy YouTube, Instagram, TikTok & Facebook Growth Services at Socialboom" width="256" src="https://joplinapp.org/images/sponsors/Socialboom.png" alt="Buy YouTube, Instagram, TikTok & Facebook Growth Services at Socialboom"/></a> <a href="https://essaypro.com/"><img title="write my essay EssayPro" width="256" src="https://joplinapp.org/images/sponsors/EssayPro.png" alt="write my essay EssayPro"/></a> <a href="https://writepaper.com/"><img title="write my paper" width="256" src="https://joplinapp.org/images/sponsors/WritePaper.png" alt="write my paper"/></a> <a href="https://mypaperhelp.com/"><img title="MyPaperHelp" width="256" src="https://joplinapp.org/images/sponsors/MyPaperHelp.png" alt="MyPaperHelp"/></a> <a href="https://essaypay.com/"><img title="EssayPay" width="256" src="https://joplinapp.org/images/sponsors/EssayPay.png" alt="EssayPay"/></a>
|
||||
<a href="https://seirei.ne.jp"><img title="Serei Network" width="256" src="https://joplinapp.org/images/sponsors/SeireiNetwork.png"/></a> <a href="https://citricsheep.com"><img title="Citric Sheep" width="256" src="https://joplinapp.org/images/sponsors/CitricSheep.png"/></a> <a href="https://sorted.travel/?utm_source=joplinapp"><img title="Sorted Travel" width="256" src="https://joplinapp.org/images/sponsors/SortedTravel.png"/></a> <a href="https://celebian.com"><img title="Celebian" width="256" src="https://joplinapp.org/images/sponsors/Celebian.png"/></a> <a href="https://bestkru.com"><img title="BestKru" width="256" src="https://joplinapp.org/images/sponsors/BestKru.png"/></a> <a href="https://route4me.com"><img title="Route4Me" width="256" src="https://joplinapp.org/images/sponsors/Route4Me.png"/></a> <a href="https://socialboom.io/"><img title="Buy YouTube, Instagram, TikTok & Facebook Growth Services at Socialboom" width="256" src="https://joplinapp.org/images/sponsors/Socialboom.png" alt="Buy YouTube, Instagram, TikTok & Facebook Growth Services at Socialboom"/></a>
|
||||
<!-- SPONSORS-ORG -->
|
||||
|
||||
* * *
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@
|
||||
},
|
||||
"nodejs": "24.12.0",
|
||||
"pkg-config": "latest",
|
||||
"python": "3.14.3",
|
||||
"python": "3.14.2",
|
||||
"bat": "latest",
|
||||
"electron": {
|
||||
"version": "latest",
|
||||
|
||||
+7
-6
@@ -87,7 +87,7 @@
|
||||
"eslint-plugin-promise": "7.3.0",
|
||||
"eslint-plugin-react": "7.37.5",
|
||||
"execa": "5.1.1",
|
||||
"fs-extra": "11.3.5",
|
||||
"fs-extra": "11.3.4",
|
||||
"glob": "11.1.0",
|
||||
"globals": "17.6.0",
|
||||
"gulp": "4.0.2",
|
||||
@@ -107,8 +107,8 @@
|
||||
},
|
||||
"packageManager": "yarn@4.16.0",
|
||||
"resolutions": {
|
||||
"@codemirror/view": "6.43.6",
|
||||
"@codemirror/state": "6.7.1",
|
||||
"@codemirror/view": "6.42.1",
|
||||
"@codemirror/state": "6.6.0",
|
||||
"@codemirror/language": "6.12.3",
|
||||
"@codemirror/lint": "6.9.5",
|
||||
"@codemirror/autocomplete": "6.20.1",
|
||||
@@ -121,6 +121,8 @@
|
||||
"pdfjs-dist": "patch:pdfjs-dist@npm%3A3.11.174#./.yarn/patches/pdfjs-dist-npm-3.11.174-67f2fee6d6.patch",
|
||||
"chokidar@^2.0.0": "3.5.3",
|
||||
"rn-fetch-blob@0.12.0": "patch:rn-fetch-blob@npm%3A0.12.0#./.yarn/patches/rn-fetch-blob-npm-0.12.0-cf02e3c544.patch",
|
||||
"app-builder-lib@26.0.0-alpha.7": "patch:app-builder-lib@npm%3A26.0.0-alpha.7#./.yarn/patches/app-builder-lib-npm-26.0.0-alpha.7-e1b3dca119.patch",
|
||||
"app-builder-lib@24.13.3": "patch:app-builder-lib@npm%3A24.13.3#./.yarn/patches/app-builder-lib-npm-24.13.3-86a66c0bf3.patch",
|
||||
"react-native-sqlite-storage@6.0.1": "patch:react-native-sqlite-storage@npm%3A6.0.1#./.yarn/patches/react-native-sqlite-storage-npm-6.0.1-8369d747bd.patch",
|
||||
"react-native-paper@5.13.1": "patch:react-native-paper@npm%3A5.13.1#./.yarn/patches/react-native-paper-npm-5.13.1-f153e542e2.patch",
|
||||
"react-native-popup-menu@0.17.0": "patch:react-native-popup-menu@npm%3A0.17.0#./.yarn/patches/react-native-popup-menu-npm-0.17.0-8b745d88dd.patch",
|
||||
@@ -133,7 +135,7 @@
|
||||
"node-gyp@npm:^9.0.0": "11.2.0",
|
||||
"node-forge@npm:^1": "1.3.2",
|
||||
"node-forge@npm:^1.3.0": "1.3.2",
|
||||
"moment@npm:^2.8.4": "2.30.1",
|
||||
"moment@npm:^2.8.4": "2.29.4",
|
||||
"depd@npm:^2.0.0": "patch:depd@npm%3A2.0.0#~/.yarn/patches/depd-npm-2.0.0-b6c51a4b43.patch",
|
||||
"depd@npm:~2.0.0": "patch:depd@npm%3A2.0.0#~/.yarn/patches/depd-npm-2.0.0-b6c51a4b43.patch",
|
||||
"depd@npm:~1.1.2": "patch:depd@npm%3A2.0.0#~/.yarn/patches/depd-npm-2.0.0-b6c51a4b43.patch",
|
||||
@@ -145,7 +147,6 @@
|
||||
"@react-native-vector-icons/fontawesome5@npm:12.3.0": "patch:@react-native-vector-icons/fontawesome5@npm%3A12.3.0#~/.yarn/patches/@react-native-vector-icons-fontawesome5-npm-12.3.0-a1ca46610f.patch",
|
||||
"@react-native-vector-icons/ionicons@npm:12.3.0": "patch:@react-native-vector-icons/ionicons@npm%3A12.3.0#~/.yarn/patches/@react-native-vector-icons-ionicons-npm-12.3.0-9bd4746f3f.patch",
|
||||
"@react-native-vector-icons/material-design-icons@npm:12.4.0": "patch:@react-native-vector-icons/material-design-icons@npm%3A12.4.0#~/.yarn/patches/@react-native-vector-icons-material-design-icons-npm-12.4.0-890f7f618b.patch",
|
||||
"@react-native-vector-icons/material-icons@npm:12.4.0": "patch:@react-native-vector-icons/material-icons@npm%3A12.4.0#~/.yarn/patches/@react-native-vector-icons-material-icons-npm-12.4.0-94138e627b.patch",
|
||||
"app-builder-lib@npm:26.15.6": "patch:app-builder-lib@npm%3A26.15.6#~/.yarn/patches/app-builder-lib-npm-26.15.6-010811df92.patch"
|
||||
"@react-native-vector-icons/material-icons@npm:12.4.0": "patch:@react-native-vector-icons/material-icons@npm%3A12.4.0#~/.yarn/patches/@react-native-vector-icons-material-icons-npm-12.4.0-94138e627b.patch"
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,7 @@
|
||||
"chalk": "4.1.2",
|
||||
"compare-version": "0.1.2",
|
||||
"file-type": "16.5.4",
|
||||
"fs-extra": "11.3.5",
|
||||
"fs-extra": "11.3.4",
|
||||
"html-entities": "1.4.0",
|
||||
"keytar": "7.9.0",
|
||||
"md5": "2.3.0",
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE en-export SYSTEM "http://xml.evernote.com/pub/evernote-export4.dtd">
|
||||
<en-export export-date="20260805T200517Z" application="Evernote" version="11.28.2">
|
||||
<note>
|
||||
<title>Note 2</title>
|
||||
<created>20260805T185318Z</created>
|
||||
<updated>20260805T200333Z</updated>
|
||||
<note-attributes>
|
||||
<author>author</author>
|
||||
</note-attributes>
|
||||
<content>
|
||||
<![CDATA[<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE en-note SYSTEM "http://xml.evernote.com/pub/enml2.dtd"><en-note><div style="display:none;"> </div><div>This is a test.</div><div><br/></div><div><span style="--en-highlight:red;background-color: #ffe2d5;">Link to a note in another notebook</span>: <a type="inline-richlink" rev="{"isCached":false}" href="https://share.evernote.com/note/3784df9c-5e9f-dd33-c246-a6e9616db99b">Test</a></div><div>Link to a note in the same notebook: <a type="inline-richlink" rev="{"isCached":false}" href="https://share.evernote.com/note/471e9dd8-9436-276a-0594-6d3f5a006084">Example note</a></div></en-note> ]]>
|
||||
</content>
|
||||
</note>
|
||||
<note>
|
||||
<title>Example note</title>
|
||||
<created>20260805T200313Z</created>
|
||||
<updated>20260805T200320Z</updated>
|
||||
<note-attributes>
|
||||
<author>author</author>
|
||||
</note-attributes>
|
||||
<content>
|
||||
<![CDATA[<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE en-note SYSTEM "http://xml.evernote.com/pub/enml2.dtd"><en-note><div style="display:none;"> </div><div>Testing!</div></en-note> ]]>
|
||||
</content>
|
||||
</note>
|
||||
</en-export>
|
||||
@@ -1,40 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE en-export SYSTEM "http://xml.evernote.com/pub/evernote-export4.dtd">
|
||||
<en-export export-date="20260805T200508Z" application="Evernote" version="11.28.2">
|
||||
<note>
|
||||
<title>Test</title>
|
||||
<created>20260805T184526Z</created>
|
||||
<updated>20260805T200439Z</updated>
|
||||
<note-attributes>
|
||||
<author>author</author>
|
||||
</note-attributes>
|
||||
<content>
|
||||
<![CDATA[<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE en-note SYSTEM "http://xml.evernote.com/pub/enml2.dtd"><en-note><div style="display:none;"> </div><div><br/></div><div><a type="inline-richlink" rev="{"isCached":false}" href="evernote:///view/312260475/s710/0c61536b-4d58-e7ee-1070-7a1a50d94852/e312cdf9-8314-440f-91a6-4bfca42da635">Test note</a>. <a type="inline-richlink" rev="{"isCached":false}" href="https://share.evernote.com/note/471e9dd8-9436-276a-0594-6d3f5a006084">Example note</a></div></en-note> ]]>
|
||||
</content>
|
||||
</note>
|
||||
<note>
|
||||
<title>Testing</title>
|
||||
<created>20260805T185202Z</created>
|
||||
<updated>20260805T200413Z</updated>
|
||||
<note-attributes>
|
||||
<author>author</author>
|
||||
</note-attributes>
|
||||
<content>
|
||||
<![CDATA[<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE en-note SYSTEM "http://xml.evernote.com/pub/enml2.dtd"><en-note><div style="display:none;"> </div><div><a rev="en_rl_none" href="https://share.evernote.com/note/3784df9c-5e9f-dd33-c246-a6e9616db99b">Test</a>...</div><div><br/></div><div><br/></div></en-note> ]]>
|
||||
</content>
|
||||
</note>
|
||||
<note>
|
||||
<title>Test note</title>
|
||||
<created>20260805T184449Z</created>
|
||||
<updated>20260805T184526Z</updated>
|
||||
<note-attributes>
|
||||
<author>author</author>
|
||||
</note-attributes>
|
||||
<content>
|
||||
<![CDATA[<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE en-note SYSTEM "http://xml.evernote.com/pub/enml2.dtd"><en-note><div style="display:none;"> </div><div>This is a test note!</div><ul><li><div>Test</div></li></ul><div><br/></div><div><br/></div></en-note> ]]>
|
||||
</content>
|
||||
</note>
|
||||
</en-export>
|
||||
@@ -146,14 +146,13 @@ describe('app.reducer', () => {
|
||||
toolCallId: 'call-1',
|
||||
userDescription: '',
|
||||
isError: false,
|
||||
isEdit: true,
|
||||
content: 'Result',
|
||||
} satisfies ChatToolMessage),
|
||||
});
|
||||
|
||||
expect(state.aiChatMessages).toMatchObject([
|
||||
{ id: 'id-0', role: 'user', text: 'Test', raw: [{ role: 'user' }] },
|
||||
{ id: 'id-1', role: 'assistant', text: 'Testing' },
|
||||
{ id: 'id-1', role: 'assistant', text: 'Testing', editsApplied: 1, editsMissed: 0 },
|
||||
]);
|
||||
|
||||
|
||||
|
||||
@@ -13,7 +13,8 @@ export interface AiChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'error' | 'separator';
|
||||
text: string;
|
||||
hide?: boolean;
|
||||
editsApplied?: number;
|
||||
editsMissed?: number;
|
||||
|
||||
// The raw message(s) corresponding to this event
|
||||
raw: ChatMessage[];
|
||||
@@ -316,8 +317,15 @@ export default function(state: AppState, action: any) {
|
||||
state, action.windowId, 'aiChatMessages', messages => {
|
||||
let lastMessage = messages[messages.length - 1];
|
||||
if (lastMessage) {
|
||||
const toolCall = action.toolCall;
|
||||
const error = toolCall.isError;
|
||||
const editsApplied = (lastMessage.editsApplied ?? 0) + (error ? 0 : 1);
|
||||
const editsMissed = (lastMessage.editsMissed ?? 0) + (error ? 1 : 0);
|
||||
|
||||
lastMessage = {
|
||||
...lastMessage,
|
||||
editsApplied,
|
||||
editsMissed,
|
||||
raw: [
|
||||
...lastMessage.raw,
|
||||
action.toolCall,
|
||||
|
||||
@@ -772,15 +772,6 @@ class Application extends BaseApplication {
|
||||
});
|
||||
});
|
||||
|
||||
addTask('app/listen for note lock session events', () => {
|
||||
eventManager.on(EventName.NoteLockSessionChange, (event) => {
|
||||
this.dispatch({
|
||||
type: 'SET_NOTE_LOCK_SESSION_UNLOCKED',
|
||||
value: event.unlocked,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
addTask('app/setupOcrService', () => this.setupOcrService());
|
||||
|
||||
return tasks;
|
||||
|
||||
@@ -3,7 +3,6 @@ import { _ } from '@joplin/lib/locale';
|
||||
import { stateUtils } from '@joplin/lib/reducer';
|
||||
import ExternalEditWatcher from '@joplin/lib/services/ExternalEditWatcher';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
|
||||
import bridge from '../services/bridge';
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
@@ -19,14 +18,11 @@ export const runtime = (): CommandRuntime => {
|
||||
|
||||
try {
|
||||
const note = await Note.load(noteId);
|
||||
// The enabled condition can be bypassed when the command is executed directly (e.g. via
|
||||
// toggleExternalEditing): the external editor would get ciphertext, or write plaintext to disk.
|
||||
if (isNoteLockEnabled() && note.is_locked) throw new Error(_('Locked notes cannot be opened in an external editor'));
|
||||
void ExternalEditWatcher.instance().openAndWatch(note);
|
||||
} catch (error) {
|
||||
bridge().showErrorMessageBox(_('Error opening note in editor: %s', error.message));
|
||||
}
|
||||
},
|
||||
enabledCondition: 'oneNoteSelected && !noteIsReadOnly && !noteIsLocked',
|
||||
enabledCondition: 'oneNoteSelected && !noteIsReadOnly',
|
||||
};
|
||||
};
|
||||
@@ -23,7 +23,7 @@ export const runtime = (): CommandRuntime => {
|
||||
void CommandService.instance().execute('startExternalEditing', noteId);
|
||||
}
|
||||
},
|
||||
enabledCondition: 'oneNoteSelected && !noteIsReadOnly && !noteIsLocked && (!modalDialogVisible || gotoAnythingVisible)',
|
||||
enabledCondition: 'oneNoteSelected && !noteIsReadOnly && (!modalDialogVisible || gotoAnythingVisible)',
|
||||
mapStateToTitle: (state: AppState) => {
|
||||
const noteId = stateUtils.selectedNoteId(state);
|
||||
return state.watchedNoteFiles.includes(noteId) ? _('Stop') : '';
|
||||
|
||||
@@ -2,45 +2,17 @@ import * as React from 'react';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { AiChatMessage } from '../../app.reducer';
|
||||
import InlineMarkdownDisplay from '../InlineMarkdownDisplay';
|
||||
import { ChatMessage, ChatRole, ChatToolMessage } from '@joplin/lib/services/ai/types';
|
||||
import { ChatMessage, ChatRole } from '@joplin/lib/services/ai/types';
|
||||
|
||||
|
||||
const toolResultSummary = (actions: ChatMessage[]) => {
|
||||
const toolResults = actions.filter(action => action.role === ChatRole.Tool);
|
||||
|
||||
const editSummary = () => {
|
||||
let applied = 0;
|
||||
let missed = 0;
|
||||
let lastEdit: ChatToolMessage|null = null;
|
||||
for (const result of toolResults) {
|
||||
if (!result.isEdit) continue;
|
||||
|
||||
if (result.isError) {
|
||||
missed ++;
|
||||
} else {
|
||||
applied ++;
|
||||
lastEdit = result;
|
||||
}
|
||||
}
|
||||
|
||||
if (applied + missed === 0) return '';
|
||||
if (missed === 0 && applied > 1) return _('%d edit(s) applied.', applied);
|
||||
if (missed === 0 && lastEdit) return lastEdit.userDescription;
|
||||
return _('%d edit(s) applied, %d could not be placed automatically.', applied, missed);
|
||||
};
|
||||
|
||||
return [
|
||||
...toolResults
|
||||
.filter(result => !result.isEdit)
|
||||
.map(result => result.userDescription),
|
||||
editSummary(),
|
||||
]
|
||||
.filter(item => !!item)
|
||||
.join('\n');
|
||||
};
|
||||
|
||||
const hasToolError = (actions: ChatMessage[]) => {
|
||||
return actions.some(action => action.role === ChatRole.Tool && action.isError);
|
||||
const editsSummary = (actions: ChatMessage[], applied: number, missed: number) => {
|
||||
if (applied + missed === 0) return '';
|
||||
if (missed === 0 && applied > 1) return _('%d edit(s) applied.', applied);
|
||||
if (missed === 0) {
|
||||
const toolResults = actions.filter(action => action.role === ChatRole.Tool);
|
||||
return toolResults.map(result => result.userDescription).join('\n');
|
||||
}
|
||||
return _('%d edit(s) applied, %d could not be placed automatically.', applied, missed);
|
||||
};
|
||||
|
||||
interface Props {
|
||||
@@ -55,7 +27,7 @@ const ChatMessageItem: React.FC<Props> = ({ message }) => {
|
||||
return <div className='error'>{message.text}</div>;
|
||||
}
|
||||
|
||||
const summary = message.role === 'assistant' ? toolResultSummary(message.raw) : '';
|
||||
const summary = message.role === 'assistant' ? editsSummary(message.raw, message.editsApplied ?? 0, message.editsMissed ?? 0) : '';
|
||||
// Always show something in the message box:
|
||||
const textContent = !message.text && !summary ? _('(no message)') : message.text;
|
||||
|
||||
@@ -72,7 +44,7 @@ const ChatMessageItem: React.FC<Props> = ({ message }) => {
|
||||
{content}
|
||||
{summary && (
|
||||
<div className='meta'>
|
||||
{hasToolError(message.raw)
|
||||
{(message.editsMissed ?? 0) > 0
|
||||
? <span className='warning'>{summary}</span>
|
||||
: <span>{summary}</span>}
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,6 @@ import JoplinError from '@joplin/lib/JoplinError';
|
||||
import eventManager, { EventName, ItemChangeEvent } from '@joplin/lib/eventManager';
|
||||
import { Second } from '@joplin/utils/time';
|
||||
import ChatMessageItem from './ChatMessageItem';
|
||||
import NavService from '@joplin/lib/services/NavService';
|
||||
|
||||
const logger = Logger.create('ChatPanel');
|
||||
|
||||
@@ -143,13 +142,11 @@ const ChatPanel: React.FC<Props> = (props) => {
|
||||
lastNoteIdRef.current = props.noteId;
|
||||
if (prev === null || prev === props.noteId || !props.noteId) return;
|
||||
if (messagesLengthRef.current === 0) return;
|
||||
|
||||
const text = _('— now viewing: %s —', props.noteTitle || _('(untitled)'));
|
||||
appendMessage({
|
||||
id: makeId(),
|
||||
role: 'separator',
|
||||
text,
|
||||
raw: [{ role: ChatRole.User, content: _('Switched notes: %s', text) }],
|
||||
text: _('— now viewing: %s —', props.noteTitle || _('(untitled)')),
|
||||
raw: [],
|
||||
});
|
||||
}, [props.noteId, props.noteTitle, appendMessage]);
|
||||
|
||||
@@ -191,7 +188,7 @@ const ChatPanel: React.FC<Props> = (props) => {
|
||||
// Captured so we can roll it back on failure — otherwise a retry would
|
||||
// send the prior user turn as history alongside the new prompt.
|
||||
const userTurnId = makeId();
|
||||
let hadVisibleResponse = false;
|
||||
let hadSuccessfulResponse = false;
|
||||
appendMessage({
|
||||
id: userTurnId,
|
||||
role: 'user',
|
||||
@@ -220,8 +217,6 @@ const ChatPanel: React.FC<Props> = (props) => {
|
||||
body: note.body,
|
||||
title: note.title,
|
||||
selection,
|
||||
noteId: note.id,
|
||||
folderId: note.parent_id,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -241,16 +236,15 @@ const ChatPanel: React.FC<Props> = (props) => {
|
||||
// System messages are not shown in the UI
|
||||
if (entry.role === ChatRole.System) continue;
|
||||
|
||||
hadSuccessfulResponse = true;
|
||||
|
||||
if (entry.role === ChatRole.Tool) {
|
||||
addToolResult(entry);
|
||||
} else {
|
||||
hadVisibleResponse ||= !entry.hide;
|
||||
|
||||
appendMessage({
|
||||
id: makeId(),
|
||||
role: entry.role,
|
||||
text: entry.content,
|
||||
hide: entry.hide,
|
||||
raw: [entry],
|
||||
});
|
||||
}
|
||||
@@ -301,7 +295,7 @@ const ChatPanel: React.FC<Props> = (props) => {
|
||||
logger.warn('Chat failed:', error);
|
||||
if (abortController.signal.aborted) return;
|
||||
|
||||
if (!hadVisibleResponse) {
|
||||
if (!hadSuccessfulResponse) {
|
||||
dispatch({ type: 'AI_CHAT_REMOVE', windowId, id: userTurnId });
|
||||
setInput(text);
|
||||
}
|
||||
@@ -322,10 +316,6 @@ const ChatPanel: React.FC<Props> = (props) => {
|
||||
dispatch({ type: 'AI_CHAT_RESET', windowId: windowId });
|
||||
}, [dispatch, windowId, cancelRequest]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
void CommandService.instance().executeInWindow('toggleAiChat', { windowId: windowId, args: [] });
|
||||
}, [windowId]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// Don't send while an IME composition is in flight — Enter commits
|
||||
// the composition for CJK / accented input.
|
||||
@@ -353,24 +343,15 @@ const ChatPanel: React.FC<Props> = (props) => {
|
||||
return { content, showingMessages: false };
|
||||
}
|
||||
|
||||
const visibleMessages = messages.filter(m => !m.hide);
|
||||
|
||||
const content = <>
|
||||
{props.aiDegraded && <AiDegradedNotice className='degraded-status' />}
|
||||
<div className='messages' aria-live={hasFocus ? 'polite' : undefined}>
|
||||
{visibleMessages.length === 0 && (
|
||||
{messages.length === 0 && (
|
||||
<div className='empty'>
|
||||
{_('Ask about this note, or request changes. Select text in the editor first to scope the request to that selection.')}
|
||||
<br/>
|
||||
<br/>
|
||||
<button
|
||||
type='button'
|
||||
className='link-button'
|
||||
onClick={() => NavService.go('Config', { props: { defaultSection: 'ai.tools' } })}
|
||||
>{_('Manage capabilities')}</button>
|
||||
</div>
|
||||
)}
|
||||
{visibleMessages.map(m => <ChatMessageItem key={m.id} message={m}/>)}
|
||||
{messages.map(m => <ChatMessageItem key={m.id} message={m}/>)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
<div className='composer'>
|
||||
@@ -404,28 +385,13 @@ const ChatPanel: React.FC<Props> = (props) => {
|
||||
</div>
|
||||
</>;
|
||||
|
||||
return { content, showingMessages: visibleMessages.length > 0 };
|
||||
return { content, showingMessages: messages.length > 0 };
|
||||
};
|
||||
|
||||
const sendButtonLabel = sending ? _('Stop generating') : _('Send');
|
||||
const headerId = useId();
|
||||
const { content, showingMessages } = renderContent();
|
||||
|
||||
const renderHeaderActions = () => {
|
||||
if (showingMessages) {
|
||||
return <button type='button' className='reset' onClick={handleReset}>{_('Reset')}</button>;
|
||||
}
|
||||
|
||||
const closeLabel = _('Close');
|
||||
return <button
|
||||
type='button'
|
||||
className='close'
|
||||
onClick={handleClose}
|
||||
title={closeLabel}
|
||||
aria-label={closeLabel}
|
||||
><i className='fas fa-times' role='img' aria-hidden={true}/></button>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className='chat-panel'
|
||||
@@ -434,9 +400,11 @@ const ChatPanel: React.FC<Props> = (props) => {
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
>
|
||||
<div className='header chat-panel-header'>
|
||||
<div className='header'>
|
||||
<h1 className='title' id={headerId}>{_('AI Chat')}</h1>
|
||||
{renderHeaderActions()}
|
||||
{showingMessages && (
|
||||
<button type='button' className='reset' onClick={handleReset}>{_('Reset')}</button>
|
||||
)}
|
||||
</div>
|
||||
{content}
|
||||
</div>
|
||||
|
||||
@@ -19,25 +19,23 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chat-panel-header {
|
||||
> .title {
|
||||
flex: 1;
|
||||
font-size: inherit;
|
||||
margin: 0;
|
||||
}
|
||||
.chat-panel > .header > .title {
|
||||
flex: 1;
|
||||
font-size: inherit;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
> .reset, > .close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--joplin-color-faded);
|
||||
cursor: pointer;
|
||||
font-size: 0.85em;
|
||||
padding: 2px 6px;
|
||||
.chat-panel > .header > .reset {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--joplin-color-faded);
|
||||
cursor: pointer;
|
||||
font-size: 0.85em;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--joplin-color);
|
||||
}
|
||||
}
|
||||
.chat-panel > .header > .reset:hover {
|
||||
color: var(--joplin-color);
|
||||
}
|
||||
|
||||
.chat-panel > .degraded-status {
|
||||
|
||||
@@ -27,8 +27,7 @@ import EncryptionService from '@joplin/lib/services/e2ee/EncryptionService';
|
||||
import { ShareInvitation } from '@joplin/lib/services/share/reducer';
|
||||
import removeKeylessItems from './ResizableLayout/utils/removeKeylessItems';
|
||||
import { localSyncInfoFromState } from '@joplin/lib/services/synchronizer/syncInfoUtils';
|
||||
import { isCallbackUrl } from '@joplin/lib/callbackUrlUtils';
|
||||
import executeCallbackUrl from './MainScreen/handleCallbackUrl';
|
||||
import { isCallbackUrl, parseCallbackUrl } from '@joplin/lib/callbackUrlUtils';
|
||||
import ElectronAppWrapper from '../ElectronAppWrapper';
|
||||
import { showMissingMasterKeyMessage } from '@joplin/lib/services/e2ee/utils';
|
||||
import { MasterKeyEntity } from '@joplin/lib/services/e2ee/types';
|
||||
@@ -154,23 +153,20 @@ class MainScreenComponent extends React.Component<Props, State> {
|
||||
|
||||
ipcRenderer.on('asynchronous-message', (_event: import('electron').IpcRendererEvent, message: string, args: { url: string }) => {
|
||||
if (message === 'openCallbackUrl') {
|
||||
void this.openCallbackUrl(args.url);
|
||||
this.openCallbackUrl(args.url);
|
||||
}
|
||||
});
|
||||
|
||||
const initialCallbackUrl = (bridge().electronApp() as ElectronAppWrapper).initialCallbackUrl();
|
||||
if (initialCallbackUrl) {
|
||||
void this.openCallbackUrl(initialCallbackUrl);
|
||||
this.openCallbackUrl(initialCallbackUrl);
|
||||
}
|
||||
}
|
||||
|
||||
private async openCallbackUrl(url: string) {
|
||||
try {
|
||||
if (!isCallbackUrl(url)) throw new Error(`Invalid callback URL: ${url}`);
|
||||
await executeCallbackUrl(url);
|
||||
} catch (error) {
|
||||
logger.error('Error handling callback URL:', error);
|
||||
}
|
||||
private openCallbackUrl(url: string) {
|
||||
if (!isCallbackUrl(url)) throw new Error(`Invalid callback URL: ${url}`);
|
||||
const { command, params } = parseCallbackUrl(url);
|
||||
void CommandService.instance().execute(command.toString(), params.id);
|
||||
}
|
||||
|
||||
private updateLayoutPluginViews(layout: LayoutItem, plugins: PluginStates) {
|
||||
@@ -186,7 +182,6 @@ class MainScreenComponent extends React.Component<Props, State> {
|
||||
if (!existingItem) {
|
||||
draftLayout.children.push({
|
||||
key: viewId,
|
||||
visible: info.view.opened,
|
||||
context: {
|
||||
pluginId: info.plugin.id,
|
||||
},
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import { CallbackUrlCommand, getNoteCallbackUrl, parseCallbackUrl } from '@joplin/lib/callbackUrlUtils';
|
||||
import CommandService, { utils as commandUtils } from '@joplin/lib/services/CommandService';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
import { stateUtils } from '@joplin/lib/reducer';
|
||||
import { AppState } from '../../app.reducer';
|
||||
import bridge from '../../services/bridge';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
|
||||
const logger = Logger.create('handleCallbackUrl');
|
||||
|
||||
// Opens the caller's x-success / x-error URL with the result appended as query params.
|
||||
const respond = (target: string, params: Record<string, string> = {}) => {
|
||||
if (!target) return;
|
||||
const query = Object.entries(params)
|
||||
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
|
||||
.join('&');
|
||||
|
||||
const fragmentIndex = target.indexOf('#');
|
||||
const base = fragmentIndex === -1 ? target : target.substring(0, fragmentIndex);
|
||||
const fragment = fragmentIndex === -1 ? '' : target.substring(fragmentIndex);
|
||||
const separator = base.includes('?') ? '&' : '?';
|
||||
const responseUrl = query ? `${base}${separator}${query}${fragment}` : target;
|
||||
void bridge().openExternal(responseUrl);
|
||||
};
|
||||
|
||||
const handleGetCurrentNote = async (params: Record<string, string>) => {
|
||||
const state = commandUtils.store.getState() as AppState;
|
||||
const noteId = stateUtils.selectedNoteId(state);
|
||||
if (!noteId) {
|
||||
respond(params['x-error'], { errorMessage: 'No note is currently selected' });
|
||||
return;
|
||||
}
|
||||
|
||||
const note = await Note.load(noteId);
|
||||
if (!note) {
|
||||
respond(params['x-error'], { errorMessage: 'The selected note could not be loaded' });
|
||||
return;
|
||||
}
|
||||
|
||||
respond(params['x-success'], {
|
||||
title: note.title,
|
||||
url: getNoteCallbackUrl(note.id),
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreateNote = async (params: Record<string, string>) => {
|
||||
const folder = await Folder.getValidActiveFolder();
|
||||
if (!folder) {
|
||||
respond(params['x-error'], { errorMessage: 'No valid notebook is available to create the note in' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Not provisional: an externally-requested note should persist even if unedited.
|
||||
const note = await Note.save({
|
||||
...Note.previewFieldsWithDefaultValues({ includeTimestamps: false }),
|
||||
title: params.title ?? '',
|
||||
body: params.body ?? '',
|
||||
parent_id: folder.id,
|
||||
});
|
||||
|
||||
commandUtils.store.dispatch({ type: 'NOTE_SELECT', id: note.id });
|
||||
|
||||
respond(params['x-success'], {
|
||||
title: note.title,
|
||||
url: getNoteCallbackUrl(note.id),
|
||||
});
|
||||
};
|
||||
|
||||
// Verbs dispatched directly to CommandService by name. Kept as an explicit
|
||||
// allowlist (not a catch-all) so a crafted URL can't run arbitrary commands. See 69826610a.
|
||||
const openCommands: string[] = [
|
||||
CallbackUrlCommand.OpenNote,
|
||||
CallbackUrlCommand.OpenFolder,
|
||||
CallbackUrlCommand.OpenTag,
|
||||
];
|
||||
|
||||
const executeCallbackUrl = async (url: string) => {
|
||||
const info = parseCallbackUrl(url);
|
||||
|
||||
if (!openCommands.includes(info.command) &&
|
||||
info.command !== CallbackUrlCommand.GetCurrentNote &&
|
||||
info.command !== CallbackUrlCommand.CreateNote) {
|
||||
throw new Error(`Unhandled callback URL command: ${info.command}`);
|
||||
}
|
||||
|
||||
try {
|
||||
if (info.command === CallbackUrlCommand.GetCurrentNote) {
|
||||
await handleGetCurrentNote(info.params);
|
||||
} else if (info.command === CallbackUrlCommand.CreateNote) {
|
||||
await handleCreateNote(info.params);
|
||||
} else {
|
||||
await CommandService.instance().execute(info.command.toString(), info.params.id);
|
||||
logger.info(`Executed callback URL command: ${info.command}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error handling callback URL command "${info.command}":`, error);
|
||||
respond(info.params['x-error'], { errorMessage: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
export default executeCallbackUrl;
|
||||
@@ -13,7 +13,6 @@ import versionInfo, { PackageInfo } from '@joplin/lib/versionInfo';
|
||||
import { ImportModule } from '@joplin/lib/services/interop/Module';
|
||||
import InteropServiceHelper from '../InteropServiceHelper';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
|
||||
import { isContextMenuItemLocation, MenuItem, MenuItemLocation } from '@joplin/lib/services/plugins/api/types';
|
||||
import SpellCheckerService from '@joplin/lib/services/spellChecker/SpellCheckerService';
|
||||
import menuCommandNames from './menuCommandNames';
|
||||
@@ -865,15 +864,10 @@ function useMenu(props: Props) {
|
||||
menuItemDic.toggleExternalEditing,
|
||||
separator(),
|
||||
menuItemDic.linkToNote,
|
||||
menuItemDic.copyNoteExternalLink,
|
||||
menuItemDic.setTags,
|
||||
menuItemDic.showShareNoteDialog,
|
||||
menuItemDic.convertNoteToMarkdown,
|
||||
...(isNoteLockEnabled() ? [
|
||||
separator(),
|
||||
menuItemDic.enableNoteEncryption,
|
||||
menuItemDic.disableNoteEncryption,
|
||||
menuItemDic.lockEncryptedNotes,
|
||||
] : []),
|
||||
separator(),
|
||||
menuItemDic.showNoteProperties,
|
||||
menuItemDic.showNoteContentProperties,
|
||||
|
||||
+13
-16
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import CodeMirror5Emulation from '@joplin/editor/CodeMirror/CodeMirror5Emulation/CodeMirror5Emulation';
|
||||
import { HighlightedWord } from '@joplin/lib/reducer';
|
||||
|
||||
const logger = Logger.create('useEditorSearch');
|
||||
|
||||
@@ -15,8 +14,9 @@ interface SetMarkersOptions {
|
||||
showEditorMarkers?: boolean;
|
||||
withSelection?: boolean;
|
||||
}
|
||||
type Keyword = { value: string };
|
||||
|
||||
export type OnSetMarkers = (cm: CodeMirror5Emulation, keywords: HighlightedWord[], options: SetMarkersOptions)=> number;
|
||||
export type OnSetMarkers = (cm: CodeMirror5Emulation, keywords: Keyword[], options: SetMarkersOptions)=> number;
|
||||
|
||||
|
||||
// Modified from codemirror/addons/search/search.js
|
||||
@@ -109,11 +109,11 @@ export default function useEditorSearchExtension() {
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#Escaping
|
||||
function escapeRegExp(keyword: string) {
|
||||
return keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
|
||||
return keyword.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
|
||||
}
|
||||
|
||||
function getSearchTerm(keyword: HighlightedWord) {
|
||||
const value = escapeRegExp(typeof keyword === 'string' ? keyword : keyword.value);
|
||||
function getSearchTerm(keyword: Keyword) {
|
||||
const value = escapeRegExp(keyword.value);
|
||||
return new RegExp(value, 'gi');
|
||||
}
|
||||
|
||||
@@ -139,8 +139,6 @@ export default function useEditorSearchExtension() {
|
||||
|
||||
clearMarkers();
|
||||
|
||||
const keywordValue = (keyword: HighlightedWord) => typeof keyword === 'string' ? keyword : keyword.value;
|
||||
|
||||
// HIGHLIGHT KEYWORDS
|
||||
// When doing a global search it's possible to have multiple keywords
|
||||
// This means we need to highlight each one
|
||||
@@ -148,12 +146,12 @@ export default function useEditorSearchExtension() {
|
||||
for (let i = 0; i < keywords.length; i++) {
|
||||
const keyword = keywords[i];
|
||||
|
||||
if (keywordValue(keyword) === '') continue;
|
||||
if (keyword.value === '') continue;
|
||||
|
||||
const searchTerm = getSearchTerm(keyword);
|
||||
|
||||
// We only want to scroll the first keyword into view in the case of a multi keyword search
|
||||
const scrollTo = i === 0 && (previousKeywordValue !== keywordValue(keyword) || previousIndex !== options.selectedIndex || options.searchTimestamp !== previousSearchTimestamp);
|
||||
const scrollTo = i === 0 && (previousKeywordValue !== keyword.value || previousIndex !== options.selectedIndex || options.searchTimestamp !== previousSearchTimestamp);
|
||||
|
||||
try {
|
||||
const match = highlightSearch(cm, searchTerm, options.selectedIndex, scrollTo, !!options.withSelection);
|
||||
@@ -174,16 +172,15 @@ export default function useEditorSearchExtension() {
|
||||
setPreviousSearchTimestamp(options.searchTimestamp);
|
||||
|
||||
// SEARCHOVERLAY
|
||||
// We only want to highlight all matches when there is only 1 main search term
|
||||
const mainSearch = keywords.filter(keyword => typeof keyword === 'string' || keyword.source !== 'semantic');
|
||||
if (mainSearch.length !== 1 || keywordValue(mainSearch[0]) === '') {
|
||||
// We only want to highlight all matches when there is only 1 search term
|
||||
if (keywords.length !== 1 || keywords[0].value === '') {
|
||||
clearOverlay(cm);
|
||||
const prev = keywords.length > 1 ? keywordValue(keywords[0]) : '';
|
||||
const prev = keywords.length > 1 ? keywords[0].value : '';
|
||||
setPreviousKeywordValue(prev);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const searchTerm = getSearchTerm(mainSearch[0]);
|
||||
const searchTerm = getSearchTerm(keywords[0]);
|
||||
|
||||
// Determine the number of matches in the source, this is passed on
|
||||
// to the NoteEditor component
|
||||
@@ -192,10 +189,10 @@ export default function useEditorSearchExtension() {
|
||||
|
||||
// Don't bother clearing and re-calculating the overlay if the search term
|
||||
// hasn't changed
|
||||
if (keywordValue(keywords[0]) === previousKeywordValue) return nMatches;
|
||||
if (keywords[0].value === previousKeywordValue) return nMatches;
|
||||
|
||||
clearOverlay(cm);
|
||||
setPreviousKeywordValue(keywordValue(keywords[0]));
|
||||
setPreviousKeywordValue(keywords[0].value);
|
||||
|
||||
// These operations are pretty slow, so we won't add use them until the user
|
||||
// has finished typing, 500ms is probably enough time
|
||||
|
||||
@@ -109,11 +109,7 @@ const Editor = (props: Props, ref: ForwardedRef<CodeMirrorControl>) => {
|
||||
}
|
||||
|
||||
const searchState = editor.getSearchState();
|
||||
const externalSearchText = props.externalSearch
|
||||
.keywords
|
||||
.filter(k => typeof k === 'string' || k.source !== 'semantic')
|
||||
.map(k => typeof k === 'string' ? k : k.value)
|
||||
.join(' ') || searchState.searchText;
|
||||
const externalSearchText = props.externalSearch.keywords.map(k => k.value).join(' ') || searchState.searchText;
|
||||
|
||||
if (externalSearchText === searchState.searchText && searchState.dialogVisible === props.useLocalSearch) {
|
||||
return;
|
||||
|
||||
@@ -25,17 +25,4 @@ export default () => ({
|
||||
'replaced $ matches': _('replaced $ matches'),
|
||||
'replaced match on line $': _('replaced match on line $'),
|
||||
'on line': _('on line'),
|
||||
|
||||
// Table editor context menu. These keys must be kept in sync with the
|
||||
// state.phrase() calls in packages/editor renderTables.ts.
|
||||
'Insert row above': _('Insert row above'),
|
||||
'Insert row below': _('Insert row below'),
|
||||
'Insert column left': _('Insert column left'),
|
||||
'Insert column right': _('Insert column right'),
|
||||
'Move row up': _('Move row up'),
|
||||
'Move row down': _('Move row down'),
|
||||
'Move column left': _('Move column left'),
|
||||
'Move column right': _('Move column right'),
|
||||
'Delete row': _('Delete row'),
|
||||
'Delete column': _('Delete column'),
|
||||
});
|
||||
@@ -33,7 +33,7 @@ import { DropHandler } from '../../utils/useDropHandler';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import useWebViewApi from './utils/useWebViewApi';
|
||||
import useLinkTooltips from './utils/useLinkTooltips';
|
||||
import { blur, focus } from '@joplin/lib/utils/focusHandler';
|
||||
import { focus } from '@joplin/lib/utils/focusHandler';
|
||||
const md5 = require('md5');
|
||||
import { clipboard } from 'electron';
|
||||
const supportedLocales = require('./supportedLocales');
|
||||
@@ -186,9 +186,6 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
|
||||
if (!editorRef.current) return '';
|
||||
return prop_htmlToMarkdownRef.current(props.contentMarkupLanguage, editorRef.current.getContent(), props.contentOriginalCss);
|
||||
},
|
||||
blurEditor: () => {
|
||||
if (editor) blur('TinyMCE::reload', editor.getBody());
|
||||
},
|
||||
resetScroll: () => {
|
||||
if (editor) editor.getWin().scrollTo(0, 0);
|
||||
},
|
||||
@@ -1246,8 +1243,6 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
|
||||
// https://github.com/facebook/react/issues/14010#issuecomment-433788147
|
||||
const props_onChangeRef = useRef<NoteBodyEditorProps['onChange']>(null);
|
||||
props_onChangeRef.current = props.onChange;
|
||||
const editorNoteReloadTimeRequestRef = useRef(props.editorNoteReloadTimeRequest);
|
||||
editorNoteReloadTimeRequestRef.current = props.editorNoteReloadTimeRequest;
|
||||
|
||||
const prop_htmlToMarkdownRef = useRef<HtmlToMarkdownHandler>(null);
|
||||
prop_htmlToMarkdownRef.current = props.htmlToMarkdown;
|
||||
@@ -1260,14 +1255,12 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
|
||||
if (!info) return;
|
||||
|
||||
nextOnChangeEventInfo.current = null;
|
||||
if (info.editorNoteReloadTimeRequest !== editorNoteReloadTimeRequestRef.current) return;
|
||||
|
||||
resetLinkTooltips();
|
||||
const contentMd = await prop_htmlToMarkdownRef.current(info.contentMarkupLanguage, info.editor.getContent(), info.contentOriginalCss);
|
||||
|
||||
lastOnChangeEventInfo.current.content = contentMd;
|
||||
lastOnChangeEventInfo.current.resourceInfos = await attachedResources(contentMd);
|
||||
if (info.editorNoteReloadTimeRequest !== editorNoteReloadTimeRequestRef.current) return;
|
||||
|
||||
props_onChangeRef.current({
|
||||
changeId: info.changeId,
|
||||
@@ -1308,7 +1301,6 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
|
||||
editor: editor,
|
||||
contentMarkupLanguage: props.contentMarkupLanguage,
|
||||
contentOriginalCss: props.contentOriginalCss,
|
||||
editorNoteReloadTimeRequest: editorNoteReloadTimeRequestRef.current,
|
||||
};
|
||||
|
||||
onChangeHandlerTimeoutRef.current = shim.setTimeout(async () => {
|
||||
|
||||
@@ -18,7 +18,7 @@ import styles_ from './styles';
|
||||
import { NoteEditorProps, FormNote, OnChangeEvent, AllAssetsOptions, NoteBodyEditorRef, NoteBodyEditorPropsAndRef, NoteBodyEditorType } from './utils/types';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import Button, { ButtonLevel } from '../Button/Button';
|
||||
import eventManager, { EventName, NoteLockNoteStateChangeEvent } from '@joplin/lib/eventManager';
|
||||
import eventManager, { EventName } from '@joplin/lib/eventManager';
|
||||
import { AppState } from '../../app.reducer';
|
||||
import ToolbarButtonUtils, { ToolbarButtonInfo } from '@joplin/lib/services/commands/ToolbarButtonUtils';
|
||||
import { _, _n } from '@joplin/lib/locale';
|
||||
@@ -28,10 +28,6 @@ import Setting from '@joplin/lib/models/Setting';
|
||||
import stateToWhenClauseContext from '../../services/commands/stateToWhenClauseContext';
|
||||
import ExternalEditWatcher from '@joplin/lib/services/ExternalEditWatcher';
|
||||
import { itemIsReadOnly } from '@joplin/lib/models/utils/readOnly';
|
||||
import NoteLockSession from '@joplin/lib/services/noteLock/NoteLockSession';
|
||||
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
|
||||
import { SyncInfo } from '@joplin/lib/services/synchronizer/syncInfoUtils';
|
||||
import NoteLockPanel from './NoteLockPanel/NoteLockPanel';
|
||||
import { themeStyle } from '@joplin/lib/theme';
|
||||
import { substrWithEllipsis } from '@joplin/lib/string-utils';
|
||||
import NoteSearchBar from '../NoteSearchBar';
|
||||
@@ -66,7 +62,6 @@ import useConnectToEditorPlugin from './utils/useConnectToEditorPlugin';
|
||||
import getResourceBaseUrl from './utils/getResourceBaseUrl';
|
||||
import useInitialCursorLocation from './utils/useInitialCursorLocation';
|
||||
import NotePositionService, { EditorCursorLocations } from '@joplin/lib/services/NotePositionService';
|
||||
import { blur } from '@joplin/lib/utils/focusHandler';
|
||||
|
||||
const debounce = require('debounce');
|
||||
|
||||
@@ -85,7 +80,6 @@ function NoteEditorContent(props: NoteEditorProps) {
|
||||
const [showRevisions, setShowRevisions] = useState(false);
|
||||
const [titleHasBeenManuallyChanged, setTitleHasBeenManuallyChanged] = useState(false);
|
||||
const [isReadOnly, setIsReadOnly] = useState<boolean>(false);
|
||||
const [reloadInProgress, setReloadInProgress] = useState(false);
|
||||
|
||||
const editorRef = useRef<NoteBodyEditorRef|null>(null);
|
||||
const titleInputRef = useRef<HTMLInputElement|null>(null);
|
||||
@@ -98,20 +92,11 @@ function NoteEditorContent(props: NoteEditorProps) {
|
||||
}, []);
|
||||
|
||||
const setFormNoteRef = useRef<OnSetFormNote>(null);
|
||||
const formNoteRef = useRef<FormNote>(null);
|
||||
const { saveNoteIfWillChange, scheduleSaveNote } = useScheduleSaveCallbacks({
|
||||
setFormNote: setFormNoteRef, formNote: formNoteRef, dispatch: props.dispatch, editorRef, editorId,
|
||||
editorNoteReloadTimeRequest: props.editorNoteReloadTimeRequest,
|
||||
setFormNote: setFormNoteRef, dispatch: props.dispatch, editorRef, editorId,
|
||||
});
|
||||
const formNote_beforeLoad = useCallback(async (event: OnLoadEvent) => {
|
||||
await saveNoteIfWillChange(event.formNote);
|
||||
// The lock must wait for the pending saves of the note being left: a locked note's save
|
||||
// needs the unlocked session to encrypt, so locking first would drop the last edits.
|
||||
// The note being left may have no form note at all (unlock or cannot-decrypt overlay).
|
||||
if (isNoteLockEnabled() && Setting.value('noteLock.lockOnNoteSwitch')) {
|
||||
await event.formNote.saveActionQueue?.waitForAllDone();
|
||||
NoteLockSession.instance().lock();
|
||||
}
|
||||
setShowRevisions(false);
|
||||
}, [saveNoteIfWillChange]);
|
||||
|
||||
@@ -122,37 +107,8 @@ function NoteEditorContent(props: NoteEditorProps) {
|
||||
const effectiveNoteId = useEffectiveNoteId(props);
|
||||
const { editorPlugin, editorView } = usePluginEditorView(props.plugins);
|
||||
const builtInEditorVisible = !editorPlugin;
|
||||
const windowId = useContext(WindowIdContext);
|
||||
const onDecryptFailedChange = useCallback((value: boolean) => {
|
||||
props.dispatch({ type: 'SET_ACTIVE_NOTE_IS_UNDECRYPTABLE', value, windowId });
|
||||
}, [props.dispatch, windowId]);
|
||||
const onReloadInProgressChange = useCallback((value: boolean) => {
|
||||
if (value) {
|
||||
// TinyMCE edits inside its own document. Blurring only the outer window does
|
||||
// not reliably stop input when the editor is in a secondary window.
|
||||
editorRef.current?.blurEditor?.();
|
||||
|
||||
// Blur only the window whose editor is being refreshed. A secondary window
|
||||
// can refresh in response to a change saved by the main editor; blurring the
|
||||
// global document here would incorrectly interrupt typing in the main window.
|
||||
const editorWindowActiveElement = containerRef.current?.ownerDocument?.activeElement;
|
||||
if (editorWindowActiveElement) {
|
||||
blur('NoteEditor::reloadEditorWindow', editorWindowActiveElement);
|
||||
}
|
||||
}
|
||||
setReloadInProgress(value);
|
||||
if (!value) {
|
||||
// TinyMCE can report onWillChange before a reload, then have its delayed
|
||||
// onChange rejected while the reload is in progress. Since no save is queued
|
||||
// in that case, clear the saving status when the reload completes.
|
||||
props.dispatch({
|
||||
type: 'EDITOR_NOTE_STATUS_REMOVE',
|
||||
id: effectiveNoteId,
|
||||
});
|
||||
}
|
||||
}, [effectiveNoteId, props.dispatch]);
|
||||
|
||||
const { formNote, setFormNote, isNewNote, resourceInfos, decryptFailed, loadBlocked } = useFormNote({
|
||||
const { formNote, setFormNote, isNewNote, resourceInfos } = useFormNote({
|
||||
noteId: effectiveNoteId,
|
||||
isProvisional: props.isProvisional,
|
||||
titleInputRef: titleInputRef,
|
||||
@@ -161,48 +117,14 @@ function NoteEditorContent(props: NoteEditorProps) {
|
||||
onAfterLoad: formNote_afterLoad,
|
||||
builtInEditorVisible,
|
||||
editorId,
|
||||
noteLockSessionUnlocked: props.noteLockSessionUnlocked,
|
||||
onDecryptFailedChange,
|
||||
onReloadInProgressChange,
|
||||
editorNoteReloadTimeRequest: props.editorNoteReloadTimeRequest,
|
||||
});
|
||||
setFormNoteRef.current = setFormNote;
|
||||
const formNoteRef = useRef<FormNote>(formNote);
|
||||
formNoteRef.current = { ...formNote };
|
||||
|
||||
useEffect(() => {
|
||||
if (!isNoteLockEnabled()) return () => {};
|
||||
// Pending scheduled saves read the lock state from the form note, so it must follow an
|
||||
// enable/disable triggered outside the editor (note list menu, another window) immediately.
|
||||
const onLockStateChange = (event: NoteLockNoteStateChangeEvent) => {
|
||||
if (formNoteRef.current.id !== event.noteId) return;
|
||||
// Enabling requires an unlocked session, so capture the key the same way a decrypt
|
||||
// does - a pending save can then still encrypt if the session locks before it runs.
|
||||
const noteLockKey = event.isLocked && NoteLockSession.instance().isUnlocked() ? NoteLockSession.instance().decryptedKey() : null;
|
||||
const newFormNote = { ...formNoteRef.current, is_locked: event.isLocked ? 1 : 0, noteLockKey, isDecrypted: event.isLocked };
|
||||
setFormNote(newFormNote);
|
||||
void scheduleSaveNote(newFormNote);
|
||||
};
|
||||
eventManager.on(EventName.NoteLockNoteStateChange, onLockStateChange);
|
||||
return () => {
|
||||
eventManager.off(EventName.NoteLockNoteStateChange, onLockStateChange);
|
||||
};
|
||||
}, [setFormNote, scheduleSaveNote]);
|
||||
|
||||
useAsyncEffect(async () => {
|
||||
if (!isNoteLockEnabled() || props.noteLockSessionUnlocked) return;
|
||||
const lockedFormNote = formNoteRef.current;
|
||||
if (!lockedFormNote?.is_locked || !lockedFormNote.hasChanged) return;
|
||||
// The panel replaces the editor only once the edits are written, so flush them the same way
|
||||
// a note switch does before locking.
|
||||
await saveNoteIfWillChange(lockedFormNote);
|
||||
// The editor dispatches its pending change when it unmounts, which would be saved over the
|
||||
// flushed one - clearing the change id makes onFieldChange drop it, as a note switch does.
|
||||
setFormNote(prev => ({ ...prev, bodyWillChangeId: 0 }));
|
||||
await lockedFormNote.saveActionQueue?.waitForAllDone();
|
||||
}, [props.noteLockSessionUnlocked, saveNoteIfWillChange]);
|
||||
|
||||
const formNoteFolder = useFolder({ folderId: formNote.parent_id });
|
||||
|
||||
const windowId = useContext(WindowIdContext);
|
||||
const shownEditorViewIds = useVisiblePluginEditorViewIds(props.plugins, windowId);
|
||||
useConnectToEditorPlugin({
|
||||
startupPluginsLoaded: props.startupPluginsLoaded,
|
||||
@@ -288,7 +210,6 @@ function NoteEditorContent(props: NoteEditorProps) {
|
||||
}, [formNote.title, props.onTitleChange]);
|
||||
|
||||
const onFieldChange = useCallback(async (field: string, value: string, changeId = 0) => {
|
||||
if (reloadInProgress) return;
|
||||
if (!isMountedRef.current) {
|
||||
// When the component is unmounted, various actions can happen which can
|
||||
// trigger onChange events, for example the textarea might be cleared.
|
||||
@@ -337,7 +258,7 @@ function NoteEditorContent(props: NoteEditorProps) {
|
||||
// - debounced because many calls to scheduleSaveNote can resolve at once
|
||||
scheduleNoteListResort();
|
||||
}
|
||||
}, [reloadInProgress, handleProvisionalFlag, formNote, setFormNote, isNewNote, titleHasBeenManuallyChanged, scheduleNoteListResort, scheduleSaveNote]);
|
||||
}, [handleProvisionalFlag, formNote, setFormNote, isNewNote, titleHasBeenManuallyChanged, scheduleNoteListResort, scheduleSaveNote]);
|
||||
|
||||
const onDrop = useDropHandler({ editorRef });
|
||||
|
||||
@@ -496,16 +417,7 @@ function NoteEditorContent(props: NoteEditorProps) {
|
||||
return <div style={emptyDivStyle} ref={containerRef}></div>;
|
||||
}
|
||||
|
||||
const searchMarkers = useSearchMarkers({
|
||||
showLocalSearch,
|
||||
localSearchMarkerOptions,
|
||||
noteId: props.noteId,
|
||||
searchResults: props.searchResults,
|
||||
searchId: props.selectedSearchId,
|
||||
searches: props.searches,
|
||||
highlightedWords: props.highlightedWords,
|
||||
noteTitle: formNote.title,
|
||||
});
|
||||
const searchMarkers = useSearchMarkers(showLocalSearch, localSearchMarkerOptions, props.searches, props.selectedSearchId, props.highlightedWords);
|
||||
const initialCursorLocation = useInitialCursorLocation({
|
||||
noteId: props.noteId,
|
||||
});
|
||||
@@ -522,14 +434,13 @@ function NoteEditorContent(props: NoteEditorProps) {
|
||||
content: formNote.body,
|
||||
contentMarkupLanguage: markupLanguage,
|
||||
contentOriginalCss: formNote.originalCss,
|
||||
editorNoteReloadTimeRequest: props.editorNoteReloadTimeRequest,
|
||||
initialCursorLocation,
|
||||
resourceInfos: resourceInfos,
|
||||
resourceDirectory: Setting.value('resourceDir'),
|
||||
htmlToMarkdown: htmlToMarkdown,
|
||||
markupToHtml: markupToHtml,
|
||||
allAssets: allAssets,
|
||||
disabled: isReadOnly || reloadInProgress,
|
||||
disabled: isReadOnly,
|
||||
themeId: props.themeId,
|
||||
dispatch: props.dispatch,
|
||||
noteToolbar: null,
|
||||
@@ -558,9 +469,7 @@ function NoteEditorContent(props: NoteEditorProps) {
|
||||
useCustomPdfViewer: props.useCustomPdfViewer,
|
||||
// We need it to identify the context for which media is rendered.
|
||||
// It is currently used to remember pdf scroll position for each attachments of each note uniquely.
|
||||
// Use the form note object to ensure the editor is associated with the loaded form note until onBeforeLoad
|
||||
// has flushed any pending changes from it.
|
||||
noteId: formNote.id,
|
||||
noteId: props.noteId,
|
||||
watchedNoteFiles: props.watchedNoteFiles,
|
||||
enableHtmlToMarkdownBanner: props.enableHtmlToMarkdownBanner,
|
||||
showNoteLinkIcon: props.showNoteLinkIcon,
|
||||
@@ -751,42 +660,6 @@ function NoteEditorContent(props: NoteEditorProps) {
|
||||
/>;
|
||||
};
|
||||
|
||||
// A locked note has no form note while the session is locked (see loadNoteForForm), so the
|
||||
// panel is driven by the note metadata. A loaded form note stays mounted on lock only if it
|
||||
// has unsaved changes, so they are not thrown away.
|
||||
const lockedNoteMetadata = isNoteLockEnabled() && effectiveNoteId ? props.notes.find(n => n.id === effectiveNoteId) : null;
|
||||
if (lockedNoteMetadata?.is_locked) {
|
||||
// The session is unlocked but the note content failed to decrypt (e.g. it was encrypted
|
||||
// prior to a password reset) - locking the session again shows the regular unlock panel.
|
||||
if (decryptFailed && props.noteLockSessionUnlocked) {
|
||||
return (
|
||||
<div style={styles.root} ref={containerRef}>
|
||||
<NoteLockPanel
|
||||
noteTitle={lockedNoteMetadata.title}
|
||||
hasNoteLockKey={props.hasNoteLockKey}
|
||||
dispatch={props.dispatch}
|
||||
undecryptable={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const formNoteLoaded = formNote.id === effectiveNoteId;
|
||||
const showLockPanel = formNoteLoaded
|
||||
? !props.noteLockSessionUnlocked && !formNote.hasChanged
|
||||
: !props.noteLockSessionUnlocked || loadBlocked;
|
||||
if (showLockPanel) {
|
||||
return (
|
||||
<div style={styles.root} ref={containerRef}>
|
||||
<NoteLockPanel
|
||||
noteTitle={lockedNoteMetadata.title}
|
||||
hasNoteLockKey={props.hasNoteLockKey}
|
||||
dispatch={props.dispatch}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (formNote.encryption_applied || !formNote.id || !effectiveNoteId) {
|
||||
return renderNoNotes(styles.root);
|
||||
}
|
||||
@@ -827,7 +700,7 @@ function NoteEditorContent(props: NoteEditorProps) {
|
||||
noteTitle={formNote.title}
|
||||
noteUserUpdatedTime={formNote.user_updated_time}
|
||||
onTitleChange={onTitleChange}
|
||||
disabled={isReadOnly || reloadInProgress}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
{renderSearchInfo()}
|
||||
<div style={{ display: 'flex', flex: 1, paddingLeft: theme.editorPaddingLeft, maxHeight: '100%', minHeight: '0' }}>
|
||||
@@ -852,15 +725,6 @@ interface ConnectProps {
|
||||
windowId: string;
|
||||
}
|
||||
|
||||
// Memoized because mapStateToProps runs on every dispatch and SyncInfo parses the cached JSON.
|
||||
let hasNoteLockKeyCache: { syncInfoCache: string; value: boolean } = null;
|
||||
const hasNoteLockKey = (syncInfoCache: string) => {
|
||||
if (!hasNoteLockKeyCache || hasNoteLockKeyCache.syncInfoCache !== syncInfoCache) {
|
||||
hasNoteLockKeyCache = { syncInfoCache, value: !!new SyncInfo(syncInfoCache).noteLockKey };
|
||||
}
|
||||
return hasNoteLockKeyCache.value;
|
||||
};
|
||||
|
||||
const mapStateToProps = (state: AppState, ownProps: ConnectProps) => {
|
||||
const whenClauseContext = stateToWhenClauseContext(state, { windowId: ownProps.windowId });
|
||||
const windowState = stateUtils.windowStateById(state, ownProps.windowId);
|
||||
@@ -920,9 +784,6 @@ const mapStateToProps = (state: AppState, ownProps: ConnectProps) => {
|
||||
enableInEditorRendering: state.settings['editor.inlineRendering'],
|
||||
showNoteLinkIcon: state.settings['notes.showNoteLinkIcon'],
|
||||
whiteboardForceMarkdown: windowState.whiteboardForceMarkdown ?? {},
|
||||
noteLockSessionUnlocked: state.noteLockSessionUnlocked,
|
||||
hasNoteLockKey: hasNoteLockKey(state.settings['syncInfoCache']),
|
||||
editorNoteReloadTimeRequest: windowState.windowEditorNoteReloadTimeRequest,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Dispatch } from 'redux';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import NoteLockSession from '@joplin/lib/services/noteLock/NoteLockSession';
|
||||
import Button, { ButtonLevel } from '../../Button/Button';
|
||||
import LabelledPasswordInput from '../../PasswordInput/LabelledPasswordInput';
|
||||
|
||||
interface Props {
|
||||
noteTitle: string;
|
||||
hasNoteLockKey: boolean;
|
||||
dispatch: Dispatch;
|
||||
undecryptable?: boolean;
|
||||
}
|
||||
|
||||
export default function NoteLockPanel(props: Props) {
|
||||
const [password, setPassword] = useState('');
|
||||
const [unlocking, setUnlocking] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
|
||||
const onPasswordChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPassword(event.target.value);
|
||||
}, []);
|
||||
|
||||
const unlock = useCallback(async () => {
|
||||
if (!password || unlocking) return;
|
||||
setUnlocking(true);
|
||||
try {
|
||||
await NoteLockSession.instance().unlock(password);
|
||||
// No success handling: the unlock event swaps this panel out for the note.
|
||||
} catch (error) {
|
||||
// WebCrypto reports a wrong password as a generic OperationError.
|
||||
setErrorMessage(error.name === 'OperationError' ? _('Invalid password') : error.message);
|
||||
setUnlocking(false);
|
||||
}
|
||||
}, [password, unlocking]);
|
||||
|
||||
const onSubmit = useCallback((event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
void unlock();
|
||||
}, [unlock]);
|
||||
|
||||
const onSetUpClick = useCallback(() => {
|
||||
props.dispatch({
|
||||
type: 'NAV_GO',
|
||||
routeName: 'Config',
|
||||
props: { defaultSection: 'noteLock' },
|
||||
});
|
||||
}, [props.dispatch]);
|
||||
|
||||
const renderAction = () => {
|
||||
if (props.undecryptable) {
|
||||
return <p className="message">{_('This note could not be decrypted. If it was encrypted prior to a password reset, the contents are no longer recoverable.')}</p>;
|
||||
}
|
||||
|
||||
if (!props.hasNoteLockKey) {
|
||||
return (
|
||||
<>
|
||||
<p className="message">{_('Reading this note requires the note lock password, which has not been set up on this device yet.')}</p>
|
||||
<Button level={ButtonLevel.Primary} title={_('Set up note lock')} onClick={onSetUpClick} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="form unlock-form" onSubmit={onSubmit}>
|
||||
<p className="message">{_('This note is encrypted. Enter the note lock password to unlock encrypted notes for this session.')}</p>
|
||||
<div className="field">
|
||||
<LabelledPasswordInput
|
||||
labelText={_('Note lock password')}
|
||||
value={password}
|
||||
onChange={onPasswordChange}
|
||||
/>
|
||||
</div>
|
||||
{!!errorMessage && <p className="error" role="alert">{errorMessage}</p>}
|
||||
<Button type='button' level={ButtonLevel.Primary} title={_('Unlock')} disabled={!password || unlocking} onClick={unlock} />
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="note-lock-panel">
|
||||
<i className="icon fas fa-lock" role="img" aria-label={_('Locked note')}></i>
|
||||
<h2 className="title">{props.noteTitle}</h2>
|
||||
{renderAction()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
.note-lock-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
padding: var(--joplin-main-padding);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.note-lock-panel > .icon {
|
||||
font-size: 32px;
|
||||
color: var(--joplin-color-faded);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.note-lock-panel > .title {
|
||||
margin: 0 0 8px 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.note-lock-panel > .message {
|
||||
max-width: 420px;
|
||||
color: var(--joplin-color-faded);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.note-lock-panel > .unlock-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.note-lock-panel > .unlock-form > .message {
|
||||
max-width: 420px;
|
||||
color: var(--joplin-color-faded);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.note-lock-panel > .unlock-form > .field {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.note-lock-panel > .unlock-form > .error {
|
||||
color: var(--joplin-color-error);
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { FormNote } from './types';
|
||||
|
||||
import HtmlToMd, { ParseOptions } from '@joplin/lib/HtmlToMd';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import { NoteEntity } from '@joplin/lib/services/database/types';
|
||||
import { MarkupToHtml } from '@joplin/renderer';
|
||||
|
||||
export async function htmlToMarkdown(markupLanguage: number, html: string, originalCss: string, parseOptions: ParseOptions = null): Promise<string> {
|
||||
@@ -25,7 +26,7 @@ export async function htmlToMarkdown(markupLanguage: number, html: string, origi
|
||||
return newBody;
|
||||
}
|
||||
|
||||
export async function formNoteToNote(formNote: FormNote) {
|
||||
export async function formNoteToNote(formNote: FormNote): Promise<NoteEntity> {
|
||||
return {
|
||||
id: formNote.id,
|
||||
// Should also include parent_id and deleted_time so that the reducer
|
||||
@@ -36,7 +37,5 @@ export async function formNoteToNote(formNote: FormNote) {
|
||||
is_conflict: formNote.is_conflict,
|
||||
title: formNote.title,
|
||||
body: formNote.body,
|
||||
is_locked: formNote.is_locked,
|
||||
isDecrypted: formNote.isDecrypted,
|
||||
};
|
||||
}
|
||||
@@ -16,8 +16,6 @@ import { RefObject, SetStateAction } from 'react';
|
||||
import * as React from 'react';
|
||||
import { ResourceEntity, ResourceLocalStateEntity } from '@joplin/lib/services/database/types';
|
||||
import { EditorCursorLocations } from '@joplin/lib/services/NotePositionService';
|
||||
import type { DecryptedNoteLockKey } from '@joplin/lib/services/noteLock/NoteLockKey';
|
||||
import { HighlightedWord, SearchEntry } from '@joplin/lib/reducer';
|
||||
|
||||
export interface AllAssetsOptions {
|
||||
contentMaxWidthTarget?: string;
|
||||
@@ -49,12 +47,13 @@ export interface NoteEditorProps {
|
||||
notesParentType: string;
|
||||
selectedNoteTags: TagEntity[];
|
||||
selectedNoteHash: string;
|
||||
searches: SearchEntry[];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- searches: any[] is the shape in lib's reducer; tightening requires updating the reducer first
|
||||
searches: any[];
|
||||
selectedSearchId: string;
|
||||
customCss: string;
|
||||
noteVisiblePanes: string[];
|
||||
watchedResources: Record<string, unknown>;
|
||||
highlightedWords: HighlightedWord[];
|
||||
highlightedWords: string[];
|
||||
tabMovesFocus: boolean;
|
||||
plugins: PluginStates;
|
||||
toolbarButtonInfos: ToolbarItem[];
|
||||
@@ -74,14 +73,10 @@ export interface NoteEditorProps {
|
||||
enableHtmlToMarkdownBanner: boolean;
|
||||
showNoteLinkIcon: boolean;
|
||||
whiteboardForceMarkdown: Record<string, boolean>;
|
||||
noteLockSessionUnlocked: boolean;
|
||||
hasNoteLockKey: boolean;
|
||||
editorNoteReloadTimeRequest: number;
|
||||
}
|
||||
|
||||
export interface NoteBodyEditorRef {
|
||||
content(): string|Promise<string>;
|
||||
blurEditor?(): void;
|
||||
resetScroll(): void;
|
||||
scrollTo(options: ScrollOptions): void;
|
||||
|
||||
@@ -118,7 +113,6 @@ export interface NoteBodyEditorProps {
|
||||
contentKey: string;
|
||||
contentMarkupLanguage: number;
|
||||
contentOriginalCss: string;
|
||||
editorNoteReloadTimeRequest: number;
|
||||
initialCursorLocation: EditorCursorLocations;
|
||||
onChange(event: OnChangeEvent): void;
|
||||
onWillChange(event: { changeId: number }): void;
|
||||
@@ -174,12 +168,6 @@ export interface FormNote {
|
||||
markup_language: number;
|
||||
user_updated_time: number;
|
||||
encryption_applied: number;
|
||||
is_locked: number;
|
||||
// The key captured when the locked note was decrypted; pending saves encrypt with it so they
|
||||
// can complete even if the session locks before they run.
|
||||
noteLockKey: DecryptedNoteLockKey|null;
|
||||
// Proof for gated saves that the body is plaintext.
|
||||
isDecrypted: boolean;
|
||||
deleted_time: number;
|
||||
|
||||
hasChanged: boolean;
|
||||
@@ -232,9 +220,6 @@ export function defaultFormNote(): FormNote {
|
||||
hasChanged: false,
|
||||
user_updated_time: 0,
|
||||
encryption_applied: 0,
|
||||
is_locked: 0,
|
||||
noteLockKey: null,
|
||||
isDecrypted: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { setupDatabaseAndSynchronizer, supportDir, switchClient } from '@joplin/lib/testing/test-utils';
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import useFormNote, { HookDependencies } from './useFormNote';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import Resource from '@joplin/lib/models/Resource';
|
||||
import ItemChange from '@joplin/lib/models/ItemChange';
|
||||
import { join } from 'path';
|
||||
import { formNoteToNote } from '.';
|
||||
import NoteLockNote from '@joplin/lib/services/noteLock/NoteLockNote';
|
||||
import NoteLockSession from '@joplin/lib/services/noteLock/NoteLockSession';
|
||||
|
||||
const defaultFormNoteProps: HookDependencies = {
|
||||
noteId: '',
|
||||
@@ -20,16 +16,6 @@ const defaultFormNoteProps: HookDependencies = {
|
||||
onAfterLoad: () => { },
|
||||
editorId: 'editor',
|
||||
builtInEditorVisible: false,
|
||||
noteLockSessionUnlocked: false,
|
||||
onDecryptFailedChange: () => {},
|
||||
};
|
||||
|
||||
const deferred = <T>() => {
|
||||
let resolve: (value: T)=> void;
|
||||
const promise = new Promise<T>(resolvePromise => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
return { promise, resolve: resolve! };
|
||||
};
|
||||
|
||||
describe('useFormNote', () => {
|
||||
@@ -38,99 +24,6 @@ describe('useFormNote', () => {
|
||||
await switchClient(1);
|
||||
});
|
||||
|
||||
// The session and decryption internals are covered by the lib tests; here they are mocked to
|
||||
// test only the hook's gating: ciphertext must never reach the form note.
|
||||
it('should not produce a form note for a locked note while the session is locked, and decrypt it once unlocked', async () => {
|
||||
Setting.setValue('featureFlag.noteLock', true);
|
||||
// A direct save of a new note with is_locked keeps the raw body, standing in for ciphertext.
|
||||
const testNote = await Note.save({ title: 'Locked note', body: 'ciphertext', is_locked: 1 });
|
||||
|
||||
const isUnlockedMock = jest.spyOn(NoteLockSession.instance(), 'isUnlocked').mockReturnValue(false);
|
||||
const decryptedKeyMock = jest.spyOn(NoteLockSession.instance(), 'decryptedKey').mockReturnValue({ id: 'key-id', plainText: 'key' });
|
||||
const decryptBodyMock = jest.spyOn(NoteLockNote, 'decryptBody').mockImplementation(async note => ({ ...note, body: 'secret content' }));
|
||||
const onBeforeLoad = jest.fn();
|
||||
const onDecryptFailedChange = jest.fn();
|
||||
|
||||
try {
|
||||
const lockedRender = renderHook(props => useFormNote(props), {
|
||||
initialProps: { ...defaultFormNoteProps, noteId: testNote.id, onBeforeLoad, onDecryptFailedChange },
|
||||
});
|
||||
// The blocked load produces no state change to wait for; onBeforeLoad is its last
|
||||
// await, so once it has run a single flush completes the branch.
|
||||
await waitFor(() => expect(onBeforeLoad).toHaveBeenCalled());
|
||||
await act(async () => {});
|
||||
expect(lockedRender.result.current.formNote).toMatchObject({
|
||||
id: '',
|
||||
body: '',
|
||||
noteLockKey: null,
|
||||
});
|
||||
expect(lockedRender.result.current.loadBlocked).toBe(true);
|
||||
expect(onDecryptFailedChange).toHaveBeenLastCalledWith(false);
|
||||
|
||||
isUnlockedMock.mockReturnValue(true);
|
||||
lockedRender.rerender({ ...defaultFormNoteProps, noteId: testNote.id, onBeforeLoad, onDecryptFailedChange, noteLockSessionUnlocked: true });
|
||||
await waitFor(() => {
|
||||
expect(lockedRender.result.current.formNote).toMatchObject({
|
||||
id: testNote.id,
|
||||
is_locked: 1,
|
||||
body: 'secret content',
|
||||
noteLockKey: { id: 'key-id', plainText: 'key' },
|
||||
});
|
||||
});
|
||||
expect(lockedRender.result.current.loadBlocked).toBe(false);
|
||||
lockedRender.unmount();
|
||||
|
||||
Setting.setValue('featureFlag.noteLock', false);
|
||||
isUnlockedMock.mockReturnValue(false);
|
||||
const flagOffRender = renderHook(props => useFormNote(props), {
|
||||
initialProps: { ...defaultFormNoteProps, noteId: testNote.id },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(flagOffRender.result.current.formNote.id).toBe(testNote.id);
|
||||
});
|
||||
expect(flagOffRender.result.current.formNote).toMatchObject({
|
||||
body: 'ciphertext',
|
||||
});
|
||||
flagOffRender.unmount();
|
||||
} finally {
|
||||
isUnlockedMock.mockRestore();
|
||||
decryptedKeyMock.mockRestore();
|
||||
decryptBodyMock.mockRestore();
|
||||
Setting.setValue('featureFlag.noteLock', false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should report a decryption failure instead of throwing, without producing a form note', async () => {
|
||||
Setting.setValue('featureFlag.noteLock', true);
|
||||
const testNote = await Note.save({ title: 'Locked note', body: 'ciphertext', is_locked: 1 });
|
||||
// The save's own change event would otherwise reach the hook's listener and reload the note.
|
||||
await ItemChange.waitForAllSaved();
|
||||
|
||||
const isUnlockedMock = jest.spyOn(NoteLockSession.instance(), 'isUnlocked').mockReturnValue(true);
|
||||
const decryptBodyMock = jest.spyOn(NoteLockNote, 'decryptBody').mockRejectedValue(new Error('OperationError'));
|
||||
const onDecryptFailedChange = jest.fn();
|
||||
|
||||
try {
|
||||
const render = renderHook(props => useFormNote(props), {
|
||||
initialProps: { ...defaultFormNoteProps, noteId: testNote.id, noteLockSessionUnlocked: true, onDecryptFailedChange },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(render.result.current.decryptFailed).toBe(true);
|
||||
});
|
||||
expect(render.result.current.formNote).toMatchObject({
|
||||
id: '',
|
||||
body: '',
|
||||
});
|
||||
expect(render.result.current.loadBlocked).toBe(false);
|
||||
expect(onDecryptFailedChange).toHaveBeenLastCalledWith(true);
|
||||
render.unmount();
|
||||
} finally {
|
||||
isUnlockedMock.mockRestore();
|
||||
decryptBodyMock.mockRestore();
|
||||
Setting.setValue('featureFlag.noteLock', false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should update note when decryption completes', async () => {
|
||||
const testNote = await Note.save({ title: 'Test Note!' });
|
||||
|
||||
@@ -208,7 +101,7 @@ describe('useFormNote', () => {
|
||||
is_conflict: 1,
|
||||
title: testNote.title,
|
||||
});
|
||||
}, { timeout: 15_000 });
|
||||
});
|
||||
|
||||
// Should preserve is_conflict after save.
|
||||
expect(await formNoteToNote(formNote.result.current.formNote)).toMatchObject({
|
||||
@@ -222,12 +115,10 @@ describe('useFormNote', () => {
|
||||
|
||||
it('should reload the note when it is changed outside of the editor', async () => {
|
||||
const note = await Note.save({ title: 'Test Note!', body: '...' });
|
||||
const onReloadInProgressChange = jest.fn();
|
||||
|
||||
const props = {
|
||||
...defaultFormNoteProps,
|
||||
noteId: note.id,
|
||||
onReloadInProgressChange,
|
||||
};
|
||||
|
||||
const formNote = renderHook(props => useFormNote(props), {
|
||||
@@ -246,59 +137,10 @@ describe('useFormNote', () => {
|
||||
await waitFor(() => {
|
||||
expect(formNote.result.current.formNote.title).toBe('Modified');
|
||||
});
|
||||
expect(onReloadInProgressChange).not.toHaveBeenCalled();
|
||||
|
||||
formNote.unmount();
|
||||
});
|
||||
|
||||
it('should force a reload when editorNoteReloadTimeRequest changes', async () => {
|
||||
const note = await Note.save({ title: 'Original', body: '...' });
|
||||
const onReloadInProgressChange = jest.fn();
|
||||
const props = { ...defaultFormNoteProps, noteId: note.id, editorNoteReloadTimeRequest: 0, onReloadInProgressChange };
|
||||
const formNote = renderHook(hookProps => useFormNote(hookProps), { initialProps: props });
|
||||
await waitFor(() => expect(formNote.result.current.formNote.title).toBe('Original'));
|
||||
|
||||
act(() => {
|
||||
formNote.result.current.setFormNote(previous => ({ ...previous, title: 'Unsaved local title', hasChanged: true }));
|
||||
});
|
||||
await Note.save({ id: note.id, title: 'Remote title' }, { changeId: 'test-editor' });
|
||||
formNote.rerender({ ...props, editorNoteReloadTimeRequest: 1 });
|
||||
|
||||
await waitFor(() => expect(onReloadInProgressChange).toHaveBeenCalledWith(true));
|
||||
await waitFor(() => expect(formNote.result.current.formNote.title).toBe('Remote title'));
|
||||
await waitFor(() => expect(onReloadInProgressChange).toHaveBeenLastCalledWith(false));
|
||||
formNote.unmount();
|
||||
});
|
||||
|
||||
it('should clear reloadInProgress after overlapping reload requests', async () => {
|
||||
const note = await Note.save({ title: 'Original', body: '...' });
|
||||
const onReloadInProgressChange = jest.fn();
|
||||
const props = { ...defaultFormNoteProps, noteId: note.id, editorNoteReloadTimeRequest: 0, onReloadInProgressChange };
|
||||
const formNote = renderHook(hookProps => useFormNote(hookProps), { initialProps: props });
|
||||
await waitFor(() => expect(formNote.result.current.formNote.title).toBe('Original'));
|
||||
|
||||
const firstLoad = deferred<typeof note>();
|
||||
const lastLoad = deferred<typeof note>();
|
||||
const loadMock = jest.spyOn(Note, 'load')
|
||||
.mockImplementationOnce(() => firstLoad.promise)
|
||||
.mockImplementationOnce(() => lastLoad.promise);
|
||||
|
||||
formNote.rerender({ ...props, editorNoteReloadTimeRequest: 1 });
|
||||
await waitFor(() => expect(loadMock).toHaveBeenCalledTimes(1));
|
||||
formNote.rerender({ ...props, editorNoteReloadTimeRequest: 2 });
|
||||
|
||||
firstLoad.resolve({ ...note, title: 'First reload' });
|
||||
await waitFor(() => expect(loadMock).toHaveBeenCalledTimes(2));
|
||||
expect(onReloadInProgressChange).toHaveBeenLastCalledWith(true);
|
||||
|
||||
lastLoad.resolve({ ...note, title: 'Last reload' });
|
||||
await waitFor(() => expect(formNote.result.current.formNote.title).toBe('Last reload'));
|
||||
await waitFor(() => expect(onReloadInProgressChange).toHaveBeenLastCalledWith(false));
|
||||
|
||||
loadMock.mockRestore();
|
||||
formNote.unmount();
|
||||
});
|
||||
|
||||
test('should refresh resource infos when changed outside the editor', async () => {
|
||||
let note = await Note.save({});
|
||||
note = await shim.attachFileToNote(note, join(supportDir, 'sample.txt'));
|
||||
|
||||
@@ -9,15 +9,12 @@ import attachedResources, { clearResourceCache } from '@joplin/lib/utils/attache
|
||||
import { MarkupToHtml } from '@joplin/renderer';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import ResourceFetcher from '@joplin/lib/services/ResourceFetcher';
|
||||
import { NoteEntity } from '@joplin/lib/services/database/types';
|
||||
import { focus } from '@joplin/lib/utils/focusHandler';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import eventManager, { EventName } from '@joplin/lib/eventManager';
|
||||
import DecryptionWorker from '@joplin/lib/services/DecryptionWorker';
|
||||
import useQueuedAsyncEffect from '@joplin/lib/hooks/useQueuedAsyncEffect';
|
||||
import { NoteEntity } from '@joplin/lib/services/database/types';
|
||||
import NoteLockNote from '@joplin/lib/services/noteLock/NoteLockNote';
|
||||
import NoteLockSession from '@joplin/lib/services/noteLock/NoteLockSession';
|
||||
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
|
||||
|
||||
const logger = Logger.create('useFormNote');
|
||||
|
||||
@@ -34,10 +31,6 @@ export interface HookDependencies {
|
||||
onBeforeLoad(event: OnLoadEvent): void;
|
||||
onAfterLoad(event: OnLoadEvent): void;
|
||||
builtInEditorVisible: boolean;
|
||||
noteLockSessionUnlocked: boolean;
|
||||
onDecryptFailedChange(value: boolean): void;
|
||||
onReloadInProgressChange?(value: boolean): void;
|
||||
editorNoteReloadTimeRequest?: number;
|
||||
}
|
||||
|
||||
type MapFormNoteCallback = (previousFormNote: FormNote)=> FormNote;
|
||||
@@ -73,41 +66,16 @@ function resourceInfosChanged(a: ResourceInfos, b: ResourceInfos): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
// While the session is locked, a locked note produces no form note at all - the unlock panel
|
||||
// takes the editor's place. So no placeholder body exists that a save could write over the note.
|
||||
const loadNoteForForm = async (noteId: string): Promise<{ note: NoteEntity|null; blocked: boolean; decryptFailed: boolean }> => {
|
||||
if (isNoteLockEnabled()) {
|
||||
const lockState = await Note.load(noteId, { fields: ['is_locked'] });
|
||||
if (lockState && NoteLockNote.isLocked(lockState)) {
|
||||
if (!NoteLockSession.instance().isUnlocked()) return { note: null, blocked: true, decryptFailed: false };
|
||||
try {
|
||||
return { note: await Note.load(noteId, { useNoteLock: true }), blocked: false, decryptFailed: false };
|
||||
} catch (error) {
|
||||
// Happens when the note was encrypted with a different key, e.g. before a password
|
||||
// reset. Fail closed and let the editor show the cannot-decrypt panel.
|
||||
logger.warn('Could not decrypt locked note:', noteId, error);
|
||||
return { note: null, blocked: false, decryptFailed: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
return { note: await Note.load(noteId), blocked: false, decryptFailed: false };
|
||||
};
|
||||
|
||||
type InitNoteStateCallback = (note: NoteEntity, isNew: boolean, force?: boolean)=> Promise<FormNote>;
|
||||
const useRefreshFormNoteOnChange = (formNoteRef: RefObject<FormNote>, editorId: string, noteId: string, initNoteState: InitNoteStateCallback, clearFormNote: ()=> void, builtInEditorVisible: boolean, noteLockSessionUnlocked: boolean, setDecryptFailed: (value: boolean)=> void, setLoadBlocked: (value: boolean)=> void, onReloadInProgressChange: (value: boolean)=> void, editorNoteReloadTimeRequest: number) => {
|
||||
type InitNoteStateCallback = (note: NoteEntity, isNew: boolean)=> Promise<FormNote>;
|
||||
const useRefreshFormNoteOnChange = (formNoteRef: RefObject<FormNote>, editorId: string, noteId: string, initNoteState: InitNoteStateCallback, builtInEditorVisible: boolean) => {
|
||||
// Increasing the value of this counter cancels any ongoing note refreshes and starts
|
||||
// a new refresh.
|
||||
const [formNoteRefreshRequest, setFormNoteRefreshRequest] = useState({ counter: 0, force: false });
|
||||
const previousEditorNoteReloadTimeRequestRef = useRef(editorNoteReloadTimeRequest);
|
||||
const [formNoteRefreshScheduled, setFormNoteRefreshScheduled] = useState<number>(0);
|
||||
const prevBuiltInEditorVisible = usePrevious<boolean>(builtInEditorVisible);
|
||||
// Seeded because usePrevious defaults to null, which would read as a session change on mount.
|
||||
const prevNoteLockSessionUnlocked = usePrevious<boolean>(noteLockSessionUnlocked, noteLockSessionUnlocked);
|
||||
|
||||
useQueuedAsyncEffect(async (event) => {
|
||||
if (event.cancelled) return;
|
||||
if (formNoteRefreshRequest.counter <= 0) return;
|
||||
const forceRefresh = formNoteRefreshRequest.force;
|
||||
if (formNoteRef.current.hasChanged && !forceRefresh) {
|
||||
if (formNoteRefreshScheduled <= 0) return;
|
||||
if (formNoteRef.current.hasChanged) {
|
||||
logger.info('Form note changed between scheduling a refresh and the refresh itself. Cancelling the refresh.');
|
||||
return;
|
||||
}
|
||||
@@ -115,58 +83,38 @@ const useRefreshFormNoteOnChange = (formNoteRef: RefObject<FormNote>, editorId:
|
||||
logger.info('Sync has finished and note has never been changed - reloading it');
|
||||
|
||||
const loadNote = async () => {
|
||||
setDecryptFailed(false);
|
||||
const { note: n, blocked, decryptFailed } = await loadNoteForForm(noteId);
|
||||
if (event.cancelled || (formNoteRef.current.hasChanged && !forceRefresh)) return;
|
||||
const n = await Note.load(noteId);
|
||||
if (event.cancelled || formNoteRef.current.hasChanged) return;
|
||||
|
||||
setLoadBlocked(blocked);
|
||||
setDecryptFailed(decryptFailed);
|
||||
if (blocked || decryptFailed) {
|
||||
// The session locked with this note open (or its content stopped decrypting) - drop
|
||||
// the decrypted content and let the corresponding panel take over.
|
||||
if (formNoteRef.current.id) clearFormNote();
|
||||
} else {
|
||||
// Normally should not happened because if the note has been deleted via sync
|
||||
// it would not have been loaded in the editor (due to note selection changing
|
||||
// on delete)
|
||||
if (!n) {
|
||||
logger.warn('Trying to reload note that has been deleted:', noteId);
|
||||
return;
|
||||
}
|
||||
|
||||
await initNoteState(n, false, forceRefresh);
|
||||
if (event.cancelled) return;
|
||||
// Normally should not happened because if the note has been deleted via sync
|
||||
// it would not have been loaded in the editor (due to note selection changing
|
||||
// on delete)
|
||||
if (!n) {
|
||||
logger.warn('Trying to reload note that has been deleted:', noteId);
|
||||
return;
|
||||
}
|
||||
setFormNoteRefreshRequest(oldValue => {
|
||||
|
||||
await initNoteState(n, false);
|
||||
if (event.cancelled) return;
|
||||
setFormNoteRefreshScheduled(oldValue => {
|
||||
// If a new refresh was scheduled between initNoteState
|
||||
// and now:
|
||||
if (oldValue.counter !== formNoteRefreshRequest.counter) {
|
||||
if (oldValue !== formNoteRefreshScheduled) {
|
||||
return oldValue;
|
||||
}
|
||||
// A refresh is no longer scheduled
|
||||
return { counter: 0, force: false };
|
||||
return 0;
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
await loadNote();
|
||||
} finally {
|
||||
if (forceRefresh && !event.cancelled) onReloadInProgressChange(false);
|
||||
}
|
||||
}, [formNoteRefreshRequest, noteId, editorId, initNoteState, clearFormNote, setDecryptFailed, onReloadInProgressChange]);
|
||||
await loadNote();
|
||||
}, [formNoteRefreshScheduled, noteId, editorId, initNoteState]);
|
||||
|
||||
const refreshFormNote = useCallback((force = false) => {
|
||||
const refreshFormNote = useCallback(() => {
|
||||
// Increase the counter to cancel any ongoing refresh attempts
|
||||
// and start a new one.
|
||||
if (force) onReloadInProgressChange(true);
|
||||
setFormNoteRefreshRequest(previous => ({ counter: previous.counter + 1, force: previous.force || force }));
|
||||
}, [onReloadInProgressChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editorNoteReloadTimeRequest === previousEditorNoteReloadTimeRequestRef.current) return;
|
||||
previousEditorNoteReloadTimeRequestRef.current = editorNoteReloadTimeRequest;
|
||||
refreshFormNote(true);
|
||||
}, [editorNoteReloadTimeRequest, refreshFormNote]);
|
||||
setFormNoteRefreshScheduled(count => count + 1);
|
||||
}, []);
|
||||
|
||||
// When switching from the plugin editor to the built-in editor, we refresh the note since the
|
||||
// plugin may have modified it via the data API.
|
||||
@@ -176,14 +124,6 @@ const useRefreshFormNoteOnChange = (formNoteRef: RefObject<FormNote>, editorId:
|
||||
}
|
||||
}, [builtInEditorVisible, prevBuiltInEditorVisible, refreshFormNote]);
|
||||
|
||||
// Unlocking loads the note the panel was blocking (the form is then empty), locking must drop
|
||||
// a locked note's plaintext - reload to recompute.
|
||||
useEffect(() => {
|
||||
if (isNoteLockEnabled() && prevNoteLockSessionUnlocked !== noteLockSessionUnlocked && (formNoteRef.current.is_locked || formNoteRef.current.id !== noteId)) {
|
||||
refreshFormNote();
|
||||
}
|
||||
}, [noteLockSessionUnlocked, prevNoteLockSessionUnlocked, noteId, formNoteRef, refreshFormNote]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!noteId) return ()=>{};
|
||||
@@ -213,38 +153,18 @@ const useRefreshFormNoteOnChange = (formNoteRef: RefObject<FormNote>, editorId:
|
||||
|
||||
export default function useFormNote(dependencies: HookDependencies) {
|
||||
const {
|
||||
noteId, isProvisional, titleInputRef, editorRef, onBeforeLoad, onAfterLoad, builtInEditorVisible, editorId, noteLockSessionUnlocked, onDecryptFailedChange,
|
||||
noteId, isProvisional, titleInputRef, editorRef, onBeforeLoad, onAfterLoad, builtInEditorVisible, editorId,
|
||||
} = dependencies;
|
||||
const onReloadInProgressChange = dependencies.onReloadInProgressChange ?? (() => {});
|
||||
const editorNoteReloadTimeRequest = dependencies.editorNoteReloadTimeRequest ?? 0;
|
||||
|
||||
const [formNote, setFormNote] = useState<FormNote>(defaultFormNote());
|
||||
const [isNewNote, setIsNewNote] = useState(false);
|
||||
// Keyed by note id so a failure can never carry over to another note the user switches to.
|
||||
const [decryptFailedId, setDecryptFailedId] = useState<string|null>(null);
|
||||
const setDecryptFailed = useCallback((value: boolean) => {
|
||||
setDecryptFailedId(value ? noteId : null);
|
||||
onDecryptFailedChange(value);
|
||||
}, [noteId, onDecryptFailedChange]);
|
||||
const [loadBlockedId, setLoadBlockedId] = useState<string|null>(null);
|
||||
const setLoadBlocked = useCallback((value: boolean) => setLoadBlockedId(value ? noteId : null), [noteId]);
|
||||
const previousNoteId = usePrevious(formNote.id);
|
||||
const [resourceInfos, setResourceInfos] = useState<ResourceInfos>({});
|
||||
|
||||
const formNoteRef = useRef(formNote);
|
||||
formNoteRef.current = formNote;
|
||||
|
||||
const initNoteState: InitNoteStateCallback = useCallback(async (n, isNewNote, force = false) => {
|
||||
let noteLockKey = null;
|
||||
if (isNoteLockEnabled() && NoteLockNote.isLocked(n)) {
|
||||
// The session can lock between the gated load and here (e.g. lock-on-switch) - drop
|
||||
// the load rather than show plaintext that pending saves could no longer encrypt.
|
||||
if (!NoteLockSession.instance().isUnlocked()) return null;
|
||||
// Capture the key with the plaintext so pending saves can re-encrypt even after
|
||||
// the session locks.
|
||||
noteLockKey = NoteLockSession.instance().decryptedKey();
|
||||
}
|
||||
|
||||
const initNoteState: InitNoteStateCallback = useCallback(async (n, isNewNote) => {
|
||||
let originalCss = '';
|
||||
|
||||
if (n.markup_language === MarkupToHtml.MARKUP_LANGUAGE_HTML) {
|
||||
@@ -268,9 +188,6 @@ export default function useFormNote(dependencies: HookDependencies) {
|
||||
hasChanged: false,
|
||||
user_updated_time: n.user_updated_time,
|
||||
encryption_applied: n.encryption_applied,
|
||||
is_locked: n.is_locked,
|
||||
noteLockKey,
|
||||
isDecrypted: 'isDecrypted' in n && !!n.isDecrypted,
|
||||
};
|
||||
|
||||
logger.debug('Initializing note state');
|
||||
@@ -283,7 +200,7 @@ export default function useFormNote(dependencies: HookDependencies) {
|
||||
|
||||
// If the user changes the note while resources are loading, this can lead to
|
||||
// a note being incorrectly marked as "unchanged".
|
||||
if (!isNewNote && formNoteRef.current?.hasChanged && !force) {
|
||||
if (!isNewNote && formNoteRef.current?.hasChanged) {
|
||||
logger.info('Cancelled note refresh -- form note changed while loading attached resources.');
|
||||
return null;
|
||||
}
|
||||
@@ -298,16 +215,10 @@ export default function useFormNote(dependencies: HookDependencies) {
|
||||
return newFormNote;
|
||||
}, []);
|
||||
|
||||
const clearFormNote = useCallback(() => {
|
||||
formNoteRef.current = defaultFormNote();
|
||||
setFormNote(formNoteRef.current);
|
||||
}, []);
|
||||
|
||||
useRefreshFormNoteOnChange(formNoteRef, editorId, noteId, initNoteState, clearFormNote, builtInEditorVisible, noteLockSessionUnlocked, setDecryptFailed, setLoadBlocked, onReloadInProgressChange, editorNoteReloadTimeRequest);
|
||||
useRefreshFormNoteOnChange(formNoteRef, editorId, noteId, initNoteState, builtInEditorVisible);
|
||||
|
||||
useEffect(() => {
|
||||
if (!noteId) {
|
||||
setDecryptFailed(false);
|
||||
if (formNote.id) setFormNote(defaultFormNote());
|
||||
return () => {};
|
||||
}
|
||||
@@ -333,21 +244,8 @@ export default function useFormNote(dependencies: HookDependencies) {
|
||||
}
|
||||
|
||||
async function loadNote() {
|
||||
setDecryptFailed(false);
|
||||
const { note: n, blocked, decryptFailed } = await loadNoteForForm(noteId);
|
||||
const n = await Note.load(noteId);
|
||||
if (cancelled) return;
|
||||
|
||||
setLoadBlocked(blocked);
|
||||
setDecryptFailed(decryptFailed);
|
||||
if (blocked || decryptFailed) {
|
||||
await onBeforeLoad({ formNote });
|
||||
if (cancelled) return;
|
||||
// Drop the previous form so no note content lingers behind the unlock or
|
||||
// cannot-decrypt panel. The id guard stops the resulting effect re-run from looping.
|
||||
if (formNoteRef.current.id) clearFormNote();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!n) throw new Error(`Cannot find note with ID: ${noteId}`);
|
||||
logger.debug('Loaded note:', n);
|
||||
|
||||
@@ -429,7 +327,5 @@ export default function useFormNote(dependencies: HookDependencies) {
|
||||
formNote,
|
||||
setFormNote: onSetFormNote,
|
||||
resourceInfos,
|
||||
decryptFailed: !!noteId && decryptFailedId === noteId,
|
||||
loadBlocked: !!noteId && loadBlockedId === noteId,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import { RefObject, useCallback, useRef } from 'react';
|
||||
import { RefObject, useCallback } from 'react';
|
||||
import { FormNote, NoteBodyEditorRef } from './types';
|
||||
import { formNoteToNote } from '.';
|
||||
import ExternalEditWatcher from '@joplin/lib/services/ExternalEditWatcher';
|
||||
@@ -7,44 +7,27 @@ import Note from '@joplin/lib/models/Note';
|
||||
import type { Dispatch } from 'redux';
|
||||
import eventManager, { EventName } from '@joplin/lib/eventManager';
|
||||
import type { OnSetFormNote } from './useFormNote';
|
||||
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
|
||||
|
||||
const logger = Logger.create('useScheduleSaveCallbacks');
|
||||
|
||||
interface Props {
|
||||
setFormNote: RefObject<OnSetFormNote>;
|
||||
formNote: RefObject<FormNote>;
|
||||
editorId: string;
|
||||
dispatch: Dispatch;
|
||||
editorRef: RefObject<NoteBodyEditorRef>;
|
||||
editorNoteReloadTimeRequest: number;
|
||||
}
|
||||
|
||||
const useScheduleSaveCallbacks = (props: Props) => {
|
||||
const editorNoteReloadTimeRequestRef = useRef(props.editorNoteReloadTimeRequest);
|
||||
editorNoteReloadTimeRequestRef.current = props.editorNoteReloadTimeRequest;
|
||||
|
||||
const scheduleSaveNote = useCallback((formNote: FormNote) => {
|
||||
if (!formNote.saveActionQueue) throw new Error('saveActionQueue is not set!!'); // Sanity check
|
||||
|
||||
// reg.logger().debug('Scheduling...', formNote);
|
||||
|
||||
const editorNoteReloadTimeRequest = editorNoteReloadTimeRequestRef.current;
|
||||
const makeAction = (formNote: FormNote) => {
|
||||
return async function() {
|
||||
if (editorNoteReloadTimeRequestRef.current > editorNoteReloadTimeRequest) return;
|
||||
|
||||
// The lock state may change between scheduling and execution (e.g. encryption enabled
|
||||
// from the note list menu), so the save uses the latest form state for this note.
|
||||
const latestFormNote = props.formNote.current?.id === formNote.id ? props.formNote.current : formNote;
|
||||
let note;
|
||||
if (isNoteLockEnabled()) {
|
||||
note = await formNoteToNote({ ...formNote, is_locked: latestFormNote.is_locked, isDecrypted: latestFormNote.isDecrypted });
|
||||
} else {
|
||||
note = await formNoteToNote(formNote);
|
||||
}
|
||||
logger.debug('Saving note...', isNoteLockEnabled() && note.is_locked ? note.id : note);
|
||||
const savedNote = await Note.save(note, { changeId: `editorChange-${props.editorId}`, useNoteLock: true, noteLockKey: latestFormNote.noteLockKey });
|
||||
const note = await formNoteToNote(formNote);
|
||||
logger.debug('Saving note...', note);
|
||||
const savedNote = await Note.save(note, { changeId: `editorChange-${props.editorId}` });
|
||||
|
||||
props.setFormNote.current((prev: FormNote) => {
|
||||
return { ...prev, user_updated_time: savedNote.user_updated_time, hasChanged: false };
|
||||
@@ -63,7 +46,7 @@ const useScheduleSaveCallbacks = (props: Props) => {
|
||||
|
||||
formNote.saveActionQueue.push(makeAction(formNote));
|
||||
return formNote.saveActionQueue.waitForAllDone();
|
||||
}, [props.dispatch, props.editorId, props.setFormNote, props.formNote]);
|
||||
}, [props.dispatch, props.editorId, props.setFormNote]);
|
||||
|
||||
const saveNoteIfWillChange = useCallback(async (formNote: FormNote) => {
|
||||
if (!formNote.id || !formNote.bodyWillChangeId || !props.editorRef.current) return;
|
||||
@@ -76,7 +59,6 @@ const useScheduleSaveCallbacks = (props: Props) => {
|
||||
bodyWillChangeId: 0,
|
||||
bodyChangeId: 0,
|
||||
});
|
||||
await formNote.saveActionQueue.processAllNow();
|
||||
}, [scheduleSaveNote, props.editorRef]);
|
||||
|
||||
return { saveNoteIfWillChange, scheduleSaveNote };
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { runWithFakeTimers, setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import useSearchMarkers from './useSearchMarkers';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import SearchEngine from '@joplin/lib/services/search/SearchEngine';
|
||||
import { Second } from '@joplin/utils/time';
|
||||
import { setUpSemanticSearch, tearDownSemanticSearch, updateSearchIndex } from '@joplin/lib/testing/ai/semanticSearch';
|
||||
|
||||
|
||||
describe('useSearchMarkers', () => {
|
||||
beforeEach(async () => {
|
||||
await setupDatabaseAndSynchronizer(1);
|
||||
await switchClient(1);
|
||||
await setUpSemanticSearch();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await tearDownSemanticSearch();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: 'should return markers for semantic search results',
|
||||
noteBody: 'example example example example',
|
||||
searchQuery: 'examples example example example',
|
||||
expected: [{ type: 'text', value: 'example example example example' }],
|
||||
},
|
||||
{
|
||||
label: 'should exclude Markdown from keywords',
|
||||
noteBody: '*test-test-test-test*! test',
|
||||
// cSpell:disable
|
||||
searchQuery: 'testt-test-test-test',
|
||||
// cSpell:enable
|
||||
expected: [{ type: 'text', value: 'test-test-test-test' }],
|
||||
},
|
||||
])('should return keywords for semantic search matches: $label', async ({ noteBody, searchQuery, expected }) => {
|
||||
const note = await Note.save({ title: 'test', body: noteBody });
|
||||
await updateSearchIndex();
|
||||
|
||||
await runWithFakeTimers(async () => {
|
||||
const searchResults = await SearchEngine.instance().search(searchQuery);
|
||||
const test = renderHook(() => useSearchMarkers({
|
||||
showLocalSearch: false,
|
||||
localSearchMarkerOptions: () => null,
|
||||
noteId: note.id,
|
||||
searchResults,
|
||||
searchId: '1',
|
||||
searches: [{ id: '1', query_pattern: searchQuery, query_type: 1, type_: -1, title: searchQuery }],
|
||||
highlightedWords: [],
|
||||
noteTitle: note.title,
|
||||
}));
|
||||
|
||||
await act(() => jest.advanceTimersByTimeAsync(5 * Second));
|
||||
|
||||
await waitFor(() => {
|
||||
const result = test.result.current;
|
||||
expect(result.keywords).toMatchObject(expected);
|
||||
});
|
||||
test.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
@@ -1,9 +1,4 @@
|
||||
import useQueuedAsyncEffect from '@joplin/lib/hooks/useQueuedAsyncEffect';
|
||||
import { HighlightedWord, SearchEntry } from '@joplin/lib/reducer';
|
||||
import SearchService, { SearchResult } from '@joplin/lib/services/ai/SearchService';
|
||||
import { ProcessResultsRow, SearchType } from '@joplin/lib/services/search/SearchEngine';
|
||||
import { Second } from '@joplin/utils/time';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
interface SearchMarkersOptions {
|
||||
searchTimestamp: number;
|
||||
@@ -12,9 +7,8 @@ interface SearchMarkersOptions {
|
||||
withSelection?: boolean;
|
||||
}
|
||||
|
||||
|
||||
export interface SearchMarkers {
|
||||
keywords: HighlightedWord[];
|
||||
keywords: { value: string; type?: string; accuracy?: string }[];
|
||||
options: SearchMarkersOptions;
|
||||
}
|
||||
|
||||
@@ -29,116 +23,16 @@ function defaultSearchMarkers(): SearchMarkers {
|
||||
};
|
||||
}
|
||||
|
||||
// The indexer can prepend the note title to the first chunk:
|
||||
// Remove it so that the results are exact substrings of the note body
|
||||
const removeNoteTitle = (result: SearchResult, noteTitle: string) => {
|
||||
if (result.chunkIndex !== 0) return result;
|
||||
|
||||
let chunkText = result.chunkText.trim();
|
||||
while (chunkText.startsWith(`${noteTitle}\n`)) {
|
||||
chunkText = chunkText.substring(noteTitle.length).trim();
|
||||
}
|
||||
|
||||
return {
|
||||
...result,
|
||||
chunkText,
|
||||
};
|
||||
};
|
||||
|
||||
interface UseSemanticSearchMatchesProps {
|
||||
query: string;
|
||||
noteId: string;
|
||||
noteTitle: string;
|
||||
}
|
||||
|
||||
// Returns matching substrings of the note for the given search query
|
||||
const useSemanticSearchMatches = ({ query, noteId, noteTitle }: UseSemanticSearchMatchesProps) => {
|
||||
const [matchingChunks, setMatchingChunks] = useState<string[]>([]);
|
||||
const matchingChunksRef = useRef(matchingChunks);
|
||||
matchingChunksRef.current = matchingChunks;
|
||||
|
||||
useEffect(() => {
|
||||
if (!query && matchingChunksRef.current.length !== 0) {
|
||||
setMatchingChunks([]);
|
||||
}
|
||||
}, [query]);
|
||||
|
||||
const noteTitleRef = useRef(noteTitle);
|
||||
noteTitleRef.current = noteTitle;
|
||||
|
||||
useQueuedAsyncEffect(async (event) => {
|
||||
if (!query) {
|
||||
return;
|
||||
}
|
||||
|
||||
const results = await SearchService.instance().search({
|
||||
query: { text: query }, scope: { type: 'note', noteId }, relevance: 'strict',
|
||||
});
|
||||
if (event.cancelled) return;
|
||||
|
||||
const matches = [];
|
||||
for (const result of results) {
|
||||
const bestMatch = await SearchService.instance().bestMatchInResult(
|
||||
query, removeNoteTitle(result, noteTitleRef.current),
|
||||
);
|
||||
if (event.cancelled) return;
|
||||
matches.push(bestMatch);
|
||||
}
|
||||
|
||||
setMatchingChunks(matches);
|
||||
}, [query, noteId], { interval: Second });
|
||||
|
||||
return matchingChunks;
|
||||
};
|
||||
|
||||
interface UseSearchMarkersProps {
|
||||
showLocalSearch: boolean;
|
||||
localSearchMarkerOptions: ()=> SearchMarkers;
|
||||
noteId: string;
|
||||
searchResults: ProcessResultsRow[];
|
||||
searchId: string;
|
||||
searches: SearchEntry[];
|
||||
highlightedWords: HighlightedWord[];
|
||||
noteTitle: string;
|
||||
}
|
||||
|
||||
export default function useSearchMarkers({
|
||||
showLocalSearch,
|
||||
localSearchMarkerOptions,
|
||||
noteId,
|
||||
searchResults,
|
||||
searchId,
|
||||
searches,
|
||||
highlightedWords,
|
||||
noteTitle,
|
||||
}: UseSearchMarkersProps) {
|
||||
const currentNoteSearchResult = useMemo(() => {
|
||||
return searchResults.find(result => result.id === noteId);
|
||||
}, [searchResults, noteId]);
|
||||
|
||||
const semanticSearchQuery = useMemo(() => {
|
||||
if (!currentNoteSearchResult) return null;
|
||||
if (!currentNoteSearchResult.searchType.includes(SearchType.Semantic)) return null;
|
||||
const search = searches.find(search => search.id === searchId);
|
||||
return search.query_pattern;
|
||||
}, [searches, searchId, currentNoteSearchResult]);
|
||||
|
||||
const semanticSearchMatches = useSemanticSearchMatches({
|
||||
query: semanticSearchQuery, noteId, noteTitle,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type, @typescript-eslint/no-explicit-any -- Function type matches the original signature; any[] matches the lib reducer's searches shape and the heterogeneous highlightedWords shape (string[] at the call site, keyword shapes inside)
|
||||
export default function useSearchMarkers(showLocalSearch: boolean, localSearchMarkerOptions: Function, searches: any[], selectedSearchId: string, highlightedWords: any[] = []) {
|
||||
return useMemo((): SearchMarkers => {
|
||||
if (showLocalSearch) return localSearchMarkerOptions();
|
||||
|
||||
const output = defaultSearchMarkers();
|
||||
output.keywords = highlightedWords ?? [];
|
||||
|
||||
if (semanticSearchMatches.length) {
|
||||
output.keywords = output.keywords.concat(semanticSearchMatches.map(match => (
|
||||
{ type: 'text', accuracy: 'partial', value: match, source: 'semantic' }
|
||||
)));
|
||||
}
|
||||
output.keywords = highlightedWords;
|
||||
|
||||
return output;
|
||||
}, [highlightedWords, showLocalSearch, localSearchMarkerOptions, semanticSearchMatches]);
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [highlightedWords, showLocalSearch, localSearchMarkerOptions, searches, selectedSearchId]);
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { PluginStates } from '@joplin/lib/services/plugins/reducer';
|
||||
import { Size } from '@joplin/utils/types';
|
||||
import { Dispatch } from 'redux';
|
||||
import { EventEmitter } from 'events';
|
||||
import { HighlightedWord } from '@joplin/lib/reducer';
|
||||
|
||||
export interface Props {
|
||||
themeId: number;
|
||||
@@ -28,7 +27,7 @@ export interface Props {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- searches: any[] is the shape in lib's reducer; tightening requires updating the reducer first
|
||||
searches: any[];
|
||||
selectedSearchId: string;
|
||||
highlightedWords: HighlightedWord[];
|
||||
highlightedWords: string[];
|
||||
provisionalNoteIds: string[];
|
||||
visible: boolean;
|
||||
focusedField: string;
|
||||
|
||||
@@ -7,20 +7,14 @@ import useItemElement from './utils/useItemElement';
|
||||
import { ItemEventHandlers, OnInputChange } from './utils/types';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import { NoteEntity } from '@joplin/lib/services/database/types';
|
||||
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
|
||||
import NoteLockNote from '@joplin/lib/services/noteLock/NoteLockNote';
|
||||
import NoteLockSession from '@joplin/lib/services/noteLock/NoteLockSession';
|
||||
import bridge from '../../services/bridge';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import useRenderedNote from './utils/useRenderedNote';
|
||||
import { Dispatch } from 'redux';
|
||||
import getNoteElementIdFromJoplinId from './utils/getNoteElementIdFromJoplinId';
|
||||
import { HighlightedWord } from '@joplin/lib/reducer';
|
||||
|
||||
interface NoteItemProps {
|
||||
dragIndex: number;
|
||||
flow: ItemFlow;
|
||||
highlightedWords: HighlightedWord[];
|
||||
highlightedWords: string[];
|
||||
index: number;
|
||||
isProvisional: boolean;
|
||||
itemSize: Size;
|
||||
@@ -63,15 +57,6 @@ const NoteListItem = (props: NoteItemProps, ref: LegacyRef<HTMLDivElement>) => {
|
||||
};
|
||||
|
||||
if (changeEvent.elementId === 'todo-checkbox') {
|
||||
if (isNoteLockEnabled()) {
|
||||
const lockState = await Note.load(changeEvent.noteId, { fields: ['is_locked'] });
|
||||
if (NoteLockNote.isLocked(lockState) && !NoteLockSession.instance().isUnlocked()) {
|
||||
// event.currentTarget is already cleared here, after the await - target is the same input
|
||||
event.target.checked = !changeEvent.value;
|
||||
bridge().showErrorMessageBox(_('Cannot change a locked note while the session is locked'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
await Note.save({
|
||||
id: changeEvent.noteId,
|
||||
todo_completed: changeEvent.value ? Date.now() : 0,
|
||||
|
||||
@@ -2,9 +2,8 @@ import { htmlentities } from '@joplin/utils/html';
|
||||
const Mark = require('mark.js/dist/mark.min.js');
|
||||
const markJsUtils = require('@joplin/lib/markJsUtils');
|
||||
import { replaceRegexDiacritics, pregQuote } from '@joplin/lib/string-utils';
|
||||
import { HighlightedWord } from '@joplin/lib/reducer';
|
||||
|
||||
const getNoteTitleHtml = (highlightedWords: HighlightedWord[], displayTitle: string) => {
|
||||
const getNoteTitleHtml = (highlightedWords: string[], displayTitle: string) => {
|
||||
if (highlightedWords.length) {
|
||||
const titleElement = document.createElement('span');
|
||||
titleElement.textContent = displayTitle;
|
||||
|
||||
@@ -11,7 +11,6 @@ import prepareViewProps from './prepareViewProps';
|
||||
import Tag from '@joplin/lib/models/Tag';
|
||||
import { unique } from '@joplin/lib/array';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
import { HighlightedWord } from '@joplin/lib/reducer';
|
||||
|
||||
interface RenderedNote {
|
||||
id: string;
|
||||
@@ -23,7 +22,7 @@ const hashContent = (content: unknown) => {
|
||||
return createHash('sha1').update(JSON.stringify(content)).digest('hex');
|
||||
};
|
||||
|
||||
export default (note: NoteEntity, isSelected: boolean, isWatched: boolean, isPublished: boolean, listRenderer: ListRenderer, highlightedWords: HighlightedWord[], itemIndex: number, columns: NoteListColumns) => {
|
||||
export default (note: NoteEntity, isSelected: boolean, isWatched: boolean, isPublished: boolean, listRenderer: ListRenderer, highlightedWords: string[], itemIndex: number, columns: NoteListColumns) => {
|
||||
const [renderedNote, setRenderedNote] = useState<RenderedNote>(null);
|
||||
|
||||
let dependencies = columns && columns.length ? columns.map(c => c.name) as ListRendererDependency[] : [];
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { Dispatch } from 'redux';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import DialogButtonRow, { ClickEvent } from '../DialogButtonRow';
|
||||
import Dialog from '@joplin/lib/components/Dialog';
|
||||
import DialogTitle from '../DialogTitle';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import NoteLockSession from '@joplin/lib/services/noteLock/NoteLockSession';
|
||||
import LabelledPasswordInput from '../PasswordInput/LabelledPasswordInput';
|
||||
|
||||
interface SuccessCommand {
|
||||
name: string;
|
||||
args: string[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
dispatch: Dispatch;
|
||||
successCommand?: SuccessCommand;
|
||||
}
|
||||
|
||||
export default function(props: Props) {
|
||||
const [password, setPassword] = useState('');
|
||||
const [unlocking, setUnlocking] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
props.dispatch({
|
||||
type: 'DIALOG_CLOSE',
|
||||
name: 'noteLockUnlock',
|
||||
});
|
||||
}, [props.dispatch]);
|
||||
|
||||
const onPasswordChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPassword(event.target.value);
|
||||
}, []);
|
||||
|
||||
const onButtonRowClick = useCallback(async (event: ClickEvent) => {
|
||||
if (event.buttonName === 'cancel') {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.buttonName === 'ok') {
|
||||
setUnlocking(true);
|
||||
try {
|
||||
await NoteLockSession.instance().unlock(password);
|
||||
} catch (error) {
|
||||
// WebCrypto reports a wrong password as a generic OperationError.
|
||||
setErrorMessage(error.name === 'OperationError' ? _('Invalid password') : error.message);
|
||||
setUnlocking(false);
|
||||
return;
|
||||
}
|
||||
setUnlocking(false);
|
||||
onClose();
|
||||
if (props.successCommand) {
|
||||
void CommandService.instance().execute(props.successCommand.name, ...props.successCommand.args);
|
||||
}
|
||||
}
|
||||
}, [password, onClose, props.successCommand]);
|
||||
|
||||
return (
|
||||
<Dialog onCancel={onClose} className="note-lock-unlock-dialog">
|
||||
<div className="dialog-root">
|
||||
<DialogTitle title={_('Unlock encrypted notes')}/>
|
||||
<div className="dialog-content">
|
||||
<p>{_('Enter the note lock password to unlock encrypted notes for this session.')}</p>
|
||||
<LabelledPasswordInput
|
||||
labelText={_('Note lock password')}
|
||||
value={password}
|
||||
onChange={onPasswordChange}
|
||||
/>
|
||||
{!!errorMessage && <p className="error-message" role="alert">{errorMessage}</p>}
|
||||
</div>
|
||||
<DialogButtonRow
|
||||
themeId={props.themeId}
|
||||
onClick={onButtonRowClick}
|
||||
okButtonLabel={_('Unlock')}
|
||||
okButtonDisabled={!password || unlocking}
|
||||
cancelButtonDisabled={unlocking}
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -35,7 +35,7 @@ const PasswordInput = (props: Props) => {
|
||||
value={props.value}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
<button type="button" onClick={onShowPassword} className="showpasswordbutton">
|
||||
<button onClick={onShowPassword} className="showpasswordbutton">
|
||||
<i className={icon} role='img' aria-label={title} title={title}></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
display: flex;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
padding-right: 28px;
|
||||
}
|
||||
|
||||
> .showpasswordbutton {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { stateUtils } from '@joplin/lib/reducer';
|
||||
import { getNoteCallbackUrl } from '@joplin/lib/callbackUrlUtils';
|
||||
import { clipboard } from 'electron';
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'copyNoteExternalLink',
|
||||
label: () => _('Copy external link'),
|
||||
iconName: 'fas fa-link',
|
||||
};
|
||||
|
||||
export const runtime = (): CommandRuntime => {
|
||||
return {
|
||||
execute: async (context: CommandContext, noteId: string = null) => {
|
||||
noteId = noteId || stateUtils.selectedNoteId(context.state);
|
||||
if (!noteId) return;
|
||||
clipboard.writeText(getNoteCallbackUrl(noteId));
|
||||
},
|
||||
enabledCondition: 'oneNoteSelected',
|
||||
};
|
||||
};
|
||||
@@ -1,35 +0,0 @@
|
||||
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
|
||||
import { disableNoteLock } from '@joplin/lib/services/noteLock/setNoteLockState';
|
||||
import bridge from '../../../services/bridge';
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'disableNoteEncryption',
|
||||
label: () => _('Disable encryption'),
|
||||
};
|
||||
|
||||
export const runtime = (): CommandRuntime => {
|
||||
return {
|
||||
execute: async (context: CommandContext, noteId: string = null) => {
|
||||
if (!isNoteLockEnabled()) return;
|
||||
if (noteId === null) {
|
||||
if (context.state.selectedNoteIds.length !== 1) return;
|
||||
noteId = context.state.selectedNoteIds[0];
|
||||
}
|
||||
|
||||
// Same pending-save guard as enableNoteEncryption.
|
||||
if (context.state.editorNoteStatuses[noteId] === 'saving') {
|
||||
bridge().showErrorMessageBox(_('This note is currently being saved. Please try again in a moment.'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await disableNoteLock(noteId);
|
||||
} catch (error) {
|
||||
bridge().showErrorMessageBox(error.message);
|
||||
}
|
||||
},
|
||||
enabledCondition: 'oneNoteSelected && noteIsLocked && noteLockSessionUnlocked && !noteIsReadOnly && !noteIsDeleted && !inTrash && !inConflictFolder',
|
||||
};
|
||||
};
|
||||
@@ -19,6 +19,6 @@ export const runtime = (): CommandRuntime => {
|
||||
});
|
||||
}
|
||||
},
|
||||
enabledCondition: '!noteIsReadOnlyShare',
|
||||
enabledCondition: '!noteIsReadOnly',
|
||||
};
|
||||
};
|
||||
@@ -1,70 +0,0 @@
|
||||
import { CommandRuntime, CommandDeclaration } from '@joplin/lib/services/CommandService';
|
||||
import { DesktopCommandContext } from '../../../services/commands/types';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
|
||||
import NoteLockKey from '@joplin/lib/services/noteLock/NoteLockKey';
|
||||
import NoteLockSession from '@joplin/lib/services/noteLock/NoteLockSession';
|
||||
import { enableNoteLock } from '@joplin/lib/services/noteLock/setNoteLockState';
|
||||
import ExternalEditWatcher from '@joplin/lib/services/ExternalEditWatcher';
|
||||
import bridge from '../../../services/bridge';
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'enableNoteEncryption',
|
||||
label: () => _('Enable encryption'),
|
||||
};
|
||||
|
||||
export const runtime = (): CommandRuntime => {
|
||||
return {
|
||||
execute: async (context: DesktopCommandContext, noteId: string = null) => {
|
||||
if (!isNoteLockEnabled()) return;
|
||||
if (noteId === null) {
|
||||
if (context.state.selectedNoteIds.length !== 1) return;
|
||||
noteId = context.state.selectedNoteIds[0];
|
||||
}
|
||||
|
||||
// An open editor's pending save still has the old lock state and would fail against
|
||||
// the note once its lock state changes underneath it.
|
||||
if (context.state.editorNoteStatuses[noteId] === 'saving') {
|
||||
bridge().showErrorMessageBox(_('This note is currently being saved. Please try again in a moment.'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!NoteLockKey.instance().load()) {
|
||||
const setUpNow = bridge().showConfirmMessageBox(_('Encrypting a note requires a note lock password, which has not yet been set. Set it up now?'), {
|
||||
buttons: [_('Yes'), _('No')],
|
||||
});
|
||||
if (setUpNow) {
|
||||
context.dispatch({
|
||||
type: 'NAV_GO',
|
||||
routeName: 'Config',
|
||||
props: { defaultSection: 'noteLock' },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// A note that is not yet encrypted has no unlock overlay, so the session unlock has to
|
||||
// happen through the dialog here.
|
||||
if (!NoteLockSession.instance().isUnlocked()) {
|
||||
context.dispatch({
|
||||
type: 'DIALOG_OPEN',
|
||||
name: 'noteLockUnlock',
|
||||
props: { successCommand: { name: 'enableNoteEncryption', args: [noteId] } },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// An externally edited note has a plaintext file on disk; flush and delete it before locking.
|
||||
if (context.state.watchedNoteFiles.includes(noteId)) {
|
||||
await ExternalEditWatcher.instance().stopWatching(noteId);
|
||||
}
|
||||
|
||||
try {
|
||||
await enableNoteLock(noteId);
|
||||
} catch (error) {
|
||||
bridge().showErrorMessageBox(error.message);
|
||||
}
|
||||
},
|
||||
enabledCondition: 'oneNoteSelected && !noteIsLocked && !noteIsReadOnly && !noteIsDeleted && !inTrash && !inConflictFolder',
|
||||
};
|
||||
};
|
||||
@@ -2,11 +2,10 @@
|
||||
import * as addNoteToWhiteboard from './addNoteToWhiteboard';
|
||||
import * as addProfile from './addProfile';
|
||||
import * as commandPalette from './commandPalette';
|
||||
import * as copyNoteExternalLink from './copyNoteExternalLink';
|
||||
import * as deleteFolder from './deleteFolder';
|
||||
import * as disableNoteEncryption from './disableNoteEncryption';
|
||||
import * as duplicateNote from './duplicateNote';
|
||||
import * as editAlarm from './editAlarm';
|
||||
import * as enableNoteEncryption from './enableNoteEncryption';
|
||||
import * as exportPdf from './exportPdf';
|
||||
import * as globalRedo from './globalRedo';
|
||||
import * as globalUndo from './globalUndo';
|
||||
@@ -14,7 +13,6 @@ import * as gotoAnything from './gotoAnything';
|
||||
import * as hideModalMessage from './hideModalMessage';
|
||||
import * as importFrom from './importFrom';
|
||||
import * as linkToNote from './linkToNote';
|
||||
import * as lockEncryptedNotes from './lockEncryptedNotes';
|
||||
import * as moveToFolder from './moveToFolder';
|
||||
import * as newFolder from './newFolder';
|
||||
import * as newNote from './newNote';
|
||||
@@ -61,11 +59,10 @@ const index: any[] = [
|
||||
addNoteToWhiteboard,
|
||||
addProfile,
|
||||
commandPalette,
|
||||
copyNoteExternalLink,
|
||||
deleteFolder,
|
||||
disableNoteEncryption,
|
||||
duplicateNote,
|
||||
editAlarm,
|
||||
enableNoteEncryption,
|
||||
exportPdf,
|
||||
globalRedo,
|
||||
globalUndo,
|
||||
@@ -73,7 +70,6 @@ const index: any[] = [
|
||||
hideModalMessage,
|
||||
importFrom,
|
||||
linkToNote,
|
||||
lockEncryptedNotes,
|
||||
moveToFolder,
|
||||
newFolder,
|
||||
newNote,
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { CommandRuntime, CommandDeclaration } from '@joplin/lib/services/CommandService';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
|
||||
import NoteLockSession from '@joplin/lib/services/noteLock/NoteLockSession';
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'lockEncryptedNotes',
|
||||
label: () => _('Lock encrypted notes'),
|
||||
};
|
||||
|
||||
export const runtime = (): CommandRuntime => {
|
||||
return {
|
||||
execute: async () => {
|
||||
if (!isNoteLockEnabled()) return;
|
||||
NoteLockSession.instance().lock();
|
||||
},
|
||||
enabledCondition: 'noteLockSessionUnlocked',
|
||||
};
|
||||
};
|
||||
@@ -64,6 +64,6 @@ export const runtime = (comp: WindowControl): CommandRuntime => {
|
||||
}
|
||||
}
|
||||
},
|
||||
enabledCondition: 'someNotesSelected && !noteIsReadOnlyShare',
|
||||
enabledCondition: 'someNotesSelected && !noteIsReadOnly',
|
||||
};
|
||||
};
|
||||
@@ -21,6 +21,6 @@ export const runtime = (comp: WindowControl): CommandRuntime => {
|
||||
bridge().showErrorMessageBox(error.message);
|
||||
}
|
||||
},
|
||||
enabledCondition: 'someNotesSelected && !noteLockContentUnavailable',
|
||||
enabledCondition: 'someNotesSelected',
|
||||
};
|
||||
};
|
||||
@@ -2,8 +2,6 @@ import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import Tag from '@joplin/lib/models/Tag';
|
||||
import { TagEntity } from '@joplin/lib/services/database/types';
|
||||
import hasLockedNoteWhileSessionLocked from '@joplin/lib/services/noteLock/hasLockedNoteWhileSessionLocked';
|
||||
import bridge from '../../../services/bridge';
|
||||
import { WindowControl } from '../utils/useWindowControl';
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
@@ -22,11 +20,6 @@ export const runtime = (comp: WindowControl): CommandRuntime => {
|
||||
execute: async (context: CommandContext, noteIds: string[] = null) => {
|
||||
noteIds = noteIds || context.state.selectedNoteIds;
|
||||
|
||||
if (await hasLockedNoteWhileSessionLocked(noteIds)) {
|
||||
bridge().showErrorMessageBox(_('Tags cannot be changed for locked notes while the session is locked'));
|
||||
return;
|
||||
}
|
||||
|
||||
const tags = await Tag.commonTagsByNoteIds(noteIds);
|
||||
const startTags: TagOption[] = tags
|
||||
.map((a: TagEntity) => {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import hasLockedNoteWhileSessionLocked from '@joplin/lib/services/noteLock/hasLockedNoteWhileSessionLocked';
|
||||
import bridge from '../../../services/bridge';
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'toggleNoteType',
|
||||
@@ -14,11 +12,6 @@ export const runtime = (): CommandRuntime => {
|
||||
execute: async (context: CommandContext, noteIds: string[] = null) => {
|
||||
if (noteIds === null) noteIds = context.state.selectedNoteIds;
|
||||
|
||||
if (await hasLockedNoteWhileSessionLocked(noteIds)) {
|
||||
bridge().showErrorMessageBox(_('Cannot change a locked note while the session is locked'));
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < noteIds.length; i++) {
|
||||
const note = await Note.load(noteIds[i]);
|
||||
await Note.save(Note.toggleIsTodo(note), { userSideValidation: true });
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Dispatch } from 'redux';
|
||||
|
||||
import SyncWizardDialog from '../../SyncWizard/Dialog';
|
||||
import MasterPasswordDialog from '../../MasterPasswordDialog/Dialog';
|
||||
import NoteLockUnlockDialog from '../../NoteLockUnlockDialog/Dialog';
|
||||
import EditFolderDialog from '../../EditFolderDialog/Dialog';
|
||||
import PdfViewer from '../../PdfViewer';
|
||||
|
||||
@@ -33,13 +32,6 @@ const appDialogs: Record<string, RegisteredDialog> = {
|
||||
},
|
||||
},
|
||||
|
||||
noteLockUnlock: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- See RegisteredDialog.render
|
||||
render: (props: RegisteredDialogProps, customProps: any) => {
|
||||
return <NoteLockUnlockDialog key={props.key} dispatch={props.dispatch} themeId={props.themeId} {...customProps}/>;
|
||||
},
|
||||
},
|
||||
|
||||
editFolder: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- See RegisteredDialog.render
|
||||
render: (props: RegisteredDialogProps, customProps: any) => {
|
||||
|
||||
@@ -2,6 +2,7 @@ export default function() {
|
||||
return [
|
||||
'attachFile',
|
||||
'copyDevCommand',
|
||||
'copyNoteExternalLink',
|
||||
'exportPdf',
|
||||
'focusElementNoteBody',
|
||||
'focusElementNoteViewer',
|
||||
@@ -37,9 +38,6 @@ export default function() {
|
||||
'textSelectAll',
|
||||
'textBulletedList',
|
||||
'toggleExternalEditing',
|
||||
'enableNoteEncryption',
|
||||
'disableNoteEncryption',
|
||||
'lockEncryptedNotes',
|
||||
'openNoteInNewWindow',
|
||||
'toggleLayoutMoveMode',
|
||||
'resetLayout',
|
||||
|
||||
@@ -100,7 +100,7 @@ describe('NoteListUtils', () => {
|
||||
'deleteNote',
|
||||
'separator',
|
||||
'Copy Markdown link',
|
||||
'Copy external link',
|
||||
'copyNoteExternalLink',
|
||||
'separator',
|
||||
'Export',
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import MenuUtils from '@joplin/lib/services/commands/MenuUtils';
|
||||
import InteropServiceHelper from '../../InteropServiceHelper';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { MenuItemLocation } from '@joplin/lib/services/plugins/api/types';
|
||||
import { getNoteCallbackUrl } from '@joplin/lib/callbackUrlUtils';
|
||||
import bridge from '../../services/bridge';
|
||||
import BaseModel from '@joplin/lib/BaseModel';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
@@ -14,7 +13,6 @@ import { clipboard } from 'electron';
|
||||
import { Dispatch } from 'redux';
|
||||
import { NoteEntity } from '@joplin/lib/services/database/types';
|
||||
import { MarkupLanguage } from '@joplin/renderer';
|
||||
import hasLockedNoteWhileSessionLocked from '@joplin/lib/services/noteLock/hasLockedNoteWhileSessionLocked';
|
||||
|
||||
const Menu = bridge().Menu;
|
||||
const MenuItem = bridge().MenuItem;
|
||||
@@ -72,10 +70,6 @@ export default class NoteListUtils {
|
||||
);
|
||||
} else {
|
||||
const switchNoteType = async (noteIds: string[], type: string) => {
|
||||
if (await hasLockedNoteWhileSessionLocked(noteIds)) {
|
||||
bridge().showErrorMessageBox(_('Cannot change a locked note while the session is locked'));
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < noteIds.length; i++) {
|
||||
const note = await Note.load(noteIds[i]);
|
||||
const newNote = Note.changeNoteType(note, type);
|
||||
@@ -138,12 +132,7 @@ export default class NoteListUtils {
|
||||
|
||||
if (noteIds.length === 1) {
|
||||
menu.append(
|
||||
new MenuItem({
|
||||
label: _('Copy external link'),
|
||||
click: () => {
|
||||
clipboard.writeText(getNoteCallbackUrl(noteIds[0]));
|
||||
},
|
||||
}),
|
||||
new MenuItem(menuUtils.commandToStatefulMenuItem('copyNoteExternalLink', noteIds[0])),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -33,8 +33,7 @@ export default class MainScreen {
|
||||
|
||||
public async setup() {
|
||||
await this.waitFor();
|
||||
const folder = await this.sidebar.createNewFolder('Test');
|
||||
await folder.waitFor();
|
||||
await this.sidebar.createNewFolder('Test');
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
@@ -281,34 +281,5 @@ test.describe('richTextEditor', () => {
|
||||
await expect(editorBody).toHaveText('');
|
||||
});
|
||||
|
||||
test('should save rich text changes when switching notes immediately after typing', async ({ mainWindow }) => {
|
||||
const mainScreen = await new MainScreen(mainWindow).setup();
|
||||
await mainScreen.createNewNote('Test 1');
|
||||
await mainScreen.createNewNote('Test 2');
|
||||
const test1Header = mainScreen.noteList.getNoteItemByTitle('Test 1');
|
||||
const test2Header = mainScreen.noteList.getNoteItemByTitle('Test 2');
|
||||
|
||||
const editor = mainScreen.noteEditor;
|
||||
await editor.toggleEditorsButton.click();
|
||||
await editor.richTextEditor.waitFor();
|
||||
const editorBody = editor.getRichTextEditorBody();
|
||||
|
||||
await test1Header.click();
|
||||
await expect(editor.noteTitleInput).toHaveValue('Test 1');
|
||||
await expect(editorBody).toHaveText('');
|
||||
await expect(editor.toggleEditorsButton).toBeEnabled();
|
||||
await editorBody.pressSequentially('Unsaved text');
|
||||
// This confirms that onWillChange has reached the parent without waiting for
|
||||
// TinyMCE's delayed onChange/save.
|
||||
await expect(editor.toggleEditorsButton).toBeDisabled();
|
||||
await test2Header.click();
|
||||
|
||||
await expect(editor.noteTitleInput).toHaveValue('Test 2');
|
||||
await expect(editorBody).toHaveText('');
|
||||
await test1Header.click();
|
||||
await expect(editor.noteTitleInput).toHaveValue('Test 1');
|
||||
await expect(editorBody).toHaveText('Unsaved text');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -124,10 +124,8 @@ test.describe('wcag', () => {
|
||||
await mainScreen.chatPanel.open(electronApp);
|
||||
|
||||
await mainScreen.chatPanel.sendMessage('/reply-with test');
|
||||
await mainScreen.chatPanel.waitForMessageCount(2);
|
||||
|
||||
await mainScreen.chatPanel.sendMessage(
|
||||
'/tool editor_appendToNote {"text": "test"}\n/reply-with done',
|
||||
'/tool appendToNote {"text": "test"}\n/reply-with done',
|
||||
);
|
||||
await mainScreen.chatPanel.waitForMessageCount(5);
|
||||
|
||||
|
||||
@@ -27,9 +27,7 @@ jest.mock('@electron/remote', () => {
|
||||
const { afterEachCleanUp, afterAllCleanUp } = require('@joplin/lib/testing/test-utils.js');
|
||||
const React = require('react');
|
||||
|
||||
const sqliteVec = require('sqlite-vec');
|
||||
|
||||
shimInit({ nodeSqlite: sqlite3, React, sqliteVec });
|
||||
shimInit({ nodeSqlite: sqlite3, React });
|
||||
|
||||
afterEach(async () => {
|
||||
await afterEachCleanUp();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/app-desktop",
|
||||
"version": "3.7.12",
|
||||
"version": "3.7.9",
|
||||
"description": "Joplin for Desktop",
|
||||
"main": "main.bundle.js",
|
||||
"private": true,
|
||||
@@ -28,7 +28,6 @@
|
||||
"bugs": {
|
||||
"url": "https://github.com/laurent22/joplin/issues"
|
||||
},
|
||||
"desktopName": "appimagekit-joplin",
|
||||
"build": {
|
||||
"appId": "net.cozic.joplin-desktop",
|
||||
"compression": "normal",
|
||||
@@ -51,13 +50,11 @@
|
||||
"./node_modules/sqlite-vec-*/**"
|
||||
],
|
||||
"win": {
|
||||
"signtoolOptions": {
|
||||
"sign": "./sign.js",
|
||||
"rfc3161TimeStampServer": "http://timestamp.digicert.com",
|
||||
"publisherName": [
|
||||
"Joplin"
|
||||
]
|
||||
},
|
||||
"publisherName": [
|
||||
"Joplin"
|
||||
],
|
||||
"sign": "./sign.js",
|
||||
"rfc3161TimeStampServer": "http://timestamp.digicert.com",
|
||||
"icon": "../../Assets/ImageSources/Joplin.ico",
|
||||
"target": [
|
||||
{
|
||||
@@ -131,14 +128,11 @@
|
||||
},
|
||||
"linux": {
|
||||
"icon": "../../Assets/LinuxIcons",
|
||||
"syncDesktopName": true,
|
||||
"category": "Office",
|
||||
"desktop": {
|
||||
"entry": {
|
||||
"Icon": "joplin",
|
||||
"MimeType": "x-scheme-handler/joplin;",
|
||||
"StartupWMClass": "@joplin/app-desktop"
|
||||
}
|
||||
"Icon": "joplin",
|
||||
"MimeType": "x-scheme-handler/joplin;",
|
||||
"StartupWMClass": "@joplin/app-desktop"
|
||||
},
|
||||
"target": [
|
||||
"AppImage",
|
||||
@@ -147,17 +141,14 @@
|
||||
"executableName": "joplin",
|
||||
"maintainer": "Joplin Team <no-reply@joplinapp.org>",
|
||||
"artifactName": "Joplin-${version}.${ext}"
|
||||
},
|
||||
"toolsets": {
|
||||
"appimage": "1.0.3"
|
||||
}
|
||||
},
|
||||
"homepage": "https://github.com/laurent22/joplin#readme",
|
||||
"devDependencies": {
|
||||
"7zip-bin": "5.2.0",
|
||||
"@axe-core/playwright": "4.11.3",
|
||||
"@axe-core/playwright": "4.11.2",
|
||||
"@electron/notarize": "2.5.0",
|
||||
"@electron/rebuild": "4.1.0",
|
||||
"@electron/rebuild": "3.7.2",
|
||||
"@fortawesome/fontawesome-free": "5.15.4",
|
||||
"@joeattardi/emoji-button": "4.6.4",
|
||||
"@joplin/default-plugins": "~3.7",
|
||||
@@ -166,7 +157,7 @@
|
||||
"@joplin/renderer": "~3.7",
|
||||
"@joplin/tools": "~3.7",
|
||||
"@joplin/utils": "~3.7",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@playwright/test": "1.58.2",
|
||||
"@sentry/electron": "4.24.0",
|
||||
"@testing-library/dom": "10.4.1",
|
||||
"@testing-library/react": "16.3.2",
|
||||
@@ -186,7 +177,7 @@
|
||||
"compare-versions": "6.1.1",
|
||||
"debounce": "1.2.1",
|
||||
"electron": "42.3.0",
|
||||
"electron-builder": "26.15.6",
|
||||
"electron-builder": "24.13.3",
|
||||
"electron-updater": "6.6.8",
|
||||
"electron-window-state": "5.0.3",
|
||||
"esbuild": "^0.27.0",
|
||||
@@ -236,7 +227,7 @@
|
||||
"@huggingface/transformers": "4.2.0",
|
||||
"@joplin/onenote-converter": "~3.7",
|
||||
"@xyflow/react": "12.10.2",
|
||||
"fs-extra": "11.3.5",
|
||||
"fs-extra": "11.3.4",
|
||||
"keytar": "7.9.0",
|
||||
"node-fetch": "2.6.7",
|
||||
"onnxruntime-node": "1.24.3",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
@use 'gui/ConfigScreen/style.scss' as config-screen;
|
||||
@use 'gui/EditFolderDialog/style.scss' as edit-folder-dialog;
|
||||
@use 'gui/EncryptionConfigScreen/style.scss' as encryption-config-screen;
|
||||
@use 'gui/NoteEditor/NoteLockPanel/style.scss' as note-lock-panel;
|
||||
@use 'gui/PasswordInput/style.scss' as password-input;
|
||||
@use 'gui/lib/SearchInput/style.scss' as search-input;
|
||||
@use 'gui/JoplinCloudConfigScreen.scss' as joplin-cloud-config-screen;
|
||||
|
||||
@@ -83,8 +83,8 @@ android {
|
||||
applicationId "net.cozic.joplin"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 2097815
|
||||
versionName "3.7.6"
|
||||
versionCode 2097812
|
||||
versionName "3.7.3"
|
||||
|
||||
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { RefObject, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Animated, Dimensions, Easing, GestureResponderEvent, LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, PanResponder, PanResponderGestureState, Platform, Pressable, ScrollView, StyleProp, StyleSheet, useWindowDimensions, View, ViewStyle } from 'react-native';
|
||||
import { Animated, Dimensions, Easing, GestureResponderEvent, LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, PanResponder, PanResponderGestureState, Platform, Pressable, StyleSheet, useWindowDimensions, View, ViewStyle } from 'react-native';
|
||||
import useSafeAreaPadding from '../utils/hooks/useSafeAreaPadding';
|
||||
import { themeStyle, ThemeStyle } from './global-style';
|
||||
import Modal from './Modal';
|
||||
@@ -9,33 +9,17 @@ import { AppState } from '../utils/types';
|
||||
import useReduceMotionEnabled from '../utils/hooks/useReduceMotionEnabled';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
|
||||
export enum MenuAlignment {
|
||||
Center,
|
||||
Right,
|
||||
}
|
||||
|
||||
export enum MenuType {
|
||||
Docked,
|
||||
Floating,
|
||||
}
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
menuType?: MenuType;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
contentStyle?: StyleProp<ViewStyle>;
|
||||
alignment: MenuAlignment;
|
||||
style: ViewStyle;
|
||||
children: React.ReactNode;
|
||||
visible: boolean;
|
||||
draggable: boolean;
|
||||
autoScrollToEnd?: boolean;
|
||||
onDismiss: ()=> void;
|
||||
onShow?: ()=> void;
|
||||
}
|
||||
|
||||
interface UseStylesProps {
|
||||
alignment: MenuAlignment;
|
||||
menuType: MenuType;
|
||||
theme: ThemeStyle;
|
||||
dragging: boolean;
|
||||
draggable: boolean;
|
||||
@@ -43,12 +27,11 @@ interface UseStylesProps {
|
||||
dragOffset: Animated.AnimatedInterpolation<number>;
|
||||
}
|
||||
|
||||
const useStyles = ({ theme, menuType, dragging, alignment, draggable, dragOffset, backgroundOpacity }: UseStylesProps) => {
|
||||
const useStyles = ({ theme, dragging, draggable, dragOffset, backgroundOpacity }: UseStylesProps) => {
|
||||
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
||||
const safeAreaPadding = useSafeAreaPadding();
|
||||
|
||||
const menuMarginTop = theme.margin + safeAreaPadding.paddingTop;
|
||||
const showDragHandle = draggable && menuType === MenuType.Docked;
|
||||
|
||||
return useMemo(() => {
|
||||
const isSmallWidthScreen = windowWidth < 500;
|
||||
@@ -78,7 +61,7 @@ const useStyles = ({ theme, menuType, dragging, alignment, draggable, dragOffset
|
||||
},
|
||||
menuStyle: {
|
||||
zIndex: 1,
|
||||
alignSelf: alignment === MenuAlignment.Center ? 'center' : 'flex-end',
|
||||
alignSelf: 'flex-end',
|
||||
...(isSmallWidthScreen ? {
|
||||
// Center on small screens, rather than float right.
|
||||
alignSelf: 'center',
|
||||
@@ -89,23 +72,11 @@ const useStyles = ({ theme, menuType, dragging, alignment, draggable, dragOffset
|
||||
|
||||
backgroundColor: theme.backgroundColor,
|
||||
borderRadius: 16,
|
||||
...(menuType === MenuType.Docked ? {
|
||||
borderBottomRightRadius: 0,
|
||||
borderBottomLeftRadius: 0,
|
||||
marginBottom: -spaceBelowScreenEdge,
|
||||
} : {
|
||||
marginBottom: safeAreaPadding.paddingBottom,
|
||||
}),
|
||||
maxWidth: Math.min(
|
||||
menuType === MenuType.Floating ? 250 : 400,
|
||||
windowWidth - menuGapRight - menuGapLeft,
|
||||
),
|
||||
|
||||
shadowRadius: 4,
|
||||
shadowColor: theme.color,
|
||||
shadowOpacity: 0.15,
|
||||
elevation: 2,
|
||||
borderBottomRightRadius: 0,
|
||||
borderBottomLeftRadius: 0,
|
||||
maxWidth: Math.min(400, windowWidth - menuGapRight - menuGapLeft),
|
||||
|
||||
marginBottom: -spaceBelowScreenEdge,
|
||||
|
||||
userSelect: dragging ? 'none' : 'auto',
|
||||
transform: [
|
||||
@@ -127,17 +98,12 @@ const useStyles = ({ theme, menuType, dragging, alignment, draggable, dragOffset
|
||||
flexShrink: 1,
|
||||
flexGrow: 1,
|
||||
|
||||
padding: 20,
|
||||
marginBottom: spaceBelowScreenEdge,
|
||||
|
||||
...(menuType === MenuType.Docked ? {
|
||||
paddingBottom: 14 + safeAreaPadding.paddingBottom,
|
||||
// The drag handle should be at the very top of the menu
|
||||
paddingTop: showDragHandle ? 0 : undefined,
|
||||
marginBottom: spaceBelowScreenEdge,
|
||||
} : {
|
||||
paddingTop: theme.marginSmall,
|
||||
paddingBottom: theme.marginSmall,
|
||||
}),
|
||||
// The drag handle should be at the very top of the menu
|
||||
paddingTop: draggable ? 0 : undefined,
|
||||
paddingBottom: 14 + safeAreaPadding.paddingBottom,
|
||||
padding: 20,
|
||||
},
|
||||
modalBackground: {
|
||||
paddingLeft: 0,
|
||||
@@ -154,7 +120,7 @@ const useStyles = ({ theme, menuType, dragging, alignment, draggable, dragOffset
|
||||
},
|
||||
|
||||
dragHandleContainer: {
|
||||
display: showDragHandle ? 'flex' : 'none',
|
||||
display: draggable ? 'flex' : 'none',
|
||||
width: '100%',
|
||||
height: theme.margin,
|
||||
cursor: 'auto',
|
||||
@@ -182,7 +148,7 @@ const useStyles = ({ theme, menuType, dragging, alignment, draggable, dragOffset
|
||||
zIndex: 2,
|
||||
},
|
||||
});
|
||||
}, [theme, menuType, safeAreaPadding, showDragHandle, windowWidth, dragging, alignment, dragOffset, windowHeight, backgroundOpacity, menuMarginTop]);
|
||||
}, [theme, safeAreaPadding, windowWidth, dragging, draggable, dragOffset, windowHeight, backgroundOpacity, menuMarginTop]);
|
||||
};
|
||||
|
||||
interface UsePanResponderProps {
|
||||
@@ -235,13 +201,6 @@ const usePanResponder = ({
|
||||
[onStartEvent]: (event: GestureResponderEvent) => {
|
||||
return isInDragHandle(event.nativeEvent.pageY);
|
||||
},
|
||||
|
||||
// On Android, we need to set the pan responder immediately to allow dragging the menu on non-Pressable elements
|
||||
...(Platform.OS === 'android' ? {
|
||||
onStartShouldSetPanResponder: () => isScrolledToTopRef.current,
|
||||
onShouldBlockNativeResponder: event => isInDragHandle(event.nativeEvent.pageY),
|
||||
} : {}),
|
||||
|
||||
[onMoveEvent]: (_event: GestureResponderEvent, gestureState: PanResponderGestureState) => {
|
||||
if (!isScrolledToTopRef.current) {
|
||||
return false;
|
||||
@@ -277,105 +236,52 @@ const usePanResponder = ({
|
||||
|
||||
interface UseSyncVisibleProps {
|
||||
visible: boolean;
|
||||
slideAnimation: boolean;
|
||||
menuDragOffset: Animated.Value;
|
||||
dragToOffset: (offset: number)=> Promise<void>;
|
||||
onDismiss: ()=> void;
|
||||
containerRef: RefObject<View|null>;
|
||||
scrollViewRef: RefObject<ScrollView|null>;
|
||||
autoScrollToEnd: boolean;
|
||||
}
|
||||
|
||||
const useUpdateOnVisibilityChange = (props: UseSyncVisibleProps) => {
|
||||
const propsRef = useRef(props);
|
||||
propsRef.current = props;
|
||||
|
||||
const screenSize = useWindowDimensions();
|
||||
const screenSizeRef = useRef(screenSize);
|
||||
screenSizeRef.current = screenSize;
|
||||
|
||||
const [animating, setAnimating] = useState(false);
|
||||
const currentAnimation = useRef<Animated.CompositeAnimation|null>(null);
|
||||
const dragToOffset = useCallback(async (offset: number, animate: boolean) => {
|
||||
currentAnimation.current?.stop();
|
||||
|
||||
const baseAnimationProps = {
|
||||
toValue: offset,
|
||||
easing: Easing.elastic(0.5),
|
||||
duration: animate ? 250 : 0,
|
||||
useNativeDriver: true,
|
||||
};
|
||||
const animation = Animated.timing(propsRef.current.menuDragOffset, baseAnimationProps);
|
||||
currentAnimation.current = animation;
|
||||
|
||||
setAnimating(true);
|
||||
return new Promise<void>(resolve => {
|
||||
animation.start(() => {
|
||||
const interrupted = currentAnimation.current !== animation;
|
||||
if (!interrupted) {
|
||||
setAnimating(false);
|
||||
currentAnimation.current = null;
|
||||
const slideMenuOut = useCallback(() => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
propsRef.current.containerRef.current.measure(async (_x, _y, _width, height) => {
|
||||
try {
|
||||
await propsRef.current.dragToOffset(height);
|
||||
resolve();
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
const slideMenuOut = useCallback(async () => {
|
||||
const getMenuDismissedOffset = () => new Promise<number>((resolve) => {
|
||||
const container = propsRef.current.containerRef.current;
|
||||
if (!container) {
|
||||
// If the container isn't mounted use the screen height as an offset for fully-dismissed:
|
||||
resolve(screenSizeRef.current.height);
|
||||
return;
|
||||
}
|
||||
|
||||
container.measure(async (_x, _y, _width, height, _pageX, pageY) => {
|
||||
const menuBottom = screenSizeRef.current.height - (pageY + height);
|
||||
resolve(height + menuBottom);
|
||||
});
|
||||
});
|
||||
return await dragToOffset(await getMenuDismissedOffset(), propsRef.current.slideAnimation);
|
||||
}, [dragToOffset]);
|
||||
|
||||
useEffect(() => {
|
||||
const slideMenuIn = () => dragToOffset(0, propsRef.current.slideAnimation);
|
||||
const slideMenuIn = () => propsRef.current.dragToOffset(0);
|
||||
|
||||
if (props.visible) {
|
||||
if (propsRef.current.autoScrollToEnd) {
|
||||
props.scrollViewRef.current?.scrollToEnd({ animated: false });
|
||||
}
|
||||
|
||||
void slideMenuIn();
|
||||
}
|
||||
}, [props.visible, props.scrollViewRef, dragToOffset]);
|
||||
}, [props.visible]);
|
||||
|
||||
const isDismissingRef = useRef(false);
|
||||
|
||||
const resetDrag = useCallback(() => {
|
||||
void dragToOffset(0, true);
|
||||
}, [dragToOffset]);
|
||||
|
||||
const onHide = useCallback(async () => {
|
||||
return useCallback(async () => {
|
||||
// Avoid duplicate dismiss animations
|
||||
if (isDismissingRef.current) return;
|
||||
try {
|
||||
isDismissingRef.current = true;
|
||||
if (propsRef.current.slideAnimation) {
|
||||
await slideMenuOut();
|
||||
}
|
||||
|
||||
await slideMenuOut();
|
||||
propsRef.current.onDismiss();
|
||||
} finally {
|
||||
isDismissingRef.current = false;
|
||||
}
|
||||
}, [slideMenuOut]);
|
||||
|
||||
return { onHide, resetDrag, animating };
|
||||
};
|
||||
|
||||
const BottomDrawer: React.FC<Props> = props => {
|
||||
const menuType = props.menuType ?? MenuType.Docked;
|
||||
const theme = themeStyle(props.themeId);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
|
||||
@@ -399,30 +305,50 @@ const BottomDrawer: React.FC<Props> = props => {
|
||||
);
|
||||
}, [menuHeight, menuDragOffset]);
|
||||
|
||||
const [animating, setAnimating] = useState(false);
|
||||
const menuYOffset = useMemo(() => menuDragOffset, [menuDragOffset]);
|
||||
const styles = useStyles({
|
||||
theme, menuType: menuType, dragging, alignment: props.alignment, draggable: props.draggable, dragOffset: menuYOffset, backgroundOpacity,
|
||||
theme, dragging, draggable: props.draggable, dragOffset: menuYOffset, backgroundOpacity,
|
||||
});
|
||||
|
||||
const reduceMotion = useReduceMotionEnabled();
|
||||
const slideAnimationEnabled = !reduceMotion && menuType === MenuType.Docked;
|
||||
const nativeFadeAnimation = !slideAnimationEnabled;
|
||||
const slideAnimationEnabledRef = useRef(false);
|
||||
slideAnimationEnabledRef.current = slideAnimationEnabled;
|
||||
const reduceMotionEnabled = useReduceMotionEnabled();
|
||||
const reduceMotionEnabledRef = useRef(false);
|
||||
reduceMotionEnabledRef.current = reduceMotionEnabled;
|
||||
|
||||
const dragToOffset = useCallback(async (offset: number) => {
|
||||
const baseAnimationProps = {
|
||||
toValue: offset,
|
||||
easing: Easing.elastic(0.5),
|
||||
duration: reduceMotionEnabledRef.current ? 0 : 250,
|
||||
useNativeDriver: true,
|
||||
};
|
||||
const animation = Animated.timing(menuDragOffset, baseAnimationProps);
|
||||
|
||||
setAnimating(true);
|
||||
return new Promise<void>(resolve => {
|
||||
animation.start(() => {
|
||||
setAnimating(false);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}, [menuDragOffset]);
|
||||
|
||||
const clearDragOffset = useCallback(() => {
|
||||
void dragToOffset(0);
|
||||
}, [dragToOffset]);
|
||||
|
||||
const containerRef = useRef<View|null>(null);
|
||||
const scrollViewRef = useRef<ScrollView|null>(null);
|
||||
const { onHide, resetDrag, animating } = useUpdateOnVisibilityChange({
|
||||
visible: props.visible, slideAnimation: slideAnimationEnabled, menuDragOffset, containerRef, onDismiss: props.onDismiss, scrollViewRef, autoScrollToEnd: props.autoScrollToEnd,
|
||||
const onHide = useUpdateOnVisibilityChange({
|
||||
visible: props.visible, dragToOffset, containerRef, onDismiss: props.onDismiss,
|
||||
});
|
||||
|
||||
const onDragEnd = useCallback((_dx: number, dy: number) => {
|
||||
if (dy > 50) {
|
||||
void onHide();
|
||||
} else {
|
||||
resetDrag();
|
||||
clearDragOffset();
|
||||
}
|
||||
}, [resetDrag, onHide]);
|
||||
}, [clearDragOffset, onHide]);
|
||||
|
||||
const dragHandleRef = useRef<View|null>(null);
|
||||
const { panResponder, onScroll: onPanResponderScroll } = usePanResponder({
|
||||
@@ -434,20 +360,21 @@ const BottomDrawer: React.FC<Props> = props => {
|
||||
dragHandleRef,
|
||||
});
|
||||
|
||||
const onScrollDragEnd = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
const onContainerScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
const offsetY = event.nativeEvent.contentOffset.y;
|
||||
// Use a smaller tolerance for smaller menus to ensure that they're still dismissible
|
||||
const overscrollTolerance = Math.min(80, menuHeightRef.current / 4);
|
||||
|
||||
// On platforms that support overscroll (iOS), support menu dismissal through the
|
||||
// native scrollview's overscroll behavior:
|
||||
// On iOS, support menu dismissal through the native scrollview's overscroll behavior:
|
||||
if (offsetY < -overscrollTolerance) {
|
||||
// Start the animation at the current scroll position, to avoid a jump when starting
|
||||
// the animation:
|
||||
menuDragOffset.setValue(-offsetY);
|
||||
void onHide();
|
||||
} else {
|
||||
onPanResponderScroll(event);
|
||||
}
|
||||
}, [onHide, menuDragOffset]);
|
||||
}, [onHide, onPanResponderScroll, menuDragOffset]);
|
||||
|
||||
return <Modal
|
||||
visible={props.visible}
|
||||
@@ -462,24 +389,22 @@ const BottomDrawer: React.FC<Props> = props => {
|
||||
{view}
|
||||
</>;
|
||||
}}
|
||||
containerStyle={[styles.menuStyle, props.style]}
|
||||
animationType={nativeFadeAnimation ? 'fade' : 'none'}
|
||||
containerStyle={styles.menuStyle}
|
||||
animationType={reduceMotionEnabled ? 'fade' : 'none'}
|
||||
scrollOverflow={{
|
||||
onScroll: onPanResponderScroll,
|
||||
onScrollEndDrag: onScrollDragEnd,
|
||||
onScroll: onContainerScroll,
|
||||
|
||||
// Throttling scroll events avoids a warning on web
|
||||
scrollEventThrottle: 30,
|
||||
|
||||
// Disable scrollbars during in/out animations on web to avoid layout shift
|
||||
scrollEnabled: !animating,
|
||||
ref: scrollViewRef,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
{...panResponder.panHandlers}
|
||||
onLayout={onContainerLayout}
|
||||
style={[styles.contentContainer, props.contentStyle]}
|
||||
style={[styles.contentContainer, props.style]}
|
||||
ref={containerRef}
|
||||
>
|
||||
{dragging && <View style={styles.dragOverlay} />}
|
||||
@@ -494,12 +419,11 @@ const BottomDrawer: React.FC<Props> = props => {
|
||||
</Modal>;
|
||||
};
|
||||
|
||||
const BottomDrawerComponent: React.FC<Omit<Props, 'themeId'>> = connect((state: AppState) => {
|
||||
export default connect((state: AppState) => {
|
||||
return {
|
||||
themeId: state.settings.theme,
|
||||
};
|
||||
})(BottomDrawer);
|
||||
export default BottomDrawerComponent;
|
||||
|
||||
|
||||
interface DragHandleProps {
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { StyleSheet, View, useWindowDimensions, TextStyle, StyleProp, ViewStyle } from 'react-native';
|
||||
import { themeStyle } from './global-style';
|
||||
import BottomDrawer, { MenuAlignment, MenuType } from './BottomDrawer';
|
||||
import { TouchableRipple, Text } from 'react-native-paper';
|
||||
import Icon from './Icon';
|
||||
import focusView from '../utils/focusView';
|
||||
import debounce from '../utils/debounce';
|
||||
|
||||
interface MenuOptionDivider {
|
||||
isDivider: true;
|
||||
}
|
||||
|
||||
export enum MenuOptionStyle {
|
||||
Normal = 'normal',
|
||||
Destructive = 'destructive',
|
||||
}
|
||||
|
||||
export interface MenuOptionButton {
|
||||
key?: string;
|
||||
isDivider?: false|undefined;
|
||||
disabled?: boolean;
|
||||
style?: MenuOptionStyle;
|
||||
onPress: ()=> void;
|
||||
icon?: string;
|
||||
title: string;
|
||||
|
||||
accessibilityHint?: string;
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
|
||||
export type MenuOption = MenuOptionDivider|MenuOptionButton;
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
visible: boolean;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
menuType?: MenuType;
|
||||
alignment: MenuAlignment;
|
||||
autoScrollToEnd?: boolean;
|
||||
title?: string;
|
||||
onDismiss: ()=> void;
|
||||
options: MenuOption[];
|
||||
}
|
||||
|
||||
const useStyles = (themeId: number) => {
|
||||
const windowWidth = useWindowDimensions().width;
|
||||
return useMemo(() => {
|
||||
const theme = themeStyle(themeId);
|
||||
const dividerMargin = theme.marginSmall;
|
||||
|
||||
return StyleSheet.create({
|
||||
title: {
|
||||
paddingLeft: theme.marginLeft,
|
||||
paddingRight: theme.marginRight,
|
||||
// Match the space from button text to a neighbouring divider:
|
||||
paddingVertical: theme.marginMedium + dividerMargin,
|
||||
marginBottom: theme.marginSmall,
|
||||
|
||||
lineHeight: theme.fontSizeLarger,
|
||||
fontSize: theme.fontSizeLarger,
|
||||
|
||||
borderColor: theme.dividerColor,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
menu: {
|
||||
paddingHorizontal: 0,
|
||||
width: 350,
|
||||
maxWidth: windowWidth,
|
||||
},
|
||||
menuContent: { flexDirection: 'column', width: '100%' },
|
||||
menuItem: {
|
||||
alignItems: 'flex-start',
|
||||
padding: theme.marginMedium,
|
||||
paddingLeft: theme.marginLeft,
|
||||
paddingRight: theme.marginRight,
|
||||
},
|
||||
menuItemContent: {
|
||||
flexDirection: 'row',
|
||||
gap: theme.marginSmall,
|
||||
alignItems: 'center',
|
||||
},
|
||||
menuItemText: {
|
||||
color: theme.color,
|
||||
fontSize: theme.fontSize,
|
||||
},
|
||||
menuItemTextDisabled: {
|
||||
opacity: theme.disabledOpacity,
|
||||
},
|
||||
menuItemTextDestructive: {
|
||||
color: theme.destructiveColor,
|
||||
},
|
||||
divider: {
|
||||
height: 0,
|
||||
borderBottomWidth: 1,
|
||||
borderColor: theme.dividerColor,
|
||||
marginVertical: dividerMargin,
|
||||
marginHorizontal: theme.margin,
|
||||
},
|
||||
});
|
||||
}, [themeId, windowWidth]);
|
||||
};
|
||||
|
||||
const useFocusView = (visible: boolean) => {
|
||||
const visibleRef = useRef(visible);
|
||||
visibleRef.current = visible;
|
||||
|
||||
const autoFocusView = useMemo(() => {
|
||||
// Debounce: Auto-focus seems to need to occur after a delay
|
||||
return debounce((view: View|null) => {
|
||||
if (!view || !visibleRef.current) return;
|
||||
focusView('BottomDrawerMenu', view);
|
||||
}, 100);
|
||||
}, []);
|
||||
|
||||
return autoFocusView;
|
||||
};
|
||||
|
||||
const BottomDrawerMenu: React.FC<Props> = props => {
|
||||
const styles = useStyles(props.themeId);
|
||||
|
||||
const autoFocusView = useFocusView(props.visible);
|
||||
|
||||
const menuOptionComponents: React.ReactNode[] = [];
|
||||
|
||||
let keyCounter = 0;
|
||||
for (const option of props.options) {
|
||||
if (option.isDivider === true) {
|
||||
menuOptionComponents.push(
|
||||
<View key={`menuOption_divider_${keyCounter++}`} style={styles.divider} />,
|
||||
);
|
||||
} else {
|
||||
const key = `menuOption_${option.key ?? keyCounter++}`;
|
||||
const textStyles: TextStyle[] = [styles.menuItemText];
|
||||
if (option.disabled) {
|
||||
textStyles.push(styles.menuItemTextDisabled);
|
||||
}
|
||||
if (option.style === MenuOptionStyle.Destructive) {
|
||||
textStyles.push(styles.menuItemTextDestructive);
|
||||
}
|
||||
|
||||
menuOptionComponents.push(
|
||||
<TouchableRipple
|
||||
borderless={true}
|
||||
role='button'
|
||||
style={styles.menuItem}
|
||||
onPress={() => {
|
||||
option.onPress();
|
||||
props.onDismiss();
|
||||
}}
|
||||
accessibilityHint={option.accessibilityHint}
|
||||
ref={(option.autoFocus && props.visible) ? autoFocusView : undefined}
|
||||
key={key}
|
||||
disabled={!!option.disabled}
|
||||
>
|
||||
<View style={styles.menuItemContent}>
|
||||
{option.icon && <Icon name={option.icon} style={textStyles} accessibilityLabel={null} />}
|
||||
<Text
|
||||
style={textStyles}
|
||||
disabled={option.disabled}
|
||||
>{option.title}</Text>
|
||||
</View>
|
||||
</TouchableRipple>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const titleComponent = props.title ? <>
|
||||
<Text
|
||||
role='heading'
|
||||
variant='titleMedium'
|
||||
style={styles.title}
|
||||
>{props.title}</Text>
|
||||
</> : null;
|
||||
return <BottomDrawer
|
||||
visible={props.visible}
|
||||
onDismiss={props.onDismiss}
|
||||
alignment={props.alignment}
|
||||
autoScrollToEnd={props.autoScrollToEnd}
|
||||
draggable={true}
|
||||
menuType={props.menuType}
|
||||
contentStyle={styles.menu}
|
||||
style={props.style}
|
||||
>
|
||||
<View
|
||||
style={styles.menuContent}
|
||||
testID={`menu-content-${props.visible ? 'open' : 'closed'}`}
|
||||
>
|
||||
{titleComponent}
|
||||
{menuOptionComponents}
|
||||
</View>
|
||||
</BottomDrawer>;
|
||||
};
|
||||
|
||||
export default BottomDrawerMenu;
|
||||
@@ -39,10 +39,6 @@ const useStyles = (themeId: number, isMenu: boolean) => {
|
||||
dialogLabel: isMenu ? {
|
||||
textAlign: 'center',
|
||||
} : {},
|
||||
dialogTitle: isMenu ? {} : {
|
||||
fontWeight: 'bold',
|
||||
marginBottom: theme.margin,
|
||||
},
|
||||
});
|
||||
}, [isMenu, themeId]);
|
||||
};
|
||||
@@ -62,7 +58,7 @@ const PromptDialog: React.FC<Props> = ({ dialog, containerStyle, themeId }) => {
|
||||
const titleComponent = <Text
|
||||
variant='titleMedium'
|
||||
accessibilityRole='header'
|
||||
style={[styles.dialogLabel, styles.dialogTitle]}
|
||||
style={styles.dialogLabel}
|
||||
>{dialog.title}</Text>;
|
||||
|
||||
return (
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import * as React from 'react';
|
||||
import { RefObject, useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { Animated, GestureResponderEvent, Modal, Platform, Pressable, ScrollView, ScrollViewProps, StyleProp, StyleSheet, View, ViewStyle } from 'react-native';
|
||||
import { Animated, GestureResponderEvent, Modal, Platform, Pressable, ScrollView, ScrollViewProps, StyleSheet, View, ViewStyle } from 'react-native';
|
||||
import FocusControl from './accessibility/FocusControl/FocusControl';
|
||||
import { msleep, Second } from '@joplin/utils/time';
|
||||
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
|
||||
@@ -15,8 +15,6 @@ import KeyboardAvoidingView from './KeyboardAvoidingView';
|
||||
import Dialog from '@joplin/lib/components/Dialog';
|
||||
import useKeyboardState from '../utils/hooks/useKeyboardState';
|
||||
|
||||
type AllScrollViewProps = ScrollViewProps & { ref?: React.Ref<ScrollView> };
|
||||
|
||||
type OnClose = ()=> void;
|
||||
type OnShow = ()=> void;
|
||||
export interface ModalElementProps {
|
||||
@@ -32,7 +30,7 @@ export interface ModalElementProps {
|
||||
statusBarTranslucent?: boolean;
|
||||
|
||||
children: React.ReactNode;
|
||||
containerStyle?: StyleProp<ViewStyle>;
|
||||
containerStyle?: ViewStyle;
|
||||
backgroundColor?: string;
|
||||
modalBackgroundStyle?: ViewStyle;
|
||||
// Extra styles for the accessibility tools dismiss button. For example,
|
||||
@@ -43,7 +41,7 @@ export interface ModalElementProps {
|
||||
// If scrollOverflow is provided, the modal is wrapped in a vertical
|
||||
// ScrollView. This allows the user to scroll parts of dialogs into
|
||||
// view that would otherwise be clipped by the screen edge.
|
||||
scrollOverflow?: boolean|AllScrollViewProps;
|
||||
scrollOverflow?: boolean|ScrollViewProps;
|
||||
}
|
||||
|
||||
const useStyles = (hasScrollView: boolean, backgroundColor: string|undefined) => {
|
||||
|
||||
@@ -60,7 +60,10 @@ const useStyles = (themeId: number) => {
|
||||
const ModalDialog: React.FC<Props> = props => {
|
||||
const styles = useStyles(props.themeId);
|
||||
const theme = themeStyle(props.themeId);
|
||||
const containerStyle = !props.modalProps.containerStyle ? styles.container : [styles.container, props.modalProps.containerStyle].flat();
|
||||
const containerStyle = !props.modalProps.containerStyle ? styles.container : {
|
||||
...styles.container,
|
||||
...props.modalProps.containerStyle,
|
||||
};
|
||||
const modalProps = {
|
||||
...props.modalProps,
|
||||
containerStyle,
|
||||
|
||||
@@ -169,10 +169,6 @@ const MarkdownEditor: React.FC<EditorProps> = props => {
|
||||
|
||||
editorWebViewSetup.webViewEventHandlers.onMessage(event);
|
||||
}, [editorWebViewSetup]);
|
||||
const onLoadEnd = useCallback(() => {
|
||||
editorWebViewSetup.webViewEventHandlers.onLoadEnd();
|
||||
props.onLoadEnd?.();
|
||||
}, [editorWebViewSetup, props.onLoadEnd]);
|
||||
|
||||
const onError = useCallback((event: NativeSyntheticEvent<WebViewErrorEvent>) => {
|
||||
logger.error(`Load error: Code ${event.nativeEvent.code}: ${event.nativeEvent.description}`);
|
||||
@@ -189,7 +185,7 @@ const MarkdownEditor: React.FC<EditorProps> = props => {
|
||||
css={css}
|
||||
hasPluginScripts={editorWebViewSetup.hasPlugins}
|
||||
onMessage={onMessage}
|
||||
onLoadEnd={onLoadEnd}
|
||||
onLoadEnd={editorWebViewSetup.webViewEventHandlers.onLoadEnd}
|
||||
onError={onError}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -75,7 +75,6 @@ interface Props {
|
||||
onUndoRedoDepthChange: OnUndoRedoDepthChange;
|
||||
onAttach: OnAttach;
|
||||
refreshKey?: number;
|
||||
onLoadEnd?: ()=> void;
|
||||
}
|
||||
|
||||
function fontFamilyFromSettings() {
|
||||
@@ -494,7 +493,6 @@ function NoteEditor(props: Props) {
|
||||
noteResources={props.noteResources}
|
||||
plugins={props.plugins}
|
||||
onAttach={onAttach}
|
||||
onLoadEnd={props.onLoadEnd}
|
||||
/>
|
||||
</View>
|
||||
|
||||
|
||||
@@ -144,10 +144,6 @@ const RichTextEditor: React.FC<EditorProps> = props => {
|
||||
|
||||
editorWebViewSetup.webViewEventHandlers.onMessage(event);
|
||||
}, [editorWebViewSetup]);
|
||||
const onLoadEnd = useCallback(() => {
|
||||
editorWebViewSetup.webViewEventHandlers.onLoadEnd();
|
||||
props.onLoadEnd?.();
|
||||
}, [editorWebViewSetup, props.onLoadEnd]);
|
||||
|
||||
const onError = useCallback((event: NativeSyntheticEvent<WebViewErrorEvent>) => {
|
||||
logger.error(`Load error: Code ${event.nativeEvent.code}: ${event.nativeEvent.description}`);
|
||||
@@ -164,7 +160,7 @@ const RichTextEditor: React.FC<EditorProps> = props => {
|
||||
css={css}
|
||||
hasPluginScripts={false}
|
||||
onMessage={onMessage}
|
||||
onLoadEnd={onLoadEnd}
|
||||
onLoadEnd={editorWebViewSetup.webViewEventHandlers.onLoadEnd}
|
||||
onError={onError}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -68,7 +68,6 @@ export interface EditorProps {
|
||||
plugins: PluginStates;
|
||||
|
||||
onAttach: OnAttachCallback;
|
||||
onLoadEnd?: ()=> void;
|
||||
onEditorEvent: (event: EditorEvent)=> void;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import { memo, useCallback, useMemo, useRef } from 'react';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { Text, StyleSheet, TextStyle, View, ViewStyle, AccessibilityInfo } from 'react-native';
|
||||
import { Text, StyleSheet, TextStyle, ViewStyle, AccessibilityInfo } from 'react-native';
|
||||
import Checkbox from './Checkbox';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import time from '@joplin/lib/time';
|
||||
@@ -13,9 +13,6 @@ import { NoteEntity } from '@joplin/lib/services/database/types';
|
||||
import useOnLongPressProps from '../utils/hooks/useOnLongPressProps';
|
||||
import MultiTouchableOpacity from './buttons/MultiTouchableOpacity';
|
||||
import { escapeRegExp } from '@joplin/lib/string-utils';
|
||||
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
|
||||
import NoteLockNote from '@joplin/lib/services/noteLock/NoteLockNote';
|
||||
import NoteLockSession from '@joplin/lib/services/noteLock/NoteLockSession';
|
||||
|
||||
interface Props {
|
||||
dispatch: Dispatch;
|
||||
@@ -31,14 +28,13 @@ const useStyles = (themeId: number, showTopBorder: boolean) => {
|
||||
return useMemo(() => {
|
||||
const theme = themeStyle(themeId);
|
||||
|
||||
const listItemDivider: ViewStyle = {
|
||||
const listItem: ViewStyle = {
|
||||
borderTopWidth: showTopBorder ? 1 : 0,
|
||||
borderTopColor: theme.dividerColor,
|
||||
marginLeft: theme.marginLeft,
|
||||
marginRight: theme.marginRight,
|
||||
};
|
||||
|
||||
const selectionWrapper: ViewStyle = {
|
||||
paddingTop: theme.marginTop,
|
||||
paddingBottom: theme.marginBottom,
|
||||
flexDirection: 'row',
|
||||
// backgroundColor: theme.backgroundColor,
|
||||
};
|
||||
@@ -47,8 +43,6 @@ const useStyles = (themeId: number, showTopBorder: boolean) => {
|
||||
flexGrow: 1,
|
||||
flexShrink: 1,
|
||||
alignSelf: 'stretch',
|
||||
paddingTop: theme.marginTop,
|
||||
paddingBottom: theme.marginBottom,
|
||||
};
|
||||
const listItemPressableWithCheckbox: ViewStyle = {
|
||||
...listItemPressable,
|
||||
@@ -56,8 +50,6 @@ const useStyles = (themeId: number, showTopBorder: boolean) => {
|
||||
};
|
||||
const listItemPressableWithoutCheckbox: ViewStyle = {
|
||||
...listItemPressable,
|
||||
paddingLeft: theme.marginLeft,
|
||||
paddingRight: theme.marginRight,
|
||||
};
|
||||
|
||||
const listItemText: TextStyle = {
|
||||
@@ -68,15 +60,13 @@ const useStyles = (themeId: number, showTopBorder: boolean) => {
|
||||
|
||||
const listItemTextWithCheckbox = { ...listItemText };
|
||||
|
||||
const selectionWrapper: ViewStyle = { };
|
||||
|
||||
const selectionWrapperSelected = { ...selectionWrapper };
|
||||
selectionWrapperSelected.backgroundColor = theme.selectedColor;
|
||||
selectionWrapperSelected.borderColor = theme.selectedColor;
|
||||
selectionWrapperSelected.borderTopWidth = 1;
|
||||
selectionWrapperSelected.borderBottomWidth = 1;
|
||||
selectionWrapperSelected.marginVertical = -1;
|
||||
|
||||
return StyleSheet.create({
|
||||
listItemDivider,
|
||||
listItem,
|
||||
listItemText,
|
||||
selectionWrapper,
|
||||
listItemPressableWithoutCheckbox,
|
||||
@@ -89,7 +79,6 @@ const useStyles = (themeId: number, showTopBorder: boolean) => {
|
||||
selectionWrapperSelected,
|
||||
checkboxStyle: {
|
||||
color: theme.color,
|
||||
paddingLeft: theme.marginLeft,
|
||||
paddingRight: 10,
|
||||
},
|
||||
checkedOpacityStyle: {
|
||||
@@ -102,19 +91,10 @@ const useStyles = (themeId: number, showTopBorder: boolean) => {
|
||||
|
||||
const NoteItemComponent: React.FC<Props> = memo(props => {
|
||||
const styles = useStyles(props.themeId, props.index !== 0);
|
||||
const suppressPressUntilRef = useRef(0);
|
||||
|
||||
const todoCheckbox_change = useCallback(async (checked: boolean) => {
|
||||
if (!props.note) return;
|
||||
|
||||
// Duplicates the locked-note guard in app-desktop/gui/NoteListItem/NoteListItem.tsx.
|
||||
if (isNoteLockEnabled()) {
|
||||
const lockState = await Note.load(props.note.id, { fields: ['is_locked'] });
|
||||
if (NoteLockNote.isLocked(lockState) && !NoteLockSession.instance().isUnlocked()) {
|
||||
throw new Error('Cannot change a locked note while the session is locked');
|
||||
}
|
||||
}
|
||||
|
||||
const newNote = {
|
||||
id: props.note.id,
|
||||
todo_completed: checked ? time.unixMs() : 0,
|
||||
@@ -125,8 +105,6 @@ const NoteItemComponent: React.FC<Props> = memo(props => {
|
||||
}, [props.note, props.dispatch]);
|
||||
|
||||
const onPress = useCallback(() => {
|
||||
// Suppress touch release triggers during interval, to avoid conflicting with right click event handling on web
|
||||
if (Date.now() < suppressPressUntilRef.current) return;
|
||||
if (!props.note) return;
|
||||
if (props.note.encryption_applied) return;
|
||||
|
||||
@@ -145,11 +123,6 @@ const NoteItemComponent: React.FC<Props> = memo(props => {
|
||||
}, [props.note, props.noteSelectionEnabled, props.dispatch]);
|
||||
|
||||
const onLongPress = useCallback(() => {
|
||||
const now = Date.now();
|
||||
// Suppress duplicate long press triggers during interval, to avoid conflicting with right click event handling on web
|
||||
if (now < suppressPressUntilRef.current) return;
|
||||
suppressPressUntilRef.current = now + 500;
|
||||
|
||||
if (!props.note) return;
|
||||
|
||||
if (!props.noteSelectionEnabled) {
|
||||
@@ -197,19 +170,16 @@ const NoteItemComponent: React.FC<Props> = memo(props => {
|
||||
...onLongPressProps,
|
||||
};
|
||||
return (
|
||||
<View style={opacityStyle}>
|
||||
<View style={styles.listItemDivider}/>
|
||||
<MultiTouchableOpacity
|
||||
{...pressableProps}
|
||||
containerProps={{
|
||||
style: selectionWrapperStyle,
|
||||
}}
|
||||
onPress={onPress}
|
||||
beforePressable={todoCheckbox}
|
||||
>
|
||||
<Text style={listItemTextStyle}>{displayedNoteTitle}</Text>
|
||||
</MultiTouchableOpacity>
|
||||
</View>
|
||||
<MultiTouchableOpacity
|
||||
{...pressableProps}
|
||||
containerProps={{
|
||||
style: [selectionWrapperStyle, opacityStyle, styles.listItem],
|
||||
}}
|
||||
onPress={onPress}
|
||||
beforePressable={todoCheckbox}
|
||||
>
|
||||
<Text style={listItemTextStyle}>{displayedNoteTitle}</Text>
|
||||
</MultiTouchableOpacity>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -119,16 +119,16 @@ const ProfileListItem: React.FC<ProfileItemProps> = ({ profile, profileConfig, s
|
||||
_('Delete this profile?'),
|
||||
_('All data, including notes, notebooks and tags will be permanently deleted.'),
|
||||
[
|
||||
{
|
||||
text: _('Cancel'),
|
||||
onPress: () => {},
|
||||
style: 'cancel',
|
||||
},
|
||||
{
|
||||
text: _('Delete profile "%s"', profile.name),
|
||||
onPress: () => doIt(),
|
||||
style: 'destructive',
|
||||
},
|
||||
{
|
||||
text: _('Cancel'),
|
||||
onPress: () => {},
|
||||
style: 'cancel',
|
||||
},
|
||||
],
|
||||
);
|
||||
}, [dialogs, profileConfig, setProfileConfigTime]);
|
||||
@@ -139,19 +139,19 @@ const ProfileListItem: React.FC<ProfileItemProps> = ({ profile, profileConfig, s
|
||||
'',
|
||||
[
|
||||
{
|
||||
text: _('Close'),
|
||||
onPress: () => {},
|
||||
style: 'cancel',
|
||||
text: _('Edit'),
|
||||
onPress: () => onEditProfile(profile.id),
|
||||
style: 'default',
|
||||
},
|
||||
{
|
||||
text: _('Delete'),
|
||||
onPress: () => onDeleteProfile(profile),
|
||||
style: 'destructive',
|
||||
style: 'default',
|
||||
},
|
||||
{
|
||||
text: _('Edit'),
|
||||
onPress: () => onEditProfile(profile.id),
|
||||
style: 'default',
|
||||
text: _('Close'),
|
||||
onPress: () => {},
|
||||
style: 'cancel',
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1,21 +1,129 @@
|
||||
import * as React from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { StyleSheet, View, Text, useWindowDimensions, TextStyle } from 'react-native';
|
||||
import { themeStyle } from '../global-style';
|
||||
import BottomDrawer from '../BottomDrawer';
|
||||
import { TouchableRipple } from 'react-native-paper';
|
||||
import BottomDrawerMenu, { MenuOption } from '../BottomDrawerMenu';
|
||||
import { MenuAlignment } from '../BottomDrawer';
|
||||
import Icon from '../Icon';
|
||||
|
||||
interface MenuOptionDivider {
|
||||
isDivider: true;
|
||||
}
|
||||
|
||||
export enum MenuOptionStyle {
|
||||
Normal = 'normal',
|
||||
Destructive = 'destructive',
|
||||
}
|
||||
|
||||
export interface MenuOptionButton {
|
||||
key?: string;
|
||||
isDivider?: false|undefined;
|
||||
disabled?: boolean;
|
||||
style?: MenuOptionStyle;
|
||||
onPress: ()=> void;
|
||||
icon?: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export type MenuOptionType = MenuOptionDivider|MenuOptionButton;
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
options: MenuOption[];
|
||||
options: MenuOptionType[];
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const useStyles = (themeId: number) => {
|
||||
const windowWidth = useWindowDimensions().width;
|
||||
return useMemo(() => {
|
||||
const theme = themeStyle(themeId);
|
||||
|
||||
return StyleSheet.create({
|
||||
menu: {
|
||||
paddingHorizontal: 0,
|
||||
},
|
||||
menuContent: { flexDirection: 'column', width: '100%' },
|
||||
menuItem: {
|
||||
alignItems: 'flex-start',
|
||||
padding: theme.marginMedium,
|
||||
paddingLeft: theme.marginLeft,
|
||||
paddingRight: theme.marginRight,
|
||||
minWidth: Math.min(350, windowWidth),
|
||||
},
|
||||
menuItemContent: {
|
||||
flexDirection: 'row',
|
||||
gap: theme.marginSmall,
|
||||
alignItems: 'center',
|
||||
},
|
||||
menuItemText: {
|
||||
color: theme.color,
|
||||
fontSize: theme.fontSize,
|
||||
},
|
||||
menuItemTextDisabled: {
|
||||
opacity: theme.disabledOpacity,
|
||||
},
|
||||
menuItemTextDestructive: {
|
||||
color: theme.destructiveColor,
|
||||
},
|
||||
divider: {
|
||||
height: 0,
|
||||
borderBottomWidth: 1,
|
||||
borderColor: theme.dividerColor,
|
||||
marginVertical: theme.marginSmall,
|
||||
marginHorizontal: theme.margin,
|
||||
},
|
||||
});
|
||||
}, [themeId, windowWidth]);
|
||||
};
|
||||
|
||||
const MenuComponent: React.FC<Props> = props => {
|
||||
const styles = useStyles(props.themeId);
|
||||
const theme = themeStyle(props.themeId);
|
||||
|
||||
const menuOptionComponents: React.ReactNode[] = [];
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
let keyCounter = 0;
|
||||
for (const option of props.options) {
|
||||
if (option.isDivider === true) {
|
||||
menuOptionComponents.push(
|
||||
<View key={`menuOption_divider_${keyCounter++}`} style={styles.divider} />,
|
||||
);
|
||||
} else {
|
||||
const key = `menuOption_${option.key ?? keyCounter++}`;
|
||||
const textStyles: TextStyle[] = [styles.menuItemText];
|
||||
if (option.disabled) {
|
||||
textStyles.push(styles.menuItemTextDisabled);
|
||||
}
|
||||
if (option.style === MenuOptionStyle.Destructive) {
|
||||
textStyles.push(styles.menuItemTextDestructive);
|
||||
}
|
||||
|
||||
menuOptionComponents.push(
|
||||
<TouchableRipple
|
||||
borderless={true}
|
||||
role='button'
|
||||
style={styles.menuItem}
|
||||
onPress={() => {
|
||||
option.onPress();
|
||||
setOpen(false);
|
||||
}}
|
||||
key={key}
|
||||
disabled={!!option.disabled}
|
||||
>
|
||||
<View style={styles.menuItemContent}>
|
||||
{option.icon && <Icon name={option.icon} style={textStyles} accessibilityLabel={null} />}
|
||||
<Text
|
||||
style={textStyles}
|
||||
disabled={option.disabled}
|
||||
>{option.title}</Text>
|
||||
</View>
|
||||
</TouchableRipple>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const onMenuClosed = useCallback(() => {
|
||||
setOpen(false);
|
||||
}, []);
|
||||
@@ -30,14 +138,19 @@ const MenuComponent: React.FC<Props> = props => {
|
||||
>
|
||||
{props.children}
|
||||
</TouchableRipple>
|
||||
<BottomDrawerMenu
|
||||
<BottomDrawer
|
||||
visible={open}
|
||||
onDismiss={onMenuClosed}
|
||||
// To match the side of the screen with the action button:
|
||||
alignment={MenuAlignment.Right}
|
||||
themeId={props.themeId}
|
||||
options={props.options}
|
||||
/>
|
||||
draggable={true}
|
||||
style={styles.menu}
|
||||
>
|
||||
<View
|
||||
style={styles.menuContent}
|
||||
testID={`menu-content-${open ? 'open' : 'closed'}`}
|
||||
>
|
||||
{menuOptionComponents}
|
||||
</View>
|
||||
</BottomDrawer>
|
||||
</>;
|
||||
};
|
||||
|
||||
|
||||
@@ -24,9 +24,8 @@ import WarningBanner from './WarningBanner';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import Icon from '../Icon';
|
||||
import Menu from './Menu';
|
||||
import { MenuOption, MenuOptionStyle } from '../BottomDrawerMenu';
|
||||
export { MenuOption, MenuOptionStyle };
|
||||
import Menu, { MenuOptionType } from './Menu';
|
||||
export { MenuOptionType };
|
||||
|
||||
// Rather than applying a padding to the whole bar, it is applied to each
|
||||
// individual component (button, picker, etc.) so that the touchable areas
|
||||
@@ -59,7 +58,7 @@ interface ScreenHeaderProps {
|
||||
showUndoButton: boolean;
|
||||
undoButtonDisabled?: boolean;
|
||||
showRedoButton: boolean;
|
||||
menuOptions: MenuOption[];
|
||||
menuOptions: MenuOptionType[];
|
||||
title?: string|null;
|
||||
folders: FolderEntity[];
|
||||
folderPickerOptions?: FolderPickerOptions;
|
||||
@@ -560,7 +559,7 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
|
||||
);
|
||||
}
|
||||
|
||||
const menuOptions: MenuOption[] = [...this.props.menuOptions];
|
||||
const menuOptions: MenuOptionType[] = [...this.props.menuOptions];
|
||||
|
||||
const selectedFolder = this.props.notesParentType === 'Folder' ? Folder.byId(this.props.folders, this.props.selectedFolderId) : null;
|
||||
const selectedFolderInTrash = itemIsInTrash(selectedFolder);
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import * as React from 'react';
|
||||
import { useState, useCallback, useMemo, useRef, RefObject, useLayoutEffect } from 'react';
|
||||
import { useState, useCallback, useMemo, useRef } from 'react';
|
||||
import { FAB } from 'react-native-paper';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { Dispatch } from 'redux';
|
||||
import { AccessibilityActionEvent, AccessibilityActionInfo, Platform, StyleSheet, useWindowDimensions, View } from 'react-native';
|
||||
import { AccessibilityActionEvent, AccessibilityActionInfo, View } from 'react-native';
|
||||
import { connect } from 'react-redux';
|
||||
import { MenuAlignment, MenuType } from '../BottomDrawer';
|
||||
import BottomDrawer from '../BottomDrawer';
|
||||
import { Ionicons as Icon } from '@react-native-vector-icons/ionicons';
|
||||
import BottomDrawerMenu, { MenuOption } from '../BottomDrawerMenu';
|
||||
import { AppState } from '../../utils/types';
|
||||
import { themeStyle } from '../global-style';
|
||||
import useSafeAreaPadding from '../../utils/hooks/useSafeAreaPadding';
|
||||
|
||||
type OnButtonPress = ()=> void;
|
||||
interface ButtonSpec {
|
||||
@@ -21,13 +17,11 @@ interface ButtonSpec {
|
||||
}
|
||||
|
||||
interface ActionButtonProps {
|
||||
themeId: number;
|
||||
|
||||
// If not given, an "add" button will be used.
|
||||
mainButton: ButtonSpec;
|
||||
dispatch: Dispatch;
|
||||
|
||||
menuContent?: MenuOption[];
|
||||
menuContent?: React.ReactNode;
|
||||
onMenuShow?: ()=> void;
|
||||
|
||||
accessibilityActions?: readonly AccessibilityActionInfo[];
|
||||
@@ -48,63 +42,6 @@ const useIcon = (iconName: string) => {
|
||||
}, [iconName]);
|
||||
};
|
||||
|
||||
const useMenuMarginBottom = (buttonContainerRef: RefObject<View>, themeId: number) => {
|
||||
const { height: windowHeight } = useWindowDimensions();
|
||||
const safeAreaPadding = useSafeAreaPadding();
|
||||
const [menuMarginBottom, setMenuMarginBottom] = useState(0);
|
||||
|
||||
const recomputeMargin = useCallback(() => {
|
||||
const theme = themeStyle(themeId);
|
||||
|
||||
buttonContainerRef.current?.measure((_x, _y, _width, _height, _pageX, pageY) => {
|
||||
// On Android, the safe area padding doesn't seem to be included in windowHeight,
|
||||
// but **does** seem to be taken into account when determining absolute/relative positioning
|
||||
const includeSafeArea = Platform.OS === 'android';
|
||||
const extraMargin = theme.marginBottom + (includeSafeArea ? safeAreaPadding.paddingBottom : 0);
|
||||
|
||||
setMenuMarginBottom(windowHeight - pageY + extraMargin);
|
||||
});
|
||||
}, [windowHeight, buttonContainerRef, themeId, safeAreaPadding]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
recomputeMargin();
|
||||
}, [recomputeMargin]);
|
||||
|
||||
const recomputeMarginRef = useRef(recomputeMargin);
|
||||
recomputeMarginRef.current = recomputeMargin;
|
||||
|
||||
const onMenuContainerLayout = useCallback(() => {
|
||||
recomputeMarginRef.current();
|
||||
}, []);
|
||||
|
||||
return { onMenuContainerLayout, menuMarginBottom };
|
||||
};
|
||||
|
||||
|
||||
interface StylesProps {
|
||||
menuMarginBottom: number;
|
||||
}
|
||||
|
||||
const useStyles = ({ menuMarginBottom }: StylesProps) => {
|
||||
return useMemo(() => {
|
||||
return StyleSheet.create({
|
||||
menu: {
|
||||
marginBottom: menuMarginBottom,
|
||||
// Always float right:
|
||||
alignSelf: 'flex-end',
|
||||
},
|
||||
buttonContainer: {
|
||||
position: 'absolute',
|
||||
bottom: 10,
|
||||
right: 10,
|
||||
},
|
||||
button: {
|
||||
alignSelf: 'flex-end',
|
||||
},
|
||||
});
|
||||
}, [menuMarginBottom]);
|
||||
};
|
||||
|
||||
const FloatingActionButton = (props: ActionButtonProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const onMenuToggled = useCallback(() => {
|
||||
@@ -128,43 +65,36 @@ const FloatingActionButton = (props: ActionButtonProps) => {
|
||||
|
||||
const label = props.mainButton?.label ?? _('Add new');
|
||||
|
||||
const buttonContainerRef = useRef<View|null>(null);
|
||||
|
||||
const { menuMarginBottom, onMenuContainerLayout } = useMenuMarginBottom(buttonContainerRef, props.themeId);
|
||||
const styles = useStyles({ menuMarginBottom });
|
||||
|
||||
const menuButton = <FAB
|
||||
ref={mainButtonRef}
|
||||
icon={open ? openIcon : closedIcon}
|
||||
accessibilityLabel={label}
|
||||
onPress={props.mainButton?.onPress ?? onMenuToggled}
|
||||
style={styles.button}
|
||||
style={{
|
||||
alignSelf: 'flex-end',
|
||||
}}
|
||||
accessibilityActions={props.accessibilityActions}
|
||||
onAccessibilityAction={props.onAccessibilityAction}
|
||||
/>;
|
||||
|
||||
return <>
|
||||
<View
|
||||
style={styles.buttonContainer}
|
||||
ref={buttonContainerRef}
|
||||
onLayout={onMenuContainerLayout}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 10,
|
||||
right: 10,
|
||||
}}
|
||||
>
|
||||
{menuButton}
|
||||
</View>
|
||||
{ props.menuContent && <BottomDrawerMenu
|
||||
<BottomDrawer
|
||||
visible={open}
|
||||
onDismiss={onDismiss}
|
||||
alignment={MenuAlignment.Right}
|
||||
style={styles.menu}
|
||||
menuType={MenuType.Floating}
|
||||
themeId={props.themeId}
|
||||
options={props.menuContent}
|
||||
autoScrollToEnd
|
||||
/> }
|
||||
onShow={props.onMenuShow}
|
||||
>
|
||||
{props.menuContent}
|
||||
</BottomDrawer>
|
||||
</>;
|
||||
};
|
||||
|
||||
const ConnectedComponent: React.FC<Omit<ActionButtonProps, 'themeId'|'dispatch'>> = (
|
||||
connect((state: AppState) => ({ themeId: state.settings.theme }))(FloatingActionButton)
|
||||
);
|
||||
export default ConnectedComponent;
|
||||
export default connect()(FloatingActionButton);
|
||||
@@ -36,8 +36,6 @@ import SectionDescription from './SectionDescription';
|
||||
import EnablePluginSupportPage from './plugins/EnablePluginSupportPage';
|
||||
import getVersionInfoText from '../../../utils/getVersionInfoText';
|
||||
import JoplinCloudConfig, { emailToNoteDescription, emailToNoteLabel } from './JoplinCloudConfig';
|
||||
import NoteLockConfig from './NoteLockConfig';
|
||||
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import SettingsToggle from './SettingsToggle';
|
||||
import { UpdateSettingValueCallback } from './types';
|
||||
@@ -554,10 +552,6 @@ class ConfigScreenComponent extends BaseScreenComponent<ConfigScreenProps, Confi
|
||||
addSettingButton('e2ee_config_button', _('Encryption Config'), this.e2eeConfig_);
|
||||
}
|
||||
|
||||
if (section.name === 'noteLock' && isNoteLockEnabled()) {
|
||||
addSettingComponent(<NoteLockConfig key='note-lock-config'/>, [_('Password setup'), _('Note lock password')]);
|
||||
}
|
||||
|
||||
if (section.name === 'joplinCloud') {
|
||||
addSettingComponent(
|
||||
<JoplinCloudConfig
|
||||
|
||||
+4
-1
@@ -70,7 +70,10 @@ const TaskButton: FunctionComponent<Props> = props => {
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Task ${props.taskName} failed`, error);
|
||||
await shim.showErrorDialog(_('Task "%s" failed with error: %s', props.taskName, error.toString()));
|
||||
await shim.showMessageBox(_('Task "%s" failed with error: %s', props.taskName, error.toString()), {
|
||||
title: _('Error'),
|
||||
buttons: [_('OK')],
|
||||
});
|
||||
} finally {
|
||||
if (!completedSuccessfully) {
|
||||
setTaskStatus(TaskStatus.NotStarted);
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { Store } from 'redux';
|
||||
import { AppState } from '../../../utils/types';
|
||||
import TestProviderStack from '../../testing/TestProviderStack';
|
||||
import NoteLockConfig from './NoteLockConfig';
|
||||
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
|
||||
import createMockReduxStore from '../../../utils/testing/createMockReduxStore';
|
||||
import setupGlobalStore from '../../../utils/testing/setupGlobalStore';
|
||||
import NoteLockKey from '@joplin/lib/services/noteLock/NoteLockKey';
|
||||
import { MasterKeyEntity } from '@joplin/lib/services/e2ee/types';
|
||||
import { act, render, screen } from '../../../utils/testing/testingLibrary';
|
||||
|
||||
let store: Store<AppState>;
|
||||
const WrappedNoteLockConfig: React.FC = () => {
|
||||
return <TestProviderStack store={store}>
|
||||
<NoteLockConfig/>
|
||||
</TestProviderStack>;
|
||||
};
|
||||
|
||||
describe('NoteLockConfig', () => {
|
||||
beforeEach(async () => {
|
||||
await setupDatabaseAndSynchronizer(0);
|
||||
await switchClient(0);
|
||||
NoteLockKey.destroyInstance();
|
||||
|
||||
store = createMockReduxStore();
|
||||
setupGlobalStore(store);
|
||||
});
|
||||
afterEach(() => {
|
||||
screen.unmount();
|
||||
});
|
||||
|
||||
test('should show the password setup form only while no note lock key is set', async () => {
|
||||
const { unmount } = render(<WrappedNoteLockConfig/>);
|
||||
|
||||
expect(screen.getByText('Password setup')).toBeVisible();
|
||||
expect(screen.getByLabelText('Password')).toBeVisible();
|
||||
|
||||
// Seed the key via save() rather than create(): the mobile test crypto shim can't
|
||||
// run real key generation.
|
||||
act(() => {
|
||||
NoteLockKey.instance().save({ id: 'test-note-lock-key' } as MasterKeyEntity);
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Password setup')).toBeNull();
|
||||
expect(screen.queryByLabelText('Password')).toBeNull();
|
||||
expect(screen.getByText(/Note lock password:.*Set/)).toBeVisible();
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -1,153 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { View, Text, TextInput, Button, StyleSheet } from 'react-native';
|
||||
import { connect } from 'react-redux';
|
||||
import { themeStyle } from '../../global-style';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import NoteLockKey from '@joplin/lib/services/noteLock/NoteLockKey';
|
||||
import { SyncInfo } from '@joplin/lib/services/synchronizer/syncInfoUtils';
|
||||
import { State } from '@joplin/lib/reducer';
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
hasNoteLockKey: boolean;
|
||||
}
|
||||
|
||||
const NoteLockConfig = (props: Props) => {
|
||||
const [password, setPassword] = useState('');
|
||||
const [passwordRepeat, setPasswordRepeat] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const theme = useMemo(() => themeStyle(props.themeId), [props.themeId]);
|
||||
|
||||
const styles = useMemo(() => {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
padding: theme.margin,
|
||||
},
|
||||
titleText: {
|
||||
fontWeight: 'bold',
|
||||
fontSize: theme.fontSize,
|
||||
marginTop: theme.marginTop,
|
||||
marginBottom: 5,
|
||||
color: theme.color,
|
||||
},
|
||||
normalText: {
|
||||
fontSize: theme.fontSize,
|
||||
color: theme.color,
|
||||
marginBottom: 5,
|
||||
},
|
||||
boldText: {
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
passwordInput: {
|
||||
marginTop: 5,
|
||||
marginBottom: theme.itemMarginBottom,
|
||||
color: theme.color,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.dividerColor,
|
||||
borderRadius: 3,
|
||||
padding: 10,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: theme.fontSize,
|
||||
color: theme.colorError,
|
||||
marginBottom: theme.itemMarginBottom,
|
||||
},
|
||||
reminderText: {
|
||||
fontSize: theme.fontSize,
|
||||
fontWeight: 'bold',
|
||||
color: theme.color,
|
||||
marginTop: theme.itemMarginTop,
|
||||
},
|
||||
});
|
||||
}, [theme]);
|
||||
|
||||
const hasKey = props.hasNoteLockKey;
|
||||
const passwordMismatch = !!passwordRepeat && password !== passwordRepeat;
|
||||
const canSave = !!password && !!passwordRepeat && !passwordMismatch && !saving;
|
||||
|
||||
const onCreate = useCallback(async () => {
|
||||
if (!canSave) return;
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
await NoteLockKey.instance().create(password);
|
||||
setPassword('');
|
||||
setPasswordRepeat('');
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [canSave, password]);
|
||||
|
||||
const onPasswordChange = useCallback((value: string) => {
|
||||
setPassword(value);
|
||||
setError('');
|
||||
}, []);
|
||||
|
||||
const onPasswordRepeatChange = useCallback((value: string) => {
|
||||
setPasswordRepeat(value);
|
||||
setError('');
|
||||
}, []);
|
||||
|
||||
const passwordLabelId = 'note-lock-password';
|
||||
const repeatLabelId = 'note-lock-password-repeat';
|
||||
|
||||
const renderPasswordSetup = () => {
|
||||
if (hasKey) return null;
|
||||
return (
|
||||
<View>
|
||||
<Text style={styles.titleText} accessibilityRole='header'>{_('Password setup')}</Text>
|
||||
<Text nativeID={passwordLabelId} style={styles.normalText}>{_('Password')}</Text>
|
||||
<TextInput
|
||||
accessibilityLabelledBy={passwordLabelId}
|
||||
selectionColor={theme.textSelectionColor}
|
||||
keyboardAppearance={theme.keyboardAppearance}
|
||||
style={styles.passwordInput}
|
||||
secureTextEntry={true}
|
||||
autoCapitalize='none'
|
||||
autoCorrect={false}
|
||||
textContentType='newPassword'
|
||||
importantForAutofill='yes'
|
||||
value={password}
|
||||
onChangeText={onPasswordChange}
|
||||
/>
|
||||
<Text nativeID={repeatLabelId} style={styles.normalText}>{_('Repeat password')}</Text>
|
||||
<TextInput
|
||||
accessibilityLabelledBy={repeatLabelId}
|
||||
selectionColor={theme.textSelectionColor}
|
||||
keyboardAppearance={theme.keyboardAppearance}
|
||||
style={styles.passwordInput}
|
||||
secureTextEntry={true}
|
||||
autoCapitalize='none'
|
||||
autoCorrect={false}
|
||||
textContentType='newPassword'
|
||||
importantForAutofill='yes'
|
||||
value={passwordRepeat}
|
||||
onChangeText={onPasswordRepeatChange}
|
||||
/>
|
||||
{passwordMismatch ? <Text style={styles.errorText}>{_('Passwords do not match')}</Text> : null}
|
||||
{error ? <Text style={styles.errorText}>{error}</Text> : null}
|
||||
<Button title={_('Save')} disabled={!canSave} onPress={onCreate} />
|
||||
<Text style={styles.reminderText}>{_('Please make sure you remember your password. It cannot be recovered if lost, and any data encrypted with it will become inaccessible.')}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.normalText}><Text style={styles.boldText}>{_('Note lock password:')}</Text> {hasKey ? _('Set') : _('Not set')}</Text>
|
||||
{renderPasswordSetup()}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default connect((state: State) => {
|
||||
return {
|
||||
themeId: state.settings.theme,
|
||||
hasNoteLockKey: !!new SyncInfo(state.settings['syncInfoCache']).noteLockKey,
|
||||
};
|
||||
})(NoteLockConfig);
|
||||
-2
@@ -16,8 +16,6 @@ import mockRepositoryApiConstructor from './testUtils/mockRepositoryApiConstruct
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import mockPluginServiceSetup from '../../../../utils/testing/mockPluginServiceSetup';
|
||||
|
||||
// Plugin install/uninstall tests have been observed to fail or time out in CI:
|
||||
jest.retryTimes(2);
|
||||
|
||||
let reduxStore: Store<AppState> = null;
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import { themeStyle } from '../global-style';
|
||||
import Logger, { LogEntry } from '@joplin/utils/Logger';
|
||||
import { BaseScreenComponent } from '../base-screen';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { MenuOption } from '../ScreenHeader';
|
||||
import { MenuOptionType } from '../ScreenHeader';
|
||||
import { AppState } from '../../utils/types';
|
||||
import { writeTextToCacheFile } from '../../utils/ShareUtils';
|
||||
import shim from '@joplin/lib/shim';
|
||||
@@ -30,7 +30,7 @@ interface State {
|
||||
}
|
||||
|
||||
class LogScreenComponent extends BaseScreenComponent<Props, State> {
|
||||
private readonly menuOptions_: MenuOption[];
|
||||
private readonly menuOptions_: MenuOptionType[];
|
||||
private styles_: Record<number, ReturnType<typeof StyleSheet.create>>;
|
||||
private readonly logListRef_ = React.createRef<FlatList>();
|
||||
|
||||
|
||||
@@ -221,24 +221,6 @@ describe('screens/Note', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should discard a queued save when the reload time has moved on', async () => {
|
||||
const noteId = await openNewNote({ title: 'Original title', body: 'Body' });
|
||||
const { unmount } = render(<WrappedNoteScreen />);
|
||||
const titleInput = await screen.findByDisplayValue('Original title');
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.changeText(titleInput, 'Stale local title');
|
||||
const remotelyUpdatedNote = await Note.load(noteId);
|
||||
remotelyUpdatedNote.title = 'Remote title';
|
||||
await Note.save(remotelyUpdatedNote);
|
||||
store.dispatch({ type: 'EDITOR_NOTE_NEEDS_RELOAD', noteId });
|
||||
});
|
||||
|
||||
await waitForNoteToMatch(noteId, { title: 'Remote title' });
|
||||
unmount();
|
||||
await waitForNoteToMatch(noteId, { title: 'Remote title' });
|
||||
});
|
||||
|
||||
it('changing the note body in the editor should update the note\'s body', async () => {
|
||||
const defaultBody = 'Change me!';
|
||||
const noteId = await openNewNote({ title: 'Unchanged title', body: defaultBody });
|
||||
|
||||
@@ -22,7 +22,7 @@ import NavService, { OnNavigateCallback as OnNavigateCallback } from '@joplin/li
|
||||
import { ModelType } from '@joplin/lib/BaseModel';
|
||||
import { fileExtension, safeFileExtension } from '@joplin/lib/path-utils';
|
||||
import * as mimeUtils from '@joplin/lib/mime-utils';
|
||||
import ScreenHeader, { FolderPickerOptions, MenuOption, ViewToggleButtonMode } from '../../ScreenHeader';
|
||||
import ScreenHeader, { FolderPickerOptions, MenuOptionType, ViewToggleButtonMode } from '../../ScreenHeader';
|
||||
import NoteTagsDialog from '../NoteTagsDialog';
|
||||
import time from '@joplin/lib/time';
|
||||
import Checkbox from '../../Checkbox';
|
||||
@@ -49,7 +49,7 @@ import restoreItems from '@joplin/lib/services/trash/restoreItems';
|
||||
import { getDisplayParentTitle } from '@joplin/lib/services/trash';
|
||||
import { PluginHtmlContents, PluginStates, utils as pluginUtils } from '@joplin/lib/services/plugins/reducer';
|
||||
import debounce from '../../../utils/debounce';
|
||||
import { blur, focus } from '@joplin/lib/utils/focusHandler';
|
||||
import { focus } from '@joplin/lib/utils/focusHandler';
|
||||
import CommandService, { RegisteredRuntime } from '@joplin/lib/services/CommandService';
|
||||
import { ResourceInfo } from '../../NoteBodyViewer/hooks/useRerenderHandler';
|
||||
import getImageDimensions from '../../../utils/image/getImageDimensions';
|
||||
@@ -69,7 +69,7 @@ import SpeechToTextBanner from '../../voiceTyping/SpeechToTextBanner';
|
||||
import CameraView from '../../CameraView/CameraView';
|
||||
import ShareNoteDialog from '../ShareNoteDialog';
|
||||
import stateToWhenClauseContext from '../../../services/commands/stateToWhenClauseContext';
|
||||
import { defaultWindowId, HighlightedWord } from '@joplin/lib/reducer';
|
||||
import { defaultWindowId } from '@joplin/lib/reducer';
|
||||
import useVisiblePluginEditorViewIds from '@joplin/lib/hooks/plugins/useVisiblePluginEditorViewIds';
|
||||
import { SelectionRange } from '../../../contentScripts/markdownEditorBundle/types';
|
||||
import { EditorType } from '../../NoteEditor/types';
|
||||
@@ -84,7 +84,7 @@ import { Second } from '@joplin/utils/time';
|
||||
import TextWrapCalculator from '../Notes/TextWrapCalculator';
|
||||
import SearchEngine from '@joplin/lib/services/search/SearchEngine';
|
||||
import { ALL_NOTES_FILTER_ID } from '@joplin/lib/reserved-ids';
|
||||
import { MenuOptionStyle, MenuOptionButton } from '../../BottomDrawerMenu';
|
||||
import { MenuOptionButton, MenuOptionStyle } from '../../ScreenHeader/Menu';
|
||||
|
||||
const emptyArray: never[] = [];
|
||||
|
||||
@@ -117,7 +117,7 @@ interface Props extends BaseProps {
|
||||
showSideMenu: boolean;
|
||||
searchQuery: string;
|
||||
ftsEnabled: number;
|
||||
highlightedWords: HighlightedWord[];
|
||||
highlightedWords: string[];
|
||||
noteHash: string;
|
||||
toolbarEnabled: boolean;
|
||||
pluginHtmlContents: PluginHtmlContents;
|
||||
@@ -154,7 +154,6 @@ interface State {
|
||||
noteResources: Record<string, ResourceInfo>;
|
||||
newAndNoTitleChangeNoteId: boolean|null;
|
||||
noteLastLoadTime: number;
|
||||
reloadInProgress: boolean;
|
||||
|
||||
undoRedoButtonState: {
|
||||
canUndo: boolean;
|
||||
@@ -185,7 +184,7 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
private noteTagDialog_closeRequested: ()=> void;
|
||||
private refreshResource: (resource: ResourceEntity, noteBody?: string)=> Promise<void>;
|
||||
private selection: SelectionRange;
|
||||
private menuOptionsCache_: Record<string, MenuOption[]>;
|
||||
private menuOptionsCache_: Record<string, MenuOptionType[]>;
|
||||
private focusUpdateIID_: ReturnType<typeof setTimeout> | null;
|
||||
private folderPickerOptions_: FolderPickerOptions;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- dialogbox is the react-native-dialogbox ref; the library ships no types
|
||||
@@ -195,7 +194,6 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
return shared.noteComponent_change(this, 'body', saveEvent.body);
|
||||
});
|
||||
private refreshKey: number | undefined;
|
||||
private reloadInProgress_ = false;
|
||||
|
||||
public static navigationOptions(): { header: null } {
|
||||
return { header: null };
|
||||
@@ -228,7 +226,6 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
imageEditorResourceFilepath: null,
|
||||
newAndNoTitleChangeNoteId: null,
|
||||
noteLastLoadTime: Date.now(),
|
||||
reloadInProgress: false,
|
||||
|
||||
undoRedoButtonState: {
|
||||
canUndo: false,
|
||||
@@ -574,7 +571,6 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
{
|
||||
buttons: [_('Yes'), _('No')],
|
||||
title: _('Save geolocation?'),
|
||||
cancelId: 1,
|
||||
},
|
||||
);
|
||||
return result === yesIndex;
|
||||
@@ -712,11 +708,6 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
const explicitReloadRequired = !editorPlugin && this.props.editorNoteReloadTimeRequest > this.state.noteLastLoadTime;
|
||||
|
||||
if (explicitReloadRequired) {
|
||||
this.reloadInProgress_ = true;
|
||||
Keyboard.dismiss();
|
||||
this.editorRef.current?.hideKeyboard();
|
||||
blur('Note::reload', this.titleTextFieldRef.current);
|
||||
this.setState({ reloadInProgress: true });
|
||||
void this.reloadNoteAndUpdateRefreshKey();
|
||||
}
|
||||
|
||||
@@ -774,24 +765,10 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
|
||||
private async reloadNoteAndUpdateRefreshKey() {
|
||||
await shared.reloadNote(this);
|
||||
|
||||
const refreshKey = this.props.editorNoteReloadTimeRequest;
|
||||
this.refreshKey = refreshKey;
|
||||
if (this.useEditorBeta() && this.state.mode === 'edit') {
|
||||
this.forceUpdate();
|
||||
} else {
|
||||
this.setState({}, () => this.editorReloadComplete(refreshKey));
|
||||
}
|
||||
this.refreshKey = this.props.editorNoteReloadTimeRequest;
|
||||
}
|
||||
|
||||
private editorReloadComplete = (refreshKey: number|undefined) => {
|
||||
if (!this.reloadInProgress_ || refreshKey !== this.props.editorNoteReloadTimeRequest) return;
|
||||
this.reloadInProgress_ = false;
|
||||
this.setState({ reloadInProgress: false });
|
||||
};
|
||||
|
||||
private title_changeText(text: string) {
|
||||
if (this.reloadInProgress_) return;
|
||||
let newText = text;
|
||||
newText = text.replace(/(\r\n|\n|\r)/gm, ' ');
|
||||
shared.noteComponent_change(this, 'title', newText);
|
||||
@@ -806,7 +783,6 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
}
|
||||
|
||||
private onPlainEditorTextChange = (text: string) => {
|
||||
if (this.reloadInProgress_) return;
|
||||
if (!this.undoRedoService_.canUndo) {
|
||||
this.undoRedoService_.push(this.undoState());
|
||||
} else {
|
||||
@@ -820,16 +796,10 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
// and updating this.state.note immediately causes slow rerenders.
|
||||
//
|
||||
// See https://github.com/laurent22/joplin/issues/10130
|
||||
private debouncedMarkdownEditorTextChange_ = debounce((event: EditorChangeEvent, reloadRequest: number) => {
|
||||
if (this.reloadInProgress_ || reloadRequest !== this.props.editorNoteReloadTimeRequest) return;
|
||||
private onMarkdownEditorTextChange = debounce((event: EditorChangeEvent) => {
|
||||
shared.noteComponent_change(this, 'body', event.value);
|
||||
}, 100);
|
||||
|
||||
private onMarkdownEditorTextChange = (event: EditorChangeEvent) => {
|
||||
if (this.reloadInProgress_) return;
|
||||
this.debouncedMarkdownEditorTextChange_(event, this.props.editorNoteReloadTimeRequest);
|
||||
};
|
||||
|
||||
private onPlainEditorSelectionChange = (event: NativeSyntheticEvent<{ selection: SelectionRange }>) => {
|
||||
this.selection = event.nativeEvent.selection;
|
||||
};
|
||||
@@ -842,12 +812,9 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
);
|
||||
};
|
||||
|
||||
public makeSaveAction(state: State, editorNoteReloadTimeRequest: number) {
|
||||
public makeSaveAction(state: State) {
|
||||
return async () => {
|
||||
return shared.saveNoteButton_press(this, state, null, {
|
||||
editorNoteReloadTimeRequest,
|
||||
getEditorNoteReloadTimeRequest: () => this.props.editorNoteReloadTimeRequest,
|
||||
});
|
||||
return shared.saveNoteButton_press(this, state, null, null);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -859,9 +826,7 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
}
|
||||
|
||||
public scheduleSave(state: State) {
|
||||
if (this.reloadInProgress_) return;
|
||||
const editorNoteReloadTimeRequest = this.props.editorNoteReloadTimeRequest;
|
||||
this.saveActionQueue(state.note.id).push(this.makeSaveAction(state, editorNoteReloadTimeRequest));
|
||||
this.saveActionQueue(state.note.id).push(this.makeSaveAction(state));
|
||||
}
|
||||
|
||||
private async saveNoteButton_press(folderId: string = null) {
|
||||
@@ -1326,7 +1291,7 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
|
||||
if (this.menuOptionsCache_[cacheKey]) return this.menuOptionsCache_[cacheKey];
|
||||
|
||||
const output: MenuOption[] = [];
|
||||
const output: MenuOptionType[] = [];
|
||||
|
||||
// The file attachment modules only work in Android >= 5 (Version 21)
|
||||
// https://github.com/react-community/react-native-image-picker/issues/606
|
||||
@@ -1766,7 +1731,6 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
keyboardAppearance={theme.keyboardAppearance}
|
||||
placeholder={_('Add body')}
|
||||
placeholderTextColor={theme.colorFaded}
|
||||
editable={!this.state.readOnly && !this.state.reloadInProgress}
|
||||
// need some extra padding for iOS so that the keyboard won't cover last line of the note
|
||||
// see https://github.com/laurent22/joplin/issues/3607
|
||||
// Property is gone as of RN 0.72?
|
||||
@@ -1792,7 +1756,7 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
onSearchVisibleChange={this.onSearchVisibleChange_}
|
||||
onAttach={this.onAttach}
|
||||
noteResources={this.state.noteResources}
|
||||
readOnly={this.state.readOnly || this.state.reloadInProgress}
|
||||
readOnly={this.state.readOnly}
|
||||
plugins={this.props.plugins}
|
||||
style={{
|
||||
...editorStyle,
|
||||
@@ -1811,7 +1775,6 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
|
||||
mode={this.props.editorType}
|
||||
refreshKey={this.refreshKey}
|
||||
onLoadEnd={() => this.editorReloadComplete(this.refreshKey)}
|
||||
/>;
|
||||
}
|
||||
}
|
||||
@@ -1872,7 +1835,7 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
keyboardAppearance={theme.keyboardAppearance}
|
||||
placeholder={_('Add title')}
|
||||
placeholderTextColor={theme.colorFaded}
|
||||
editable={!this.state.readOnly && !this.state.reloadInProgress}
|
||||
editable={!this.state.readOnly}
|
||||
multiline={this.state.multiline}
|
||||
submitBehavior = "blurAndSubmit"
|
||||
/>
|
||||
|
||||
@@ -7,7 +7,7 @@ import Revision from '@joplin/lib/models/Revision';
|
||||
import BaseModel, { ModelType } from '@joplin/lib/BaseModel';
|
||||
import { IconButton, Text } from 'react-native-paper';
|
||||
import Dropdown from '../Dropdown';
|
||||
import ScreenHeader, { MenuOption } from '../ScreenHeader';
|
||||
import ScreenHeader, { MenuOptionType } from '../ScreenHeader';
|
||||
import { formatMsToLocal } from '@joplin/utils/time';
|
||||
import { useCallback, useContext, useMemo, useState } from 'react';
|
||||
import { PrimaryButton } from '../buttons';
|
||||
@@ -24,7 +24,7 @@ import { DialogContext } from '../DialogManager';
|
||||
import useDeleteHistoryClick from '@joplin/lib/components/shared/NoteRevisionViewer/useDeleteHistoryClick';
|
||||
import { OnScrollCallback } from '../NoteBodyViewer/types';
|
||||
import TextWrapCalculator from './Notes/TextWrapCalculator';
|
||||
import { MenuOptionStyle } from '../BottomDrawerMenu';
|
||||
import { MenuOptionStyle } from '../ScreenHeader/Menu';
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
@@ -194,7 +194,7 @@ const NoteRevisionViewer: React.FC<Props> = props => {
|
||||
|
||||
const disableDeleteHistory = deleting || !hasRevisions;
|
||||
const menuOptions = useMemo(() => {
|
||||
const output: MenuOption[] = [{
|
||||
const output: MenuOptionType[] = [{
|
||||
title: _('Delete history'),
|
||||
icon: 'material delete-outline',
|
||||
style: MenuOptionStyle.Destructive,
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import * as React from 'react';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import { Divider } from 'react-native-paper';
|
||||
import FloatingActionButton from '../../buttons/FloatingActionButton';
|
||||
import { AccessibilityActionEvent, AccessibilityActionInfo } from 'react-native';
|
||||
import { AccessibilityActionEvent, AccessibilityActionInfo, StyleSheet, View } from 'react-native';
|
||||
import { AttachFileAction } from '../Note/commands/attachFile';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import LabelledIconButton from '../../buttons/LabelledIconButton';
|
||||
import TextButton, { ButtonSize, ButtonType } from '../../buttons/TextButton';
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import focusView from '../../../utils/focusView';
|
||||
import NavService from '@joplin/lib/services/NavService';
|
||||
import { MenuOption } from '../../BottomDrawerMenu';
|
||||
|
||||
const logger = Logger.create('NewNoteButton');
|
||||
|
||||
interface Props {
|
||||
|
||||
}
|
||||
|
||||
const makeNewNote = (isTodo: boolean, action?: AttachFileAction) => {
|
||||
@@ -20,35 +24,85 @@ const makeNewNote = (isTodo: boolean, action?: AttachFileAction) => {
|
||||
return CommandService.instance().execute('newNote', body, isTodo, { attachFileAction: action });
|
||||
};
|
||||
|
||||
const NewNoteButton: React.FC<Props> = () => {
|
||||
const styles = StyleSheet.create({
|
||||
buttonRow: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap-reverse',
|
||||
justifyContent: 'space-between',
|
||||
gap: 2,
|
||||
},
|
||||
mainButtonRow: {
|
||||
flexWrap: 'wrap',
|
||||
gap: 12,
|
||||
},
|
||||
shortcutButton: {
|
||||
flexGrow: 0,
|
||||
flexShrink: 1,
|
||||
width: 88,
|
||||
},
|
||||
mainButton: {
|
||||
flexShrink: 1,
|
||||
flexGrow: 1,
|
||||
},
|
||||
mainButtonLabel: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
menuContent: {
|
||||
gap: 12,
|
||||
flexShrink: 1,
|
||||
flexGrow: 1,
|
||||
flexDirection: 'column',
|
||||
},
|
||||
});
|
||||
|
||||
const menuContent = useMemo(() => {
|
||||
const menuItem = (icon: string, title: string, action: ()=> void) => {
|
||||
return {
|
||||
icon,
|
||||
title,
|
||||
onPress: action,
|
||||
};
|
||||
};
|
||||
const attachmentMenuItem = (icon: string, title: string, action: AttachFileAction, attachmentLabel: string|null = null) => {
|
||||
return {
|
||||
...menuItem(icon, title, () => makeNewNote(false, action)),
|
||||
accessibilityHint: _('Creates a new note with an attachment of type %s', attachmentLabel ?? title),
|
||||
};
|
||||
};
|
||||
const NewNoteButton: React.FC<Props> = _props => {
|
||||
const newNoteRef = useRef<View|null>(null);
|
||||
|
||||
const items: MenuOption[] = [
|
||||
attachmentMenuItem('material camera-outline', _('Camera'), AttachFileAction.TakePhoto),
|
||||
attachmentMenuItem('material attachment', _('Attachment'), AttachFileAction.AttachFile, _('File')),
|
||||
{ icon: 'material data-matrix-scan', title: _('Scan notebook'), onPress: () => NavService.go('DocumentScanner') },
|
||||
attachmentMenuItem('material draw', _('Drawing'), AttachFileAction.AttachDrawing),
|
||||
attachmentMenuItem('material microphone-outline', _('Recording'), AttachFileAction.RecordAudio),
|
||||
{ isDivider: true },
|
||||
{ icon: 'material file-document-check-outline', title: _('New to-do'), onPress: () => makeNewNote(true), autoFocus: true },
|
||||
{ icon: 'material file-document-outline', title: _('New note'), onPress: () => makeNewNote(false) },
|
||||
];
|
||||
return items;
|
||||
}, []);
|
||||
type ActionType = AttachFileAction|(()=> void);
|
||||
const renderShortcutButton = (action: ActionType, icon: string, title: string) => {
|
||||
const actionSource = typeof action === 'function' ? null : action;
|
||||
action = typeof action === 'function' ? action : () => makeNewNote(false, actionSource);
|
||||
return <LabelledIconButton
|
||||
onPress={action}
|
||||
style={styles.shortcutButton}
|
||||
title={title}
|
||||
accessibilityHint={_('Creates a new note with an attachment of type %s', title)}
|
||||
icon={icon}
|
||||
/>;
|
||||
};
|
||||
|
||||
const menuContent = <View style={styles.menuContent}>
|
||||
<View style={styles.buttonRow}>
|
||||
{renderShortcutButton(AttachFileAction.AttachFile, 'material attachment', _('Attachment'))}
|
||||
{renderShortcutButton(AttachFileAction.RecordAudio, 'material microphone', _('Recording'))}
|
||||
{renderShortcutButton(AttachFileAction.TakePhoto, 'material camera', _('Camera'))}
|
||||
{renderShortcutButton(AttachFileAction.AttachDrawing, 'material draw', _('Drawing'))}
|
||||
{renderShortcutButton(() => NavService.go('DocumentScanner'), 'material data-matrix-scan', _('Scan notebook'))}
|
||||
</View>
|
||||
<Divider/>
|
||||
<View style={[styles.buttonRow, styles.mainButtonRow]}>
|
||||
<TextButton
|
||||
icon='checkbox-outline'
|
||||
style={styles.mainButton}
|
||||
onPress={() => {
|
||||
void makeNewNote(true);
|
||||
}}
|
||||
type={ButtonType.Secondary}
|
||||
size={ButtonSize.Larger}
|
||||
>{_('New to-do')}</TextButton>
|
||||
<TextButton
|
||||
touchableRef={newNoteRef}
|
||||
icon='file-document-outline'
|
||||
style={styles.mainButton}
|
||||
onPress={() => {
|
||||
void makeNewNote(false);
|
||||
}}
|
||||
type={ButtonType.Primary}
|
||||
size={ButtonSize.Larger}
|
||||
>{_('New note')}</TextButton>
|
||||
</View>
|
||||
</View>;
|
||||
|
||||
// Android and iOS: Accessibility actions simplify creating new notes and to-dos. These
|
||||
// are extra important because the "note with attachment" items are annoyingly first in
|
||||
@@ -71,6 +125,14 @@ const NewNoteButton: React.FC<Props> = () => {
|
||||
}
|
||||
return Promise.resolve();
|
||||
}, []);
|
||||
const onMenuShown = useCallback(() => {
|
||||
focusView(
|
||||
'NewNoteButton',
|
||||
newNoteRef.current,
|
||||
// Don't scroll into view: This breaks the slide-in animation on web
|
||||
{ scrollIntoView: false },
|
||||
);
|
||||
}, []);
|
||||
|
||||
return <FloatingActionButton
|
||||
mainButton={{
|
||||
@@ -78,6 +140,7 @@ const NewNoteButton: React.FC<Props> = () => {
|
||||
label: _('Add new'),
|
||||
}}
|
||||
menuContent={menuContent}
|
||||
onMenuShow={onMenuShown}
|
||||
accessibilityActions={accessibilityActions}
|
||||
onAccessibilityAction={onAccessibilityAction}
|
||||
/>;
|
||||
|
||||
@@ -155,42 +155,24 @@ const TagsScreenComponent: React.FC<Props> = props => {
|
||||
const menuItems: PromptButtonSpec[] = [];
|
||||
|
||||
const generateTagDeletion = () => {
|
||||
const options = [
|
||||
{
|
||||
text: _('Cancel'),
|
||||
onPress: () => { },
|
||||
style: 'cancel' as const,
|
||||
},
|
||||
{
|
||||
text: _('OK'),
|
||||
onPress: async () => {
|
||||
await Tag.delete(tag.id, { sourceDescription: 'tags-screen (long-press)' });
|
||||
setRefreshTrigger(prev => prev + 1);
|
||||
},
|
||||
},
|
||||
|
||||
];
|
||||
return () => {
|
||||
dialogs.prompt(
|
||||
_('Delete tag "%s"?', substrWithEllipsis(tag.title, 0, 32)),
|
||||
_('All notes associated with this tag will remain, but the tag will be removed from all notes.'),
|
||||
options,
|
||||
);
|
||||
dialogs.prompt('', _('Delete tag "%s"?\n\nAll notes associated with this tag will remain, but the tag will be removed from all notes.', substrWithEllipsis(tag.title, 0, 32)), [
|
||||
{
|
||||
text: _('OK'),
|
||||
onPress: async () => {
|
||||
await Tag.delete(tag.id, { sourceDescription: 'tags-screen (long-press)' });
|
||||
setRefreshTrigger(prev => prev + 1);
|
||||
},
|
||||
},
|
||||
{
|
||||
text: _('Cancel'),
|
||||
onPress: () => { },
|
||||
style: 'cancel',
|
||||
},
|
||||
]);
|
||||
};
|
||||
};
|
||||
|
||||
menuItems.push({
|
||||
text: _('Cancel'),
|
||||
onPress: () => {},
|
||||
style: 'cancel',
|
||||
});
|
||||
|
||||
menuItems.push({
|
||||
text: _('Delete'),
|
||||
onPress: generateTagDeletion(),
|
||||
style: 'destructive',
|
||||
});
|
||||
|
||||
menuItems.push({
|
||||
text: _('Rename'),
|
||||
onPress: async () => {
|
||||
@@ -207,6 +189,18 @@ const TagsScreenComponent: React.FC<Props> = props => {
|
||||
},
|
||||
});
|
||||
|
||||
menuItems.push({
|
||||
text: _('Delete'),
|
||||
onPress: generateTagDeletion(),
|
||||
style: 'destructive',
|
||||
});
|
||||
|
||||
menuItems.push({
|
||||
text: _('Cancel'),
|
||||
onPress: () => {},
|
||||
style: 'cancel',
|
||||
});
|
||||
|
||||
dialogs.prompt(
|
||||
'',
|
||||
_('Tag: %s', tag.title),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from 'react';
|
||||
import { useMemo, useEffect, useCallback, useContext, useState } from 'react';
|
||||
import { Easing, Animated, TouchableOpacity, Text, StyleSheet, ScrollView, View, Image, ImageStyle } from 'react-native';
|
||||
import { useMemo, useEffect, useCallback, useContext } from 'react';
|
||||
import { Easing, Animated, TouchableOpacity, Text, StyleSheet, ScrollView, View, Image, ImageStyle, Platform } from 'react-native';
|
||||
import { Dispatch } from 'redux';
|
||||
import { connect } from 'react-redux';
|
||||
import Icon from './Icon';
|
||||
@@ -20,6 +20,7 @@ import restoreItems from '@joplin/lib/services/trash/restoreItems';
|
||||
import emptyTrash from '@joplin/lib/services/trash/emptyTrash';
|
||||
import { ModelType } from '@joplin/lib/BaseModel';
|
||||
import { DialogContext } from './DialogManager';
|
||||
import { PromptButtonSpec } from './DialogManager/types';
|
||||
import { TextStyle, ViewStyle } from 'react-native';
|
||||
import { StateDecryptionWorker, StateResourceFetcher } from '@joplin/lib/reducer';
|
||||
import useOnLongPressProps from '../utils/hooks/useOnLongPressProps';
|
||||
@@ -27,8 +28,6 @@ import { TouchableRipple } from 'react-native-paper';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import getConflictFolderId from '@joplin/lib/models/utils/getConflictFolderId';
|
||||
import { substrWithEllipsis } from '@joplin/lib/string-utils';
|
||||
import BottomDrawerMenu, { MenuOption, MenuOptionStyle } from './BottomDrawerMenu';
|
||||
import { MenuAlignment } from './BottomDrawer';
|
||||
|
||||
interface Props {
|
||||
syncStarted: boolean;
|
||||
@@ -344,47 +343,48 @@ const SideMenuContentComponent = (props: Props) => {
|
||||
});
|
||||
};
|
||||
|
||||
const [folderMenuVisible, setFolderMenuVisible] = useState(false);
|
||||
const [folderMenuTitle, setFolderMenuTitle] = useState('');
|
||||
const [folderMenuOptions, setFolderMenuOptions] = useState<MenuOption[]>([]);
|
||||
const folder_longPress = async (folderOrAll: FolderEntity | string) => {
|
||||
if (folderOrAll === 'all') return;
|
||||
|
||||
const folder = folderOrAll as FolderEntity;
|
||||
|
||||
const menuItems: MenuOption[] = [];
|
||||
const menuItems: PromptButtonSpec[] = [];
|
||||
menuItems.push({
|
||||
text: _('Cancel'),
|
||||
onPress: () => {},
|
||||
style: 'cancel',
|
||||
});
|
||||
|
||||
if (folder && folder.id === getConflictFolderId()) return;
|
||||
|
||||
if (folder && folder.id === getTrashFolderId()) {
|
||||
menuItems.push({
|
||||
title: _('Empty trash'),
|
||||
icon: 'material delete-outline',
|
||||
text: _('Empty trash'),
|
||||
onPress: async () => {
|
||||
dialogs.prompt('', _('This will permanently delete all items in the trash. Continue?'), [
|
||||
{
|
||||
text: _('Cancel'),
|
||||
onPress: () => { },
|
||||
style: 'cancel',
|
||||
},
|
||||
{
|
||||
text: _('Empty trash'),
|
||||
onPress: async () => {
|
||||
await emptyTrash();
|
||||
},
|
||||
},
|
||||
{
|
||||
text: _('Cancel'),
|
||||
onPress: () => { },
|
||||
style: 'cancel',
|
||||
},
|
||||
]);
|
||||
},
|
||||
style: MenuOptionStyle.Destructive,
|
||||
style: 'destructive',
|
||||
});
|
||||
|
||||
} else if (folder && !!folder.deleted_time) {
|
||||
menuItems.push({
|
||||
title: _('Restore'),
|
||||
icon: 'material delete-off-outline',
|
||||
text: _('Restore'),
|
||||
onPress: async () => {
|
||||
await restoreItems(ModelType.Folder, [folder.id]);
|
||||
},
|
||||
style: 'destructive',
|
||||
});
|
||||
|
||||
// Alert.alert(
|
||||
@@ -434,15 +434,13 @@ const SideMenuContentComponent = (props: Props) => {
|
||||
return folderDeletion(_('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)));
|
||||
};
|
||||
|
||||
const deleteButton: MenuOption = {
|
||||
title: _('Delete'),
|
||||
const deleteButton: PromptButtonSpec = {
|
||||
text: _('Delete'),
|
||||
onPress: generateFolderDeletion,
|
||||
icon: 'material delete-outline',
|
||||
style: MenuOptionStyle.Destructive,
|
||||
style: 'destructive',
|
||||
};
|
||||
const editButton: MenuOption = {
|
||||
title: _('Edit'),
|
||||
icon: 'material file-edit-outline',
|
||||
const editButton: PromptButtonSpec = {
|
||||
text: _('Edit'),
|
||||
onPress: () => {
|
||||
props.dispatch({ type: 'SIDE_MENU_CLOSE' });
|
||||
|
||||
@@ -454,12 +452,20 @@ const SideMenuContentComponent = (props: Props) => {
|
||||
},
|
||||
};
|
||||
|
||||
menuItems.push(editButton, deleteButton);
|
||||
// On iOS, "cancel" is always shown at the bottom. Push the edit button first
|
||||
// so that "delete" is still shown in the middle.
|
||||
if (Platform.OS === 'ios') {
|
||||
menuItems.push(editButton, deleteButton);
|
||||
} else {
|
||||
menuItems.push(deleteButton, editButton);
|
||||
}
|
||||
}
|
||||
|
||||
setFolderMenuOptions(menuItems);
|
||||
setFolderMenuTitle(_('Notebook: %s', folder.title));
|
||||
setFolderMenuVisible(true);
|
||||
dialogs.prompt(
|
||||
'',
|
||||
_('Notebook: %s', folder.title),
|
||||
menuItems,
|
||||
);
|
||||
};
|
||||
|
||||
const folder_togglePress = (folder: FolderEntity) => {
|
||||
@@ -734,14 +740,6 @@ const SideMenuContentComponent = (props: Props) => {
|
||||
</ScrollView>
|
||||
{renderBottomPanel()}
|
||||
</View>
|
||||
<BottomDrawerMenu
|
||||
visible={folderMenuVisible}
|
||||
onDismiss={() => setFolderMenuVisible(false)}
|
||||
themeId={props.themeId}
|
||||
alignment={MenuAlignment.Center}
|
||||
title={folderMenuTitle}
|
||||
options={folderMenuOptions}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -78,27 +78,7 @@ const useWebViewSetup = ({
|
||||
${setInitialSelectionJs}
|
||||
${setInitialSearchJs}
|
||||
|
||||
let scrollAllowed = false;
|
||||
let scrollAllowedTimeout;
|
||||
|
||||
window.onclick = () => {
|
||||
scrollAllowed = true;
|
||||
|
||||
// Allow scrolling into view for a brief period after clicking / tapping the editor, which is what triggers opening
|
||||
// the on-screen keyboard. A 1 second delay is needed to give time for the onresize event to trigger, then disallow
|
||||
// the scroll so that other actions which trigger resize events will not cause unwanted scrolling. A small caveat is
|
||||
// that dismissing the keyboard within 1 second of tapping may still cause a scroll, but this is acceptable given a
|
||||
// user would be unable to scroll away much from the cursor position in just 1 second.
|
||||
clearTimeout(scrollAllowedTimeout);
|
||||
scrollAllowedTimeout = setTimeout(() => {
|
||||
scrollAllowed = false;
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
window.onresize = () => {
|
||||
if (!scrollAllowed) return;
|
||||
// Do not reset scrollAllowed here, let scrollAllowedTimeout do it, because multiple resize events fire on keyboard open,
|
||||
// so scroll should not be suppressed after the first one
|
||||
cm.execCommand('scrollSelectionIntoView');
|
||||
};
|
||||
} else if (parentClassName) {
|
||||
|
||||
@@ -597,7 +597,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 161;
|
||||
CURRENT_PROJECT_VERSION = 160;
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Joplin/Info.plist;
|
||||
@@ -606,7 +606,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 13.7.2;
|
||||
MARKETING_VERSION = 13.7.1;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
@@ -632,7 +632,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 161;
|
||||
CURRENT_PROJECT_VERSION = 160;
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
INFOPLIST_FILE = Joplin/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
|
||||
@@ -640,7 +640,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 13.7.2;
|
||||
MARKETING_VERSION = 13.7.1;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
@@ -670,7 +670,7 @@
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
FORCE_BUNDLING = YES;
|
||||
@@ -709,7 +709,7 @@
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
FORCE_BUNDLING = YES;
|
||||
@@ -910,7 +910,7 @@
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 161;
|
||||
CURRENT_PROJECT_VERSION = 160;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
@@ -921,7 +921,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 13.7.2;
|
||||
MARKETING_VERSION = 13.7.1;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
OTHER_LDFLAGS = (
|
||||
@@ -953,7 +953,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 161;
|
||||
CURRENT_PROJECT_VERSION = 160;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
@@ -964,7 +964,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 13.7.2;
|
||||
MARKETING_VERSION = 13.7.1;
|
||||
MTL_FAST_MATH = YES;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
|
||||
Loaded 100 of 263 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user