mirror of
https://github.com/laurent22/joplin.git
synced 2026-10-26 11:15:57 +00:00
Compare commits
71
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87211d1996 | ||
|
|
26f7166200 | ||
|
|
09f6a209dd | ||
|
|
e983444b96 | ||
|
|
aed692d5c6 | ||
|
|
96e78ad023 | ||
|
|
b598fa2911 | ||
|
|
248ef10ab2 | ||
|
|
9395c9fa87 | ||
|
|
151faf9f88 | ||
|
|
4246e09f89 | ||
|
|
75cdbafba9 | ||
|
|
341948e0ba | ||
|
|
e3b51d850f | ||
|
|
e6a32029b4 | ||
|
|
7798a33739 | ||
|
|
f5941b28d4 | ||
|
|
70846df6b1 | ||
|
|
1321b154da | ||
|
|
6a21ec6cc1 | ||
|
|
b5ed2c944e | ||
|
|
fa6d4a259b | ||
|
|
32c29dd2f6 | ||
|
|
de93ebed45 | ||
|
|
154f12744d | ||
|
|
2c11cbbb6b | ||
|
|
fca026f0d9 | ||
|
|
e51d9f2670 | ||
|
|
4c2c38f43e | ||
|
|
dafe4cd010 | ||
|
|
6aa4040762 | ||
|
|
22689817c7 | ||
|
|
3515c6aee1 | ||
|
|
a180f9151a | ||
|
|
15207dc931 | ||
|
|
193a17274a | ||
|
|
b0c977b2b6 | ||
|
|
45309e1d0a | ||
|
|
f410aced1e | ||
|
|
6d5444565b | ||
|
|
54b8e69750 | ||
|
|
2e99bd00ae | ||
|
|
7524c73526 | ||
|
|
655806e79c | ||
|
|
68ef1623ab | ||
|
|
b0f27acaf7 | ||
|
|
ce475ea22e | ||
|
|
0faa20d827 | ||
|
|
a7d1e41c09 | ||
|
|
4dbbf2c3f8 | ||
|
|
24ab23045b | ||
|
|
bbc8999a45 | ||
|
|
0ef2649edd | ||
|
|
974198b3a6 | ||
|
|
d58a0bb461 | ||
|
|
5480a56ff5 | ||
|
|
7b2ee03091 | ||
|
|
66cbacca6e | ||
|
|
6baede235e | ||
|
|
84035e579b | ||
|
|
ca7cc0fd31 | ||
|
|
34ea6c7d8e | ||
|
|
ac4d934ebb | ||
|
|
06a49e6988 | ||
|
|
1f9cec33d8 | ||
|
|
39aefb1ad0 | ||
|
|
103ab78b09 | ||
|
|
674f45d06b | ||
|
|
9367ec21f0 | ||
|
|
5178633558 | ||
|
|
8949fa25f7 |
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -864,6 +864,7 @@ function useMenu(props: Props) {
|
||||
menuItemDic.toggleExternalEditing,
|
||||
separator(),
|
||||
menuItemDic.linkToNote,
|
||||
menuItemDic.copyNoteExternalLink,
|
||||
menuItemDic.setTags,
|
||||
menuItemDic.showShareNoteDialog,
|
||||
menuItemDic.convertNoteToMarkdown,
|
||||
|
||||
+45
-7
@@ -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;
|
||||
|
||||
@@ -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])),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
@@ -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'}\""
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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)",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+14
-2
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
Reference in new issue
Block a user