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
Laurent Cozic 09f6a209dd Chore: Retry "Install yarn" CI step on failure 2026-07-22 11:34:47 +01:00
renovate[bot] e983444b96 fix(deps): update dependency @react-native-documents/picker to v12 (#16008)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-22 11:29:33 +01:00
Henry Heino aed692d5c6 Chore: Server: Prevent regenerating database types when there are migrations left over from switching branches (#16006) 2026-07-22 11:29:18 +01:00
Laurent Cozic 96e78ad023 Desktop: Sync Wizard: Make Joplin Cloud the primary sync option (#16004) 2026-07-22 11:29:05 +01:00
Laurent Cozic b598fa2911 Doc: Fixed price of JSB license 2026-07-21 19:22:40 +01:00
Laurent Cozic 248ef10ab2 Doc: enable self-hosting self-service 2026-07-21 18:29:05 +01:00
Henry Heino 9395c9fa87 Desktop: Resolves #15880: Avoid including unused onnxruntime-node files in the built application (#15997) 2026-07-21 17:16:31 +01:00
Henry Heino 151faf9f88 Mobile: Note actions menu: Decrease padding size (#15998) 2026-07-21 10:15:40 +01:00
Henry Heino 4246e09f89 Mobile: Note action menu: Vertically align icons (#15996) 2026-07-21 00:17:53 +01:00
renovate[bot] 75cdbafba9 chore(deps): update nvidia/cuda docker tag to v13.2.1 (#15992)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-20 00:04:48 +01:00
renovate[bot] 341948e0ba fix(deps): update dependency @react-native-vector-icons/material-design-icons to v12.5.0 (#15980)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-19 09:04:26 +01:00
renovate[bot] e3b51d850f fix(deps): update dependency @fortawesome/react-fontawesome to v3.3.1 (#15989)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-19 09:04:12 +01:00
renovate[bot] e6a32029b4 chore(deps): update dependency @types/serviceworker to v0.0.195 (#15988)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-19 09:03:54 +01:00
renovate[bot] 7798a33739 fix(deps): update dependency @react-native-vector-icons/material-icons to v12.5.0 (#15984)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-19 00:15:58 +01:00
mrjo118 f5941b28d4 Desktop, Mobile, Cli: Fixes #15975: Fix WebDAV support for Koofr (#15983) 2026-07-18 21:05:39 +01:00
Laurent Cozic 70846df6b1 Desktop: Make links in the table editor clickable with Ctrl/Cmd+click (#15977) 2026-07-18 20:52:51 +01:00
renovate[bot] 1321b154da fix(deps): update dependency @react-native-vector-icons/ionicons to v12.5.0 (#15979)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-18 20:51:28 +01:00
renovate[bot] 6a21ec6cc1 fix(deps): update dependency @react-native-vector-icons/fontawesome5 to v12.4.0 (#15978)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-18 20:51:24 +01:00
Laurent Cozic b5ed2c944e Desktop, Server: Record which app screen a Joplin Cloud sign-up started from
Adds an optional "source" label to the Joplin Cloud sign-up link (e.g.
"desktop-sync-wizard") so we can see which screens lead people to subscribe.
The label is a fixed string naming the button that was clicked - it carries
no personal or device information. It travels with the checkout as Stripe
metadata and is saved on the subscription record.
2026-07-18 16:05:00 +01:00
mrjo118andLaurent Cozic fa6d4a259b Mobile: Fixes #15914: Fix auto navigate to latest notebook shortly after app start, when the selected notebook was previously deleted (#15950)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-07-18 11:03:52 +01:00
Jonatan NybergandLaurent Cozic 32c29dd2f6 Improve Swedish PO localisation (#15936)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-07-18 11:03:21 +01:00
Stephan Paternotte de93ebed45 Update nl_NL.po (#15921) 2026-07-18 11:02:58 +01:00
Aissa BenfoddaandLaurent Cozic 154f12744d Desktop, Mobile: Resolves #15828:Provide a clear message on what needs to be done when sync fails due to wrong version (#15837)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-07-18 11:01:46 +01:00
Aissa Benfodda 2c11cbbb6b Desktop: Publish a notebook (#15276) 2026-07-18 10:58:57 +01:00
Henry Heino fca026f0d9 Android: Fixes #15928: Fix menu animations and drag-to-dismiss (#15971) 2026-07-18 10:58:16 +01:00
renovate[bot] e51d9f2670 fix(deps): update dependency @fortawesome/react-fontawesome to v3.3.0 (#15976)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-18 10:57:49 +01:00
Henry Heino 4c2c38f43e Android, Web: Fixes #15959: Increase minimum prompt button width (#15966) 2026-07-17 23:52:20 +01:00
Will Hanstedt dafe4cd010 Doc: Fixes 15953: dead link to pandoc deflist documentation (#15967) 2026-07-17 23:51:06 +01:00
Laurent Cozic 6aa4040762 Desktop: Fixes #15918: Keep trailing spaces in table cells while editing (#15964) 2026-07-17 23:50:33 +01:00
Laurent Cozic 22689817c7 Desktop: Whiteboard: Fix edge keeping the blue selection colour after reconnecting 2026-07-17 12:42:27 +01:00
Laurent Cozic 3515c6aee1 Desktop: Whiteboard: Allow selecting which arrow to move when two arrows share an anchor 2026-07-17 12:26:38 +01:00
mrjo118 a180f9151a Mobile: Fixes #15915: Add file extension validation to import actions (#15960) 2026-07-17 11:22:01 +01:00
Laurent Cozic 15207dc931 Desktop: Fix layout divider behaviour in nested layouts and move mode (#15955) 2026-07-17 09:39:35 +01:00
mrjo118 193a17274a Desktop: Fixes #15867: Fix cycling of the active note when other notes in the notebook are moved (#15868) 2026-07-16 23:29:35 +01:00
Keshav b0c977b2b6 Desktop: Resolves #15786: Add note lock configuration screen (#15852) 2026-07-16 23:22:27 +01:00
Henry Heino 45309e1d0a Desktop: AI chat panel: Improve accessibility (#15956) 2026-07-16 23:16:44 +01:00
Laurent Cozic f410aced1e Desktop: Whiteboard: Fixed newline handling within cards 2026-07-16 17:58:59 +01:00
renovate[bot] 6d5444565b chore(deps): update dependency @axe-core/playwright to v4.11.2 (#15952)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-16 16:42:02 +01:00
Henry Heino 54b8e69750 Chore: Fix CI (#15949) 2026-07-16 01:37:55 +01:00
Henry Heino 2e99bd00ae Desktop: AI chat panel: Fix failure when tool responses are invalid JSON (#15886) 2026-07-15 22:33:57 +01:00
Henry Heino 7524c73526 Desktop: AI chat panel: Stop retry loop after several repeated failures (#15944) 2026-07-15 22:29:31 +01:00
Henry Heino 655806e79c Chore: Server: Add Joplin Server Business-related config logic (#15945) 2026-07-15 22:29:15 +01:00
Henry Heino 68ef1623ab Desktop: AI chat panel: Support cancelling in-progress responses (#15946) 2026-07-15 22:28:48 +01:00
renovate[bot] b0f27acaf7 chore(deps): update dependency nan to v2.26.0 (#15938)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-15 18:15:56 +01:00
renovate[bot] ce475ea22e chore(deps): update nvidia/cuda docker tag to v13.2.0 (#15929)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-15 12:55:34 +01:00
Henry Heino 0faa20d827 Chore: Server: Store Stripe period start/end in the database (#15925) 2026-07-14 22:03:54 +01:00
renovate[bot] a7d1e41c09 fix(deps): update dependency nanoid to v5.1.9 (#15926)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-14 20:56:23 +01:00
Joplin Bot 4dbbf2c3f8 Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-07-14 13:52:11 +00:00
Henry Heino 24ab23045b Chore: Build: Add build-time LICENSE_REQUIRED config flag to Joplin Server (#15913) 2026-07-14 11:24:48 +01:00
Henry Heino bbc8999a45 Chore: Server: Add logic to customize the home screen for external users (#15912) 2026-07-14 11:24:37 +01:00
Henry Heino 0ef2649edd Chore: Server: Add logic for displaying Joplin Server Business status (#15909) 2026-07-14 11:24:26 +01:00
Aissa Benfodda 974198b3a6 Mobile: Update Search screen (#15736) 2026-07-14 10:26:04 +01:00
Laurent Cozic d58a0bb461 Desktop release v3.7.9 2026-07-14 09:54:16 +01:00
Laurent Cozic 5480a56ff5 Revert "Web: Fixes #15601: Fix S3 sync failing with getReader TypeError (#15851)"
This reverts commit c2b04f26df.

May have caused https://github.com/laurent22/joplin/issues/15916
2026-07-14 09:53:36 +01:00
Joplin Bot 7b2ee03091 Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-07-14 02:38:33 +00:00
Laurent Cozic 66cbacca6e Revert "Chore: Set IPHONEOS_DEPLOYMENT_TARGET to 15.6 for tests to match application version"
It appears we need v18.x for the test units

This reverts commit 1f9cec33d8.
2026-07-14 00:12:54 +01:00
Laurent Cozic 6baede235e Chore: Update ignoreDeps in renovate.json5
Added 'node-gyp', 'nitrogen', and 'react-native-nitro-modules' to ignoreDeps in renovate.json5.
2026-07-13 21:39:00 +01:00
Henry Heino 84035e579b Desktop: Fix dependencies used for semantic search (#15891) 2026-07-13 21:33:14 +01:00
Henry Heino ca7cc0fd31 Doc: Resolves #15823: Docker compose template: Don't expose PostgreSQL container port (#15908) 2026-07-13 21:32:41 +01:00
Henry Heino 34ea6c7d8e Chore: Server: Add a utility function for determining the number of enabled non-admin users (#15907) 2026-07-13 20:49:34 +01:00
Joplin Bot ac4d934ebb Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-07-13 19:33:13 +00:00
Laurent Cozic 06a49e6988 iOS 13.7.1 2026-07-13 20:31:46 +01:00
Laurent Cozic 1f9cec33d8 Chore: Set IPHONEOS_DEPLOYMENT_TARGET to 15.6 for tests to match application version 2026-07-13 20:31:00 +01:00
renovate[bot] 39aefb1ad0 fix(deps): update dependency react-native-paper to v5.15.1 (#15905)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-13 20:09:37 +01:00
Laurent Cozic 103ab78b09 Android 3.7.3 2026-07-13 16:07:54 +01:00
Laurent Cozic 674f45d06b Desktop release v3.7.8 2026-07-13 15:52:47 +01:00
Aissa Benfodda 9367ec21f0 Desktop: Resolves #15842: Unable to select text within links (#15871) 2026-07-13 15:52:34 +01:00
Laurent Cozic 5178633558 Desktop: Whiteboard: Place new groups in empty space instead of viewport centre (#15901) 2026-07-13 13:54:29 +01:00
Laurent Cozic 8949fa25f7 Chore: Desktop: Fixed action dispatching error on startup 2026-07-13 00:02:27 +01:00
189 changed files with 6893 additions and 1388 deletions

No files matched your search

+2
View File
@@ -17,4 +17,6 @@ packages/server/db-*.sqlite
packages/server/dist/
packages/server/logs/
packages/server/temp/
packages/server/assets/license/signingKey.private.*.pem
packages/server/readme/
packages/transcribe/.env
+1 -3
View File
@@ -42,9 +42,7 @@ jobs:
node-version: '24'
cache: 'yarn'
- name: Install Yarn
run: |
corepack enable
- uses: ./.github/workflows/shared/install-yarn
- name: Install
run: yarn install
+1 -4
View File
@@ -15,10 +15,7 @@ jobs:
node-version: '24'
cache: 'yarn'
- name: Install Yarn
run: |
# https://yarnpkg.com/getting-started/install
corepack enable
- uses: ./.github/workflows/shared/install-yarn
- name: Install macOs dependencies
if: runner.os == 'macOS'
+1 -4
View File
@@ -164,10 +164,7 @@ jobs:
docker builder prune -af || true
sudo rm -rf /var/lib/docker/tmp/* || true
- name: Install Yarn
run: |
# https://yarnpkg.com/getting-started/install
corepack enable
- uses: ./.github/workflows/shared/install-yarn
- name: Build Docker Image
env:
@@ -0,0 +1,22 @@
name: 'Install Yarn'
description: 'Enable Corepack and install the pinned Yarn version, with retries'
runs:
using: 'composite'
steps:
- name: Install Yarn
shell: bash
# https://yarnpkg.com/getting-started/install
#
# `corepack enable` only sets up the shims - Corepack downloads the
# pinned Yarn version from repo.yarnpkg.com on first use. That fetch has
# a short timeout and no built-in retry, so a transient network blip on
# CI fails the whole job. Prepare it eagerly with a retry loop.
env:
COREPACK_NETWORK_TIMEOUT: '60000'
run: |
corepack enable
for i in 1 2 3; do
corepack prepare --activate && break
echo "corepack prepare failed (attempt $i), retrying in 15s..."
sleep 15
done
@@ -60,11 +60,7 @@ runs:
# See https://github.com/laurent22/joplin/commit/47d0d3eb9e89153a609fb5441344da10904c6308#commitcomment-159577783.
# cache: ${{ (!contains(runner.os, 'arm') && 'yarn') || '' }}
- name: Install Yarn
shell: bash
run: |
# https://yarnpkg.com/getting-started/install
corepack enable
- uses: ./.github/workflows/shared/install-yarn
# macos-latest ships with Python 3.12 by default, but this removes a
# utility that's used by electron-builder (distutils) so we need to pin
+15
View File
@@ -191,6 +191,7 @@ packages/app-desktop/commands/toggleSafeMode.js
packages/app-desktop/commands/toggleTabMovesFocus.js
packages/app-desktop/gui/AiDegradedNotice.js
packages/app-desktop/gui/Button/Button.js
packages/app-desktop/gui/ChatPanel/ChatMessageItem.js
packages/app-desktop/gui/ChatPanel/ChatPanel.js
packages/app-desktop/gui/ClipperConfigScreen.js
packages/app-desktop/gui/ConfigScreen/ButtonBar.js
@@ -203,6 +204,7 @@ packages/app-desktop/gui/ConfigScreen/controls/FontSearch.js
packages/app-desktop/gui/ConfigScreen/controls/GlobalHotkeyInput.test.js
packages/app-desktop/gui/ConfigScreen/controls/GlobalHotkeyInput.js
packages/app-desktop/gui/ConfigScreen/controls/MissingPasswordHelpLink.js
packages/app-desktop/gui/ConfigScreen/controls/NoteLockSettings.js
packages/app-desktop/gui/ConfigScreen/controls/SettingComponent.js
packages/app-desktop/gui/ConfigScreen/controls/SettingDescription.js
packages/app-desktop/gui/ConfigScreen/controls/SettingHeader.js
@@ -211,6 +213,7 @@ packages/app-desktop/gui/ConfigScreen/controls/ToggleAdvancedSettingsButton.js
packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.js
packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.js
packages/app-desktop/gui/ConfigScreen/controls/plugins/SearchPlugins.js
packages/app-desktop/gui/ConfigScreen/controls/useNoteLockMode.js
packages/app-desktop/gui/ConfigScreen/searchHighlight.test.js
packages/app-desktop/gui/ConfigScreen/searchHighlight.js
packages/app-desktop/gui/DialogButtonRow.js
@@ -424,6 +427,7 @@ packages/app-desktop/gui/PopupNotification/types.js
packages/app-desktop/gui/ProfileEditor.js
packages/app-desktop/gui/PromptDialog.test.js
packages/app-desktop/gui/PromptDialog.js
packages/app-desktop/gui/PublishFolderDialog.js
packages/app-desktop/gui/QuitSyncDialog.js
packages/app-desktop/gui/ResizableLayout/LayoutItemContainer.js
packages/app-desktop/gui/ResizableLayout/MoveButtons.js
@@ -438,6 +442,8 @@ packages/app-desktop/gui/ResizableLayout/utils/persist.test.js
packages/app-desktop/gui/ResizableLayout/utils/persist.js
packages/app-desktop/gui/ResizableLayout/utils/removeItem.js
packages/app-desktop/gui/ResizableLayout/utils/removeKeylessItems.js
packages/app-desktop/gui/ResizableLayout/utils/resizeLogic.test.js
packages/app-desktop/gui/ResizableLayout/utils/resizeLogic.js
packages/app-desktop/gui/ResizableLayout/utils/setLayoutItemProps.js
packages/app-desktop/gui/ResizableLayout/utils/style.js
packages/app-desktop/gui/ResizableLayout/utils/types.js
@@ -496,6 +502,7 @@ 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/duplicateNote.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/editAlarm.js
@@ -533,6 +540,7 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showModalMessage.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showNoteContentProperties.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showNoteProperties.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showPrompt.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showPublishFolderDialog.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showShareFolderDialog.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showShareNoteDialog.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showSpellCheckerMenu.test.js
@@ -583,6 +591,7 @@ packages/app-desktop/integration-tests/goToAnything.spec.js
packages/app-desktop/integration-tests/main.spec.js
packages/app-desktop/integration-tests/markdownEditor.spec.js
packages/app-desktop/integration-tests/models/ChangeAppLayoutScreen.js
packages/app-desktop/integration-tests/models/ChatPanel.js
packages/app-desktop/integration-tests/models/EditorCodeDialog.js
packages/app-desktop/integration-tests/models/GoToAnything.js
packages/app-desktop/integration-tests/models/MainScreen.js
@@ -646,6 +655,7 @@ packages/app-desktop/services/plugins/types.js
packages/app-desktop/services/restart.js
packages/app-desktop/services/spellChecker/SpellCheckerServiceDriverNative.js
packages/app-desktop/tools/bundleJs.js
packages/app-desktop/tools/cleanOnnxRuntime.js
packages/app-desktop/tools/copy7Zip.js
packages/app-desktop/tools/generateLatestArm64Yml.js
packages/app-desktop/tools/githubReleasesUtils.js
@@ -1365,6 +1375,7 @@ packages/lib/components/shared/NoteEditor/WarningBanner/useEditorTypeMigrationBa
packages/lib/components/shared/NoteList/getEmptyFolderMessage.js
packages/lib/components/shared/NoteRevisionViewer/getHelpMessage.js
packages/lib/components/shared/NoteRevisionViewer/useDeleteHistoryClick.js
packages/lib/components/shared/PublishFolderDialog/useOnPublishFolderLinkClick.js
packages/lib/components/shared/SamlShared.js
packages/lib/components/shared/ShareNoteDialog/onUnshareNoteClick.js
packages/lib/components/shared/ShareNoteDialog/types.js
@@ -1374,6 +1385,7 @@ packages/lib/components/shared/ShareNoteDialog/useShareStatusMessage.js
packages/lib/components/shared/SsoScreenShared.js
packages/lib/components/shared/config/config-search-text.js
packages/lib/components/shared/config/config-search.test.js
packages/lib/components/shared/config/config-shared.test.js
packages/lib/components/shared/config/config-shared.js
packages/lib/components/shared/config/plugins/types.js
packages/lib/components/shared/config/plugins/useOnDeleteHandler.js
@@ -1588,6 +1600,7 @@ packages/lib/services/ai/providers/ChatProviderBase.js
packages/lib/services/ai/providers/JoplinCloud.test.js
packages/lib/services/ai/providers/JoplinCloud.js
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/types.js
@@ -1890,6 +1903,8 @@ packages/lib/services/trash/permanentlyDeleteOldItems.test.js
packages/lib/services/trash/permanentlyDeleteOldItems.js
packages/lib/services/trash/restoreItems.test.js
packages/lib/services/trash/restoreItems.js
packages/lib/services/whiteboard/findEmptySpot.test.js
packages/lib/services/whiteboard/findEmptySpot.js
packages/lib/services/whiteboard/generateId.js
packages/lib/services/whiteboard/jsoncanvas.js
packages/lib/services/whiteboard/parse.js
+15
View File
@@ -217,6 +217,7 @@ packages/app-desktop/commands/toggleSafeMode.js
packages/app-desktop/commands/toggleTabMovesFocus.js
packages/app-desktop/gui/AiDegradedNotice.js
packages/app-desktop/gui/Button/Button.js
packages/app-desktop/gui/ChatPanel/ChatMessageItem.js
packages/app-desktop/gui/ChatPanel/ChatPanel.js
packages/app-desktop/gui/ClipperConfigScreen.js
packages/app-desktop/gui/ConfigScreen/ButtonBar.js
@@ -229,6 +230,7 @@ packages/app-desktop/gui/ConfigScreen/controls/FontSearch.js
packages/app-desktop/gui/ConfigScreen/controls/GlobalHotkeyInput.test.js
packages/app-desktop/gui/ConfigScreen/controls/GlobalHotkeyInput.js
packages/app-desktop/gui/ConfigScreen/controls/MissingPasswordHelpLink.js
packages/app-desktop/gui/ConfigScreen/controls/NoteLockSettings.js
packages/app-desktop/gui/ConfigScreen/controls/SettingComponent.js
packages/app-desktop/gui/ConfigScreen/controls/SettingDescription.js
packages/app-desktop/gui/ConfigScreen/controls/SettingHeader.js
@@ -237,6 +239,7 @@ packages/app-desktop/gui/ConfigScreen/controls/ToggleAdvancedSettingsButton.js
packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.js
packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.js
packages/app-desktop/gui/ConfigScreen/controls/plugins/SearchPlugins.js
packages/app-desktop/gui/ConfigScreen/controls/useNoteLockMode.js
packages/app-desktop/gui/ConfigScreen/searchHighlight.test.js
packages/app-desktop/gui/ConfigScreen/searchHighlight.js
packages/app-desktop/gui/DialogButtonRow.js
@@ -450,6 +453,7 @@ packages/app-desktop/gui/PopupNotification/types.js
packages/app-desktop/gui/ProfileEditor.js
packages/app-desktop/gui/PromptDialog.test.js
packages/app-desktop/gui/PromptDialog.js
packages/app-desktop/gui/PublishFolderDialog.js
packages/app-desktop/gui/QuitSyncDialog.js
packages/app-desktop/gui/ResizableLayout/LayoutItemContainer.js
packages/app-desktop/gui/ResizableLayout/MoveButtons.js
@@ -464,6 +468,8 @@ packages/app-desktop/gui/ResizableLayout/utils/persist.test.js
packages/app-desktop/gui/ResizableLayout/utils/persist.js
packages/app-desktop/gui/ResizableLayout/utils/removeItem.js
packages/app-desktop/gui/ResizableLayout/utils/removeKeylessItems.js
packages/app-desktop/gui/ResizableLayout/utils/resizeLogic.test.js
packages/app-desktop/gui/ResizableLayout/utils/resizeLogic.js
packages/app-desktop/gui/ResizableLayout/utils/setLayoutItemProps.js
packages/app-desktop/gui/ResizableLayout/utils/style.js
packages/app-desktop/gui/ResizableLayout/utils/types.js
@@ -522,6 +528,7 @@ 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/duplicateNote.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/editAlarm.js
@@ -559,6 +566,7 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showModalMessage.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showNoteContentProperties.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showNoteProperties.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showPrompt.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showPublishFolderDialog.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showShareFolderDialog.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showShareNoteDialog.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/showSpellCheckerMenu.test.js
@@ -609,6 +617,7 @@ packages/app-desktop/integration-tests/goToAnything.spec.js
packages/app-desktop/integration-tests/main.spec.js
packages/app-desktop/integration-tests/markdownEditor.spec.js
packages/app-desktop/integration-tests/models/ChangeAppLayoutScreen.js
packages/app-desktop/integration-tests/models/ChatPanel.js
packages/app-desktop/integration-tests/models/EditorCodeDialog.js
packages/app-desktop/integration-tests/models/GoToAnything.js
packages/app-desktop/integration-tests/models/MainScreen.js
@@ -672,6 +681,7 @@ packages/app-desktop/services/plugins/types.js
packages/app-desktop/services/restart.js
packages/app-desktop/services/spellChecker/SpellCheckerServiceDriverNative.js
packages/app-desktop/tools/bundleJs.js
packages/app-desktop/tools/cleanOnnxRuntime.js
packages/app-desktop/tools/copy7Zip.js
packages/app-desktop/tools/generateLatestArm64Yml.js
packages/app-desktop/tools/githubReleasesUtils.js
@@ -1391,6 +1401,7 @@ packages/lib/components/shared/NoteEditor/WarningBanner/useEditorTypeMigrationBa
packages/lib/components/shared/NoteList/getEmptyFolderMessage.js
packages/lib/components/shared/NoteRevisionViewer/getHelpMessage.js
packages/lib/components/shared/NoteRevisionViewer/useDeleteHistoryClick.js
packages/lib/components/shared/PublishFolderDialog/useOnPublishFolderLinkClick.js
packages/lib/components/shared/SamlShared.js
packages/lib/components/shared/ShareNoteDialog/onUnshareNoteClick.js
packages/lib/components/shared/ShareNoteDialog/types.js
@@ -1400,6 +1411,7 @@ packages/lib/components/shared/ShareNoteDialog/useShareStatusMessage.js
packages/lib/components/shared/SsoScreenShared.js
packages/lib/components/shared/config/config-search-text.js
packages/lib/components/shared/config/config-search.test.js
packages/lib/components/shared/config/config-shared.test.js
packages/lib/components/shared/config/config-shared.js
packages/lib/components/shared/config/plugins/types.js
packages/lib/components/shared/config/plugins/useOnDeleteHandler.js
@@ -1614,6 +1626,7 @@ packages/lib/services/ai/providers/ChatProviderBase.js
packages/lib/services/ai/providers/JoplinCloud.test.js
packages/lib/services/ai/providers/JoplinCloud.js
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/types.js
@@ -1916,6 +1929,8 @@ packages/lib/services/trash/permanentlyDeleteOldItems.test.js
packages/lib/services/trash/permanentlyDeleteOldItems.js
packages/lib/services/trash/restoreItems.test.js
packages/lib/services/trash/restoreItems.js
packages/lib/services/whiteboard/findEmptySpot.test.js
packages/lib/services/whiteboard/findEmptySpot.js
packages/lib/services/whiteboard/generateId.js
packages/lib/services/whiteboard/jsoncanvas.js
packages/lib/services/whiteboard/parse.js
@@ -216,6 +216,11 @@
applyPeriod(period);
});
// Keep the ?source= label so it can be sent to Stripe at checkout.
// Only overwrite when the param is present, to preserve an earlier one.
const sourceParam = urlQuery.get('source');
if (sourceParam) localStorage.setItem('source', sourceParam);
setupBetaHandling(urlQuery);
if (urlQuery.get('period') === 'monthly') {
// Nothing - this is the default
+3
View File
@@ -89,6 +89,9 @@ RUN pm2 install https://registry.npmjs.org/pm2-logrotate/-/pm2-logrotate-3.0.0.t
&& pm2 set pm2-logrotate:retain 5 \
&& pm2 set pm2-logrotate:compress true
ARG LICENSE_REQUIRED="0"
ENV LICENSE_REQUIRED=${LICENSE_REQUIRED}
ENV NODE_ENV=production
ENV RUNNING_IN_DOCKER=1
EXPOSE ${APP_PORT}
+1 -1
View File
@@ -23,7 +23,7 @@
FROM ghcr.io/ggml-org/llama.cpp:full-cuda-b5449 AS llama-cuda
# Stage 2: our runtime image.
FROM nvidia/cuda:13.1.1-cudnn-runtime-ubuntu22.04
FROM nvidia/cuda:13.2.1-cudnn-runtime-ubuntu22.04
RUN apt-get update \
&& apt-get install -y \
+2 -2
View File
@@ -30,8 +30,8 @@ services:
- ./data/postgres:/var/lib/postgresql/data
networks:
- app-network
ports:
- "5432:5432"
# (Optional): Allow accessing the database using an external tool (e.g. `psql`) from the host:
# ports: [ "127.0.0.1:5432:5432" ]
restart: unless-stopped
environment:
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
+2 -1
View File
@@ -112,6 +112,7 @@
"@codemirror/language": "6.12.3",
"@codemirror/lint": "6.9.5",
"@codemirror/autocomplete": "6.20.1",
"onnxruntime-node": "1.24.3",
"eslint": "patch:eslint@9.39.4#./.yarn/patches/eslint-npm-9.39.4-81a84865c0.patch",
"yauzl": "3.3.1",
"react-native-camera@4.2.1": "patch:react-native-camera@npm%3A4.2.1#./.yarn/patches/react-native-camera-npm-4.2.1-24b2600a7e.patch",
@@ -130,7 +131,7 @@
"pdfjs-dist@*": "patch:pdfjs-dist@npm%3A3.11.174#./.yarn/patches/pdfjs-dist-npm-3.11.174-67f2fee6d6.patch",
"pdfjs-dist@3.11.174": "patch:pdfjs-dist@npm%3A3.11.174#./.yarn/patches/pdfjs-dist-npm-3.11.174-67f2fee6d6.patch",
"canvas@npm:^2.11.2": "link:./.yarn/joplin-empty-package/",
"@xenova/transformers/sharp": "link:./.yarn/joplin-empty-package/",
"@huggingface/transformers/sharp": "link:./.yarn/joplin-empty-package/",
"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",
+1 -1
View File
@@ -134,7 +134,7 @@ describe('app.reducer', () => {
message: buildMessage(
ChatRole.Assistant,
'Testing',
[{ toolName: 'testTool', callId: 'call-1', arguments: { arg: 1 } }],
[{ toolName: 'testTool', callId: 'call-1', arguments: { arg: 1 }, parseError: null }],
),
});
state = appReducer(state, {
+6 -6
View File
@@ -68,7 +68,7 @@ import Note from '@joplin/lib/models/Note';
import Resource from '@joplin/lib/models/Resource';
import AiService from '@joplin/lib/services/ai/AiService';
import LocalEmbeddingProvider from '@joplin/lib/services/ai/LocalEmbeddingProvider';
import { installAiStatusBridge } from './services/aiStatusBridge';
import { installAiStatusBridge, AiStatusStore } from './services/aiStatusBridge';
const perfLogger = PerformanceLogger.create();
@@ -507,6 +507,11 @@ class Application extends BaseApplication {
this.initRedux();
// BaseApplication.store() is typed against the shared State; the
// runtime store carries AppState. The bridge only needs dispatch and
// getState, so narrow through unknown.
installAiStatusBridge(this.store() as unknown as AiStatusStore);
initializeCommandService(this.store(), Setting.value('env') === 'dev');
const keymapService = KeymapService.instance();
@@ -796,11 +801,6 @@ class Application extends BaseApplication {
AiService.instance().setEmbeddingProvider(new LocalEmbeddingProvider());
}
// BaseApplication.store() is typed against the shared State; the
// runtime store carries AppState. The bridge only needs dispatch and
// getState, so narrow through unknown.
installAiStatusBridge(this.store() as unknown as import('./services/aiStatusBridge').AiStatusStore);
await this.applySettingsSideEffects();
if (Setting.value('sync.upgradeState') === Setting.SYNC_UPGRADE_STATE_MUST_DO) {
+6
View File
@@ -36,6 +36,12 @@ async function fetchLatestReleases() {
return (await response.json()) as GitHubRelease[];
}
export async function isReleaseVersion(version: string): Promise<boolean | null> {
const releases = await fetchLatestReleases();
const release = releases.find(release => release.tag_name === `v${version}`);
return release ? !release.prerelease : null;
}
function truncateText(text: string, length: number) {
let truncated = text.substring(0, length);
const lastNewLine = truncated.lastIndexOf('\n');
@@ -0,0 +1,56 @@
import * as React from 'react';
import { _ } from '@joplin/lib/locale';
import { AiChatMessage } from '../../app.reducer';
import InlineMarkdownDisplay from '../InlineMarkdownDisplay';
import { ChatMessage, ChatRole } from '@joplin/lib/services/ai/types';
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 {
message: AiChatMessage;
}
const ChatMessageItem: React.FC<Props> = ({ message }) => {
if (message.role === 'separator') {
return <div className='separator'>{message.text}</div>;
}
if (message.role === 'error') {
return <div className='error'>{message.text}</div>;
}
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;
const renderMarkdown = message.role === 'assistant';
const content = renderMarkdown
? <InlineMarkdownDisplay
className='content'
markdown={textContent}
allowLinks={false} />
: <div className='content'>{textContent}</div>;
return (
<div className={`chat-message turn -${message.role}`}>
{content}
{summary && (
<div className='meta'>
{(message.editsMissed ?? 0) > 0
? <span className='warning'>{summary}</span>
: <span>{summary}</span>}
</div>
)}
</div>
);
};
export default ChatMessageItem;
@@ -1,5 +1,5 @@
import * as React from 'react';
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useContext, useEffect, useId, useMemo, useRef, useState } from 'react';
import { connect } from 'react-redux';
import { Dispatch } from 'redux';
import { _ } from '@joplin/lib/locale';
@@ -17,7 +17,7 @@ import { ChatMessage, ChatRole, ChatToolMessage } from '@joplin/lib/services/ai/
import JoplinError from '@joplin/lib/JoplinError';
import eventManager, { EventName, ItemChangeEvent } from '@joplin/lib/eventManager';
import { Second } from '@joplin/utils/time';
import InlineMarkdownDisplay from '../InlineMarkdownDisplay';
import ChatMessageItem from './ChatMessageItem';
const logger = Logger.create('ChatPanel');
@@ -39,16 +39,6 @@ const disclosureSetting = 'ai.chat.disclosureAcknowledged';
let nextMessageId = 0;
const makeId = () => `m-${Date.now()}-${++nextMessageId}`;
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);
};
const waitForNextNoteChangeOrTimeout = (noteId: string, timeout: number) => {
return new Promise<void>((resolve) => {
const listener = (event: ItemChangeEvent) => {
@@ -91,6 +81,24 @@ const useCancelCallback = () => {
return { abortControllerRef, cancelRequest };
};
const useHasFocus = () => {
const [hasFocus, setHasFocus] = useState(false);
const onFocus = useCallback(() => setHasFocus(true), []);
const onBlur: React.FocusEventHandler<HTMLDivElement> = useCallback((event) => {
const chatPanelContainer = event.currentTarget;
const newElementWithFocus = event.relatedTarget;
const movingFocusOut = !chatPanelContainer.contains(newElementWithFocus);
// Avoid quickly toggling hasFocus: onBlur/onFocus are emitted when moving focus between
// elements within the container.
if (movingFocusOut) {
setHasFocus(false);
}
}, []);
return { hasFocus, onFocus, onBlur };
};
// Single-window for v1: mapStateToProps hard-codes defaultWindowId and the
// toggle writes to the app-wide layout. A second window would mirror the main.
const ChatPanel: React.FC<Props> = (props) => {
@@ -113,6 +121,8 @@ const ChatPanel: React.FC<Props> = (props) => {
noteIdRef.current = props.noteId;
const messagesEndRef = useRef<HTMLDivElement>(null);
const { hasFocus, onFocus, onBlur } = useHasFocus();
const { abortControllerRef, cancelRequest } = useCancelCallback();
const windowId = useContext(WindowIdContext);
@@ -155,6 +165,11 @@ const ChatPanel: React.FC<Props> = (props) => {
const requiresDisclosure = props.providerType !== 'joplin-cloud';
const showDisclosure = requiresDisclosure && !disclosureShown && messages.length === 0;
const handleCancel = useCallback(() => {
cancelRequest();
appendMessage({ id: makeId(), role: 'assistant', text: _('(Cancelled)'), raw: [] });
}, [cancelRequest, appendMessage]);
const handleSend = useCallback(async () => {
const text = input.trim();
if (!text || sending) return;
@@ -174,7 +189,12 @@ const ChatPanel: React.FC<Props> = (props) => {
// send the prior user turn as history alongside the new prompt.
const userTurnId = makeId();
let hadSuccessfulResponse = false;
appendMessage({ id: userTurnId, role: 'user', text, raw: [] });
appendMessage({
id: userTurnId,
role: 'user',
text,
raw: [{ role: ChatRole.User, content: text }],
});
try {
const note = await Note.load(props.noteId);
@@ -305,80 +325,33 @@ const ChatPanel: React.FC<Props> = (props) => {
}
}, [handleSend, sending]);
if (!props.available) {
return (
<div className='chat-panel'>
<div className='header'>
<span className='title'>{_('AI Chat')}</span>
</div>
const renderContent = () => {
if (!props.available) {
const content = (
<div className='disabled-message'>
{props.unavailableHint}
</div>
</div>
);
}
if (props.noteIsEncrypted) {
return (
<div className='chat-panel'>
<div className='header'>
<span className='title'>{_('AI Chat')}</span>
</div>
);
return { content, showingMessages: false };
}
if (props.noteIsEncrypted) {
const content = (
<div className='disabled-message'>
{_('This note is encrypted and cannot be used with AI Chat.')}
</div>
</div>
);
}
);
return { content, showingMessages: false };
}
return (
<div className='chat-panel'>
<div className='header'>
<span className='title'>{_('AI Chat')}</span>
{messages.length > 0 && (
<button type='button' className='reset' onClick={handleReset}>{_('Reset')}</button>
)}
</div>
const content = <>
{props.aiDegraded && <AiDegradedNotice className='degraded-status' />}
<div className='messages'>
<div className='messages' aria-live={hasFocus ? 'polite' : undefined}>
{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.')}
</div>
)}
{messages.map(m => {
if (m.role === 'separator') {
return <div key={m.id} className='separator'>{m.text}</div>;
}
if (m.role === 'error') {
return <div key={m.id} className='error'>{m.text}</div>;
}
const summary = m.role === 'assistant' ? editsSummary(m.raw, m.editsApplied ?? 0, m.editsMissed ?? 0) : '';
// Always show something in the message box:
const textContent = !m.text && !summary ? _('(no message)') : m.text;
const renderMarkdown = m.role === 'assistant';
const content = renderMarkdown
? <InlineMarkdownDisplay
className='content'
markdown={textContent}
allowLinks={false} />
: <div className='content'>{textContent}</div>;
return (
<div key={m.id} className={`turn -${m.role}`}>
{content}
{summary && (
<div className='meta'>
{(m.editsMissed ?? 0) > 0
? <span className='warning'>{summary}</span>
: <span>{summary}</span>}
</div>
)}
</div>
);
})}
{messages.map(m => <ChatMessageItem key={m.id} message={m}/>)}
<div ref={messagesEndRef} />
</div>
<div className='composer'>
@@ -401,15 +374,39 @@ const ChatPanel: React.FC<Props> = (props) => {
<button
type='button'
className='send'
onClick={() => { void handleSend(); }}
disabled={sending || !input.trim()}
aria-label={sending ? _('Sending') : _('Send')}
title={sending ? _('Sending…') : _('Send')}
onClick={sending ? handleCancel : handleSend}
disabled={!sending && !input.trim()}
aria-label={sendButtonLabel}
title={sendButtonLabel}
>
<i className={sending ? 'fas fa-spinner' : 'fas fa-paper-plane'} aria-hidden='true' />
<i className={sending ? 'fas fa-stop' : 'fas fa-paper-plane'} aria-hidden='true' />
</button>
</div>
</div>
</>;
return { content, showingMessages: messages.length > 0 };
};
const sendButtonLabel = sending ? _('Stop generating') : _('Send');
const headerId = useId();
const { content, showingMessages } = renderContent();
return (
<div
className='chat-panel'
role='region'
aria-labelledby={headerId}
onFocus={onFocus}
onBlur={onBlur}
>
<div className='header'>
<h1 className='title' id={headerId}>{_('AI Chat')}</h1>
{showingMessages && (
<button type='button' className='reset' onClick={handleReset}>{_('Reset')}</button>
)}
</div>
{content}
</div>
);
};
@@ -21,6 +21,8 @@
.chat-panel > .header > .title {
flex: 1;
font-size: inherit;
margin: 0;
}
.chat-panel > .header > .reset {
@@ -7,6 +7,8 @@ import bridge from '../../services/bridge';
import Setting, { AppType, SettingMetadataSection, SettingValueType, SyncStartupOperation } from '@joplin/lib/models/Setting';
import { AppState } from '../../app.reducer';
import EncryptionConfigScreen from '../EncryptionConfigScreen/EncryptionConfigScreen';
import NoteLockSettings from './controls/NoteLockSettings';
import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled';
import { reg } from '@joplin/lib/registry';
import { connect } from 'react-redux';
import { themeStyle } from '@joplin/lib/theme';
@@ -272,6 +274,10 @@ class ConfigScreenComponent extends React.Component<any, any> {
settingComps.push(<AiStatus key='ai_status' />);
}
if (section.name === 'noteLock' && isNoteLockEnabled()) {
settingComps.push(<NoteLockSettings key='note_lock_settings'/>);
}
if (section.name === 'sync') {
const syncTargetMd = SyncTargetRegistry.idToMetadata(settings['sync.target'] as number);
const statusStyle = { ...theme.textStyle, marginTop: 10 };
@@ -0,0 +1,54 @@
.note-lock-settings > .section {
padding-bottom: 16px;
margin-bottom: 16px;
border-bottom: 1px solid var(--joplin-divider-color);
}
.note-lock-settings > .section:last-child {
padding-bottom: 0;
margin-bottom: 0;
border-bottom: 0;
}
.note-lock-settings > .section > h2 {
margin-top: 0;
}
.note-lock-settings > .section > .form {
max-width: 420px;
}
.note-lock-settings > .section > .form > .buttons {
display: flex;
gap: 8px;
margin-top: 12px;
}
.note-lock-settings > .section > .form > .buttons + .buttons {
margin-top: 20px;
}
.note-lock-settings > .section > .form > .description {
margin-top: 8px;
margin-bottom: 0;
color: var(--joplin-color-faded);
font-style: italic;
line-height: var(--joplin-line-height);
}
.note-lock-settings > .section > .form > .reminder {
margin-top: 16px;
margin-bottom: 0;
}
.note-lock-settings > .section > .form > .warning {
padding: 12px;
border-left: 3px solid var(--joplin-color-warn);
background-color: var(--joplin-background-color);
color: var(--joplin-color);
line-height: 1.4;
}
.note-lock-settings > .section > .form > .error {
color: var(--joplin-color-error);
}
@@ -0,0 +1,251 @@
import * as React from 'react';
import { useCallback, useState } from 'react';
import { connect } from 'react-redux';
import { _ } from '@joplin/lib/locale';
import NoteLockKey from '@joplin/lib/services/noteLock/NoteLockKey';
import NoteLockSession from '@joplin/lib/services/noteLock/NoteLockSession';
import shim, { MessageBoxType } from '@joplin/lib/shim';
import { SyncInfo } from '@joplin/lib/services/synchronizer/syncInfoUtils';
import LabelledPasswordInput from '../../PasswordInput/LabelledPasswordInput';
import Button, { ButtonLevel } from '../../Button/Button';
import { AppState } from '../../../app.reducer';
import useNoteLockMode, { ActionMode } from './useNoteLockMode';
interface Props {
hasNoteLockKey: boolean;
needsNoteLockKeyUpgrade: boolean;
}
// WebCrypto reports a wrong password as an OperationError with an unhelpful generic message
const errorMessage = (error: unknown) => {
if (!(error instanceof Error)) return String(error);
if (error.name === 'OperationError') return _('Invalid password');
return error.message;
};
const NoteLockSettings: React.FC<Props> = props => {
const hasKey = props.hasNoteLockKey;
const {
mode,
onModeChange,
currentPassword,
setCurrentPassword,
password,
setPassword,
passwordRepeat,
setPasswordRepeat,
error,
setError,
} = useNoteLockMode(hasKey);
const [upgradePassword, setUpgradePassword] = useState('');
const [upgradeError, setUpgradeError] = useState('');
const [saving, setSaving] = useState(false);
const passwordMismatch = !!passwordRepeat && password !== passwordRepeat;
const canSave = !!password && !!passwordRepeat && !passwordMismatch && !saving && (mode !== ActionMode.Change || !!currentPassword);
const canUpgrade = !!upgradePassword && !saving;
const forgotPasswordDescription = _('Only do this if you\'ve forgotten your current password. A new password will be created, and any notes locked with the old one will become permanently unreadable.');
const forgotPasswordWarning = `${_('Warning:')} ${forgotPasswordDescription}`;
const resetConfirmationMessage = `${forgotPasswordWarning}\n\n${_('Continue?')}`;
const onCurrentPasswordChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
setCurrentPassword(event.target.value);
}, [setCurrentPassword]);
const onPasswordChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
setPassword(event.target.value);
}, [setPassword]);
const onPasswordRepeatChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
setPasswordRepeat(event.target.value);
}, [setPasswordRepeat]);
const onUpgradePasswordChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
setUpgradePassword(event.target.value);
}, []);
const submit = useCallback(async () => {
if (!canSave) return;
if (mode === ActionMode.Reset) {
const response = await shim.showMessageBox(resetConfirmationMessage, {
buttons: [_('Yes'), _('No')],
type: MessageBoxType.Confirm,
});
if (response !== 0) return;
}
setSaving(true);
setError('');
try {
const noteLockKey = NoteLockKey.instance();
if (mode === ActionMode.Create) {
await noteLockKey.create(password);
} else if (mode === ActionMode.Change) {
await noteLockKey.changePassword(currentPassword, password);
} else if (mode === ActionMode.Reset) {
await NoteLockSession.instance().reset(password);
}
onModeChange(ActionMode.Collapsed);
} catch (error) {
setError(errorMessage(error));
} finally {
setSaving(false);
}
}, [canSave, onModeChange, currentPassword, mode, password, setError, resetConfirmationMessage]);
const submitUpgrade = useCallback(async () => {
if (!canUpgrade) return;
setSaving(true);
setUpgradeError('');
try {
await NoteLockKey.instance().upgrade(upgradePassword);
setUpgradePassword('');
} catch (error) {
setUpgradeError(errorMessage(error));
} finally {
setSaving(false);
}
}, [canUpgrade, upgradePassword]);
const onChangePasswordClick = useCallback(() => {
onModeChange(ActionMode.Change);
}, [onModeChange]);
const onForgotPasswordClick = useCallback(() => {
onModeChange(ActionMode.Reset);
}, [onModeChange]);
const onCancel = useCallback(() => {
onModeChange(ActionMode.Collapsed);
}, [onModeChange]);
const resetPasswordTitle = `⚠️ ${_('Reset password')} ⚠️`;
const actionButtonTitle = mode === ActionMode.Reset ? resetPasswordTitle : _('Save');
const getSectionTitle = () => {
if (mode === ActionMode.Reset) return resetPasswordTitle;
if (mode === ActionMode.Change) return _('Change note lock password');
if (mode === ActionMode.Collapsed) return _('Manage password');
return _('Password setup');
};
const renderManageButtons = () => {
return (
<div className='form'>
<div className='buttons'>
<Button
title={_('Change note lock password')}
level={ButtonLevel.Secondary}
onClick={onChangePasswordClick}
/>
</div>
<div className='buttons'>
<Button
title={_('Forgot password?')}
level={ButtonLevel.Secondary}
onClick={onForgotPasswordClick}
/>
</div>
<p className='description'>{forgotPasswordDescription}</p>
</div>
);
};
const renderPasswordForm = () => {
return (
<div className='form'>
{mode === ActionMode.Reset && <p className='warning' role='alert'><strong>{_('Warning:')}</strong> {forgotPasswordDescription}</p>}
{mode === ActionMode.Change && (
<LabelledPasswordInput
labelText={_('Current password')}
value={currentPassword}
onChange={onCurrentPasswordChange}
reserveIconGutter={true}
/>
)}
<LabelledPasswordInput
labelText={mode === ActionMode.Create ? _('Password') : _('New password')}
value={password}
onChange={onPasswordChange}
reserveIconGutter={true}
/>
<LabelledPasswordInput
labelText={_('Repeat password')}
value={passwordRepeat}
onChange={onPasswordRepeatChange}
valid={passwordRepeat ? !passwordMismatch : undefined}
reserveIconGutter={true}
/>
{passwordMismatch && <p className='error'>{_('Passwords do not match')}</p>}
{error ? <p className='error' role='alert'>{error}</p> : null}
<div className='buttons'>
<Button
title={actionButtonTitle}
level={mode === ActionMode.Reset ? ButtonLevel.Secondary : ButtonLevel.Primary}
disabled={!canSave}
onClick={submit}
/>
{mode !== ActionMode.Create && (
<Button
title={_('Cancel')}
level={ButtonLevel.Secondary}
disabled={saving}
onClick={onCancel}
/>
)}
</div>
<p className='reminder'><strong>{_('Please make sure you remember your password. It cannot be recovered if lost, and any data encrypted with it will become inaccessible.')}</strong></p>
</div>
);
};
const renderUpgradeSection = () => {
if (!props.needsNoteLockKeyUpgrade) return null;
return (
<div className='section'>
<h2>{_('Key upgrade')}</h2>
<p>{_('The note lock key uses an out-dated encryption algorithm and it is recommended to upgrade it. The upgraded key will still be able to decrypt and encrypt your data as usual.')}</p>
<div className='form'>
<LabelledPasswordInput
labelText={_('Password')}
value={upgradePassword}
onChange={onUpgradePasswordChange}
reserveIconGutter={true}
/>
{upgradeError ? <p className='error' role='alert'>{upgradeError}</p> : null}
<div className='buttons'>
<Button
title={_('Upgrade key')}
level={ButtonLevel.Primary}
disabled={!canUpgrade}
onClick={submitUpgrade}
/>
</div>
</div>
</div>
);
};
return (
<div className='note-lock-settings'>
<div className='section'>
<h2>{getSectionTitle()}</h2>
{mode === ActionMode.Collapsed ? renderManageButtons() : renderPasswordForm()}
</div>
{renderUpgradeSection()}
</div>
);
};
const mapStateToProps = (state: AppState) => {
return {
hasNoteLockKey: !!new SyncInfo(state.settings['syncInfoCache']).noteLockKey,
needsNoteLockKeyUpgrade: NoteLockKey.instance().needsUpgrade(),
};
};
export default connect(mapStateToProps)(NoteLockSettings);
@@ -0,0 +1,50 @@
import { useCallback, useEffect, useState } from 'react';
export enum ActionMode {
Collapsed = 'collapsed',
Create = 'create',
Change = 'change',
Reset = 'reset',
}
const useNoteLockMode = (hasKey: boolean) => {
const [mode, setMode] = useState<ActionMode>(hasKey ? ActionMode.Collapsed : ActionMode.Create);
const [currentPassword, setCurrentPassword] = useState('');
const [password, setPassword] = useState('');
const [passwordRepeat, setPasswordRepeat] = useState('');
const [error, setError] = useState('');
const clearForm = useCallback(() => {
setCurrentPassword('');
setPassword('');
setPasswordRepeat('');
setError('');
}, []);
const onModeChange = useCallback((newMode: ActionMode) => {
setMode(newMode);
clearForm();
}, [clearForm]);
// The key can also appear or disappear through sync while this screen is open;
// clear any drafted input along with the mode switch.
useEffect(() => {
if (hasKey && mode === ActionMode.Create) onModeChange(ActionMode.Collapsed);
if (!hasKey && mode !== ActionMode.Create) onModeChange(ActionMode.Create);
}, [hasKey, mode, onModeChange]);
return {
mode,
onModeChange,
currentPassword,
setCurrentPassword,
password,
setPassword,
passwordRepeat,
setPasswordRepeat,
error,
setError,
};
};
export default useNoteLockMode;
@@ -2,6 +2,7 @@
@use "./Sidebar/style.scss" as sidebar-styles;
@use "./controls/AiIndexStatus.scss" as ai-index-status;
@use "./controls/AiStatus.scss" as ai-status;
@use "./controls/NoteLockSettings.scss" as note-lock-settings;
.config-screen-content-wrapper {
padding: 24px;
+7 -1
View File
@@ -11,6 +11,10 @@ const Root = styled.h1<{ justifyContent?: string }>`
margin-bottom: 1em;
`;
const Title = styled.span<{ justifyContent?: string }>`
text-align: ${props => props.justifyContent === 'center' ? 'center' : 'start'};
`;
interface Props {
title: string;
@@ -19,6 +23,8 @@ interface Props {
export default function DialogTitle(props: Props) {
return (
<Root justifyContent={props.justifyContent}>{props.title}</Root>
<Root justifyContent={props.justifyContent}>
<Title justifyContent={props.justifyContent}>{props.title}</Title>
</Root>
);
}
@@ -350,8 +350,7 @@ export const EncryptionConfigScreen = (props: Props) => {
}
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- PasswordInput's ChangeEventHandler is typed as a custom {value} event but the runtime hands a React.ChangeEvent through
const onPasswordInputChange = (event: any) => {
const onPasswordInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setEnableEncryptionError('');
setEnableEncryptionPassword(event.target.value);
};
@@ -108,7 +108,7 @@ const JoplinCloudScreenComponent = (props: Props) => {
) : null}
</p>
{state.active === 'LINK_USED' ? <div className="loading-animation" /> : null}
{state.active !== 'COMPLETED' ? <JoplinCloudSignUpCallToAction /> : null}
{state.active !== 'COMPLETED' ? <JoplinCloudSignUpCallToAction source='desktop-login-screen' withLeadIn={true} /> : null}
</div>
<ButtonBar onCancelClick={() => props.dispatch({ type: 'NAV_BACK' })} />
</div>
@@ -1,14 +1,34 @@
import { _ } from '@joplin/lib/locale';
import * as React from 'react';
import bridge from '../services/bridge';
import Button, { ButtonLevel } from './Button/Button';
const JoplinCloudSignUpCallToAction = () => {
interface Props {
source?: string;
primary?: boolean;
withLeadIn?: boolean;
}
const JoplinCloudSignUpCallToAction = (props: Props) => {
const source = props.source ?? 'desktop-app';
const onJoplinCloudSignUpClick = async () => {
await bridge().openExternal('https://joplinapp.org/plans/');
await bridge().openExternal(`https://joplinapp.org/plans/?source=${encodeURIComponent(source)}`);
};
if (props.primary) {
return <div className="joplin-cloud-sign-up -primary">
<Button
level={ButtonLevel.Primary}
title={_('Sign up to Joplin Cloud')}
onClick={onJoplinCloudSignUpClick}
/>
</div>;
}
return <div className="joplin-cloud-sign-up">
{props.withLeadIn ? `${_('Don\'t have an account yet?')} ` : null}
<a
href="#"
onClick={onJoplinCloudSignUpClick}
+88 -1
View File
@@ -40,6 +40,7 @@ import PluginNotification from './PluginNotification/PluginNotification';
import { Toast } from '@joplin/lib/services/plugins/api/types';
import QuitSyncDialog from './QuitSyncDialog';
import Logger from '@joplin/utils/Logger';
import checkForUpdates, { isReleaseVersion } from '../checkForUpdates';
const logger = Logger.create('MainScreen');
@@ -72,6 +73,7 @@ interface Props {
lastDeletion: StateLastDeletion;
lastDeletionNotificationTime: number;
mustUpgradeAppMessage: string;
syncTargetAppMinVersion: string;
showInvalidJoplinCloudCredential: boolean;
toast: Toast;
shouldSwitchToAppleSiliconVersion: boolean;
@@ -88,6 +90,8 @@ interface State {
noteContentPropertiesDialogOptions: Record<string, unknown>;
shareNoteDialogOptions: Record<string, unknown>;
shareFolderDialogOptions: ShareFolderDialogOptions;
syncTargetAppMinVersionIsRelease: boolean | null;
didSyncTargetAppMinVersionReleaseLoadFail: boolean;
}
const StyledUserWebviewDialogContainer = styled.div`
@@ -130,6 +134,8 @@ class MainScreenComponent extends React.Component<Props, State> {
visible: false,
folderId: '',
},
syncTargetAppMinVersionIsRelease: null,
didSyncTargetAppMinVersionReleaseLoadFail: false,
};
this.updateMainLayout(this.buildLayout(props.plugins));
@@ -347,6 +353,13 @@ class MainScreenComponent extends React.Component<Props, State> {
value: false,
});
}
if (
this.props.mustUpgradeAppMessage !== prevProps.mustUpgradeAppMessage ||
this.props.syncTargetAppMinVersion !== prevProps.syncTargetAppMinVersion
) {
void this.loadSyncTargetAppMinVersionIsRelease();
}
}
public layoutModeListenerKeyDown(event: KeyboardEvent) {
@@ -357,6 +370,7 @@ class MainScreenComponent extends React.Component<Props, State> {
public componentDidMount() {
window.addEventListener('keydown', this.layoutModeListenerKeyDown);
void this.loadSyncTargetAppMinVersionIsRelease();
}
public componentWillUnmount() {
@@ -364,6 +378,29 @@ class MainScreenComponent extends React.Component<Props, State> {
window.removeEventListener('keydown', this.layoutModeListenerKeyDown);
}
private async loadSyncTargetAppMinVersionIsRelease() {
const version = this.props.syncTargetAppMinVersion;
this.setState({
syncTargetAppMinVersionIsRelease: null,
didSyncTargetAppMinVersionReleaseLoadFail: false,
});
if (!this.props.mustUpgradeAppMessage || !version) return;
try {
const syncTargetAppMinVersionIsRelease = await isReleaseVersion(version);
if (!this.props.mustUpgradeAppMessage || this.props.syncTargetAppMinVersion !== version) return;
this.setState({
syncTargetAppMinVersionIsRelease,
didSyncTargetAppMinVersionReleaseLoadFail: syncTargetAppMinVersionIsRelease === null,
});
} catch (error) {
logger.error(error);
if (!this.props.mustUpgradeAppMessage || this.props.syncTargetAppMinVersion !== version) return;
this.setState({ didSyncTargetAppMinVersionReleaseLoadFail: true });
}
}
public rootLayoutSize() {
return {
width: window.innerWidth,
@@ -437,6 +474,17 @@ class MainScreenComponent extends React.Component<Props, State> {
</a>
);
if (!callForAction2 && message.includes(callForAction)) {
const actionIndex = message.indexOf(callForAction);
return (
<span>
{message.substring(0, actionIndex)}
{cfa}
{message.substring(actionIndex + callForAction.length)}
</span>
);
}
return (
<span>
{message}{callForAction ? ' ' : ''}
@@ -491,6 +539,10 @@ class MainScreenComponent extends React.Component<Props, State> {
shim.openUrl('https://joplinapp.org/download/');
};
const onCheckForUpdates = () => {
void checkForUpdates(false, bridge().mainWindow(), { includePreReleases: false });
};
const onRestartAndUpgrade = async () => {
Setting.setValue('sync.upgradeState', Setting.SYNC_UPGRADE_STATE_MUST_DO);
await Setting.saveAll();
@@ -572,7 +624,41 @@ class MainScreenComponent extends React.Component<Props, State> {
onViewEncryptionConfigScreen,
);
} else if (this.props.mustUpgradeAppMessage) {
msg = this.renderNotificationMessage(this.props.mustUpgradeAppMessage);
if (!this.props.syncTargetAppMinVersion) {
msg = this.renderNotificationMessage(this.props.mustUpgradeAppMessage);
} else if (this.state.didSyncTargetAppMinVersionReleaseLoadFail) {
msg = this.renderNotificationMessage(
_(
'In order to synchronise, Please upgrade your application to version %s. Joplin could not check update information.',
this.props.syncTargetAppMinVersion,
),
);
} else if (this.state.syncTargetAppMinVersionIsRelease === false && shim.isLinux()) {
const callForAction = _('Download it from GitHub Releases');
msg = this.renderNotificationMessage(
_(
'In order to synchronise, Please upgrade your application to version %s: %s or update it using your package manager',
this.props.syncTargetAppMinVersion,
callForAction,
),
callForAction,
() => shim.openUrl('https://github.com/laurent22/joplin/releases'),
);
} else if (this.state.syncTargetAppMinVersionIsRelease !== null) {
const isTargetPreRelease = this.state.syncTargetAppMinVersionIsRelease === false;
const callForAction = isTargetPreRelease ? _('Download it from GitHub Releases') : _('Check for updates');
msg = this.renderNotificationMessage(
_(
'In order to synchronise, Please upgrade your application to version %s: %s',
this.props.syncTargetAppMinVersion,
callForAction,
),
callForAction,
isTargetPreRelease ? () => shim.openUrl('https://github.com/laurent22/joplin/releases') : onCheckForUpdates,
);
} else {
msg = this.renderNotificationMessage(this.props.mustUpgradeAppMessage);
}
} else if (this.props.shouldSwitchToAppleSiliconVersion) {
msg = this.renderNotificationMessage(
_('You are running the Intel version of Joplin on an Apple Silicon processor. Download the Apple Silicon one for better performance.'),
@@ -747,6 +833,7 @@ const mapStateToProps = (state: AppState) => {
lastDeletion: state.lastDeletion,
lastDeletionNotificationTime: state.lastDeletionNotificationTime,
mustUpgradeAppMessage: state.mustUpgradeAppMessage,
syncTargetAppMinVersion: syncInfo.appMinVersion,
showInvalidJoplinCloudCredential: state.settings['sync.target'] === 10 && state.mustAuthenticate,
toast: state.toast,
shouldSwitchToAppleSiliconVersion: shim.isAppleSilicon() && shim.isMac() && process.arch !== 'arm64',
@@ -104,18 +104,15 @@ export default function(props: Props) {
return !hasMasterPasswordEncryptedData;
}, [mode, showCurrentPassword, hasMasterPasswordEncryptedData]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- PasswordInput's ChangeEventHandler is typed as a custom {value} event but the runtime hands a React.ChangeEvent through
const onCurrentPasswordChange = useCallback((event: any) => {
const onCurrentPasswordChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
setCurrentPassword(event.target.value);
}, []);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Same as onCurrentPasswordChange
const onPasswordChange1 = useCallback((event: any) => {
const onPasswordChange1 = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
setPassword1(event.target.value);
}, []);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Same as onCurrentPasswordChange
const onPasswordChange2 = useCallback((event: any) => {
const onPasswordChange2 = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
setPassword2(event.target.value);
}, []);
+1
View File
@@ -864,6 +864,7 @@ function useMenu(props: Props) {
menuItemDic.toggleExternalEditing,
separator(),
menuItemDic.linkToNote,
menuItemDic.copyNoteExternalLink,
menuItemDic.setTags,
menuItemDic.showShareNoteDialog,
menuItemDic.convertNoteToMarkdown,
@@ -22,6 +22,7 @@ import {
useReactFlow,
} from '@xyflow/react';
import generateId from '@joplin/lib/services/whiteboard/generateId';
import findEmptySpot from '@joplin/lib/services/whiteboard/findEmptySpot';
import { _, _n } from '@joplin/lib/locale';
import { Canvas, CanvasColor, CanvasEdge, CanvasNode } from '@joplin/lib/services/whiteboard/jsoncanvas';
import { presetColors, resolveCanvasColor } from '@joplin/lib/services/whiteboard/presetColors';
@@ -170,7 +171,15 @@ const InnerSurface = ({ canvas, onChange }: Props) => {
}, []);
const onReconnect: OnReconnect = useCallback((oldEdge, newConnection) => {
setFlowEdges(prev => reconnectEdge(oldEdge as unknown as WhiteboardFlowEdge, newConnection, prev) as WhiteboardFlowEdge[]);
// `oldEdge` is the rendered edge, which carries the derived `style.stroke`
// (blue when selected) baked in by `renderedEdges`. reconnectEdge copies
// those fields onto the new edge, so it would persist that stroke into
// state and leave the edge blue-but-not-bold after deselection. Reconnect
// against the clean edge from state instead, keyed by id.
setFlowEdges(prev => {
const clean = prev.find(e => e.id === oldEdge.id);
return reconnectEdge((clean ?? oldEdge) as unknown as WhiteboardFlowEdge, newConnection, prev) as WhiteboardFlowEdge[];
});
}, []);
// Double-clicking an edge selects it exclusively and focuses the label
@@ -198,6 +207,28 @@ const InnerSurface = ({ canvas, onChange }: Props) => {
return { x: cx, y: cy };
}, [rf]);
// Viewport bounds in canvas coordinates.
const viewportBounds = useCallback((): { x: number; y: number; width: number; height: number } | null => {
const view = rf.getViewport();
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return null;
return {
x: -view.x / view.zoom,
y: -view.y / view.zoom,
width: rect.width / view.zoom,
height: rect.height / view.zoom,
};
}, [rf]);
const existingRects = useCallback(() => {
return flowNodes.map(n => ({
x: n.position.x,
y: n.position.y,
width: (typeof n.width === 'number' ? n.width : (typeof n.style?.width === 'number' ? n.style.width : 0)) ?? 0,
height: (typeof n.height === 'number' ? n.height : (typeof n.style?.height === 'number' ? n.style.height : 0)) ?? 0,
}));
}, [flowNodes]);
const onAddText = useCallback(() => {
const { x: cx, y: cy } = viewportCentre();
addCanvasNode({
@@ -212,17 +243,23 @@ const InnerSurface = ({ canvas, onChange }: Props) => {
}, [viewportCentre, addCanvasNode]);
const onAddGroup = useCallback(() => {
const { x: cx, y: cy } = viewportCentre();
const { x, y, width, height } = findEmptySpot({
existing: existingRects(),
viewport: viewportBounds(),
centre: viewportCentre(),
preferred: { width: 320, height: 240 },
min: { width: 100, height: 100 },
});
addCanvasNode({
id: generateId(),
type: 'group',
x: cx - 240,
y: cy - 200,
width: 480,
height: 400,
x,
y,
width,
height,
label: _('New group'),
});
}, [viewportCentre, addCanvasNode]);
}, [existingRects, viewportBounds, viewportCentre, addCanvasNode]);
const selectedEdges = useMemo(() => flowEdges.filter(e => e.selected), [flowEdges]);
const selectedNodes = useMemo(() => flowNodes.filter(n => n.selected), [flowNodes]);
@@ -415,6 +452,7 @@ const InnerSurface = ({ canvas, onChange }: Props) => {
onNodeDragStart={onNodeDragStart}
onNodeDrag={onNodeDrag}
onNodeDragStop={onNodeDragStop}
elevateEdgesOnSelect
deleteKeyCode={['Backspace', 'Delete']}
multiSelectionKeyCode={['Shift', 'Meta', 'Control']}
selectionKeyCode={['Shift']}
@@ -428,11 +428,7 @@ $hover-bg: rgba(127, 127, 127, 0.12);
h2 { font-size: 15px; margin: 4px 0; }
h3 { font-size: 14px; margin: 3px 0; }
h4, h5, h6 { font-size: 13px; margin: 3px 0; }
// Cards are informal — preserve typed newlines and tabs inside a
// paragraph rather than collapsing them per standard markdown rules, so
// what the user typed is what they see. Paragraph breaks (blank lines
// in the source) still work via <p> boundaries.
p { margin: 8px 0; white-space: pre-wrap; tab-size: 4; }
p { margin: 8px 0; }
// Drop the top margin on whatever comes first so a leading heading /
// paragraph / list sits flush against the card's own 8px padding
// instead of stacking margins on top of it. The renderer wraps its
@@ -30,8 +30,15 @@ import useFocusVisible from './utils/useFocusVisible';
import { stateUtils } from '@joplin/lib/reducer';
import { connect } from 'react-redux';
import useOnNoteDoubleClick from './utils/useOnNoteDoubleClick';
import { createSelector } from 'reselect';
import { isFolderPublished, ShareType } from '@joplin/lib/services/share/reducer';
import useAutoScroll from './utils/useAutoScroll';
const selectPublishedNoteIds = createSelector(
(state: AppState) => state.shareService.shares,
shares => shares.filter(s => s.type === ShareType.Note && !!s.note_id).map(s => s.note_id),
);
const commands = {
focusElementNoteList,
};
@@ -251,6 +258,7 @@ const NoteList = (props: Props) => {
focusVisible={focusVisible && activeNoteId === note.id}
isSelected={isSelected}
isWatched={props.watchedNoteFiles.includes(note.id)}
isPublished={props.publishedNoteIds.includes(note.id) || (!!note.is_shared && !note.share_id) || props.isFolderPublished}
listRenderer={listRenderer}
dispatch={props.dispatch}
columns={props.columns}
@@ -346,6 +354,8 @@ const mapStateToProps = (state: AppState, ownProps: ConnectProps) => {
searches: state.searches,
selectedSearchId: windowState.selectedSearchId,
watchedNoteFiles: state.watchedNoteFiles,
publishedNoteIds: selectPublishedNoteIds(state),
isFolderPublished: state.notesParentType === 'Folder' ? isFolderPublished(state, windowState.selectedFolderId) : false,
provisionalNoteIds: state.provisionalNoteIds,
isInsertingNotes: state.isInsertingNotes,
noteSortOrder: state.settings['notes.sortOrder.field'],
@@ -11,6 +11,8 @@ export interface Props {
notes: NoteEntity[];
dispatch: Dispatch;
watchedNoteFiles: string[];
publishedNoteIds: string[];
isFolderPublished: boolean;
plugins: PluginStates;
selectedFolderId: string;
customCss: string;
@@ -28,6 +28,7 @@ interface NoteItemProps {
style: CSSProperties;
note: NoteEntity;
isWatched: boolean;
isPublished: boolean;
isSelected: boolean;
tabIndex: number;
@@ -69,7 +70,7 @@ const NoteListItem = (props: NoteItemProps, ref: LegacyRef<HTMLDivElement>) => {
const rootElement = useRootElement(elementId);
const renderedNote = useRenderedNote(props.note, props.isSelected, props.isWatched, props.listRenderer, props.highlightedWords, props.index, props.columns);
const renderedNote = useRenderedNote(props.note, props.isSelected, props.isWatched, props.isPublished, props.listRenderer, props.highlightedWords, props.index, props.columns);
const itemEventHandlers = useMemo((): ItemEventHandlers => ({ onInputChange, onClick: null }), [onInputChange]);
@@ -43,6 +43,7 @@ const prepareViewProps = async (
noteTags: TagEntity[],
folder: FolderEntity | null,
itemIndex: number,
noteIsPublished = false,
) => {
const output: {
note?: Record<string, unknown> & { folder?: Record<string, unknown> };
@@ -60,6 +61,8 @@ const prepareViewProps = async (
output.note.titleHtml = noteTitleHtml;
} else if (dep === 'note.isWatched') {
output.note[propName] = noteIsWatched;
} else if (dep === 'note.is_published') {
output.note[propName] = noteIsPublished;
} else if (dep === 'note.tags') {
output.note[propName] = noteTags;
} else if (dep === 'note.folder.title') {
@@ -22,7 +22,7 @@ const hashContent = (content: unknown) => {
return createHash('sha1').update(JSON.stringify(content)).digest('hex');
};
export default (note: NoteEntity, isSelected: boolean, isWatched: boolean, listRenderer: ListRenderer, highlightedWords: string[], 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[] : [];
@@ -50,6 +50,7 @@ export default (note: NoteEntity, isSelected: boolean, isWatched: boolean, listR
note.updated_time,
isSelected,
isWatched,
isPublished,
highlightedWords,
note.encryption_applied,
JSON.stringify(columns),
@@ -71,6 +72,7 @@ export default (note: NoteEntity, isSelected: boolean, isWatched: boolean, listR
noteTags,
folder,
itemIndex,
isPublished,
);
if (event.cancelled) return null;
@@ -96,7 +98,7 @@ export default (note: NoteEntity, isSelected: boolean, isWatched: boolean, listR
};
void renderNote();
}, [note, isSelected, isWatched, listRenderer, renderedNote, columns]);
}, [note, isSelected, isWatched, isPublished, listRenderer, renderedNote, columns]);
return renderedNote;
};
@@ -9,6 +9,7 @@ interface Props {
value: string;
onChange: ChangeEventHandler;
valid?: boolean;
reserveIconGutter?: boolean;
}
const LabelledPasswordInput: React.FC<Props> = props => {
@@ -38,7 +39,8 @@ const LabelledPasswordInput: React.FC<Props> = props => {
></i>;
};
return <div className='labelled-password-input form-input-group'>
const rootClassName = `labelled-password-input form-input-group${props.reserveIconGutter ? ' -reserve-icon-gutter' : ''}`;
return <div className={rootClassName}>
<label htmlFor={inputId}>{props.labelText}</label>
<div className='password'>
<PasswordInput
@@ -7,4 +7,19 @@
flex-direction: row;
align-items: center;
}
}
// Keep field widths equal whether the status icon is shown or not, by
// reserving a gutter for the icon and taking it out of the row's flex flow.
&.-reserve-icon-gutter > .password {
position: relative;
padding-right: 26px;
> .password-status-icon {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
margin-left: 0;
}
}
}
@@ -1,6 +1,3 @@
import { ChangeEventHandler as ReactChangeEventHandler } from 'react';
export interface ChangeEvent {
value: string;
}
export type ChangeEventHandler = (event: ChangeEvent)=> void;
export type ChangeEventHandler = ReactChangeEventHandler<HTMLInputElement>;
@@ -0,0 +1,107 @@
import * as React from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { _, _n } from '@joplin/lib/locale';
import Folder from '@joplin/lib/models/Folder';
import DialogButtonRow, { ClickEvent } from './DialogButtonRow';
import Dialog from '@joplin/lib/components/Dialog';
import DialogTitle from './DialogTitle';
import { FolderEntity } from '@joplin/lib/services/database/types';
import ShareService from '@joplin/lib/services/share/ShareService';
import { ShareType, StateShare } from '@joplin/lib/services/share/reducer';
import { connect } from 'react-redux';
import { AppState } from '../app.reducer';
import useEncryptionWarningMessage from '@joplin/lib/components/shared/ShareNoteDialog/useEncryptionWarningMessage';
import { clipboard } from 'electron';
import useOnPublishFolderLinkClick from '@joplin/lib/components/shared/PublishFolderDialog/useOnPublishFolderLinkClick';
import useShareStatusMessage from '@joplin/lib/components/shared/ShareNoteDialog/useShareStatusMessage';
import { SharingStatus } from '@joplin/lib/components/shared/ShareNoteDialog/types';
interface Props {
themeId: number;
folderId: string;
onClose: ()=> void;
shares: StateShare[];
folders: FolderEntity[];
}
export function PublishFolderDialog(props: Props) {
const [folder, setFolder] = useState<FolderEntity>(null);
const [noteCount, setNoteCount] = useState<number>(0);
const [publishFolderStatus, setPublishFolderStatus] = useState<SharingStatus>(SharingStatus.Unknown);
const encryptionWarning = useEncryptionWarningMessage();
const publishedShare = useMemo(() => {
return props.shares.find(share => share.type === ShareType.PublishedFolder && share.folder_id === props.folderId) || null;
}, [props.folderId, props.shares]);
const loadData = useCallback(async () => {
const loadedFolder = await Folder.load(props.folderId);
const childFolderIds = await Folder.childrenIds(props.folderId);
const allFolderIds = [props.folderId, ...childFolderIds];
let loadedNoteCount = 0;
for (const folderId of allFolderIds) {
loadedNoteCount += (await Folder.noteIds(folderId)).length;
}
setFolder(loadedFolder);
setNoteCount(loadedNoteCount);
}, [props.folderId]);
useEffect(() => {
void ShareService.instance().refreshShares();
void loadData();
}, [loadData]);
const onShareUrlReady = useCallback((url: string) => {
clipboard.writeText(url);
}, []);
const publishFolderLinkButton_click = useOnPublishFolderLinkClick({
folderId: props.folderId,
publishedShare,
setPublishFolderStatus,
onShareUrlReady,
});
const onDialogButtonClick = useCallback((_event: ClickEvent) => {
props.onClose();
}, [props]);
const statusMessage = useShareStatusMessage({ sharesState: publishFolderStatus, noteCount: 1 });
return (
<Dialog>
<div className="form share-note-dialog">
<DialogTitle title={_('Publish Notebook')}/>
<div className="folder-info">
<p>{_('Notebook: %s', folder ? folder.title : '...')}</p>
<p>{_('Status: %s', publishedShare ? _('Published') : _('Not published'))}</p>
<p>{_n('%d note in notebook', '%d notes in notebook', noteCount, noteCount)}</p>
</div>
<button
disabled={!folder || [SharingStatus.Creating, SharingStatus.Synchronizing].indexOf(publishFolderStatus) >= 0}
className="share"
onClick={publishFolderLinkButton_click}
>{publishedShare ? _('Copy Shareable Link') : _('Publish notebook')}</button>
<div className="message">{statusMessage}</div>
{encryptionWarning && <div className="message">{encryptionWarning}<hr/></div>}
<DialogButtonRow
themeId={props.themeId}
onClick={onDialogButtonClick}
okButtonShow={false}
cancelButtonLabel={_('Close')}
/>
</div>
</Dialog>
);
}
const mapStateToProps = (state: AppState) => {
return {
shares: state.shareService.shares,
folders: state.folders,
};
};
export default connect(mapStateToProps)(PublishFolderDialog);
@@ -1,7 +1,7 @@
import * as React from 'react';
import { Resizable, ResizeCallback, ResizeStartCallback, Size } from 're-resizable';
import { LayoutItem } from './utils/types';
import { itemMinHeight, itemMinWidth, itemSize, LayoutItemSizes } from './utils/useLayoutItemSizes';
import { LayoutItem, LayoutItemDirection } from './utils/types';
import { EdgeFlags, itemMinHeight, itemMinWidth, itemSize, LayoutItemSizes } from './utils/useLayoutItemSizes';
interface Props {
item: LayoutItem;
@@ -14,24 +14,31 @@ interface Props {
children: React.ReactNode;
isLastChild: boolean;
visible: boolean;
edges: EdgeFlags;
}
const LayoutItemContainer: React.FC<Props> = ({
item, visible, parent, sizes, resizedItemMaxSize, onResize, onResizeStart, onResizeStop, children, isLastChild,
item, visible, parent, sizes, resizedItemMaxSize, onResize, onResizeStart, onResizeStop, children, isLastChild, edges,
}) => {
const style: React.CSSProperties = {
display: visible ? 'flex' : 'none',
flexDirection: item.direction,
};
const size: Size = itemSize(item, parent, sizes, true);
const size: Size = itemSize(item, parent, sizes, true, edges);
// Drag rules follow the runtime "last visible in render" flag so hidden
// panels shown by moveMode still get proper dividers.
const parentDir = parent?.direction;
const canResizeRight = parentDir === LayoutItemDirection.Row && !isLastChild;
const canResizeBottom = parentDir === LayoutItemDirection.Column && !isLastChild;
const className = `resizableLayoutItem rli-${item.key}`;
if (item.resizableRight || item.resizableBottom) {
if (canResizeRight || canResizeBottom) {
const enable = {
top: false,
right: !!item.resizableRight && !isLastChild,
bottom: !!item.resizableBottom && !isLastChild,
right: canResizeRight,
bottom: canResizeBottom,
left: false,
topRight: false,
bottomRight: false,
@@ -2,10 +2,11 @@ import * as React from 'react';
import { useRef, useState, useEffect, useCallback } from 'react';
import useWindowResizeEvent from './utils/useWindowResizeEvent';
import setLayoutItemProps from './utils/setLayoutItemProps';
import useLayoutItemSizes, { LayoutItemSizes, itemSize, calculateMaxSizeAvailableForItem, itemMinWidth, itemMinHeight } from './utils/useLayoutItemSizes';
import useLayoutItemSizes, { EdgeFlags, LayoutItemSizes, itemSize, calculateMaxSizeAvailableForItem } from './utils/useLayoutItemSizes';
import validateLayout from './utils/validateLayout';
import { Size, LayoutItem } from './utils/types';
import { canMove, MoveDirection } from './utils/movements';
import { buildResizeSnapshot, computeEdges, isItemVisibleInRender, lastVisibleChildIndex, planResize, ResizeStartSnapshot } from './utils/resizeLogic';
import MoveButtons, { MoveButtonClickEvent } from './MoveButtons';
import { StyledWrapperRoot, StyledMoveOverlay, MoveModeRootMessage } from './utils/style';
import type { ResizeCallback, ResizeStartCallback } from 're-resizable';
@@ -17,18 +18,8 @@ interface OnResizeEvent {
layout: LayoutItem;
}
interface ResizedItem {
key: string;
initialWidth: number;
initialHeight: number;
interface ResizedItem extends ResizeStartSnapshot {
maxSize: Size;
// A divider drag updates the dragged item and its next visible sibling,
// so the delta stays between the two panels on either side of it.
nextSiblingKey: string | null;
nextSiblingInitialWidth: number;
nextSiblingInitialHeight: number;
itemAbsorbsAlongAxis: { width: boolean; height: boolean };
nextAbsorbsAlongAxis: { width: boolean; height: boolean };
}
export interface RenderItemEvent {
@@ -50,11 +41,7 @@ interface Props {
moveModeMessage: string;
}
function itemVisible(item: LayoutItem, moveMode: boolean) {
if (moveMode) return true;
if (item.children && !item.children.length) return false;
return item.visible !== false;
}
const itemVisible = isItemVisibleInRender;
function ResizableLayout(props: Props) {
const eventEmitter = useRef(new EventEmitter());
@@ -95,64 +82,22 @@ function ResizableLayout(props: Props) {
}
function renderLayoutItem(
item: LayoutItem, parent: LayoutItem | null, sizes: LayoutItemSizes, isVisible: boolean, isLastChild: boolean, onlyMoveControls: boolean,
item: LayoutItem, parent: LayoutItem | null, sizes: LayoutItemSizes, isVisible: boolean, isLastChild: boolean, onlyMoveControls: boolean, parentEdges: EdgeFlags = { ownRight: false, ownBottom: false, parentRight: false, parentBottom: false },
): React.ReactNode {
const onResizeStart: ResizeStartCallback = () => {
let nextSiblingKey: string | null = null;
const nextAbsorbsAlongAxis = { width: false, height: false };
if (parent) {
const siblings = parent.children;
const idx = siblings.findIndex(c => c.key === item.key);
for (let i = idx + 1; i < siblings.length; i++) {
if (siblings[i].visible !== false) {
nextSiblingKey = siblings[i].key;
nextAbsorbsAlongAxis.width = !('width' in siblings[i]);
nextAbsorbsAlongAxis.height = !('height' in siblings[i]);
break;
}
}
}
const edges = computeEdges(parent, isLastChild, parentEdges);
const onResizeStart: ResizeStartCallback = () => {
setResizedItem({
key: item.key,
initialWidth: sizes[item.key].width,
initialHeight: sizes[item.key].height,
...buildResizeSnapshot(item, parent, props.moveMode, sizes),
maxSize: calculateMaxSizeAvailableForItem(item, parent, sizes),
nextSiblingKey,
nextSiblingInitialWidth: nextSiblingKey ? sizes[nextSiblingKey].width : 0,
nextSiblingInitialHeight: nextSiblingKey ? sizes[nextSiblingKey].height : 0,
itemAbsorbsAlongAxis: {
width: !('width' in item),
height: !('height' in item),
},
nextAbsorbsAlongAxis,
});
};
const onResize: ResizeCallback = (_event, direction, _refToElement, delta) => {
// The absorber (width/height-less side) is skipped so the layout
// system keeps it flexible.
const isHorizontal = direction !== 'bottom';
const minSize = isHorizontal ? itemMinWidth : itemMinHeight;
const rawDelta = isHorizontal ? delta.width : delta.height;
const itemIsAbsorber = isHorizontal ? resizedItem.itemAbsorbsAlongAxis.width : resizedItem.itemAbsorbsAlongAxis.height;
const nextIsAbsorber = isHorizontal ? resizedItem.nextAbsorbsAlongAxis.width : resizedItem.nextAbsorbsAlongAxis.height;
let newLayout = props.layout;
if (!itemIsAbsorber) {
const initial = isHorizontal ? resizedItem.initialWidth : resizedItem.initialHeight;
const newSize = Math.max(minSize, initial + rawDelta);
newLayout = setLayoutItemProps(newLayout, resizedItem.key, isHorizontal ? { width: newSize } : { height: newSize });
for (const update of planResize(resizedItem, direction, delta)) {
newLayout = setLayoutItemProps(newLayout, update.key, update.props);
}
if (!nextIsAbsorber && resizedItem.nextSiblingKey) {
const initial = isHorizontal ? resizedItem.nextSiblingInitialWidth : resizedItem.nextSiblingInitialHeight;
const newSize = Math.max(minSize, initial - rawDelta);
newLayout = setLayoutItemProps(newLayout, resizedItem.nextSiblingKey, isHorizontal ? { width: newSize } : { height: newSize });
}
props.onResize({ layout: newLayout });
eventEmitter.current.emit('resize');
};
@@ -165,10 +110,10 @@ function ResizableLayout(props: Props) {
const resizedItemMaxSize = resizedItem && item.key === resizedItem.key ? resizedItem.maxSize : null;
const visible = itemVisible(item, props.moveMode);
const itemContainerProps = {
key: item.key, item, parent, sizes, resizedItemMaxSize, onResizeStart, onResizeStop, onResize, isLastChild, visible,
key: item.key, item, parent, sizes, resizedItemMaxSize, onResizeStart, onResizeStop, onResize, isLastChild, visible, edges,
};
if (!item.children) {
const size = itemSize(item, parent, sizes, false);
const size = itemSize(item, parent, sizes, false, edges);
const comp = props.renderItem(item.key, {
item: item,
@@ -182,11 +127,12 @@ function ResizableLayout(props: Props) {
{wrapper}
</LayoutItemContainer>;
} else {
const lastVisibleIdx = lastVisibleChildIndex(item, props.moveMode);
const childrenComponents = [];
for (let i = 0; i < item.children.length; i++) {
const child = item.children[i];
childrenComponents.push(
renderLayoutItem(child, item, sizes, isVisible && itemVisible(child, props.moveMode), i === item.children.length - 1, onlyMoveControls),
renderLayoutItem(child, item, sizes, isVisible && itemVisible(child, props.moveMode), i === lastVisibleIdx, onlyMoveControls, edges),
);
}
@@ -0,0 +1,197 @@
import { LayoutItem, LayoutItemDirection } from './types';
import {
buildResizeSnapshot,
computeEdges,
isSubtreeFlexible,
lastVisibleChildIndex,
nextVisibleSibling,
planResize,
ResizeStartSnapshot,
} from './resizeLogic';
describe('resizeLogic', () => {
const sampleSizes = {
sideBar: { width: 250, height: 800 },
noteList: { width: 400, height: 800 },
editor: { width: 300, height: 800 },
chatPanel: { width: 340, height: 800 },
};
test.each([
['flag directly on item', { key: 'a', flexible: true } as LayoutItem, true],
['flag on nested descendant', { key: 'a', children: [{ key: 'b', children: [{ key: 'c', flexible: true }] }] } as LayoutItem, true],
['no flag anywhere in subtree', { key: 'a', children: [{ key: 'b' }] } as LayoutItem, false],
['leaf without flag', { key: 'a' } as LayoutItem, false],
])('isSubtreeFlexible: %s', (_label, item, expected) => {
expect(isSubtreeFlexible(item)).toBe(expected);
});
test('nextVisibleSibling skips hidden siblings in normal mode', () => {
const parent: LayoutItem = {
key: 'root',
children: [
{ key: 'a' },
{ key: 'b', visible: false },
{ key: 'c' },
],
};
expect(nextVisibleSibling(parent.children[0], parent, false)?.key).toBe('c');
});
test('nextVisibleSibling includes hidden siblings in moveMode', () => {
const parent: LayoutItem = {
key: 'root',
children: [
{ key: 'a' },
{ key: 'b', visible: false },
{ key: 'c' },
],
};
expect(nextVisibleSibling(parent.children[0], parent, true)?.key).toBe('b');
});
test('lastVisibleChildIndex is moveMode-aware', () => {
const item: LayoutItem = {
key: 'root',
children: [{ key: 'a' }, { key: 'b' }, { key: 'c', visible: false }],
};
expect(lastVisibleChildIndex(item, false)).toBe(1);
expect(lastVisibleChildIndex(item, true)).toBe(2);
});
test('computeEdges: middle row child gets an own right edge only', () => {
const parent: LayoutItem = { key: 'root', direction: LayoutItemDirection.Row };
expect(computeEdges(parent, false)).toEqual({ ownRight: true, ownBottom: false, parentRight: false, parentBottom: false });
});
test('computeEdges: last row child inherits parent right', () => {
const parent: LayoutItem = { key: 'root', direction: LayoutItemDirection.Row };
const parentEdges = { ownRight: false, ownBottom: false, parentRight: true, parentBottom: false };
expect(computeEdges(parent, true, parentEdges)).toEqual({ ownRight: false, ownBottom: false, parentRight: true, parentBottom: false });
});
test('computeEdges: every column child sits at the column right edge, so parentRight propagates through', () => {
const parent: LayoutItem = { key: 'col', direction: LayoutItemDirection.Column };
const parentEdges = { ownRight: true, ownBottom: false, parentRight: false, parentBottom: false };
expect(computeEdges(parent, false, parentEdges).parentRight).toBe(true);
});
test('computeEdges: same-axis nesting propagates outer own-right to a last row-in-row child', () => {
// Unusual but valid nesting: the inner container's right edge
// coincides with the outer's, so the outer's own right divider gap
// must flow to the inner's descendants.
const parent: LayoutItem = { key: 'inner', direction: LayoutItemDirection.Row };
const parentEdges = { ownRight: true, ownBottom: false, parentRight: false, parentBottom: false };
expect(computeEdges(parent, true, parentEdges).parentRight).toBe(true);
});
test('buildResizeSnapshot: only truly-flexible subtrees are flagged as absorbers', () => {
const parent: LayoutItem = { key: 'root', direction: LayoutItemDirection.Row, children: [
{ key: 'sideBar', width: 250 },
{ key: 'editor', flexible: true },
] };
const snap = buildResizeSnapshot(parent.children[0], parent, false, sampleSizes);
expect(snap.itemAbsorbsAlongAxis.width).toBe(false); // sideBar
expect(snap.nextAbsorbsAlongAxis.width).toBe(true); // editor
});
test('buildResizeSnapshot: width-less non-flexible next sibling is not an absorber', () => {
// Regression: dragging noteList↔chatPanel where chatPanel is unsized
// in state but not marked flexible. Old code treated any unsized
// panel as an absorber and skipped writes to it.
const parent: LayoutItem = { key: 'root', direction: LayoutItemDirection.Row, children: [
{ key: 'noteList', width: 400 },
{ key: 'chatPanel' },
] };
const snap = buildResizeSnapshot(parent.children[0], parent, false, sampleSizes);
expect(snap.nextAbsorbsAlongAxis.width).toBe(false);
});
test('buildResizeSnapshot: picks the next sibling that is hidden in state when in moveMode', () => {
const parent: LayoutItem = { key: 'root', direction: LayoutItemDirection.Row, children: [
{ key: 'noteList', width: 400 },
{ key: 'chatPanel', visible: false },
] };
const snap = buildResizeSnapshot(parent.children[0], parent, true, sampleSizes);
expect(snap.nextSiblingKey).toBe('chatPanel');
});
const baseSnap = (overrides: Partial<ResizeStartSnapshot> = {}): ResizeStartSnapshot => ({
key: 'a',
initialWidth: 300,
initialHeight: 200,
nextSiblingKey: 'b',
nextSiblingInitialWidth: 400,
nextSiblingInitialHeight: 200,
itemAbsorbsAlongAxis: { width: false, height: false },
nextAbsorbsAlongAxis: { width: false, height: false },
...overrides,
});
test('planResize: drag right by 50 grows the dragged panel and shrinks the next', () => {
expect(planResize(baseSnap(), 'right', { width: 50, height: 0 })).toEqual([
{ key: 'a', props: { width: 350 } },
{ key: 'b', props: { width: 350 } },
]);
});
test('planResize: skips write to the next sibling when it is the absorber', () => {
const snap = baseSnap({ nextAbsorbsAlongAxis: { width: true, height: false } });
expect(planResize(snap, 'right', { width: 50, height: 0 })).toEqual([
{ key: 'a', props: { width: 350 } },
]);
});
test('planResize: redirects to the next sibling (inverse) when dragged is the absorber', () => {
// Editor is flexible and unsized; dragging its right edge should
// resize the panel to its right instead so the editor stays flex.
const snap = baseSnap({ itemAbsorbsAlongAxis: { width: true, height: false } });
expect(planResize(snap, 'right', { width: 50, height: 0 })).toEqual([
{ key: 'b', props: { width: 350 } },
]);
});
test('planResize: writes to the dragged panel when both sides are absorbers (#15934)', () => {
// Sidebar and note list stacked in a column, both unsized. Without
// the bothAbsorb branch, neither was written and the divider never
// moved.
const snap = baseSnap({
itemAbsorbsAlongAxis: { width: false, height: true },
nextAbsorbsAlongAxis: { width: false, height: true },
});
expect(planResize(snap, 'bottom', { width: 0, height: 40 })).toEqual([
{ key: 'a', props: { height: 240 } },
]);
});
test('planResize: no next sibling means only the dragged is written', () => {
const snap = baseSnap({ nextSiblingKey: null });
expect(planResize(snap, 'right', { width: 50, height: 0 })).toEqual([
{ key: 'a', props: { width: 350 } },
]);
});
test('planResize: clamps a shared delta so both sides respect min and the combined size is preserved', () => {
const snap = baseSnap();
const originalSum = snap.initialWidth + snap.nextSiblingInitialWidth;
const updates = planResize(snap, 'right', { width: -500, height: 0 });
// itemMinWidth is 40; dragged clamps to 40, next grows by the same
// amount so the two sizes sum to the original combined width.
expect(updates[0].props.width).toBe(40);
expect(updates[1].props.width).toBe(originalSum - 40);
expect(updates[0].props.width + updates[1].props.width).toBe(originalSum);
});
test('planResize: preserves the combined size when the delta would push the next sibling below min', () => {
const snap = baseSnap();
const originalSum = snap.initialWidth + snap.nextSiblingInitialWidth;
const updates = planResize(snap, 'right', { width: 500, height: 0 });
// The next sibling clamps to itemMinWidth (40); the dragged panel
// grows by exactly the same amount so the sum is preserved.
expect(updates[1].props.width).toBe(40);
expect(updates[0].props.width).toBe(originalSum - 40);
expect(updates[0].props.width + updates[1].props.width).toBe(originalSum);
});
});
@@ -0,0 +1,154 @@
// Pure helpers extracted from ResizableLayout so they can be unit-tested
// without a React harness. Everything here is state-in / state-out — no
// re-resizable, no DOM, no hooks.
import { EdgeFlags, itemMinWidth, itemMinHeight } from './useLayoutItemSizes';
import { LayoutItem, LayoutItemDirection } from './types';
export function isItemVisibleInRender(item: LayoutItem, moveMode: boolean): boolean {
if (moveMode) return true;
if (item.children && !item.children.length) return false;
return item.visible !== false;
}
// The subtree carries an absorber if any node in it is flagged flexible.
export function isSubtreeFlexible(item: LayoutItem): boolean {
if (item.flexible) return true;
if (!item.children) return false;
for (const child of item.children) {
if (isSubtreeFlexible(child)) return true;
}
return false;
}
// The first visible-in-render sibling after `item` in `parent.children`, or
// null when `item` is the last visible child (or has no parent).
export function nextVisibleSibling(item: LayoutItem, parent: LayoutItem | null, moveMode: boolean): LayoutItem | null {
if (!parent || !parent.children) return null;
const idx = parent.children.findIndex(c => c.key === item.key);
for (let i = idx + 1; i < parent.children.length; i++) {
if (isItemVisibleInRender(parent.children[i], moveMode)) return parent.children[i];
}
return null;
}
// The index of the last visible-in-render child of `item`, or -1.
export function lastVisibleChildIndex(item: LayoutItem, moveMode: boolean): number {
if (!item.children) return -1;
for (let i = item.children.length - 1; i >= 0; i--) {
if (isItemVisibleInRender(item.children[i], moveMode)) return i;
}
return -1;
}
const zeroEdges: EdgeFlags = { ownRight: false, ownBottom: false, parentRight: false, parentBottom: false };
// Compute an item's edge flags from its parent's direction, its own position
// (is it the last visible child?), and the parent's own edges. Same-axis
// nesting is handled: a last-child of a row-in-row inherits both the outer's
// own right gap and the outer's inherited parentRight.
export function computeEdges(parent: LayoutItem | null, isLastChild: boolean, parentEdges: EdgeFlags = zeroEdges): EdgeFlags {
const parentDir = parent?.direction;
const ownRight = parentDir === LayoutItemDirection.Row && !isLastChild;
const ownBottom = parentDir === LayoutItemDirection.Column && !isLastChild;
const parentRight = parentDir === LayoutItemDirection.Column
? parentEdges.ownRight || parentEdges.parentRight
: (isLastChild && (parentEdges.ownRight || parentEdges.parentRight));
const parentBottom = parentDir === LayoutItemDirection.Row
? parentEdges.ownBottom || parentEdges.parentBottom
: (isLastChild && (parentEdges.ownBottom || parentEdges.parentBottom));
return { ownRight, ownBottom, parentRight, parentBottom };
}
export interface ResizeStartSnapshot {
key: string;
initialWidth: number;
initialHeight: number;
nextSiblingKey: string | null;
nextSiblingInitialWidth: number;
nextSiblingInitialHeight: number;
itemAbsorbsAlongAxis: { width: boolean; height: boolean };
nextAbsorbsAlongAxis: { width: boolean; height: boolean };
}
// Build the drag-start snapshot from the layout state. This captures which
// side of the divider counts as the absorber on each axis (only truly
// flexible subtrees do — an unsized panel that isn't flagged still gets
// deltas applied to it so drags on newly-shown panels behave normally).
export function buildResizeSnapshot(
item: LayoutItem,
parent: LayoutItem | null,
moveMode: boolean,
sizes: { [key: string]: { width: number; height: number } },
): ResizeStartSnapshot {
const next = nextVisibleSibling(item, parent, moveMode);
const itemFlex = isSubtreeFlexible(item);
const nextFlex = next ? isSubtreeFlexible(next) : false;
return {
key: item.key,
initialWidth: sizes[item.key].width,
initialHeight: sizes[item.key].height,
nextSiblingKey: next?.key ?? null,
nextSiblingInitialWidth: next ? sizes[next.key].width : 0,
nextSiblingInitialHeight: next ? sizes[next.key].height : 0,
itemAbsorbsAlongAxis: {
width: itemFlex && !('width' in item),
height: itemFlex && !('height' in item),
},
nextAbsorbsAlongAxis: {
width: nextFlex && next ? !('width' in next) : false,
height: nextFlex && next ? !('height' in next) : false,
},
};
}
export interface ResizeUpdate {
key: string;
props: { width?: number; height?: number };
}
// Given a drag snapshot, a direction, and re-resizable's cumulative delta,
// return the layout-state writes needed to move the divider. The dragged
// panel and its next visible sibling exchange space; either side can be
// skipped when it's an absorber so the layout system keeps it flexible.
// When both sides are absorbers (e.g. two unsized column-nested siblings),
// we write to the dragged panel so the divider still moves.
export function planResize(
snapshot: ResizeStartSnapshot,
direction: string,
delta: { width: number; height: number },
): ResizeUpdate[] {
const isHorizontal = direction !== 'bottom';
const minSize = isHorizontal ? itemMinWidth : itemMinHeight;
const rawDelta = isHorizontal ? delta.width : delta.height;
const itemIsAbsorber = isHorizontal ? snapshot.itemAbsorbsAlongAxis.width : snapshot.itemAbsorbsAlongAxis.height;
const nextIsAbsorber = isHorizontal ? snapshot.nextAbsorbsAlongAxis.width : snapshot.nextAbsorbsAlongAxis.height;
const bothAbsorb = itemIsAbsorber && nextIsAbsorber;
const writeItem = !itemIsAbsorber || bothAbsorb;
const writeNext = !nextIsAbsorber && !!snapshot.nextSiblingKey;
const itemInitial = isHorizontal ? snapshot.initialWidth : snapshot.initialHeight;
const nextInitial = isHorizontal ? snapshot.nextSiblingInitialWidth : snapshot.nextSiblingInitialHeight;
// When both panels are written, clamp a single shared delta against both
// minimums so their combined size is preserved. When only one side is
// written, the other is absorbing and we don't clamp against it.
let effectiveDelta = rawDelta;
if (writeItem) effectiveDelta = Math.max(effectiveDelta, minSize - itemInitial);
if (writeNext) effectiveDelta = Math.min(effectiveDelta, nextInitial - minSize);
const updates: ResizeUpdate[] = [];
if (writeItem) {
const newSize = itemInitial + effectiveDelta;
updates.push({ key: snapshot.key, props: isHorizontal ? { width: newSize } : { height: newSize } });
}
if (writeNext) {
const newSize = nextInitial - effectiveDelta;
updates.push({ key: snapshot.nextSiblingKey, props: isHorizontal ? { width: newSize } : { height: newSize } });
}
return updates;
}
@@ -134,8 +134,11 @@ describe('useLayoutItemSizes', () => {
expect(itemSize(layout.children[0], layout, sizes, true)).toEqual({ width: 100, height: 100 });
const parent = layout.children[0];
expect(itemSize(parent.children[0], parent, sizes, false)).toEqual({ width: 95, height: 45 });
expect(itemSize(parent.children[1], parent, sizes, false)).toEqual({ width: 95, height: 50 });
// row1: inside a column that has a right divider, and row1 itself has a
// bottom divider (there's a visible sibling below it).
expect(itemSize(parent.children[0], parent, sizes, false, { ownRight: false, ownBottom: true, parentRight: true, parentBottom: false })).toEqual({ width: 95, height: 45 });
// row2: same column, no bottom divider (it is the last row).
expect(itemSize(parent.children[1], parent, sizes, false, { ownRight: false, ownBottom: false, parentRight: true, parentBottom: false })).toEqual({ width: 95, height: 50 });
});
test('should decrease size of the largest item if the total size would be larger than the container', () => {
@@ -458,6 +461,42 @@ describe('useLayoutItemSizes', () => {
expect(after.editor.width).toBe(after.col.width);
});
test('itemSize propagates the ancestor divider gap through last-child same-axis nesting', () => {
// Same-axis nesting is unusual but valid; a row nested at the end of
// another row should inherit its outer sibling's right gap.
const sizes = { outer: { width: 200, height: 100 }, inner: { width: 200, height: 100 } };
const inner: LayoutItem = { key: 'inner' };
// The outer has its own trailing gap because it has a sibling to its right.
const edgesFromLastRowChild = { ownRight: false, ownBottom: false, parentRight: true, parentBottom: false };
const size = itemSize(inner, null, sizes, false, edgesFromLastRowChild);
// 5px reserved for the propagated right gap.
expect(size.width).toBe(195);
});
test('multiple width-less siblings stay within the container width even when combined fallbacks exceed it', () => {
const layout: LayoutItem = validateLayout({
key: 'root',
width: 400,
height: 100,
direction: LayoutItemDirection.Row,
children: [
// Three unsized non-absorber siblings would want 250 each (=750),
// far above the 400 container. The absorber (editor) must still
// get at least its minimum.
{ key: 'a' },
{ key: 'b' },
{ key: 'c' },
{ key: 'editor', flexible: true },
],
});
const { result } = renderHook(() => useLayoutItemSizes(layout));
const sizes = result.current;
const total = sizes.a.width + sizes.b.width + sizes.c.width + sizes.editor.width;
expect(total).toBeLessThanOrEqual(400);
expect(sizes.editor.width).toBeGreaterThanOrEqual(40); // itemMinWidth
});
test('flexible flag on a hidden child falls back to the last visible sibling', () => {
const layout: LayoutItem = validateLayout({
key: 'root',
@@ -6,18 +6,43 @@ const dragBarThickness = 5;
export const itemMinWidth = 40;
export const itemMinHeight = 40;
// Bigger than itemMinWidth: used as a fallback for width-less non-absorber
// siblings so newly-shown panels (moveMode) get a usable width instead of
// squeezing to the minimum.
const nonAbsorberFallbackWidth = 250;
const nonAbsorberFallbackHeight = 250;
export interface LayoutItemSizes {
[key: string]: Size;
}
function containsFlexibleDescendant(item: LayoutItem): boolean {
if (item.flexible) return true;
if (!item.children) return false;
for (const child of item.children) {
if (containsFlexibleDescendant(child)) return true;
}
return false;
}
// Whether this item's trailing (right/bottom) edge is a divider against a
// visible-in-render sibling. The parent-side flags cover the case where the
// item's edge coincides with an ancestor's divider (e.g. a column-nested item
// inheriting its column container's right edge).
export interface EdgeFlags {
ownRight: boolean;
ownBottom: boolean;
parentRight: boolean;
parentBottom: boolean;
}
const noEdges: EdgeFlags = { ownRight: false, ownBottom: false, parentRight: false, parentBottom: false };
// Container always take the full space while the items within it need to
// accommodate for the resize handle.
export function itemSize(item: LayoutItem, parent: LayoutItem | null, sizes: LayoutItemSizes, isContainer: boolean): Size {
const parentResizableRight = !!parent && parent.resizableRight;
const parentResizableBottom = !!parent && parent.resizableBottom;
const rightGap = !isContainer && (item.resizableRight || parentResizableRight) ? dragBarThickness : 0;
const bottomGap = !isContainer && (item.resizableBottom || parentResizableBottom) ? dragBarThickness : 0;
export function itemSize(item: LayoutItem, _parent: LayoutItem | null, sizes: LayoutItemSizes, isContainer: boolean, edges: EdgeFlags = noEdges): Size {
const rightGap = !isContainer && (edges.ownRight || edges.parentRight) ? dragBarThickness : 0;
const bottomGap = !isContainer && (edges.ownBottom || edges.parentBottom) ? dragBarThickness : 0;
return {
width: sizes[item.key].width - rightGap,
@@ -93,20 +118,55 @@ function calculateChildrenSizes(item: LayoutItem, parent: LayoutItem | null, siz
remainingSize.height += dh;
}
if (noWidthChildren.length) {
const w = item.direction === 'row' ? Math.floor(remainingSize.width / noWidthChildren.length) : parentSize.width;
for (const child of noWidthChildren) {
const finalWidth = w;
sizes[child.item.key].width = finalWidth;
// When several children lack a size on the parent's axis (e.g. in
// moveMode, where normally-hidden panels are shown without one), route
// remaining space to the one that carries the flexible descendant and
// give the others a fallback size. This keeps the absorber's role
// intact and avoids equal-split behaviour that would eat editor space.
if (noWidthChildren.length && item.direction === 'row' && noWidthChildren.length > 1) {
const absorber = noWidthChildren.find(c => containsFlexibleDescendant(c.item));
if (absorber) {
const absorberMin = absorber.item.minWidth || itemMinWidth;
let budget = Math.max(0, remainingSize.width - absorberMin);
for (const child of noWidthChildren) {
if (child.item.key === absorber.item.key) continue;
const min = child.item.minWidth || itemMinWidth;
const desired = child.item.minWidth || nonAbsorberFallbackWidth;
const w = Math.max(min, Math.min(desired, budget));
sizes[child.item.key].width = w;
budget -= w;
}
sizes[absorber.item.key].width = Math.max(absorberMin, remainingSize.width - (noWidthChildren.reduce((sum, c) => c.item.key === absorber.item.key ? sum : sum + sizes[c.item.key].width, 0)));
} else {
const w = Math.floor(remainingSize.width / noWidthChildren.length);
for (const child of noWidthChildren) sizes[child.item.key].width = w;
}
} else if (noWidthChildren.length) {
const w = item.direction === 'row' ? Math.floor(remainingSize.width / noWidthChildren.length) : parentSize.width;
for (const child of noWidthChildren) sizes[child.item.key].width = w;
}
if (noHeightChildren.length) {
const h = item.direction === 'column' ? Math.floor(remainingSize.height / noHeightChildren.length) : parentSize.height;
for (const child of noHeightChildren) {
const finalHeight = h;
sizes[child.item.key].height = finalHeight;
if (noHeightChildren.length && item.direction === 'column' && noHeightChildren.length > 1) {
const absorber = noHeightChildren.find(c => containsFlexibleDescendant(c.item));
if (absorber) {
const absorberMin = absorber.item.minHeight || itemMinHeight;
let budget = Math.max(0, remainingSize.height - absorberMin);
for (const child of noHeightChildren) {
if (child.item.key === absorber.item.key) continue;
const min = child.item.minHeight || itemMinHeight;
const desired = child.item.minHeight || nonAbsorberFallbackHeight;
const h = Math.max(min, Math.min(desired, budget));
sizes[child.item.key].height = h;
budget -= h;
}
sizes[absorber.item.key].height = Math.max(absorberMin, remainingSize.height - (noHeightChildren.reduce((sum, c) => c.item.key === absorber.item.key ? sum : sum + sizes[c.item.key].height, 0)));
} else {
const h = Math.floor(remainingSize.height / noHeightChildren.length);
for (const child of noHeightChildren) sizes[child.item.key].height = h;
}
} else if (noHeightChildren.length) {
const h = item.direction === 'column' ? Math.floor(remainingSize.height / noHeightChildren.length) : parentSize.height;
for (const child of noHeightChildren) sizes[child.item.key].height = h;
}
for (const child of item.children) {
@@ -4,6 +4,7 @@ import { FolderEntity, TagsWithNoteCountEntity } from '@joplin/lib/services/data
import areAllFoldersCollapsed from '@joplin/lib/models/utils/areAllFoldersCollapsed';
import getCanBeCollapsedFolderIds from '@joplin/lib/models/utils/getCanBeCollapsedFolderIds';
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
import { StateShare } from '@joplin/lib/services/share/reducer';
import { connect } from 'react-redux';
import { Dispatch } from 'redux';
import { useMemo, useRef, useState } from 'react';
@@ -23,6 +24,7 @@ interface Props {
dispatch: Dispatch;
themeId: number;
plugins: PluginStates;
shares: StateShare[];
tags: TagsWithNoteCountEntity[];
folders: FolderEntity[];
@@ -124,6 +126,7 @@ const mapStateToProps = (state: AppState) => {
collapsedFolderIds: state.collapsedFolderIds,
selectedSmartFilterId: mainWindowState.selectedSmartFilterId,
plugins: state.pluginService.plugins,
shares: state.shareService.shares,
tagHeaderIsExpanded: state.settings.tagHeaderIsExpanded,
folderHeaderIsExpanded: state.settings.folderHeaderIsExpanded,
};
@@ -31,6 +31,7 @@ import ListItemWrapper, { ItemSelectionState } from '../listItemComponents/ListI
import { focus } from '@joplin/lib/utils/focusHandler';
import shim from '@joplin/lib/shim';
import useOnItemClick from './useOnItemClick';
import { ShareType, StateShare } from '@joplin/lib/services/share/reducer';
const Menu = bridge().Menu;
const MenuItem: typeof MenuItemType = bridge().MenuItem;
@@ -41,6 +42,7 @@ interface Props {
dispatch: Dispatch;
themeId: number;
plugins: PluginStates;
shares: StateShare[];
folders: FolderEntity[];
collapsedFolderIds: string[];
containerRef: React.RefObject<HTMLDivElement>;
@@ -255,6 +257,8 @@ const useOnRenderItem = (props: Props) => {
if (shareFolderItem.enabled) menu.append(shareFolderItem);
const leaveSharedFolderItem = folderCommandToMenuItem('leaveSharedFolder', itemId);
if (leaveSharedFolderItem.enabled) menu.append(leaveSharedFolderItem);
const publishFolderItem = folderCommandToMenuItem('showPublishFolderDialog', itemId);
if (publishFolderItem.enabled) menu.append(publishFolderItem);
menu.append(
new MenuItem({
@@ -382,6 +386,20 @@ const useOnRenderItem = (props: Props) => {
const showFolderIcons = useMemo(() => {
return Folder.shouldShowFolderIcons(props.folders);
}, [props.folders]);
const publishedFolderIds = useMemo(() => {
const output = new Set(props.shares.filter(share => share.type === ShareType.PublishedFolder).map(share => share.folder_id));
for (const folder of props.folders) {
if (folder.is_shared && !folder.share_id) output.add(folder.id);
}
let size = 0;
while (size !== output.size) {
size = output.size;
for (const folder of props.folders) {
if (output.has(folder.parent_id)) output.add(folder.id);
}
}
return output;
}, [props.folders, props.shares]);
const itemCount = props.listItems.length;
return useCallback((item: ListItem, index: number) => {
@@ -450,6 +468,7 @@ const useOnRenderItem = (props: Props) => {
folderItem_click={onItemClick}
onFolderToggleClick_={onFolderToggleClick_}
shareId={folder.share_id}
isPublished={publishedFolderIds.has(folder.id)}
parentId={folder.parent_id}
showFolderIcon={showFolderIcons}
index={index}
@@ -503,6 +522,7 @@ const useOnRenderItem = (props: Props) => {
onTagDrop_,
props.collapsedFolderIds,
props.folders,
publishedFolderIds,
showFolderIcons,
props.selectedIndex,
props.selectedIndexes,
@@ -46,6 +46,7 @@ interface FolderItemProps {
folderItem_click: (event: ItemClickEvent)=> void;
onFolderToggleClick_: ItemClickListener;
shareId: string;
isPublished: boolean;
selectionState: ItemSelectionState;
index: number;
@@ -53,10 +54,12 @@ interface FolderItemProps {
}
function FolderItem(props: FolderItemProps) {
const { hasChildren, showFolderIcon, isExpanded, parentId, depth, selectionState, folderId, folderTitle, folderIcon, noteCount, onFolderDragStart_, onFolderDragOver_, onFolderDrop_, itemContextMenu, folderItem_click, onFolderToggleClick_, shareId } = props;
const { hasChildren, showFolderIcon, isExpanded, parentId, depth, selectionState, folderId, folderTitle, folderIcon, noteCount, onFolderDragStart_, onFolderDragOver_, onFolderDrop_, itemContextMenu, folderItem_click, onFolderToggleClick_, shareId, isPublished } = props;
const shareTitle = _('Shared');
const shareIcon = shareId && !parentId ? <StyledShareIcon aria-label={shareTitle} title={shareTitle} className="fas fa-share-alt"/> : null;
const publishedTitle = _('Published');
const publishedIcon = isPublished ? <StyledShareIcon aria-label={publishedTitle} title={publishedTitle} className="fas fa-globe"/> : null;
const draggable = ![getTrashFolderId(), Folder.conflictFolderId()].includes(folderId);
const doRenderFolderIcon = () => {
@@ -98,6 +101,7 @@ function FolderItem(props: FolderItemProps) {
isConflictFolder={folderId === Folder.conflictFolderId()}
selected={selectionState.selected}
shareId={shareId}
isPublished={isPublished}
data-folder-id={folderId}
onDoubleClick={onFolderToggleClick_}
@@ -108,7 +112,7 @@ function FolderItem(props: FolderItemProps) {
}}
>
{doRenderFolderIcon()}<StyledSpanFix className="title">{folderTitle}</StyledSpanFix>
{shareIcon} <NoteCount count={noteCount}/>
{publishedIcon}{shareIcon} <NoteCount count={noteCount}/>
</StyledListItemAnchor>
<ExpandLink
// The ExpandLink is included after the title so that the screen reader reads the
@@ -8,8 +8,10 @@ interface StyleProps {
backgroundColor: string;
backgroundColorHover2: string;
color2: string;
color4: string;
colorError2: string;
colorFaded2: string;
colorPublished2: string;
colorWarn2: string;
topRowHeight: number;
mainPadding: number;
@@ -18,6 +20,7 @@ interface StyleProps {
};
isConflictFolder?: boolean;
isSpecialItem?: boolean;
isPublished?: boolean;
shareId?: string;
selected?: boolean;
}
@@ -73,6 +76,8 @@ export const StyledHeaderLabel = styled.span`
function listItemTextColor(props: StyleProps) {
if (props.isConflictFolder) return props.theme.colorError2;
if (props.isSpecialItem) return props.theme.colorFaded2;
if (props.selected && props.isPublished) return props.theme.color2;
if (props.isPublished) return props.theme.colorPublished2;
if (props.shareId) return props.theme.colorWarn2;
return props.theme.color2;
}
@@ -86,7 +91,7 @@ export const StyledListItemAnchor = styled.a`
// So that the conflicts folder and shared folders have sufficient contrast,
// use an opacity of 1 even when unselected.
const needsHigherContrast = props.isConflictFolder || props.isSpecialItem;
return (props.selected || props.shareId || needsHigherContrast) ? 1 : 0.8;
return (props.selected || props.shareId || props.isPublished || needsHigherContrast) ? 1 : 0.8;
}};
white-space: nowrap;
display: flex;
+100 -23
View File
@@ -1,6 +1,6 @@
import * as React from 'react';
import { Dispatch } from 'redux';
import { useRef, useCallback, useId } from 'react';
import { useRef, useCallback, useId, useState } from 'react';
import { _ } from '@joplin/lib/locale';
import DialogButtonRow from '../DialogButtonRow';
import Dialog from '@joplin/lib/components/Dialog';
@@ -18,9 +18,9 @@ interface Props {
dispatch: Dispatch;
}
const StyledRoot = styled.div`
min-width: 500px;
max-width: 1200px;
const StyledRoot = styled.div<{ expanded: boolean }>`
width: ${props => props.expanded ? '832px' : '432px'};
max-width: 100%;
`;
const SyncTargetDescription = styled.div<{ height: number }>`
@@ -33,12 +33,10 @@ const SyncTargetDescription = styled.div<{ height: number }>`
const ContentRoot = styled.div`
background-color: ${props => props.theme.backgroundColor3};
padding: 1em;
padding-right: 0;
`;
const SelfHostingMessage = styled.div`
color: ${props => props.theme.color};
padding-right: 1em;
font-style: italic;
margin-top: 1em;
opacity: 0.6;
@@ -50,6 +48,42 @@ const SyncTargetBoxes = styled.div`
justify-content: center;
`;
const OtherOptions = styled.div`
margin-top: 1.5em;
`;
const OtherOptionsToggle = styled.button`
display: flex;
align-items: center;
gap: 0.5em;
width: 100%;
background: none;
border: none;
cursor: pointer;
font-family: ${props => props.theme.fontFamily};
font-size: 16px;
font-weight: bold;
color: ${props => props.theme.color};
padding: 0.5em 0;
text-align: left;
&:hover {
opacity: 0.8;
}
`;
const OtherOptionsBoxes = styled.div`
display: flex;
flex-direction: row;
justify-content: center;
gap: 1em;
margin-top: 1em;
> * {
margin-right: 0;
}
`;
const SyncTargetTitle = styled.h2`
display: flex;
flex-direction: row;
@@ -64,17 +98,18 @@ const SyncTargetLogo = styled.img`
margin-right: 0.4em;
`;
const SyncTargetBox = styled.div`
const SyncTargetBox = styled.div<{ hero?: boolean }>`
display: flex;
flex: 1;
flex-direction: column;
box-sizing: border-box;
font-family: ${props => props.theme.fontFamily};
color: ${props => props.theme.color};
background-color: ${props => props.theme.backgroundColor};
border: 1px solid ${props => props.theme.dividerColor};
border: ${props => props.hero ? `2px solid ${props.theme.color4}` : `1px solid ${props.theme.dividerColor}`};
border-radius: 8px;
padding: 2em 2.2em 2em 2.2em;
margin-right: 1em;
flex: 1 1 0;
min-width: 0;
max-width: 400px;
opacity: 1;
`;
@@ -114,6 +149,13 @@ const SelectButton = styled(Button)`
font-size: 1em;
`;
const LoginLink = styled.a`
margin-top: 0.8em;
font-size: 14px;
align-self: center;
color: ${props => props.theme.urlColor};
`;
const SlowSyncWarning = styled.div`
margin-top: 1em;
opacity: 0.8;
@@ -199,6 +241,7 @@ export default function(props: Props) {
}, [props.dispatch, closeDialog]);
const baseId = useId();
const [otherOptionsExpanded, setOtherOptionsExpanded] = useState(false);
function renderSelectArea(info: SyncTargetInfo, describedById: string) {
return (
@@ -212,12 +255,23 @@ export default function(props: Props) {
);
}
function renderSignUpArea(info: SyncTargetInfo) {
if (info.name !== 'joplinCloud') return null;
return <JoplinCloudSignUpCallToAction/>;
function renderActionArea(info: SyncTargetInfo, describedById: string) {
if (info.name === 'joplinCloud') {
return (
<>
<JoplinCloudSignUpCallToAction source='desktop-sync-wizard' primary={true}/>
<LoginLink
href="#"
onClick={event => { event.preventDefault(); void onSelectButtonClick(info.name as SyncTargetInfoName); }}
aria-describedby={describedById}
>{_('Already have an account? Log in')}</LoginLink>
</>
);
}
return renderSelectArea(info, describedById);
}
function renderSyncTarget(info: SyncTargetInfo) {
function renderSyncTarget(info: SyncTargetInfo, hero: boolean) {
const key = `syncTarget_${info.name}`;
const height = info.name !== 'joplinCloud' ? descriptionHeight : null;
@@ -240,12 +294,11 @@ export default function(props: Props) {
const headerId = `${baseId}-${info.id}`;
return (
<SyncTargetBox id={key} key={key}>
<SyncTargetBox id={key} key={key} hero={hero}>
<SyncTargetTitle id={headerId}>{logo}{info.label}</SyncTargetTitle>
{descriptionComp}
{featuresComp}
{renderSelectArea(info, headerId)}
{renderSignUpArea(info)}
{renderActionArea(info, headerId)}
{renderSlowSyncWarning()}
</SyncTargetBox>
);
@@ -264,12 +317,17 @@ export default function(props: Props) {
}, [props.dispatch, closeDialog]);
function renderContent() {
const boxes: React.ReactNode[] = [];
const heroBoxes: React.ReactNode[] = [];
const otherBoxes: React.ReactNode[] = [];
for (const name of syncTargetNames) {
const info = SyncTargetRegistry.infoByName(name);
if (info.supportsSelfHosted) continue;
boxes.push(renderSyncTarget(info));
if (info.name === 'joplinCloud') {
heroBoxes.push(renderSyncTarget(info, true));
} else {
otherBoxes.push(renderSyncTarget(info, false));
}
}
const selfHostingLabelId = `${baseId}-selfHosting`;
@@ -293,20 +351,39 @@ export default function(props: Props) {
</a>.
</SelfHostingMessage>;
const otherOptionsRegionId = `${baseId}-otherOptions`;
const otherOptions = <OtherOptions>
<OtherOptionsToggle
type='button'
aria-expanded={otherOptionsExpanded}
aria-controls={otherOptionsRegionId}
onClick={() => setOtherOptionsExpanded(prev => !prev)}
>
<i className={otherOptionsExpanded ? 'fas fa-chevron-down' : 'fas fa-chevron-right'} aria-hidden={true}/>
{_('Other sync options')}
</OtherOptionsToggle>
<div id={otherOptionsRegionId} hidden={!otherOptionsExpanded}>
<OtherOptionsBoxes>
{otherBoxes}
</OtherOptionsBoxes>
{selfHostingMessage}
</div>
</OtherOptions>;
return (
<ContentRoot>
<SyncTargetBoxes>
{boxes}
{heroBoxes}
</SyncTargetBoxes>
{selfHostingMessage}
{otherOptions}
</ContentRoot>
);
}
function renderDialogWrapper() {
return (
<StyledRoot>
<DialogTitle title={_('Joplin can synchronise your notes using various providers. Select one from the list below.')} justifyContent="center"/>
<StyledRoot expanded={otherOptionsExpanded}>
<DialogTitle title={_('Sync your notes with Joplin Cloud to access them on all your devices. Other sync options are also available.')} justifyContent="center"/>
{renderContent()}
<DialogButtonRow
themeId={props.themeId}
@@ -4,6 +4,7 @@ import ShareFolderDialog from '../ShareFolderDialog/ShareFolderDialog';
import NotePropertiesDialog from '../NotePropertiesDialog';
import NoteContentPropertiesDialog from '../NoteContentPropertiesDialog';
import ShareNoteDialog from '../ShareNoteDialog';
import PublishFolderDialog from '../PublishFolderDialog';
import { PluginHtmlContents, PluginStates } from '@joplin/lib/services/plugins/reducer';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { DialogState } from './types';
@@ -49,6 +50,9 @@ const defaultDialogState: DialogState = {
shareFolderDialogOptions: {
visible: false,
},
publishFolderDialogOptions: {
visible: false,
},
promptOptions: null,
};
@@ -116,7 +120,7 @@ const WindowCommandsAndDialogs: React.FC<Props> = props => {
const pluginDialog = !dialogInfo || !Dialog ? null : <Dialog {...dialogInfo.props} />;
const {
noteContentPropertiesDialogOptions, notePropertiesDialogOptions, shareNoteDialogOptions, shareFolderDialogOptions, promptOptions,
noteContentPropertiesDialogOptions, notePropertiesDialogOptions, shareNoteDialogOptions, shareFolderDialogOptions, publishFolderDialogOptions, promptOptions,
} = dialogState;
@@ -165,6 +169,13 @@ const WindowCommandsAndDialogs: React.FC<Props> = props => {
onClose={onDialogHideCallbacks.shareFolderDialogOptions}
/>
)}
{publishFolderDialogOptions.visible && (
<PublishFolderDialog
themeId={props.themeId}
folderId={publishFolderDialogOptions.folderId}
onClose={onDialogHideCallbacks.publishFolderDialogOptions}
/>
)}
<PromptDialog
autocomplete={promptOptions && 'autocomplete' in promptOptions ? promptOptions.autocomplete : null}
@@ -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',
};
};
@@ -2,6 +2,7 @@
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 duplicateNote from './duplicateNote';
import * as editAlarm from './editAlarm';
@@ -37,6 +38,7 @@ import * as showModalMessage from './showModalMessage';
import * as showNoteContentProperties from './showNoteContentProperties';
import * as showNoteProperties from './showNoteProperties';
import * as showPrompt from './showPrompt';
import * as showPublishFolderDialog from './showPublishFolderDialog';
import * as showShareFolderDialog from './showShareFolderDialog';
import * as showShareNoteDialog from './showShareNoteDialog';
import * as showSpellCheckerMenu from './showSpellCheckerMenu';
@@ -57,6 +59,7 @@ const index: any[] = [
addNoteToWhiteboard,
addProfile,
commandPalette,
copyNoteExternalLink,
deleteFolder,
duplicateNote,
editAlarm,
@@ -92,6 +95,7 @@ const index: any[] = [
showNoteContentProperties,
showNoteProperties,
showPrompt,
showPublishFolderDialog,
showShareFolderDialog,
showShareNoteDialog,
showSpellCheckerMenu,
@@ -0,0 +1,32 @@
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
import { _ } from '@joplin/lib/locale';
export const declaration: CommandDeclaration = {
name: 'showPublishFolderDialog',
label: () => _('Publish notebook...'),
};
interface Component {
setState: (state: {
publishFolderDialogOptions: {
folderId: string;
visible: boolean;
};
})=> void;
}
export const runtime = (comp: Component): CommandRuntime => {
return {
execute: async (context: CommandContext, folderId: string = null) => {
folderId = folderId || context.state.selectedFolderId;
comp.setState({
publishFolderDialogOptions: {
folderId,
visible: true,
},
});
},
enabledCondition: 'joplinServerConnected && (folderIsShareRootAndOwnedByUser || !folderIsShared)',
};
};
@@ -20,6 +20,10 @@ export interface DialogState {
visible: boolean;
folderId?: string;
};
publishFolderDialogOptions: {
visible: boolean;
folderId?: string;
};
promptOptions: {
inputType?: string;
buttons?: unknown[];
@@ -35,6 +35,13 @@ const useSyncDialogState = (dialogState: DialogState, dispatch: Dispatch) => {
name: 'shareFolder',
});
}
if (state.publishFolderDialogOptions !== prevState.publishFolderDialogOptions) {
dispatch({
type: state.publishFolderDialogOptions && state.publishFolderDialogOptions.visible ? 'VISIBLE_DIALOGS_ADD' : 'VISIBLE_DIALOGS_REMOVE',
name: 'publishFolder',
});
}
lastDialogStateRef.current = dialogState;
}, [dialogState, dispatch]);
};
@@ -2,6 +2,7 @@ export default function() {
return [
'attachFile',
'copyDevCommand',
'copyNoteExternalLink',
'exportPdf',
'focusElementNoteBody',
'focusElementNoteViewer',
@@ -1,4 +1,18 @@
.joplin-cloud-sign-up {
margin-top: 1em;
font-size: var(--joplin-font-size);
&.-primary {
display: flex;
> button {
flex: 1;
padding: 10px 10px;
height: auto;
min-height: auto;
max-height: fit-content;
font-size: 1em;
}
}
}
@@ -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';
@@ -133,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])),
);
}
+9 -1
View File
@@ -7,6 +7,7 @@ import copy7Zip from './tools/copy7Zip';
import bundleJs from './tools/bundleJs';
import { remove } from 'fs-extra';
import execa = require('execa');
import cleanOnnxRuntime from './tools/cleanOnnxRuntime';
const tasks = {
installElectron: {
@@ -69,6 +70,9 @@ const tasks = {
);
},
},
cleanOnnxRuntime: {
fn: cleanOnnxRuntime,
},
};
utils.registerGulpTasks(gulp, tasks);
@@ -82,6 +86,7 @@ const buildBeforeStartParallel = gulp.parallel(
'updateIgnoredTypeScriptBuild',
'buildScriptIndexes',
'compileSass',
'cleanOnnxRuntime',
);
const buildRequiresTsc = gulp.series('bundle');
@@ -89,7 +94,10 @@ gulp.task('before-start', gulp.series(
buildRequiresTsc,
buildBeforeStartParallel,
));
gulp.task('before-dist', buildRequiresTsc);
gulp.task('before-dist', gulp.series(
buildRequiresTsc,
'cleanOnnxRuntime',
));
// Since "build" runs before "tsc", exclude tasks that require
// other packages to be built (i.e. don't include buildRequiresTsc).
@@ -0,0 +1,34 @@
import { ElectronApplication, Locator, Page } from '@playwright/test';
import setSettingValue from '../util/setSettingValue';
import MainScreen from './MainScreen';
import { expect } from '../util/test';
export default class ChatPanel {
public readonly container: Locator;
public readonly textInput: Locator;
public constructor(private page_: Page, private mainScreen_: MainScreen) {
this.container = page_.getByRole('region', { name: 'AI Chat' });
this.textInput = page_.getByRole('textbox', { name: 'Chat message' });
}
public async configure(electronApp: ElectronApplication) {
await setSettingValue(electronApp, this.page_, 'ai.enabled', true);
await setSettingValue(electronApp, this.page_, 'ai.allowRemote', true);
await setSettingValue(electronApp, this.page_, 'ai.chat.providerType', 'test-provider');
}
public async open(electronApp: ElectronApplication) {
await this.mainScreen_.goToAnything.runCommand(electronApp, 'toggleAiChat');
await this.container.waitFor();
}
public async sendMessage(message: string) {
await this.textInput.fill(message);
await this.textInput.press('Enter');
}
public async waitForMessageCount(count: number) {
await expect(this.container.locator('.chat-message.turn')).toHaveCount(count);
}
}
@@ -8,6 +8,7 @@ import NoteList from './NoteList';
import { expect } from '../util/test';
import ChangeAppLayoutScreen from './ChangeAppLayoutScreen';
import waitForNextWindowMatching from '../util/waitForNextWindowMatching';
import ChatPanel from './ChatPanel';
export default class MainScreen {
public readonly newNoteButton: Locator;
@@ -17,6 +18,7 @@ export default class MainScreen {
public readonly noteEditor: NoteEditorScreen;
public readonly goToAnything: GoToAnything;
public readonly changeLayoutScreen: ChangeAppLayoutScreen;
public readonly chatPanel: ChatPanel;
public constructor(private page: Page) {
this.newNoteButton = page.locator('.new-note-button');
@@ -26,6 +28,7 @@ export default class MainScreen {
this.noteEditor = new NoteEditorScreen(page);
this.goToAnything = new GoToAnything(page, this);
this.changeLayoutScreen = new ChangeAppLayoutScreen(page, this);
this.chatPanel = new ChatPanel(page, this);
}
public async setup() {
@@ -115,5 +115,21 @@ test.describe('wcag', () => {
await mainScreen.changeLayoutScreen.open(electronApp);
await expectNoViolations(mainWindow);
});
test('should not detect significant issues in the AI chat panel', async ({ mainWindow, electronApp }) => {
const mainScreen = await new MainScreen(mainWindow).setup();
await mainScreen.createNewNote('test');
await mainScreen.chatPanel.configure(electronApp);
await mainScreen.chatPanel.open(electronApp);
await mainScreen.chatPanel.sendMessage('/reply-with test');
await mainScreen.chatPanel.sendMessage(
'/tool appendToNote {"text": "test"}\n/reply-with done',
);
await mainScreen.chatPanel.waitForMessageCount(5);
await expectNoViolations(mainWindow);
});
});
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@joplin/app-desktop",
"version": "3.7.7",
"version": "3.7.9",
"description": "Joplin for Desktop",
"main": "main.bundle.js",
"private": true,
@@ -146,7 +146,7 @@
"homepage": "https://github.com/laurent22/joplin#readme",
"devDependencies": {
"7zip-bin": "5.2.0",
"@axe-core/playwright": "4.11.1",
"@axe-core/playwright": "4.11.2",
"@electron/notarize": "2.5.0",
"@electron/rebuild": "3.7.2",
"@fortawesome/fontawesome-free": "5.15.4",
@@ -196,7 +196,7 @@
"md5": "2.3.0",
"moment": "2.30.1",
"mustache": "4.2.0",
"nan": "2.25.0",
"nan": "2.26.0",
"node-notifier": "10.0.1",
"node-rsa": "1.1.1",
"pdfjs-dist": "3.11.174",
@@ -224,13 +224,13 @@
},
"dependencies": {
"@electron/remote": "2.1.3",
"@huggingface/transformers": "4.2.0",
"@joplin/onenote-converter": "~3.7",
"@xenova/transformers": "2.17.2",
"@xyflow/react": "12.10.2",
"fs-extra": "11.3.4",
"keytar": "7.9.0",
"node-fetch": "2.6.7",
"onnxruntime-node": "1.26.0",
"onnxruntime-node": "1.24.3",
"sqlite-vec": "0.1.9",
"sqlite3": "5.1.6"
}
@@ -0,0 +1,61 @@
import { pathExists, readdirSync, remove } from 'fs-extra';
import { join, sep } from 'path';
import { env } from 'process';
const findBaseOnnxRuntimePath = () => {
const fullPath = require.resolve('onnxruntime-node').split(sep);
// require.resolve returns the path to a file within the onnxruntime-node package.
// Strip the other path/file components from the path.
for (let i = fullPath.length; i >= 0; i--) {
if (fullPath[i] === 'onnxruntime-node') {
return fullPath.slice(0, i + 1).join(sep);
}
}
throw new Error('Failed to resolve path to onnxruntime-node');
};
// As of onnxruntime-node 1.24.3, only MacOS+ARM64 and Windows/Linux x64/ARM64 are supported
const isUnsupportedPlatform = (platform: string, arch: string) => {
return !['x64', 'arm64'].includes(arch) || (platform === 'darwin' && arch === 'x64');
};
// onnxruntime-node includes large binary artifacts for all platforms. Remove these during build to
// avoid significantly increasing the built app size.
// See #15880
const cleanOnnxRuntime = async () => {
const onnxRuntimePath = findBaseOnnxRuntimePath();
// Note: This path may need to be updated when updating onnxruntime-node:
const baseDir = join(onnxRuntimePath, 'bin', 'napi-v6');
if (!await pathExists(baseDir)) {
throw new Error(`onnxruntime-node NAPI not found. (Searching in ${baseDir})`);
}
const removeDir = async (fullPath: string) => {
if (!await pathExists(fullPath)) return;
console.log('Clearing unused onnxruntime files: rm -r', fullPath);
await remove(fullPath);
};
const targetArch = env['npm_config_target_arch'] || process.arch;
for (const subDir of readdirSync(baseDir)) {
const fullPath = join(baseDir, subDir);
if (subDir !== process.platform) {
await removeDir(fullPath);
} else if (targetArch === 'x64') {
await removeDir(join(fullPath, 'arm64'));
} else if (targetArch === 'arm64') {
await removeDir(join(fullPath, 'x64'));
}
}
if (!await pathExists(join(baseDir, process.platform, targetArch)) && !isUnsupportedPlatform(process.platform, targetArch)) {
throw new Error([
'Missing onnxruntime-node for the current platform. It may have been deleted as part of a previous cross-platform build.',
'To resolve, either:',
`1. Delete ${JSON.stringify(onnxRuntimePath)} and re-run "yarn install".`,
`2. Update the list of platforms unsupported by onnxruntime-node in ${JSON.stringify(__filename)}.`,
].join('\n'));
}
};
export default cleanOnnxRuntime;
+2 -2
View File
@@ -83,8 +83,8 @@ android {
applicationId "net.cozic.joplin"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 2097811
versionName "3.7.2"
versionCode 2097812
versionName "3.7.3"
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
+206 -53
View File
@@ -1,7 +1,7 @@
import * as React from 'react';
import { connect } from 'react-redux';
import { RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Animated, Easing, NativeScrollEvent, NativeScrollPoint, NativeSyntheticEvent, PanResponder, Platform, Pressable, StyleSheet, useWindowDimensions, View, ViewStyle } from 'react-native';
import { RefObject, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
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';
@@ -23,13 +23,16 @@ interface UseStylesProps {
theme: ThemeStyle;
dragging: boolean;
draggable: boolean;
backgroundOpacity: Animated.AnimatedInterpolation<number>;
dragOffset: Animated.AnimatedInterpolation<number>;
}
const useStyles = ({ theme, dragging, draggable, dragOffset }: UseStylesProps) => {
const useStyles = ({ theme, dragging, draggable, dragOffset, backgroundOpacity }: UseStylesProps) => {
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
const safeAreaPadding = useSafeAreaPadding();
const menuMarginTop = theme.margin + safeAreaPadding.paddingTop;
return useMemo(() => {
const isSmallWidthScreen = windowWidth < 500;
const menuGapLeft = safeAreaPadding.paddingLeft + 6;
@@ -39,7 +42,25 @@ const useStyles = ({ theme, dragging, draggable, dragOffset }: UseStylesProps) =
const spaceBelowScreenEdge = Platform.OS === 'web' ? 0 : windowHeight;
return StyleSheet.create({
backgroundStyle: {
backgroundColor: theme.backgroundColorTransparent2,
opacity: backgroundOpacity.interpolate({
inputRange: [0, 1],
outputRange: [0, 1],
extrapolate: 'clamp',
}),
position: 'absolute',
left: 0,
right: 0,
// Add additional space to prevent the edge of the background from being visible
// during overscroll on iOS
top: -spaceBelowScreenEdge,
bottom: -spaceBelowScreenEdge,
zIndex: 0,
},
menuStyle: {
zIndex: 1,
alignSelf: 'flex-end',
...(isSmallWidthScreen ? {
// Center on small screens, rather than float right.
@@ -85,10 +106,10 @@ const useStyles = ({ theme, dragging, draggable, dragOffset }: UseStylesProps) =
padding: 20,
},
modalBackground: {
paddingTop: 0,
paddingLeft: 0,
paddingRight: 0,
paddingBottom: 0,
paddingTop: menuMarginTop,
justifyContent: 'flex-end',
flexDirection: 'column',
},
@@ -127,36 +148,77 @@ const useStyles = ({ theme, dragging, draggable, dragOffset }: UseStylesProps) =
zIndex: 2,
},
});
}, [theme, safeAreaPadding, windowWidth, dragging, draggable, dragOffset, windowHeight]);
}, [theme, safeAreaPadding, windowWidth, dragging, draggable, dragOffset, windowHeight, backgroundOpacity, menuMarginTop]);
};
const usePanResponder = (
setDragging: (dragging: boolean)=> void,
onDragEnd: (dx: number, dy: number)=> void,
dragValue: Animated.Value,
) => {
const currentScrollRef = useRef<NativeScrollPoint|null>(null);
const onScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
currentScrollRef.current = event.nativeEvent.contentOffset;
}, []);
const panResponder = useMemo(() => {
return PanResponder.create({
// Don't use panResponderCapture
onMoveShouldSetPanResponder: (_event, gestureState) => {
if (currentScrollRef.current) {
const top = currentScrollRef.current.y;
interface UsePanResponderProps {
visible: boolean;
animating: boolean;
setDragging: (dragging: boolean)=> void;
onDragEnd: (dx: number, dy: number)=> void;
dragValue: Animated.Value;
dragHandleRef: RefObject<View|null>;
}
const tolerance = 3;
if (top > tolerance && gestureState.dy > 0) return false;
const usePanResponder = ({
visible,
animating,
setDragging,
onDragEnd,
dragValue,
dragHandleRef,
}: UsePanResponderProps) => {
const isScrolledToTopRef = useRef(true);
const onScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
isScrolledToTopRef.current = event.nativeEvent.contentOffset.y < 5;
}, []);
const dragHandleTop = useRef(0);
const dragHandleBottom = useRef(0);
const windowSize = useWindowDimensions();
// windowSizeKey forces re-measures on window size changes:
const windowSizeKey = `${windowSize.width}x${windowSize.height}`;
useLayoutEffect(() => {
if (!visible || animating || !windowSizeKey) return;
dragHandleRef.current?.measure((_x, _y, _width, height, _pageX, pageY) => {
dragHandleTop.current = pageY;
dragHandleBottom.current = pageY + height;
});
}, [visible, animating, dragHandleRef, windowSizeKey]);
const panResponder = useMemo(() => {
const isInDragHandle = (eventY: number) => {
return eventY >= dragHandleTop.current && eventY <= dragHandleBottom.current;
};
// Don't use panResponderCapture on web to prevent buttons from incorrectly being pressed
const onMoveEvent = Platform.OS === 'android' ? 'onMoveShouldSetPanResponderCapture' as const : 'onMoveShouldSetPanResponder' as const;
const onStartEvent = Platform.OS === 'android' ? 'onStartShouldSetPanResponderCapture' as const : 'onStartShouldSetPanResponder' as const;
return PanResponder.create({
// Check onStart and onMove: On Android, starting drag onMove is unreliable when the menu has scroll
[onStartEvent]: (event: GestureResponderEvent) => {
return isInDragHandle(event.nativeEvent.pageY);
},
[onMoveEvent]: (_event: GestureResponderEvent, gestureState: PanResponderGestureState) => {
if (!isScrolledToTopRef.current) {
return false;
}
if (isInDragHandle(gestureState.moveY)) return true;
// Use a large tolerance so that buttons in the menu are still clickable, even
// with a noisy input source:
return Math.abs(gestureState.dx) < 40 && gestureState.dy > 22;
const tolerance = 22;
return Math.abs(gestureState.dx) < 40 && gestureState.dy >= tolerance;
},
onPanResponderGrant: () => {
setDragging(true);
},
onPanResponderTerminate: () => setDragging(false),
onPanResponderTerminate: () => {
setDragging(false);
},
onPanResponderMove: Animated.event([
null,
// Updates menuDragOffset with the .dy property of the second argument:
@@ -174,94 +236,183 @@ const usePanResponder = (
interface UseSyncVisibleProps {
visible: boolean;
dragToOffset: (offset: number)=> void;
dragToOffset: (offset: number)=> Promise<void>;
onDismiss: ()=> void;
containerRef: RefObject<View|null>;
}
const useUpdateOnVisibilityChange = (props: UseSyncVisibleProps) => {
const propsRef = useRef(props);
useEffect(() => {
if (props.visible) {
propsRef.current.dragToOffset(0);
} else {
propsRef.current.containerRef.current?.measure((_x, _y, _width, height) => {
propsRef.current.dragToOffset(height);
propsRef.current = props;
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);
}
});
});
}, []);
useEffect(() => {
const slideMenuIn = () => propsRef.current.dragToOffset(0);
if (props.visible) {
void slideMenuIn();
}
}, [props.visible]);
const isDismissingRef = useRef(false);
return useCallback(async () => {
// Avoid duplicate dismiss animations
if (isDismissingRef.current) return;
try {
isDismissingRef.current = true;
await slideMenuOut();
propsRef.current.onDismiss();
} finally {
isDismissingRef.current = false;
}
}, [slideMenuOut]);
};
const BottomDrawer: React.FC<Props> = props => {
const theme = themeStyle(props.themeId);
const [dragging, setDragging] = useState(false);
const menuDragOffset = useMemo(() => new Animated.Value(0), []);
const [menuHeight, setMenuHeight] = useState(0);
const menuHeightRef = useRef(0);
menuHeightRef.current = menuHeight;
const menuDragOffset = useMemo(() => (
// Start with the menu offscreen so that the slide-in animation works.
// Using the window height as an initial offset ensures that the menu starts completely
// offscreen.
new Animated.Value(Dimensions.get('window').height)
), []);
const onContainerLayout = useCallback((layout: LayoutChangeEvent) => {
setMenuHeight(layout.nativeEvent.layout.height);
}, []);
const backgroundOpacity = useMemo(() => {
return Animated.divide(
Animated.add(Animated.multiply(menuDragOffset, -1), menuHeight), Math.max(menuHeight, 1),
);
}, [menuHeight, menuDragOffset]);
const [animating, setAnimating] = useState(false);
const menuYOffset = useMemo(() => menuDragOffset, [menuDragOffset]);
const styles = useStyles({ theme, dragging, draggable: props.draggable, dragOffset: menuYOffset });
const styles = useStyles({
theme, dragging, draggable: props.draggable, dragOffset: menuYOffset, backgroundOpacity,
});
const reduceMotionEnabled = useReduceMotionEnabled();
const reduceMotionEnabledRef = useRef(false);
reduceMotionEnabledRef.current = reduceMotionEnabled;
const dragToOffset = useCallback((offset: number) => {
const animation = Animated.timing(menuDragOffset, {
const dragToOffset = useCallback(async (offset: number) => {
const baseAnimationProps = {
toValue: offset,
easing: Easing.elastic(0.5),
duration: reduceMotionEnabledRef.current ? 0 : 200,
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();
});
});
animation.start();
}, [menuDragOffset]);
const clearDragOffset = useCallback(() => {
dragToOffset(0);
void dragToOffset(0);
}, [dragToOffset]);
const containerRef = useRef<View|null>(null);
useUpdateOnVisibilityChange({
visible: props.visible, dragToOffset, containerRef,
const onHide = useUpdateOnVisibilityChange({
visible: props.visible, dragToOffset, containerRef, onDismiss: props.onDismiss,
});
const onDragEnd = useCallback((_dx: number, dy: number) => {
if (dy > 50) {
props.onDismiss();
void onHide();
} else {
clearDragOffset();
}
}, [clearDragOffset, props.onDismiss]);
}, [clearDragOffset, onHide]);
const { panResponder, onScroll: onPanResponderScroll } = usePanResponder(
setDragging, onDragEnd, menuDragOffset,
);
const dragHandleRef = useRef<View|null>(null);
const { panResponder, onScroll: onPanResponderScroll } = usePanResponder({
visible: props.visible,
animating,
setDragging,
onDragEnd,
dragValue: menuDragOffset,
dragHandleRef,
});
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 iOS, support menu dismissal through the native scrollview's overscroll behavior:
if (offsetY < -80) {
props.onDismiss();
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);
}
}, [props.onDismiss, onPanResponderScroll]);
}, [onHide, onPanResponderScroll, menuDragOffset]);
return <Modal
visible={props.visible}
onClose={props.onDismiss}
onClose={onHide}
onShow={props.onShow}
backgroundColor={theme.backgroundColorTransparent2}
backgroundColor='transparent'
modalBackgroundStyle={styles.modalBackground}
dismissButtonStyle={styles.dismissButton}
wrapContent={view => {
return <>
<Animated.View style={styles.backgroundStyle}/>
{view}
</>;
}}
containerStyle={styles.menuStyle}
animationType={reduceMotionEnabled ? 'fade' : 'none'}
scrollOverflow={{
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,
}}
>
<View {...panResponder.panHandlers} style={[styles.contentContainer, props.style]} ref={containerRef}>
<View
{...panResponder.panHandlers}
onLayout={onContainerLayout}
style={[styles.contentContainer, props.style]}
ref={containerRef}
>
{dragging && <View style={styles.dragOverlay} />}
<DragHandle
containerRef={dragHandleRef}
containerStyle={styles.dragHandleContainer}
style={styles.dragHandle}
onDismiss={props.onDismiss}
onDismiss={onHide}
/>
{props.children}
</View>
@@ -277,6 +428,7 @@ export default connect((state: AppState) => {
interface DragHandleProps {
style: ViewStyle;
containerRef: RefObject<View|null>;
containerStyle: ViewStyle;
onDismiss: ()=> void;
@@ -285,8 +437,9 @@ interface DragHandleProps {
const DragHandle: React.FC<DragHandleProps> = props => {
return <Pressable
onPress={props.onDismiss}
style={props.containerStyle}
aria-label={_('Dismiss')}
style={props.containerStyle}
ref={props.containerRef}
>
<View style={props.style}/>
</Pressable>;
@@ -63,7 +63,7 @@ const useStyles = (theme: ThemeStyle, spec: PromptButtonSpec, isMenu: boolean) =
paddingHorizontal: (theme.marginMedium - 1) * 2,
paddingTop: theme.marginMedium - 1,
paddingBottom: theme.marginMedium - 1,
minWidth: theme.margin * 4,
minWidth: theme.margin * 5,
},
buttonContent: {
display: 'flex',
+7 -2
View File
@@ -24,6 +24,8 @@ export interface ModalElementProps {
// but can be `null` to prevent the default close behavior.
onClose: OnClose|null;
onShow?: OnShow;
animationType?: 'fade'|'none';
wrapContent?: (view: React.ReactNode)=> React.ReactNode;
statusBarTranslucent?: boolean;
@@ -154,6 +156,7 @@ const ModalElement: React.FC<ModalElementProps> = ({
modalBackgroundStyle: extraModalBackgroundStyles,
dismissButtonStyle,
onClose,
wrapContent,
...forwardedProps
}) => {
const styles = useStyles(!!scrollOverflow, backgroundColor);
@@ -166,7 +169,6 @@ const ModalElement: React.FC<ModalElementProps> = ({
</Animated.View>
);
const [containerComponent, setContainerComponent] = useState<View|null>(null);
const modalStatus = useModalStatus(containerComponent, forwardedProps.visible);
@@ -183,7 +185,7 @@ const ModalElement: React.FC<ModalElementProps> = ({
accessibilityRole='button'
/> : null;
const contentAndBackdrop = <View
let contentAndBackdrop: React.ReactNode = <View
ref={setContainerComponent}
style={[styles.modalBackground, extraModalBackgroundStyles]}
onStartShouldSetResponder={onShouldBackgroundCaptureTouch}
@@ -192,6 +194,9 @@ const ModalElement: React.FC<ModalElementProps> = ({
{content}
{closeButton}
</View>;
if (wrapContent) {
contentAndBackdrop = wrapContent(contentAndBackdrop);
}
const extraScrollViewProps = (typeof scrollOverflow === 'object' ? scrollOverflow : {});
const result = (
+22 -20
View File
@@ -12,6 +12,7 @@ import { Dispatch } from 'redux';
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';
interface Props {
dispatch: Dispatch;
@@ -19,19 +20,22 @@ interface Props {
note: NoteEntity;
noteSelectionEnabled: boolean;
selectedNoteIds: string[];
highlightedWord?: string;
index?: number;
}
const useStyles = (themeId: number) => {
const useStyles = (themeId: number, showTopBorder: boolean) => {
return useMemo(() => {
const theme = themeStyle(themeId);
const listItem: ViewStyle = {
borderTopWidth: showTopBorder ? 1 : 0,
borderTopColor: theme.dividerColor,
marginLeft: theme.marginLeft,
marginRight: theme.marginRight,
paddingTop: theme.marginTop,
paddingBottom: theme.marginBottom,
flexDirection: 'row',
// height: 40,
borderBottomWidth: 1,
borderBottomColor: theme.dividerColor,
alignItems: 'flex-start',
// backgroundColor: theme.backgroundColor,
};
@@ -46,21 +50,15 @@ const useStyles = (themeId: number) => {
};
const listItemPressableWithoutCheckbox: ViewStyle = {
...listItemPressable,
paddingLeft: theme.marginLeft,
paddingRight: theme.marginRight,
paddingTop: theme.itemMarginTop,
paddingBottom: theme.itemMarginBottom,
};
const listItemText: TextStyle = {
flex: 1,
flexShrink: 1,
color: theme.color,
fontSize: theme.fontSize,
};
const listItemTextWithCheckbox = { ...listItemText };
listItemTextWithCheckbox.marginTop = theme.itemMarginTop - 1;
listItemTextWithCheckbox.marginBottom = listItem.paddingBottom;
const selectionWrapper: ViewStyle = { };
@@ -74,24 +72,25 @@ const useStyles = (themeId: number) => {
listItemPressableWithoutCheckbox,
listItemPressableWithCheckbox,
listItemTextWithCheckbox,
highlightedText: {
backgroundColor: theme.searchMarkerBackgroundColor,
color: theme.searchMarkerColor,
},
selectionWrapperSelected,
checkboxStyle: {
color: theme.color,
paddingRight: 10,
paddingTop: theme.itemMarginTop,
paddingBottom: theme.itemMarginBottom,
paddingLeft: theme.marginLeft,
},
checkedOpacityStyle: {
opacity: 0.4,
},
uncheckedOpacityStyle: { },
});
}, [themeId]);
}, [themeId, showTopBorder]);
};
const NoteItemComponent: React.FC<Props> = memo(props => {
const styles = useStyles(props.themeId);
const styles = useStyles(props.themeId, props.index !== 0);
const todoCheckbox_change = useCallback(async (checked: boolean) => {
if (!props.note) return;
@@ -149,6 +148,10 @@ const NoteItemComponent: React.FC<Props> = memo(props => {
const selectionWrapperStyle = isSelected ? styles.selectionWrapperSelected : styles.selectionWrapper;
const noteTitle = Note.displayTitle(note);
const highlightedWord = props.highlightedWord;
const displayedNoteTitle = highlightedWord ? noteTitle.split(new RegExp(`(${escapeRegExp(highlightedWord)})`, 'i')).map((part, index) => {
return part.toLowerCase() === highlightedWord.toLowerCase() ? <Text key={index} style={styles.highlightedText}>{part}</Text> : part;
}) : noteTitle;
const selectDeselectLabel = isSelected ? _('Deselect') : _('Select');
const onLongPressProps = useOnLongPressProps({ onLongPress, actionDescription: selectDeselectLabel });
@@ -175,7 +178,7 @@ const NoteItemComponent: React.FC<Props> = memo(props => {
onPress={onPress}
beforePressable={todoCheckbox}
>
<Text style={listItemTextStyle}>{noteTitle}</Text>
<Text style={listItemTextStyle}>{displayedNoteTitle}</Text>
</MultiTouchableOpacity>
);
});
@@ -187,4 +190,3 @@ export default connect((state: AppState) => {
selectedNoteIds: state.selectedNoteIds,
};
})(NoteItemComponent);
+1 -1
View File
@@ -90,7 +90,7 @@ class NoteListComponent extends Component<NoteListProps> {
return <FlatList
ref={ref => { this.rootRef_ = ref; }}
data={this.props.items}
renderItem={({ item }) => <NoteItem note={item} />}
renderItem={({ item, index }) => <NoteItem note={item} index={index} />}
keyExtractor={item => item.id}
/>;
} else {
@@ -45,12 +45,16 @@ const useStyles = (themeId: number) => {
menuContent: { flexDirection: 'column', width: '100%' },
menuItem: {
alignItems: 'flex-start',
padding: theme.margin,
padding: theme.marginMedium,
paddingLeft: theme.marginLeft,
paddingRight: theme.marginRight,
minWidth: Math.min(350, windowWidth),
},
menuItemContent: { flexDirection: 'row', gap: theme.marginSmall },
menuItemContent: {
flexDirection: 'row',
gap: theme.marginSmall,
alignItems: 'center',
},
menuItemText: {
color: theme.color,
fontSize: theme.fontSize,
@@ -1,5 +1,7 @@
import * as React from 'react';
import { useState } from 'react';
import { connect } from 'react-redux';
import { Platform } from 'react-native';
import { AppState } from '../../utils/types';
import WarningBox from './WarningBox';
import { _ } from '@joplin/lib/locale';
@@ -8,6 +10,11 @@ import { localSyncInfoFromState } from '@joplin/lib/services/synchronizer/syncIn
import Setting from '@joplin/lib/models/Setting';
import { ShareInvitation, ShareUserStatus } from '@joplin/lib/services/share/reducer';
import { substrWithEllipsis } from '@joplin/lib/string-utils';
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
import shim from '@joplin/lib/shim';
import Logger from '@joplin/utils/Logger';
const logger = Logger.create('WarningBanner');
interface Props {
themeId: number;
@@ -17,25 +24,89 @@ interface Props {
showShouldUpgradeSyncTargetMessage: boolean|undefined;
hasDisabledEncryptionItems: boolean;
mustUpgradeAppMessage: string;
syncTargetAppMinVersion?: string;
shareInvitations: ShareInvitation[];
processingShareInvitationResponse: boolean;
showInvalidJoplinCloudCredential: boolean;
}
const androidGooglePlayUrl = 'https://play.google.com/store/apps/details?id=net.cozic.joplin';
const androidPreReleaseUrl = 'https://github.com/laurent22/joplin-android/tags';
const iosAppStoreUrl = 'https://apps.apple.com/app/id1315599797';
const fetchAndroidVersionIsPreRelease = async (version: string) => {
const response = await shim.fetch(`https://api.github.com/repos/laurent22/joplin-android/releases/tags/android-v${version}`);
if (!response.ok) return null;
const release = await response.json();
return !!release.prerelease;
};
export const WarningBannerComponent: React.FC<Props> = props => {
const warningComps = [];
const renderWarningBox = (screen: string, message: string) => {
const [isAndroidTargetPreRelease, setIsAndroidTargetPreRelease] = useState<boolean|null>(null);
useAsyncEffect(async event => {
setIsAndroidTargetPreRelease(null);
if (Platform.OS !== 'android' || !props.mustUpgradeAppMessage || !props.syncTargetAppMinVersion) return;
try {
const isPreRelease = await fetchAndroidVersionIsPreRelease(props.syncTargetAppMinVersion);
if (!event.cancelled) setIsAndroidTargetPreRelease(isPreRelease);
} catch (error) {
logger.error('Could not load release metadata for version', props.syncTargetAppMinVersion, error);
}
}, [props.mustUpgradeAppMessage, props.syncTargetAppMinVersion]);
const renderWarningBox = (screen: string, message: string, url?: string) => {
return <WarningBox
key={screen}
themeId={props.themeId}
targetScreen={screen}
url={url}
message={message}
testID='warning-box'
/>;
};
const renderMustUpgradeAppMessage = () => {
if (props.syncTargetAppMinVersion) {
const upgradeMessage = (message: string) => _(
'In order to synchronise, Please upgrade your application to version %s: %s',
props.syncTargetAppMinVersion,
message,
);
if (Platform.OS === 'android' && isAndroidTargetPreRelease !== null) {
if (isAndroidTargetPreRelease) {
return renderWarningBox(
'UpgradeApp',
upgradeMessage(_('Download it from the Joplin Android repository')),
androidPreReleaseUrl,
);
}
return renderWarningBox(
'UpgradeApp',
upgradeMessage(_('Update it from Google Play')),
androidGooglePlayUrl,
);
}
if (Platform.OS === 'ios') {
return renderWarningBox(
'UpgradeApp',
upgradeMessage(_('Update it from the App Store')),
iosAppStoreUrl,
);
}
return renderWarningBox('UpgradeApp', _('In order to synchronise, Please upgrade your application to version %s', props.syncTargetAppMinVersion));
}
return renderWarningBox('UpgradeApp', props.mustUpgradeAppMessage);
};
if (props.showMissingMasterKeyMessage) {
warningComps.push(renderWarningBox('EncryptionConfig', _('Press to set the decryption password.')));
}
@@ -46,7 +117,7 @@ export const WarningBannerComponent: React.FC<Props> = props => {
warningComps.push(renderWarningBox('UpgradeSyncTarget', _('The sync target needs to be upgraded. Press this banner to proceed.')));
}
if (props.mustUpgradeAppMessage) {
warningComps.push(renderWarningBox('UpgradeApp', props.mustUpgradeAppMessage));
warningComps.push(renderMustUpgradeAppMessage());
}
if (props.hasDisabledEncryptionItems) {
warningComps.push(renderWarningBox('Status', _('Some items cannot be decrypted.')));
@@ -87,6 +158,7 @@ export default connect((state: AppState) => {
hasDisabledSyncItems: state.hasDisabledSyncItems,
shouldUpgradeSyncTarget: state.settings['sync.upgradeState'] === Setting.SYNC_UPGRADE_STATE_SHOULD_DO,
mustUpgradeAppMessage: state.mustUpgradeAppMessage,
syncTargetAppMinVersion: syncInfo.appMinVersion,
shareInvitations: state.shareService.shareInvitations,
processingShareInvitationResponse: state.shareService.processingShareInvitationResponse,
showInvalidJoplinCloudCredential: state.settings['sync.target'] === 10 && state.mustAuthenticate,
@@ -1,12 +1,13 @@
import * as React from 'react';
import { useMemo, useCallback } from 'react';
import { TouchableOpacity, StyleSheet, Text } from 'react-native';
import { TouchableOpacity, StyleSheet, Text, Linking } from 'react-native';
import { themeStyle } from '../global-style';
import NavService from '@joplin/lib/services/NavService';
interface Props {
themeId: number;
targetScreen: string;
url?: string;
message: string;
testID?: string;
}
@@ -32,8 +33,13 @@ const WarningBox: React.FC<Props> = props => {
const styles = useStyles(props.themeId);
const onPress = useCallback(() => {
if (props.url !== undefined) {
void Linking.openURL(props.url);
return;
}
void NavService.go(props.targetScreen);
}, [props.targetScreen]);
}, [props.targetScreen, props.url]);
return (
<TouchableOpacity
@@ -16,6 +16,7 @@ const baseStyle = {
// Smaller margins for spacing between/around items:
marginSmall: 8,
marginMedium: 12,
marginExtraSmall: 6,
disabledOpacity: 0.2,
lineHeight: '1.6em',
@@ -31,7 +31,7 @@ import { TextInput, List } from 'react-native-paper';
import PluginService, { PluginSettings } from '@joplin/lib/services/plugins/PluginService';
import PluginStates, { getSearchText as getPluginStatesSearchText } from './plugins/PluginStates';
import PluginUploadButton, { canInstallPluginsFromFile, buttonLabel as pluginUploadButtonSearchText } from './plugins/PluginUploadButton';
import NoteImportButton, { importedFolderTitle } from './NoteExportSection/NoteImportButton';
import NoteImportButton, { importedFolderTitle, textImportExtensions } from './NoteExportSection/NoteImportButton';
import SectionDescription from './SectionDescription';
import EnablePluginSupportPage from './plugins/EnablePluginSupportPage';
import getVersionInfoText from '../../../utils/getVersionInfoText';
@@ -590,11 +590,11 @@ class ConfigScreenComponent extends BaseScreenComponent<ConfigScreenProps, Confi
<NoteImportButton key='import_as_jex_button' styles={this.styles()} defaultTitle={importJexLabel()} description={importJexDescription()} format='jex' />,
[importJexLabel(), importJexDescription()],
);
const importTxtLabel = () => _('Import from TXT');
const importTxtLabel = () => _('Import from text file');
const importTxtDescription = () => {
let folderTitle = importedFolderTitle();
if (this.state.activeFolder) folderTitle = this.state.activeFolder.title;
return _('Import a note from a Text file. The note will be imported into notebook \'%s\'.', substrWithEllipsis(folderTitle, 0, 32));
return _('Import a note from a text file (%s). The note will be imported into notebook \'%s\'.', textImportExtensions.join(', '), substrWithEllipsis(folderTitle, 0, 32));
};
addSettingComponent(
<NoteImportButton key='import_as_txt_button' styles={this.styles()} defaultTitle={importTxtLabel()} description={importTxtDescription()} format='txt' activeFolder={this.state.activeFolder} />,
@@ -12,6 +12,7 @@ import TaskButton, { OnProgressCallback, SetAfterCompleteListenerCallback, TaskS
import { Platform } from 'react-native';
import { FolderEntity } from '@joplin/lib/services/database/types';
import Folder from '@joplin/lib/models/Folder';
import { fileExtension } from '@joplin/lib/path-utils';
const logger = Logger.create('NoteImportButton');
@@ -27,6 +28,8 @@ export const importedFolderTitle = () => {
return _('Imported Notes');
};
export const textImportExtensions = ['md', 'markdown', 'txt', 'html'];
const importedFolder = async () => {
let folder = await Folder.loadByFields({
title: importedFolderTitle(),
@@ -64,8 +67,17 @@ const NoteImportButton: FunctionComponent<Props> = props => {
default: sourceFileUri,
ios: decodeURIComponent(sourceFileUri),
});
let validExtensions = ['jex'];
if (props.format === 'txt') validExtensions = textImportExtensions;
// importFiles[0].fileName can be null on iOS
const sourceFileName = importFiles[0].fileName ?? basename(sourceFilePath);
const extension = fileExtension(sourceFileName).toLowerCase();
if (!validExtensions.includes(extension)) {
throw new Error(_('Unsupported file extension. Expected: %s', validExtensions.join(', ')));
}
const importTargetPath = join(await makeImportExportCacheDirectory(), sourceFileName);
setAfterCompleteListener(async (_success: boolean) => {
@@ -126,8 +126,12 @@ const NewNoteButton: React.FC<Props> = _props => {
return Promise.resolve();
}, []);
const onMenuShown = useCallback(() => {
// Note: May apply only to web:
focusView('NewNoteButton', newNoteRef.current);
focusView(
'NewNoteButton',
newNoteRef.current,
// Don't scroll into view: This breaks the slide-in animation on web
{ scrollIntoView: false },
);
}, []);
return <FloatingActionButton
@@ -1,9 +1,9 @@
import * as React from 'react';
import { _ } from '@joplin/lib/locale';
import { StyleSheet, TextInput, View } from 'react-native';
import { StyleSheet } from 'react-native';
import { themeStyle } from '../../global-style';
import IconButton from '../../IconButton';
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import { Searchbar } from 'react-native-paper';
interface Props {
themeId: number;
@@ -20,57 +20,47 @@ const useStyles = (themeId: number) => {
const theme = themeStyle(themeId);
return StyleSheet.create({
searchContainer: {
flexDirection: 'row',
alignItems: 'center',
borderWidth: 1,
borderWidth: 2,
borderColor: theme.dividerColor,
backgroundColor: theme.backgroundColor,
margin: theme.margin,
borderRadius: theme.borderRadius,
},
searchTextInput: {
...theme.lineInput,
paddingLeft: theme.marginLeft,
flex: 1,
backgroundColor: theme.backgroundColor,
color: theme.color,
},
clearIcon: {
...theme.icon,
color: theme.colorFaded,
paddingRight: theme.marginRight,
backgroundColor: theme.backgroundColor,
fontSize: theme.fontSize,
},
});
}, [themeId]);
};
const SearchBar: React.FC<Props> = ({ themeId, value, autoFocus, placeholder, onChangeText, onClearButtonPress, onSubmitEditing }) => {
const [isFocused, setIsFocused] = useState(false);
const theme = themeStyle(themeId);
const styles = useStyles(themeId);
return (
<View style={styles.searchContainer}>
<TextInput
style={styles.searchTextInput}
autoFocus={autoFocus}
autoCapitalize='none'
autoComplete='off'
autoCorrect={false}
underlineColorAndroid="#ffffff00"
onChangeText={onChangeText}
onSubmitEditing={onSubmitEditing}
placeholder={placeholder}
placeholderTextColor={theme.colorFaded}
value={value}
selectionColor={theme.textSelectionColor}
keyboardAppearance={theme.keyboardAppearance}
/>
<IconButton
themeId={themeId}
iconStyle={styles.clearIcon}
iconName='ionicon close-circle'
onPress={onClearButtonPress}
description={_('Clear')}
/>
</View>
<Searchbar
style={[styles.searchContainer, isFocused && { borderColor: theme.color4 }]}
inputStyle={styles.searchTextInput}
autoFocus={autoFocus}
autoCapitalize='none'
autoComplete='off'
autoCorrect={false}
clearAccessibilityLabel={_('Clear')}
clearIcon='close'
iconColor={theme.colorFaded}
onBlur={() => setIsFocused(false)}
onChangeText={onChangeText}
onClearIconPress={onClearButtonPress}
onFocus={() => setIsFocused(true)}
onSubmitEditing={onSubmitEditing}
placeholder={placeholder ?? _('Search')}
placeholderTextColor={theme.colorFaded}
value={value}
selectionColor={theme.textSelectionColor}
keyboardAppearance={theme.keyboardAppearance}
/>
);
};
@@ -21,6 +21,7 @@ interface Props {
const useResults = (props: Props) => {
const [notes, setNotes] = useState<NoteEntity[]>([]);
const [highlightedWord, setHighlightedWord] = useState('');
const [isProcessing, setIsProcessing] = useState(false);
const query = props.query;
const ftsEnabled = props.ftsEnabled;
@@ -51,9 +52,12 @@ const useResults = (props: Props) => {
if (event.cancelled) return;
const parsedQuery = await SearchEngine.instance().parseQuery(query);
const highlightedWords = SearchEngine.instance().allParsedQueryTerms(parsedQuery);
const searchTerms = SearchEngine.instance().allParsedQueryTerms(parsedQuery);
props.onHighlightedWordsChange(highlightedWords);
if (event.cancelled) return;
props.onHighlightedWordsChange(searchTerms);
setHighlightedWord(SearchEngine.instance().createQueryFromTerms(searchTerms));
setNotes(notes);
} finally {
setIsProcessing(false);
@@ -62,6 +66,7 @@ const useResults = (props: Props) => {
return {
notes,
highlightedWord,
isPending: isProcessing,
};
};
@@ -97,7 +102,7 @@ const useIsLongRunning = (isPending: boolean) => {
const containerStyle = { flex: 1 };
const SearchResults: React.FC<Props> = props => {
const { notes, isPending } = useResults(props);
const { notes, highlightedWord, isPending } = useResults(props);
// Don't show the progress bar immediately, only show if the search
// is taking some time.
const longRunning = useIsLongRunning(isPending);
@@ -113,8 +118,9 @@ const SearchResults: React.FC<Props> = props => {
{progressBar}
<FlatList
data={notes}
extraData={highlightedWord}
keyExtractor={(item) => item.id}
renderItem={event => <NoteItem note={event.item} />}
renderItem={event => <NoteItem note={event.item} highlightedWord={highlightedWord} />}
/>
</View>
);
@@ -106,6 +106,7 @@ const SearchScreenComponent: React.FC<Props> = props => {
onChangeText={setQuery}
onSubmitEditing={onOverridePause}
onClearButtonPress={clearButton_press}
placeholder={_('Search notes, notebooks...')}
/>
<SearchResults
query={query}
@@ -597,7 +597,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
CURRENT_PROJECT_VERSION = 159;
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.0;
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 = 159;
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.0;
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 = 1;
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 = 1;
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 = 159;
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.0;
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 = 159;
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.0;
MARKETING_VERSION = 13.7.1;
MTL_FAST_MATH = YES;
OTHER_LDFLAGS = (
"$(inherited)",
+7 -7
View File
@@ -37,12 +37,12 @@
"@react-native-community/geolocation": "3.4.0",
"@react-native-community/netinfo": "11.5.2",
"@react-native-community/push-notification-ios": "1.12.0",
"@react-native-documents/picker": "11.0.4",
"@react-native-vector-icons/fontawesome5": "12.3.3",
"@react-native-documents/picker": "12.0.0",
"@react-native-vector-icons/fontawesome5": "12.4.0",
"@react-native-vector-icons/get-image": "12.3.0",
"@react-native-vector-icons/ionicons": "12.4.3",
"@react-native-vector-icons/material-design-icons": "12.4.3",
"@react-native-vector-icons/material-icons": "12.4.3",
"@react-native-vector-icons/ionicons": "12.5.0",
"@react-native-vector-icons/material-design-icons": "12.5.0",
"@react-native-vector-icons/material-icons": "12.5.0",
"assert-browserify": "2.0.0",
"buffer": "6.0.3",
"color": "3.2.1",
@@ -71,7 +71,7 @@
"react-native-localize": "3.7.0",
"react-native-modal-datetime-picker": "18.0.0",
"react-native-nitro-modules": "0.33.9",
"react-native-paper": "5.15.0",
"react-native-paper": "5.15.1",
"react-native-popup-menu": "0.17.0",
"react-native-quick-actions": "0.3.13",
"react-native-quick-base64": "2.2.2",
@@ -118,7 +118,7 @@
"@types/node": "18.19.130",
"@types/react": "19.1.10",
"@types/react-redux": "7.1.34",
"@types/serviceworker": "0.0.194",
"@types/serviceworker": "0.0.195",
"@types/tar-stream": "3.1.4",
"babel-jest": "29.7.0",
"babel-loader": "9.1.3",
+20 -12
View File
@@ -4,21 +4,29 @@ import { AccessibilityInfo, findNodeHandle, Platform, UIManager, View } from 're
const logger = Logger.create('focusView');
const focusView = (source: string, view: View|HTMLElement) => {
interface Options {
// Web only
scrollIntoView?: boolean;
}
const focusView = (source: string, view: View|HTMLElement, { scrollIntoView = true }: Options = {}) => {
const autoFocus = () => {
if (Platform.OS === 'web') {
// react-native-web defines UIManager.focus for setting the keyboard focus. However,
// this property is not available in standard react-native. As such, access it using type
// narrowing:
// eslint-disable-next-line no-restricted-properties
if (!('focus' in UIManager) || typeof UIManager.focus !== 'function') {
throw new Error('Failed to focus sidebar. UIManager.focus is not a function.');
}
if (!scrollIntoView && view instanceof HTMLElement) {
// eslint-disable-next-line no-restricted-properties -- already in a call to focus()
view.focus({ preventScroll: true });
} else {
// react-native-web defines UIManager.focus for setting the keyboard focus. However,
// this property is not available in standard react-native. As such, access it using type
// narrowing:
// eslint-disable-next-line no-restricted-properties
if (!('focus' in UIManager) || typeof UIManager.focus !== 'function') {
throw new Error('Failed to focus sidebar. UIManager.focus is not a function.');
}
// Disable the "use focusHandler for all focus calls" rule -- UIManager.focus requires
// an argument, which is not supported by focusHandler.
// eslint-disable-next-line no-restricted-properties
UIManager.focus(view);
// eslint-disable-next-line no-restricted-properties -- already in a call to focus()
UIManager.focus(view);
}
} else {
const handle = findNodeHandle(view as View);
if (handle !== null) {
+4
View File
@@ -32,6 +32,10 @@ dialog.dialog-modal-layer {
background-color: transparent;
}
dialog.dialog-modal-layer::backdrop {
background-color: transparent;
}
/* Dialog animation
(see "Animating dialogs" on MDN: https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/dialog#animating_dialogs) */
dialog.dialog-modal-layer {
@@ -113,11 +113,11 @@ const configFromSettings = (settings: EditorSettings, context: RenderedContentCo
// Only enable in-editor rendering for Markdown notes. In-editor rendering can result in
// confusing output in HTML notes (e.g. some, but not most, tags hidden).
if (settings.inlineRenderingEnabled && settings.language === EditorLanguageType.Markdown) {
extensions.push(renderingExtension(settings.tableEditingEnabled));
extensions.push(renderingExtension(context, settings.tableEditingEnabled));
} else if (settings.tableEditingEnabled && settings.language === EditorLanguageType.Markdown) {
// Table editing can work independently of inline rendering so users
// who disable inline rendering can still use the interactive widget.
extensions.push(renderTables);
extensions.push(renderTables(context));
}
if (settings.imageRenderingEnabled) {
@@ -72,6 +72,9 @@ const createEditor = (
resolveImageSrc: (src, counter) => {
return props.resolveImageSrc(src, counter);
},
openLink: (link) => {
props.onEvent({ kind: EditorEventType.FollowLink, link });
},
};
@@ -15,7 +15,7 @@ const createEditor = async (initialMarkdown: string, expectedTags: string[] = ['
initialMarkdown,
EditorSelection.cursor(0),
expectedTags,
[renderBlockImages({ resolveImageSrc })],
[renderBlockImages({ resolveImageSrc, openLink: () => {} })],
);
await allowImageUrlsToBeFetched();
return editor;
@@ -2,13 +2,19 @@ import { EditorSelection } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
import createTestEditor from '../../testing/createTestEditor';
import renderTables, { renderInlineMarkdown } from './renderTables';
import { RenderedContentContext } from './types';
const createEditor = async (initialMarkdown: string) => {
const createEditor = async (initialMarkdown: string, context?: Partial<RenderedContentContext>) => {
const fullContext: RenderedContentContext = {
resolveImageSrc: async () => '',
openLink: () => {},
...context,
};
return await createTestEditor(
initialMarkdown,
EditorSelection.cursor(0),
['TableHeader'],
[renderTables],
[renderTables(fullContext)],
);
};
@@ -125,4 +131,65 @@ describe('renderTables', () => {
const editor = await createEditor(source);
expect(editor.state.doc.toString()).toBe(source);
});
test('ctrl/cmd-clicking a rendered cell link should open it', async () => {
const opened: string[] = [];
const editor = await createEditor(
'| [label](https://example.com) | b |\n|---|---|\n| x | y |',
{ openLink: (link) => opened.push(link) },
);
const anchor = editor.dom.querySelector<HTMLAnchorElement>('.cm-tw-text a[href]');
expect(anchor).not.toBeNull();
anchor!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
expect(opened).toEqual([]);
anchor!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, ctrlKey: true }));
expect(opened).toEqual(['https://example.com']);
});
test('holding the modifier over a link should show the pointer cursor', async () => {
const editor = await createEditor('| [label](https://example.com) | b |\n|---|---|\n| x | y |');
const container = editor.dom.querySelector<HTMLElement>('.cm-tw')!;
const anchor = editor.dom.querySelector<HTMLAnchorElement>('.cm-tw-text a[href]')!;
anchor.dispatchEvent(new MouseEvent('mousemove', { bubbles: true }));
expect(container.classList.contains('cm-tw-mod-link')).toBe(false);
anchor.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, ctrlKey: true }));
expect(container.classList.contains('cm-tw-mod-link')).toBe(true);
anchor.dispatchEvent(new MouseEvent('mousemove', { bubbles: true }));
expect(container.classList.contains('cm-tw-mod-link')).toBe(false);
});
test('typing a trailing space in a cell should keep it visible after live-sync', async () => {
jest.useFakeTimers();
let editor: EditorView | null = null;
try {
editor = await createEditor('| Head | b |\n|---|---|\n| x | y |');
// The widget must be connected for the live-sync flush to run.
document.body.appendChild(editor.dom);
const cell = findCellTextDivs(editor)[0];
focusCell(cell);
cell.textContent = 'Hello ';
cell.dispatchEvent(new Event('input'));
// Fire the 500ms debounced live-sync and the follow-up rAF that
// restores focus + caret to the rebuilt cell.
jest.advanceTimersByTime(600);
jest.runOnlyPendingTimers();
// The rebuilt-and-refocused cell must still show the trailing space
// rather than the markdown-trimmed "Hello".
const refocused = findCellTextDivs(editor)[0];
expect(refocused.textContent).toBe('Hello ');
} finally {
editor?.destroy();
jest.useRealTimers();
}
});
});
@@ -19,6 +19,8 @@ import {
Table,
} from '../../utils/markdown/tableUtils';
import { getCellContentPosition } from '../../editorCommands/tableCommands';
import { RenderedContentContext } from './types';
import { editorSettingsFacet } from '../editorSettingsExtension';
// Short class name prefix
const W = 'cm-tw';
@@ -101,6 +103,7 @@ class TableWidget extends WidgetType {
private tableText: string,
private from: number,
private to: number,
private context: RenderedContentContext,
) {
super();
this.cacheKey_ = `table_${from}_${to}_${tableText.length}`;
@@ -249,8 +252,10 @@ class TableWidget extends WidgetType {
// Called on every input so the model stays in sync even if a
// rebuild is triggered by an external event (image paste, toolbar
// command, etc.) before the deferred blur handler runs.
// Not trimmed: an edge space the user just typed is real content
// and must stay visible while editing (see #15918).
const pushToModel = () => {
const v = (textDiv.textContent || '').trim()
const v = (textDiv.textContent || '')
.replace(/\n/g, '<br>').replace(/\|/g, '\\|');
if (isHdr) table.header.cells[c].content = v;
else if (r - 1 < table.body.length) table.body[r - 1].cells[c].content = v;
@@ -278,6 +283,9 @@ class TableWidget extends WidgetType {
if (!container.isConnected) return;
const newText = serializeTable(table);
if (newText === this.tableText) return;
// The serialize/parse round-trip strips edge whitespace, so
// keep the raw value to re-inject after the rebuild (#15918).
const rawValue = textDiv.textContent || '';
this.apply(view, table, 'input.type');
// Rebuild discards this DOM — locate the same cell in the
// new widget and restore focus + caret.
@@ -288,6 +296,8 @@ class TableWidget extends WidgetType {
const target = cells && idx < cells.length ? cells[idx] as HTMLElement : null;
if (!target) return;
focus('TableWidget', target);
// Restore the raw value onfocus trimmed.
if (target.textContent !== rawValue) target.textContent = rawValue;
// Caret restoration: put it `offset` characters into
// the cell's text content.
const sel = win.getSelection();
@@ -787,6 +797,30 @@ class TableWidget extends WidgetType {
}
});
const hasOpenLinkModifier = (e: MouseEvent) => {
const settings = view.state.facet(editorSettingsFacet);
return settings?.preferMacShortcuts ? e.metaKey : e.ctrlKey;
};
// Run on mousedown (before the cell's focus handler swaps the <a>
// for raw markdown) and preventDefault so the cell stays out of edit
// mode.
container.addEventListener('mousedown', (e) => {
if (!hasOpenLinkModifier(e)) return;
const anchor = (e.target as Element | null)?.closest<HTMLAnchorElement>('a[href]');
if (!anchor) return;
e.preventDefault();
this.context.openLink(anchor.getAttribute('href')!);
});
// Mousemove carries the live modifier state, so it can toggle the
// pointer cursor without separate keydown/keyup tracking.
container.addEventListener('mousemove', (e) => {
const overLink = hasOpenLinkModifier(e)
&& !!(e.target as Element | null)?.closest('a[href]');
container.classList.toggle('cm-tw-mod-link', overLink);
});
return container;
}
@@ -821,6 +855,9 @@ const tableTheme = EditorView.theme({
backgroundColor: 'var(--joplin-search-marker-background-color, rgba(255, 220, 0, 0.45))',
color: 'var(--joplin-search-marker-color, inherit)',
},
[`& .${W}.cm-tw-mod-link .cm-tw-text a[href]`]: {
cursor: 'pointer',
},
// Cells
[`& .${CELL}`]: {
@@ -1016,7 +1053,7 @@ const searchHighlight = ViewPlugin.fromClass(class {
});
// ===================== EXTENSION =====================
const renderTables = [
const renderTables = (context: RenderedContentContext) => [
tableTheme,
selectionHighlight,
searchHighlight,
@@ -1041,7 +1078,7 @@ const renderTables = [
}
const text = state.doc.sliceString(startLine.from, endLine.to);
if (!parseTable(text)) return null;
return new TableWidget(text, startLine.from, endLine.to);
return new TableWidget(text, startLine.from, endLine.to, context);
},
getDecorationRange: (node: SyntaxNodeRef, state: EditorState) => {
if (node.name !== 'TableHeader') return null;
@@ -7,8 +7,9 @@ import replaceFormatCharacters from './replaceFormatCharacters';
import replaceInlineHtml from './replaceInlineHtml';
import renderTables from './renderTables';
import replaceLinks from './replaceLinks';
import { RenderedContentContext } from './types';
export default (tableEditingEnabled = true) => {
export default (context: RenderedContentContext, tableEditingEnabled = true) => {
return [
replaceCheckboxes,
replaceBulletLists,
@@ -18,6 +19,6 @@ export default (tableEditingEnabled = true) => {
replaceDividers,
addFormattingClasses,
replaceInlineHtml,
...(tableEditingEnabled ? [renderTables] : []),
...(tableEditingEnabled ? [renderTables(context)] : []),
];
};
@@ -36,4 +36,19 @@ describe('replaceLinks', () => {
const editor = await createTestEditor(markup, EditorSelection.cursor(0), expectedSyntaxTags, [replaceLinks]);
expect(editor.contentDOM.textContent).toBe(expectedText);
});
it('should not move cursor when URL is already visible', async () => {
const markup = '[test](https://example.com/)';
const initialCursor = markup.indexOf('test');
const clickedCursor = markup.indexOf('example');
const editor = await createTestEditor(markup, EditorSelection.cursor(initialCursor), ['URL', 'LinkMark', 'Link'], [replaceLinks]);
expect(editor.contentDOM.textContent).toBe(markup);
editor.contentDOM.dispatchEvent(new MouseEvent('mousedown', { button: 0, bubbles: true }));
editor.dispatch({ selection: EditorSelection.cursor(clickedCursor) });
editor.contentDOM.ownerDocument.dispatchEvent(new MouseEvent('mouseup', { button: 0 }));
expect(editor.state.selection.main.anchor).toBe(clickedCursor);
});
});
@@ -31,4 +31,5 @@ export interface ReplacementExtension {
export interface RenderedContentContext {
resolveImageSrc(src: string, reloadCounter: number): Promise<string>;
openLink(link: string): void;
}
@@ -42,6 +42,18 @@ export const makeInlineReplaceExtension = (extensionSpec: ReplacementExtension)
}
});
const hasHiddenDecoration = (from: number, to: number) => {
let found = false;
this.decorations.between(from, to, (_from, _to, decoration) => {
if (!Object.keys(decoration.spec).length) {
found = true;
return false;
}
return undefined;
});
return found;
};
let selectionUpdate = !selection.empty && coveredTo >= selection.to ? { anchor: selection.head } : undefined;
const line = this.view.state.doc.lineAt(selection.from);
syntaxTree(this.view.state).iterate({
@@ -52,7 +64,7 @@ export const makeInlineReplaceExtension = (extensionSpec: ReplacementExtension)
const closingBracket = node.node.getChildren('LinkMark').find(mark => (
this.view.state.sliceDoc(mark.from, mark.to) === ']'
));
if (closingBracket && selection.from >= closingBracket.from && selection.to <= node.to) {
if (closingBracket && selection.from >= closingBracket.from && selection.to <= node.to && hasHiddenDecoration(closingBracket.from, node.to)) {
selectionUpdate = { anchor: node.to };
}
},
@@ -144,7 +156,7 @@ export const makeInlineReplaceExtension = (extensionSpec: ReplacementExtension)
transaction.effects.some(effect => effect.is(updateInlineDecorationsEffect))
|| extensionSpec.shouldFullReRender?.(transaction)
));
if (this.mouseSelectionInProgress && update.selectionSet && update.state.selection.main.empty && !update.docChanged && !forceUpdate) {
if (this.mouseSelectionInProgress && !update.docChanged && (update.selectionSet || forceUpdate)) {
return;
}
-6
View File
@@ -57,9 +57,6 @@ class SyncTargetAmazonS3 extends BaseSyncTarget {
forcePathStyle: Setting.value('sync.8.forcePathStyle'), // Older implementations may not support more modern access, so we expose this to allow people the option to toggle.
endpoint: Setting.value('sync.8.url'),
ignoreTlsErrors: Setting.value('net.ignoreTlsErrors'),
// Keep checksums opt-in: aws-sdk-js-v3 >= 3.729.0 defaults to sending CRC32 headers that many S3-compatible providers reject.
requestChecksumCalculation: 'WHEN_REQUIRED',
responseChecksumValidation: 'WHEN_REQUIRED',
};
}
@@ -92,9 +89,6 @@ class SyncTargetAmazonS3 extends BaseSyncTarget {
forcePathStyle: options.forcePathStyle(),
endpoint: options.url(),
ignoreTlsErrors: options.ignoreTlsErrors(),
// Keep checksums opt-in: aws-sdk-js-v3 >= 3.729.0 defaults to sending CRC32 headers that many S3-compatible providers reject.
requestChecksumCalculation: 'WHEN_REQUIRED',
responseChecksumValidation: 'WHEN_REQUIRED',
};
const api = new S3Client(apiOptions);
+7 -3
View File
@@ -379,7 +379,7 @@ class WebDavApi {
let response: Response = null;
if (['GET', 'HEAD'].indexOf(fetchOptions.method) < 0 && this.excludeIfNoneMatch === ExcludeIfNoneMatch.Unknown) {
// some webserver, for example Apache Tomcat do not accept invalid If-None-Match header,
// some webserver, for example Apache Tomcat and Koofr do not accept invalid If-None-Match header,
// which is being sent to resolve issue with Seafile and network library on iOS
// to fix this issue, a request is sent with invalid If-None-Match header at first
//
@@ -390,9 +390,13 @@ class WebDavApi {
// if successful, excludeIfNoneMatch is set to Yes, to indicate,
// that subsequent request will be sent without If-None-Match header
response = await shim.fetch(url, fetchOptions);
// These are known error codes used for servers which reject when the If-None-Match header is sent. As these
// particular error codes are terminal, there should not be a risk of an intermittent failure selecting the
// wrong mode for the lifetime of the app
const terminalRejectionCodes = [400, 405];
if (response.ok) {
this.excludeIfNoneMatch = ExcludeIfNoneMatch.No;
} else if (response.status === 400) {
} else if (terminalRejectionCodes.includes(response.status)) {
const fetchOptionsAlt = { ... fetchOptions };
fetchOptionsAlt.headers = { ... fetchOptions.headers };
delete fetchOptionsAlt.headers['If-None-Match'];
@@ -400,7 +404,7 @@ class WebDavApi {
if (responseAlt.ok) {
this.excludeIfNoneMatch = ExcludeIfNoneMatch.Yes;
return responseAlt;
} else if (response.status === 400) {
} else if (terminalRejectionCodes.includes(response.status)) {
this.excludeIfNoneMatch = ExcludeIfNoneMatch.No;
}
}
Loaded 100 of 189 files, more files were not shown because too many files have changed in this diff. Show more