Compare commits

..
Author SHA1 Message Date
Laurent Cozic 87211d1996 update 2026-07-24 13:16:00 +01:00
Laurent Cozic 26f7166200 update 2026-07-24 13:06:20 +01:00
263 changed files with 5635 additions and 10732 deletions

No files matched your search

-9
View File
@@ -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
+1 -3
View File
@@ -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
+1 -2
View File
@@ -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}'
+1 -2
View File
@@ -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
+2 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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" \
+18 -21
View File
@@ -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")
+1 -1
View File
@@ -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 &amp; Facebook Growth Services at Socialboom" width="256" src="https://joplinapp.org/images/sponsors/Socialboom.png" alt="Buy YouTube, Instagram, TikTok &amp; 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 &amp; Facebook Growth Services at Socialboom" width="256" src="https://joplinapp.org/images/sponsors/Socialboom.png" alt="Buy YouTube, Instagram, TikTok &amp; Facebook Growth Services at Socialboom"/></a>
<!-- SPONSORS-ORG -->
* * *
+1 -1
View File
@@ -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
View File
@@ -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"
}
}
+1 -1
View File
@@ -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="{&quot;isCached&quot;: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="{&quot;isCached&quot;: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="{&quot;isCached&quot;:false}" href="evernote:///view/312260475/s710/0c61536b-4d58-e7ee-1070-7a1a50d94852/e312cdf9-8314-440f-91a6-4bfca42da635">Test note</a>. <a type="inline-richlink" rev="{&quot;isCached&quot;: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>
+1 -2
View File
@@ -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 },
]);
+9 -1
View File
@@ -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,
-9
View File
@@ -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>
+15 -17
View File
@@ -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 {
+7 -12
View File
@@ -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;
+1 -7
View File
@@ -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,
@@ -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) => {
+1 -3
View File
@@ -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);
+1 -3
View File
@@ -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();
+14 -23
View File
@@ -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
View File
@@ -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;
+2 -2
View File
@@ -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'}\""
+69 -145
View File
@@ -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 (
+3 -5
View File
@@ -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;
}
+18 -48
View File
@@ -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
@@ -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);
@@ -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}
/>;
+26 -32
View File
@@ -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