You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-08-27 20:29:45 +02:00
Compare commits
56 Commits
v3.0.3
...
android-v3
Author | SHA1 | Date | |
---|---|---|---|
|
131ec9e913 | ||
|
e31ec031f4 | ||
|
431ce430a0 | ||
|
5cdc1e93b3 | ||
|
09216b8b59 | ||
|
1bb3632a70 | ||
|
03617eb8a7 | ||
|
034e568d26 | ||
|
a0faca0997 | ||
|
5268b5bf6b | ||
|
5b3f05f939 | ||
|
b1a669de01 | ||
|
a5f118bc26 | ||
|
10978781cd | ||
|
8bdec4c2b4 | ||
|
be58fced93 | ||
|
c5dfa4c055 | ||
|
74bc9b36aa | ||
|
993fbfb93f | ||
|
97b5276f81 | ||
|
c6c7de286a | ||
|
aec77b543c | ||
|
34b265475d | ||
|
4e95486c5c | ||
|
296b60800a | ||
|
332e19ce64 | ||
|
8984243020 | ||
|
65c47189f9 | ||
|
6aca77a0ae | ||
|
0670ad92d7 | ||
|
bce71a00e9 | ||
|
83b50aaa8e | ||
|
c7c4371902 | ||
|
4978a473a1 | ||
|
4d89d9f285 | ||
|
15770e9298 | ||
|
06797ec0ab | ||
|
b69bf84ab6 | ||
|
7ec02fc8d8 | ||
|
dd28c9f4d7 | ||
|
7fe98e9dc9 | ||
|
6358c39810 | ||
|
294cc4a440 | ||
|
ae1347bb7c | ||
|
b6d659baf2 | ||
|
5756e160da | ||
|
113c046de6 | ||
|
1ef4e574b7 | ||
|
eccc74cf72 | ||
|
a4137a83d8 | ||
|
ed9b4fb831 | ||
|
1d31f63947 | ||
|
74cda4e2ab | ||
|
39db5cd061 | ||
|
11d9e0a72f | ||
|
7683284352 |
@@ -167,6 +167,7 @@ packages/app-desktop/gui/Button/Button.js
|
||||
packages/app-desktop/gui/ClipperConfigScreen.js
|
||||
packages/app-desktop/gui/ConfigScreen/ButtonBar.js
|
||||
packages/app-desktop/gui/ConfigScreen/ConfigScreen.js
|
||||
packages/app-desktop/gui/ConfigScreen/FontSearch.js
|
||||
packages/app-desktop/gui/ConfigScreen/Sidebar.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/MissingPasswordHelpLink.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/ToggleAdvancedSettingsButton.js
|
||||
@@ -395,10 +396,26 @@ packages/app-desktop/gui/Root_UpgradeSyncTarget.js
|
||||
packages/app-desktop/gui/SearchBar/SearchBar.js
|
||||
packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js
|
||||
packages/app-desktop/gui/ShareNoteDialog.js
|
||||
packages/app-desktop/gui/Sidebar/FolderAndTagList.js
|
||||
packages/app-desktop/gui/Sidebar/Sidebar.js
|
||||
packages/app-desktop/gui/Sidebar/commands/focusElementSideBar.js
|
||||
packages/app-desktop/gui/Sidebar/commands/index.js
|
||||
packages/app-desktop/gui/Sidebar/hooks/useFocusHandler.js
|
||||
packages/app-desktop/gui/Sidebar/hooks/useOnRenderItem.js
|
||||
packages/app-desktop/gui/Sidebar/hooks/useOnSidebarKeyDownHandler.js
|
||||
packages/app-desktop/gui/Sidebar/hooks/useSelectedSidebarIndex.js
|
||||
packages/app-desktop/gui/Sidebar/hooks/useSidebarCommandHandler.js
|
||||
packages/app-desktop/gui/Sidebar/hooks/useSidebarListData.js
|
||||
packages/app-desktop/gui/Sidebar/listItemComponents/AllNotesItem.js
|
||||
packages/app-desktop/gui/Sidebar/listItemComponents/EmptyExpandLink.js
|
||||
packages/app-desktop/gui/Sidebar/listItemComponents/ExpandIcon.js
|
||||
packages/app-desktop/gui/Sidebar/listItemComponents/ExpandLink.js
|
||||
packages/app-desktop/gui/Sidebar/listItemComponents/FolderItem.js
|
||||
packages/app-desktop/gui/Sidebar/listItemComponents/HeaderItem.js
|
||||
packages/app-desktop/gui/Sidebar/listItemComponents/NoteCount.js
|
||||
packages/app-desktop/gui/Sidebar/listItemComponents/TagItem.js
|
||||
packages/app-desktop/gui/Sidebar/styles/index.js
|
||||
packages/app-desktop/gui/Sidebar/types.js
|
||||
packages/app-desktop/gui/StatusScreen/StatusScreen.js
|
||||
packages/app-desktop/gui/StyleSheets/StyleSheetContainer.js
|
||||
packages/app-desktop/gui/SyncWizard/Dialog.js
|
||||
@@ -413,6 +430,7 @@ packages/app-desktop/gui/ToolbarSpace.js
|
||||
packages/app-desktop/gui/TrashNotification/TrashNotification.js
|
||||
packages/app-desktop/gui/dialogs.js
|
||||
packages/app-desktop/gui/hooks/useEffectDebugger.js
|
||||
packages/app-desktop/gui/hooks/useElementHeight.js
|
||||
packages/app-desktop/gui/hooks/useImperativeHandlerDebugger.js
|
||||
packages/app-desktop/gui/hooks/usePrevious.js
|
||||
packages/app-desktop/gui/hooks/usePropsDebugger.js
|
||||
@@ -433,6 +451,8 @@ packages/app-desktop/integration-tests/main.spec.js
|
||||
packages/app-desktop/integration-tests/models/MainScreen.js
|
||||
packages/app-desktop/integration-tests/models/NoteEditorScreen.js
|
||||
packages/app-desktop/integration-tests/models/SettingsScreen.js
|
||||
packages/app-desktop/integration-tests/models/Sidebar.js
|
||||
packages/app-desktop/integration-tests/sidebar.spec.js
|
||||
packages/app-desktop/integration-tests/simpleBackup.spec.js
|
||||
packages/app-desktop/integration-tests/util/activateMainMenuItem.js
|
||||
packages/app-desktop/integration-tests/util/createStartupArgs.js
|
||||
@@ -488,6 +508,7 @@ packages/app-mobile/components/ActionButton.js
|
||||
packages/app-mobile/components/BackButtonDialogBox.js
|
||||
packages/app-mobile/components/CameraView.js
|
||||
packages/app-mobile/components/CustomButton.js
|
||||
packages/app-mobile/components/DismissibleDialog.js
|
||||
packages/app-mobile/components/Dropdown.test.js
|
||||
packages/app-mobile/components/Dropdown.js
|
||||
packages/app-mobile/components/ExtendedWebView.js
|
||||
@@ -584,6 +605,7 @@ packages/app-mobile/components/screens/ConfigScreen/SettingsButton.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/SettingsToggle.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/configScreenStyles.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/ActionButton.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/PluginInfoButton.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/index.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginStates.test.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginStates.js
|
||||
@@ -665,6 +687,7 @@ packages/app-mobile/utils/fs-driver/tarExtract.test.js
|
||||
packages/app-mobile/utils/fs-driver/tarExtract.js
|
||||
packages/app-mobile/utils/fs-driver/testUtil/createFilesFromPathRecord.js
|
||||
packages/app-mobile/utils/fs-driver/testUtil/verifyDirectoryMatches.js
|
||||
packages/app-mobile/utils/getPackageInfo.js
|
||||
packages/app-mobile/utils/initializeCommandService.js
|
||||
packages/app-mobile/utils/ipc/RNToWebViewMessenger.js
|
||||
packages/app-mobile/utils/ipc/WebViewToRNMessenger.js
|
||||
@@ -776,6 +799,7 @@ packages/lib/ObjectUtils.js
|
||||
packages/lib/PoorManIntervals.js
|
||||
packages/lib/RotatingLogs.test.js
|
||||
packages/lib/RotatingLogs.js
|
||||
packages/lib/SyncTargetFilesystem.js
|
||||
packages/lib/SyncTargetJoplinCloud.js
|
||||
packages/lib/SyncTargetJoplinServer.js
|
||||
packages/lib/SyncTargetNone.js
|
||||
@@ -794,6 +818,7 @@ packages/lib/commands/index.js
|
||||
packages/lib/commands/openMasterPasswordDialog.js
|
||||
packages/lib/commands/synchronize.js
|
||||
packages/lib/components/EncryptionConfigScreen/utils.js
|
||||
packages/lib/components/shared/NoteList/getEmptyFolderMessage.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
|
||||
@@ -815,6 +840,7 @@ packages/lib/errorUtils.js
|
||||
packages/lib/errors.js
|
||||
packages/lib/eventManager.js
|
||||
packages/lib/file-api-driver-joplinServer.js
|
||||
packages/lib/file-api-driver-local.js
|
||||
packages/lib/file-api-driver-memory.js
|
||||
packages/lib/file-api-driver.test.js
|
||||
packages/lib/file-api.test.js
|
||||
@@ -1040,9 +1066,12 @@ packages/lib/services/plugins/defaultPlugins/desktopDefaultPluginsInfo.js
|
||||
packages/lib/services/plugins/reducer.js
|
||||
packages/lib/services/plugins/utils/createViewHandle.js
|
||||
packages/lib/services/plugins/utils/executeSandboxCall.js
|
||||
packages/lib/services/plugins/utils/getPluginIssueReportUrl.test.js
|
||||
packages/lib/services/plugins/utils/getPluginIssueReportUrl.js
|
||||
packages/lib/services/plugins/utils/getPluginNamespacedSettingKey.js
|
||||
packages/lib/services/plugins/utils/getPluginSettingKeyPrefix.js
|
||||
packages/lib/services/plugins/utils/getPluginSettingValue.js
|
||||
packages/lib/services/plugins/utils/isCompatible/getDefaultPlatforms.js
|
||||
packages/lib/services/plugins/utils/isCompatible/index.test.js
|
||||
packages/lib/services/plugins/utils/isCompatible/index.js
|
||||
packages/lib/services/plugins/utils/isCompatible/minVersionForPlatform.js
|
||||
|
@@ -70,6 +70,10 @@ module.exports = {
|
||||
'no-var': ['error'],
|
||||
'no-new-func': ['error'],
|
||||
'import/prefer-default-export': ['error'],
|
||||
'prefer-promise-reject-errors': ['error', {
|
||||
allowEmptyReject: true,
|
||||
}],
|
||||
'no-throw-literal': ['error'],
|
||||
|
||||
// This rule should not be enabled since it matters in what order
|
||||
// imports are done, in particular in relation to the shim.setReact
|
||||
|
29
.gitignore
vendored
29
.gitignore
vendored
@@ -147,6 +147,7 @@ packages/app-desktop/gui/Button/Button.js
|
||||
packages/app-desktop/gui/ClipperConfigScreen.js
|
||||
packages/app-desktop/gui/ConfigScreen/ButtonBar.js
|
||||
packages/app-desktop/gui/ConfigScreen/ConfigScreen.js
|
||||
packages/app-desktop/gui/ConfigScreen/FontSearch.js
|
||||
packages/app-desktop/gui/ConfigScreen/Sidebar.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/MissingPasswordHelpLink.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/ToggleAdvancedSettingsButton.js
|
||||
@@ -375,10 +376,26 @@ packages/app-desktop/gui/Root_UpgradeSyncTarget.js
|
||||
packages/app-desktop/gui/SearchBar/SearchBar.js
|
||||
packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.js
|
||||
packages/app-desktop/gui/ShareNoteDialog.js
|
||||
packages/app-desktop/gui/Sidebar/FolderAndTagList.js
|
||||
packages/app-desktop/gui/Sidebar/Sidebar.js
|
||||
packages/app-desktop/gui/Sidebar/commands/focusElementSideBar.js
|
||||
packages/app-desktop/gui/Sidebar/commands/index.js
|
||||
packages/app-desktop/gui/Sidebar/hooks/useFocusHandler.js
|
||||
packages/app-desktop/gui/Sidebar/hooks/useOnRenderItem.js
|
||||
packages/app-desktop/gui/Sidebar/hooks/useOnSidebarKeyDownHandler.js
|
||||
packages/app-desktop/gui/Sidebar/hooks/useSelectedSidebarIndex.js
|
||||
packages/app-desktop/gui/Sidebar/hooks/useSidebarCommandHandler.js
|
||||
packages/app-desktop/gui/Sidebar/hooks/useSidebarListData.js
|
||||
packages/app-desktop/gui/Sidebar/listItemComponents/AllNotesItem.js
|
||||
packages/app-desktop/gui/Sidebar/listItemComponents/EmptyExpandLink.js
|
||||
packages/app-desktop/gui/Sidebar/listItemComponents/ExpandIcon.js
|
||||
packages/app-desktop/gui/Sidebar/listItemComponents/ExpandLink.js
|
||||
packages/app-desktop/gui/Sidebar/listItemComponents/FolderItem.js
|
||||
packages/app-desktop/gui/Sidebar/listItemComponents/HeaderItem.js
|
||||
packages/app-desktop/gui/Sidebar/listItemComponents/NoteCount.js
|
||||
packages/app-desktop/gui/Sidebar/listItemComponents/TagItem.js
|
||||
packages/app-desktop/gui/Sidebar/styles/index.js
|
||||
packages/app-desktop/gui/Sidebar/types.js
|
||||
packages/app-desktop/gui/StatusScreen/StatusScreen.js
|
||||
packages/app-desktop/gui/StyleSheets/StyleSheetContainer.js
|
||||
packages/app-desktop/gui/SyncWizard/Dialog.js
|
||||
@@ -393,6 +410,7 @@ packages/app-desktop/gui/ToolbarSpace.js
|
||||
packages/app-desktop/gui/TrashNotification/TrashNotification.js
|
||||
packages/app-desktop/gui/dialogs.js
|
||||
packages/app-desktop/gui/hooks/useEffectDebugger.js
|
||||
packages/app-desktop/gui/hooks/useElementHeight.js
|
||||
packages/app-desktop/gui/hooks/useImperativeHandlerDebugger.js
|
||||
packages/app-desktop/gui/hooks/usePrevious.js
|
||||
packages/app-desktop/gui/hooks/usePropsDebugger.js
|
||||
@@ -413,6 +431,8 @@ packages/app-desktop/integration-tests/main.spec.js
|
||||
packages/app-desktop/integration-tests/models/MainScreen.js
|
||||
packages/app-desktop/integration-tests/models/NoteEditorScreen.js
|
||||
packages/app-desktop/integration-tests/models/SettingsScreen.js
|
||||
packages/app-desktop/integration-tests/models/Sidebar.js
|
||||
packages/app-desktop/integration-tests/sidebar.spec.js
|
||||
packages/app-desktop/integration-tests/simpleBackup.spec.js
|
||||
packages/app-desktop/integration-tests/util/activateMainMenuItem.js
|
||||
packages/app-desktop/integration-tests/util/createStartupArgs.js
|
||||
@@ -468,6 +488,7 @@ packages/app-mobile/components/ActionButton.js
|
||||
packages/app-mobile/components/BackButtonDialogBox.js
|
||||
packages/app-mobile/components/CameraView.js
|
||||
packages/app-mobile/components/CustomButton.js
|
||||
packages/app-mobile/components/DismissibleDialog.js
|
||||
packages/app-mobile/components/Dropdown.test.js
|
||||
packages/app-mobile/components/Dropdown.js
|
||||
packages/app-mobile/components/ExtendedWebView.js
|
||||
@@ -564,6 +585,7 @@ packages/app-mobile/components/screens/ConfigScreen/SettingsButton.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/SettingsToggle.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/configScreenStyles.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/ActionButton.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/PluginInfoButton.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/index.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginStates.test.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/PluginStates.js
|
||||
@@ -645,6 +667,7 @@ packages/app-mobile/utils/fs-driver/tarExtract.test.js
|
||||
packages/app-mobile/utils/fs-driver/tarExtract.js
|
||||
packages/app-mobile/utils/fs-driver/testUtil/createFilesFromPathRecord.js
|
||||
packages/app-mobile/utils/fs-driver/testUtil/verifyDirectoryMatches.js
|
||||
packages/app-mobile/utils/getPackageInfo.js
|
||||
packages/app-mobile/utils/initializeCommandService.js
|
||||
packages/app-mobile/utils/ipc/RNToWebViewMessenger.js
|
||||
packages/app-mobile/utils/ipc/WebViewToRNMessenger.js
|
||||
@@ -756,6 +779,7 @@ packages/lib/ObjectUtils.js
|
||||
packages/lib/PoorManIntervals.js
|
||||
packages/lib/RotatingLogs.test.js
|
||||
packages/lib/RotatingLogs.js
|
||||
packages/lib/SyncTargetFilesystem.js
|
||||
packages/lib/SyncTargetJoplinCloud.js
|
||||
packages/lib/SyncTargetJoplinServer.js
|
||||
packages/lib/SyncTargetNone.js
|
||||
@@ -774,6 +798,7 @@ packages/lib/commands/index.js
|
||||
packages/lib/commands/openMasterPasswordDialog.js
|
||||
packages/lib/commands/synchronize.js
|
||||
packages/lib/components/EncryptionConfigScreen/utils.js
|
||||
packages/lib/components/shared/NoteList/getEmptyFolderMessage.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
|
||||
@@ -795,6 +820,7 @@ packages/lib/errorUtils.js
|
||||
packages/lib/errors.js
|
||||
packages/lib/eventManager.js
|
||||
packages/lib/file-api-driver-joplinServer.js
|
||||
packages/lib/file-api-driver-local.js
|
||||
packages/lib/file-api-driver-memory.js
|
||||
packages/lib/file-api-driver.test.js
|
||||
packages/lib/file-api.test.js
|
||||
@@ -1020,9 +1046,12 @@ packages/lib/services/plugins/defaultPlugins/desktopDefaultPluginsInfo.js
|
||||
packages/lib/services/plugins/reducer.js
|
||||
packages/lib/services/plugins/utils/createViewHandle.js
|
||||
packages/lib/services/plugins/utils/executeSandboxCall.js
|
||||
packages/lib/services/plugins/utils/getPluginIssueReportUrl.test.js
|
||||
packages/lib/services/plugins/utils/getPluginIssueReportUrl.js
|
||||
packages/lib/services/plugins/utils/getPluginNamespacedSettingKey.js
|
||||
packages/lib/services/plugins/utils/getPluginSettingKeyPrefix.js
|
||||
packages/lib/services/plugins/utils/getPluginSettingValue.js
|
||||
packages/lib/services/plugins/utils/isCompatible/getDefaultPlatforms.js
|
||||
packages/lib/services/plugins/utils/isCompatible/index.test.js
|
||||
packages/lib/services/plugins/utils/isCompatible/index.js
|
||||
packages/lib/services/plugins/utils/isCompatible/minVersionForPlatform.js
|
||||
|
3
CONTRIBUTING
Normal file
3
CONTRIBUTING
Normal file
@@ -0,0 +1,3 @@
|
||||
# Contributing to Joplin
|
||||
|
||||
See the guide at https://github.com/laurent22/joplin/blob/dev/readme/dev/index.md
|
@@ -202,16 +202,28 @@ if command -v lsb_release &> /dev/null; then
|
||||
DISTVER=$(lsb_release -is) && DISTVER=$DISTVER$(lsb_release -rs)
|
||||
DISTCODENAME=$(lsb_release -cs)
|
||||
DISTMAJOR=$(lsb_release -rs|cut -d. -f1)
|
||||
|
||||
#-----------------------------------------------------
|
||||
# Check for "The SUID sandbox helper binary was found, but is not configured correctly" problem.
|
||||
# It is present in Debian 1X. A (temporary) patch will be applied at .desktop file
|
||||
# Linux Mint 4 Debbie is based on Debian 10 and requires the same param handling.
|
||||
#
|
||||
# This also works around Ubuntu 23.10+'s restrictions on unprivileged user namespaces. Electron
|
||||
# TODO: Remove: This is likely no longer an issue. See https://issues.chromium.org/issues/40462640.
|
||||
BAD_HELPER_BINARY=false
|
||||
if [[ $DISTVER =~ Debian1. || ( "$DISTVER" = "Linuxmint4" && "$DISTCODENAME" = "debbie" ) || ( "$DISTVER" = "CentOS" && "$DISTMAJOR" =~ 6|7 ) ]]; then
|
||||
BAD_HELPER_BINARY=true
|
||||
fi
|
||||
|
||||
# Work around Ubuntu 23.10+'s restrictions on unprivileged user namespaces. Electron
|
||||
# uses these to sandbox processes. Unfortunately, it doesn't look like we can get around this
|
||||
# without writing the AppImage to a non-user-writable location (without invalidating other security
|
||||
# controls). See https://discourse.joplinapp.org/t/possible-future-requirement-for-no-sandbox-flag-for-ubuntu-23-10/.
|
||||
if [[ $DISTVER = "Ubuntu23.10" || $DISTVER =~ Debian1. || ( "$DISTVER" = "Linuxmint4" && "$DISTCODENAME" = "debbie" ) || ( "$DISTVER" = "CentOS" && "$DISTMAJOR" =~ 6|7 ) ]]; then
|
||||
HAS_USERNS_RESTRICTIONS=false
|
||||
if [[ "$DISTVER" =~ ^Ubuntu && $DISTMAJOR -ge 23 ]]; then
|
||||
HAS_USERNS_RESTRICTIONS=true
|
||||
fi
|
||||
|
||||
if [[ $HAS_USERNS_RESTRICTIONS = true || $BAD_HELPER_BINARY = true ]]; then
|
||||
SANDBOXPARAM="--no-sandbox"
|
||||
print "${COLOR_YELLOW}WARNING${COLOR_RESET} Electron sandboxing disabled."
|
||||
print " See https://discourse.joplinapp.org/t/32160/5 for details."
|
||||
@@ -241,7 +253,7 @@ if [[ $DESKTOP =~ .*gnome.*|.*kde.*|.*xfce.*|.*mate.*|.*lxqt.*|.*unity.*|.*x-cin
|
||||
Encoding=UTF-8
|
||||
Name=Joplin
|
||||
Comment=Joplin for Desktop
|
||||
Exec=${HOME}/.joplin/Joplin.AppImage ${SANDBOXPARAM} %u
|
||||
Exec=env APPIMAGELAUNCHER_DISABLE=TRUE ${HOME}/.joplin/Joplin.AppImage ${SANDBOXPARAM} %u
|
||||
Icon=joplin
|
||||
StartupWMClass=Joplin
|
||||
Type=Application
|
||||
|
@@ -9,7 +9,7 @@
|
||||
#
|
||||
# APP_BASE_URL: This is the base public URL where the service will be running.
|
||||
# - If Joplin Server needs to be accessible over the internet, configure APP_BASE_URL as follows: https://example.com/joplin.
|
||||
# - If Joplin Server does not need to be accessible over the internet, set the the APP_BASE_URL to your server's hostname.
|
||||
# - If Joplin Server does not need to be accessible over the internet, set the APP_BASE_URL to your server's hostname.
|
||||
# For Example: http://[hostname]:22300. The base URL can include the port.
|
||||
# APP_PORT: The local port on which the Docker container will listen.
|
||||
# - This would typically be mapped to port to 443 (TLS) with a reverse proxy.
|
||||
|
@@ -9,7 +9,7 @@
|
||||
"url": "git+https://github.com/laurent22/joplin.git"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
"node": ">=18"
|
||||
},
|
||||
"scripts": {
|
||||
"buildApiDoc": "yarn workspace joplin start apidoc ../../readme/api/references/rest_api.md",
|
||||
|
@@ -452,7 +452,7 @@ class Application extends BaseApplication {
|
||||
Setting.dispatchUpdateAll();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
await refreshFolders((action: any) => this.store().dispatch(action));
|
||||
await refreshFolders((action: any) => this.store().dispatch(action), '');
|
||||
|
||||
const tags = await Tag.allWithNotes();
|
||||
|
||||
|
@@ -26,7 +26,7 @@ const sharp = require('sharp');
|
||||
const { shimInit } = require('@joplin/lib/shim-init-node.js');
|
||||
const shim = require('@joplin/lib/shim').default;
|
||||
const { _ } = require('@joplin/lib/locale');
|
||||
const { FileApiDriverLocal } = require('@joplin/lib/file-api-driver-local');
|
||||
const FileApiDriverLocal = require('@joplin/lib/file-api-driver-local').default;
|
||||
const EncryptionService = require('@joplin/lib/services/e2ee/EncryptionService').default;
|
||||
const envFromArgs = require('@joplin/lib/envFromArgs');
|
||||
const nodeSqlite = require('sqlite3');
|
||||
|
@@ -5,6 +5,8 @@ import executeSandboxCall from '@joplin/lib/services/plugins/utils/executeSandbo
|
||||
import Global from '@joplin/lib/services/plugins/api/Global';
|
||||
import mapEventHandlersToIds, { EventHandlers } from '@joplin/lib/services/plugins/utils/mapEventHandlersToIds';
|
||||
import uuid from '@joplin/lib/uuid';
|
||||
import Joplin from '@joplin/lib/services/plugins/api/Joplin';
|
||||
import { Console } from 'console';
|
||||
const sandboxProxy = require('@joplin/lib/services/plugins/sandboxProxy');
|
||||
|
||||
function createConsoleWrapper(pluginId: string) {
|
||||
@@ -33,11 +35,18 @@ function createConsoleWrapper(pluginId: string) {
|
||||
// For example, all plugin calls go through a proxy, however they could made directly since
|
||||
// the plugin script is running within the same process as the main app.
|
||||
|
||||
interface SandboxProxy {
|
||||
joplin: Joplin;
|
||||
console: typeof Console;
|
||||
stop: ()=> void;
|
||||
}
|
||||
|
||||
export default class PluginRunner extends BasePluginRunner {
|
||||
|
||||
private eventHandlers_: EventHandlers = {};
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
private activeSandboxCalls_: any = {};
|
||||
private sandboxProxies: Map<string, SandboxProxy> = new Map();
|
||||
|
||||
public constructor() {
|
||||
super();
|
||||
@@ -52,8 +61,14 @@ export default class PluginRunner extends BasePluginRunner {
|
||||
}
|
||||
|
||||
private newSandboxProxy(pluginId: string, sandbox: Global) {
|
||||
let stopped = false;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const target = async (path: string, args: any[]) => {
|
||||
if (stopped) {
|
||||
throw new Error(`Plugin with ID ${pluginId} has been stopped. Cannot execute sandbox call.`);
|
||||
}
|
||||
|
||||
const callId = `${pluginId}::${path}::${uuid.createNano()}`;
|
||||
this.activeSandboxCalls_[callId] = true;
|
||||
const promise = executeSandboxCall(pluginId, sandbox, `joplin.${path}`, mapEventHandlersToIds(args, this.eventHandlers_), this.eventHandler);
|
||||
@@ -64,10 +79,15 @@ export default class PluginRunner extends BasePluginRunner {
|
||||
return promise;
|
||||
};
|
||||
|
||||
return {
|
||||
const proxy = {
|
||||
joplin: sandboxProxy(target),
|
||||
console: createConsoleWrapper(pluginId),
|
||||
stop: () => {
|
||||
stopped = true;
|
||||
},
|
||||
};
|
||||
this.sandboxProxies.set(pluginId, proxy);
|
||||
return proxy;
|
||||
}
|
||||
|
||||
public async run(plugin: Plugin, sandbox: Global): Promise<void> {
|
||||
@@ -90,6 +110,13 @@ export default class PluginRunner extends BasePluginRunner {
|
||||
});
|
||||
}
|
||||
|
||||
public async stop(plugin: Plugin): Promise<void> {
|
||||
// TODO: Node VM doesn't support stopping plugins without running them in a child process.
|
||||
// For now, we stop the plugin by making interactions with the Joplin API throw Errors.
|
||||
const proxy = this.sandboxProxies.get(plugin.id);
|
||||
proxy?.stop();
|
||||
}
|
||||
|
||||
public async waitForSandboxCalls(): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
|
||||
|
@@ -73,7 +73,7 @@
|
||||
"@joplin/tools": "~3.0",
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"@types/jest": "29.5.8",
|
||||
"@types/node": "18.19.8",
|
||||
"@types/node": "18.19.10",
|
||||
"@types/proper-lockfile": "^4.1.2",
|
||||
"gulp": "4.0.2",
|
||||
"jest": "29.7.0",
|
||||
|
24
packages/app-cli/tests/html_to_md/code_multiline_1.html
Normal file
24
packages/app-cli/tests/html_to_md/code_multiline_1.html
Normal file
@@ -0,0 +1,24 @@
|
||||
<!-- this is from: https://flaviocopes.com/bubble-sort-javascript/ -->
|
||||
<div><pre class="astro-code github-dark" style="background-color: rgb(36, 41, 46); color: rgb(225, 228, 232); overflow-x: auto; --darkreader-inline-bgcolor: #1d2125; --darkreader-inline-color: #d7d4cf; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;" tabindex="0" data-darkreader-inline-bgcolor="" data-darkreader-inline-color=""><code><span class="line"><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color="">const</span><span style="color: rgb(179, 146, 240); --darkreader-inline-color: #ab86ee;" data-darkreader-inline-color=""> bubbleSort</span><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color=""> =</span><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color=""> (</span><span style="color: rgb(255, 171, 112); --darkreader-inline-color: #ffa668;" data-darkreader-inline-color="">originalArray</span><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color="">) </span><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color="">=></span><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color=""> {</span></span>
|
||||
<span class="line"><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color=""> let</span><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color=""> swapped </span><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color="">=</span><span style="color: rgb(121, 184, 255); --darkreader-inline-color: #6ebdff;" data-darkreader-inline-color=""> false</span></span>
|
||||
<span class="line"></span>
|
||||
<span class="line"><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color=""> const</span><span style="color: rgb(121, 184, 255); --darkreader-inline-color: #6ebdff;" data-darkreader-inline-color=""> a</span><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color=""> =</span><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color=""> [</span><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color="">...</span><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color="">originalArray]</span></span>
|
||||
<span class="line"></span>
|
||||
<span class="line"><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color=""> for</span><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color=""> (</span><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color="">let</span><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color=""> i </span><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color="">=</span><span style="color: rgb(121, 184, 255); --darkreader-inline-color: #6ebdff;" data-darkreader-inline-color=""> 1</span><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color="">; i </span><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color=""><</span><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color=""> a.</span><span style="color: rgb(121, 184, 255); --darkreader-inline-color: #6ebdff;" data-darkreader-inline-color="">length</span><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color=""> -</span><span style="color: rgb(121, 184, 255); --darkreader-inline-color: #6ebdff;" data-darkreader-inline-color=""> 1</span><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color="">; i</span><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color="">++</span><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color="">) {</span></span>
|
||||
<span class="line"><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color=""> swapped </span><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color="">=</span><span style="color: rgb(121, 184, 255); --darkreader-inline-color: #6ebdff;" data-darkreader-inline-color=""> false</span></span>
|
||||
<span class="line"></span>
|
||||
<span class="line"><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color=""> for</span><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color=""> (</span><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color="">let</span><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color=""> j </span><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color="">=</span><span style="color: rgb(121, 184, 255); --darkreader-inline-color: #6ebdff;" data-darkreader-inline-color=""> 0</span><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color="">; j </span><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color=""><</span><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color=""> a.</span><span style="color: rgb(121, 184, 255); --darkreader-inline-color: #6ebdff;" data-darkreader-inline-color="">length</span><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color=""> -</span><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color=""> i; j</span><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color="">++</span><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color="">) {</span></span>
|
||||
<span class="line"><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color=""> if</span><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color=""> (a[j </span><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color="">+</span><span style="color: rgb(121, 184, 255); --darkreader-inline-color: #6ebdff;" data-darkreader-inline-color=""> 1</span><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color="">] </span><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color=""><</span><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color=""> a[j]) {</span></span>
|
||||
<span class="line"><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color=""> ;[a[j], a[j </span><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color="">+</span><span style="color: rgb(121, 184, 255); --darkreader-inline-color: #6ebdff;" data-darkreader-inline-color=""> 1</span><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color="">]] </span><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color="">=</span><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color=""> [a[j </span><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color="">+</span><span style="color: rgb(121, 184, 255); --darkreader-inline-color: #6ebdff;" data-darkreader-inline-color=""> 1</span><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color="">], a[j]]</span></span>
|
||||
<span class="line"><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color=""> swapped </span><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color="">=</span><span style="color: rgb(121, 184, 255); --darkreader-inline-color: #6ebdff;" data-darkreader-inline-color=""> true</span></span>
|
||||
<span class="line"><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color=""> }</span></span>
|
||||
<span class="line"><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color=""> }</span></span>
|
||||
<span class="line"></span>
|
||||
<span class="line"><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color=""> if</span><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color=""> (</span><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color="">!</span><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color="">swapped) {</span></span>
|
||||
<span class="line"><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color=""> return</span><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color=""> a</span></span>
|
||||
<span class="line"><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color=""> }</span></span>
|
||||
<span class="line"><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color=""> }</span></span>
|
||||
<span class="line"></span>
|
||||
<span class="line"><span style="color: rgb(249, 117, 131); --darkreader-inline-color: #f96e7c;" data-darkreader-inline-color=""> return</span><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color=""> a</span></span>
|
||||
<span class="line"><span style="color: rgb(225, 228, 232); --darkreader-inline-color: #d7d4cf;" data-darkreader-inline-color="">}</span></span></code></pre>
|
||||
<p></p></div>
|
24
packages/app-cli/tests/html_to_md/code_multiline_1.md
Normal file
24
packages/app-cli/tests/html_to_md/code_multiline_1.md
Normal file
@@ -0,0 +1,24 @@
|
||||
```
|
||||
const bubbleSort = (originalArray) => {
|
||||
let swapped = false
|
||||
|
||||
const a = [...originalArray]
|
||||
|
||||
for (let i = 1; i < a.length - 1; i++) {
|
||||
swapped = false
|
||||
|
||||
for (let j = 0; j < a.length - i; j++) {
|
||||
if (a[j + 1] < a[j]) {
|
||||
;[a[j], a[j + 1]] = [a[j + 1], a[j]]
|
||||
swapped = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!swapped) {
|
||||
return a
|
||||
}
|
||||
}
|
||||
|
||||
return a
|
||||
}
|
||||
```
|
21
packages/app-cli/tests/html_to_md/code_multiline_2.html
Normal file
21
packages/app-cli/tests/html_to_md/code_multiline_2.html
Normal file
@@ -0,0 +1,21 @@
|
||||
<!-- this is from: https://blog.csdn.net/qq_42025798/article/details/121727781 -->
|
||||
<div><pre data-index="6" class="prettyprint set-code-show" style="font-family: "Source Code Pro", "DejaVu Sans Mono", "Ubuntu Mono", "Anonymous Pro", "Droid Sans Mono", Menlo, Monaco, Consolas, Inconsolata, Courier, monospace, "PingFang SC", "Microsoft YaHei", sans-serif;"><code class="prism language-java has-numbering" onclick="mdcp.copyCode(event)" style="position: unset;"><span class="token keyword">public</span> <span class="token keyword">class</span> <span class="token class-name">User</span> <span class="token punctuation">{</span>
|
||||
<span class="token keyword">private</span> <span class="token class-name">String</span> name<span class="token punctuation">;</span>
|
||||
|
||||
<span class="token keyword">public</span> <span class="token class-name">String</span> <span class="token function">getName</span><span class="token punctuation">(</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
|
||||
<span class="token keyword">return</span> name<span class="token punctuation">;</span>
|
||||
<span class="token punctuation">}</span>
|
||||
|
||||
<span class="token keyword">public</span> <span class="token keyword">void</span> <span class="token function">setName</span><span class="token punctuation">(</span><span class="token class-name">String</span> name<span class="token punctuation">)</span> <span class="token punctuation">{</span>
|
||||
<span class="token keyword">this</span><span class="token punctuation">.</span>name <span class="token operator">=</span> name<span class="token punctuation">;</span>
|
||||
<span class="token punctuation">}</span>
|
||||
|
||||
<span class="token annotation punctuation">@Override</span>
|
||||
<span class="token keyword">public</span> <span class="token class-name">String</span> <span class="token function">toString</span><span class="token punctuation">(</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
|
||||
<span class="token keyword">return</span> <span class="token string">"User{"</span> <span class="token operator">+</span>
|
||||
<span class="token string">"name='"</span> <span class="token operator">+</span> name <span class="token operator">+</span> <span class="token string">'\''</span> <span class="token operator">+</span>
|
||||
<span class="token string">'}'</span><span class="token punctuation">;</span>
|
||||
<span class="token punctuation">}</span>
|
||||
<span class="token punctuation">}</span>
|
||||
</code><ul class="pre-numbering" style=""><li style="color: rgb(153, 153, 153); --darkreader-inline-color: #a8a095;" data-darkreader-inline-color="">1</li><li style="color: rgb(153, 153, 153); --darkreader-inline-color: #a8a095;" data-darkreader-inline-color="">2</li><li style="color: rgb(153, 153, 153); --darkreader-inline-color: #a8a095;" data-darkreader-inline-color="">3</li><li style="color: rgb(153, 153, 153); --darkreader-inline-color: #a8a095;" data-darkreader-inline-color="">4</li><li style="color: rgb(153, 153, 153); --darkreader-inline-color: #a8a095;" data-darkreader-inline-color="">5</li><li style="color: rgb(153, 153, 153); --darkreader-inline-color: #a8a095;" data-darkreader-inline-color="">6</li><li style="color: rgb(153, 153, 153); --darkreader-inline-color: #a8a095;" data-darkreader-inline-color="">7</li><li style="color: rgb(153, 153, 153); --darkreader-inline-color: #a8a095;" data-darkreader-inline-color="">8</li><li style="color: rgb(153, 153, 153); --darkreader-inline-color: #a8a095;" data-darkreader-inline-color="">9</li><li style="color: rgb(153, 153, 153); --darkreader-inline-color: #a8a095;" data-darkreader-inline-color="">10</li><li style="color: rgb(153, 153, 153); --darkreader-inline-color: #a8a095;" data-darkreader-inline-color="">11</li><li style="color: rgb(153, 153, 153); --darkreader-inline-color: #a8a095;" data-darkreader-inline-color="">12</li><li style="color: rgb(153, 153, 153); --darkreader-inline-color: #a8a095;" data-darkreader-inline-color="">13</li><li style="color: rgb(153, 153, 153); --darkreader-inline-color: #a8a095;" data-darkreader-inline-color="">14</li><li style="color: rgb(153, 153, 153); --darkreader-inline-color: #a8a095;" data-darkreader-inline-color="">15</li><li style="color: rgb(153, 153, 153); --darkreader-inline-color: #a8a095;" data-darkreader-inline-color="">16</li><li style="color: rgb(153, 153, 153); --darkreader-inline-color: #a8a095;" data-darkreader-inline-color="">17</li><li style="color: rgb(153, 153, 153); --darkreader-inline-color: #a8a095;" data-darkreader-inline-color="">18</li></ul></pre>
|
||||
<h4></h4></div>
|
20
packages/app-cli/tests/html_to_md/code_multiline_2.md
Normal file
20
packages/app-cli/tests/html_to_md/code_multiline_2.md
Normal file
@@ -0,0 +1,20 @@
|
||||
```
|
||||
public class User {
|
||||
private String name;
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "User{" +
|
||||
"name='" + name + '\'' +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
```
|
2
packages/app-cli/tests/html_to_md/code_multiline_3.html
Normal file
2
packages/app-cli/tests/html_to_md/code_multiline_3.html
Normal file
File diff suppressed because one or more lines are too long
16
packages/app-cli/tests/html_to_md/code_multiline_3.md
Normal file
16
packages/app-cli/tests/html_to_md/code_multiline_3.md
Normal file
@@ -0,0 +1,16 @@
|
||||
```typescript
|
||||
// Import the Joplin API
|
||||
import joplin from 'api';
|
||||
|
||||
// Register the plugin
|
||||
joplin.plugins.register({
|
||||
|
||||
// Run initialisation code in the onStart event handler
|
||||
// Note that due to the plugin multi-process architecture, you should
|
||||
// always assume that all function calls and event handlers are async.
|
||||
onStart: async function() {
|
||||
console.info('TOC plugin started!');
|
||||
},
|
||||
|
||||
});
|
||||
```
|
2
packages/app-cli/tests/html_to_md/code_multiline_4.html
Normal file
2
packages/app-cli/tests/html_to_md/code_multiline_4.html
Normal file
@@ -0,0 +1,2 @@
|
||||
<!-- this is from: https://blog.csdn.net/ch5256865/article/details/52957360 -->
|
||||
<pre data-index="0" class="set-code-show" name="code" style="font-family: Consolas, Inconsolata, Courier, monospace;"><code class="hljs language-javascript"><ol class="hljs-ln" style="width:100%"><li><div class="hljs-ln-numbers"><div class="hljs-ln-line hljs-ln-n" data-line-number="1"></div></div><div class="hljs-ln-code"><div class="hljs-ln-line"><span class="hljs-keyword">let</span> str = <span class="hljs-string">` hello world ! `</span>;</div></div></li><li><div class="hljs-ln-numbers"><div class="hljs-ln-line hljs-ln-n" data-line-number="2"></div></div><div class="hljs-ln-code"><div class="hljs-ln-line"><span class="hljs-keyword">let</span> _trim = <span class="hljs-keyword">function</span> (<span class="hljs-params"></span>) {</div></div></li><li><div class="hljs-ln-numbers"><div class="hljs-ln-line hljs-ln-n" data-line-number="3"></div></div><div class="hljs-ln-code"><div class="hljs-ln-line"> <span class="hljs-keyword">if</span> (<span class="hljs-keyword">typeof</span> <span class="hljs-variable language_">this</span> == <span class="hljs-string">"number"</span>) <span class="hljs-keyword">new</span> <span class="hljs-title class_">Error</span>(<span class="hljs-string">"Invalid or unexpected token"</span>);</div></div></li><li><div class="hljs-ln-numbers"><div class="hljs-ln-line hljs-ln-n" data-line-number="4"></div></div><div class="hljs-ln-code"><div class="hljs-ln-line"> <span class="hljs-keyword">if</span> (<span class="hljs-keyword">typeof</span> <span class="hljs-variable language_">this</span> !== <span class="hljs-string">"string"</span>)</div></div></li><li><div class="hljs-ln-numbers"><div class="hljs-ln-line hljs-ln-n" data-line-number="5"></div></div><div class="hljs-ln-code"><div class="hljs-ln-line"> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Error</span>(<span class="hljs-string">"Cannot read property 'trim' of"</span> + <span class="hljs-variable language_">this</span>);</div></div></li><li><div class="hljs-ln-numbers"><div class="hljs-ln-line hljs-ln-n" data-line-number="6"></div></div><div class="hljs-ln-code"><div class="hljs-ln-line"> <span class="hljs-keyword">let</span> reg = <span class="hljs-regexp">/^\s*|\s*$/g</span>;</div></div></li><li><div class="hljs-ln-numbers"><div class="hljs-ln-line hljs-ln-n" data-line-number="7"></div></div><div class="hljs-ln-code"><div class="hljs-ln-line"> <span class="hljs-keyword">return</span> <span class="hljs-variable language_">this</span>.<span class="hljs-title function_">replace</span>(reg, <span class="hljs-string">""</span>);</div></div></li><li><div class="hljs-ln-numbers"><div class="hljs-ln-line hljs-ln-n" data-line-number="8"></div></div><div class="hljs-ln-code"><div class="hljs-ln-line">};</div></div></li><li><div class="hljs-ln-numbers"><div class="hljs-ln-line hljs-ln-n" data-line-number="9"></div></div><div class="hljs-ln-code"><div class="hljs-ln-line"> </div></div></li><li><div class="hljs-ln-numbers"><div class="hljs-ln-line hljs-ln-n" data-line-number="10"></div></div><div class="hljs-ln-code"><div class="hljs-ln-line"><span class="hljs-title class_">String</span>.<span class="hljs-property"><span class="hljs-keyword">prototype</span></span>.<span class="hljs-property">_trim</span> = _trim;</div></div></li></ol></code></pre>
|
12
packages/app-cli/tests/html_to_md/code_multiline_4.md
Normal file
12
packages/app-cli/tests/html_to_md/code_multiline_4.md
Normal file
@@ -0,0 +1,12 @@
|
||||
```
|
||||
let str = ` hello world ! `;
|
||||
let _trim = function () {
|
||||
if (typeof this == "number") new Error("Invalid or unexpected token");
|
||||
if (typeof this !== "string")
|
||||
new Error("Cannot read property 'trim' of" + this);
|
||||
let reg = /^\s*|\s*$/g;
|
||||
return this.replace(reg, "");
|
||||
};
|
||||
|
||||
String.prototype._trim = _trim;
|
||||
```
|
42
packages/app-cli/tests/html_to_md/code_multiline_5.html
Normal file
42
packages/app-cli/tests/html_to_md/code_multiline_5.html
Normal file
@@ -0,0 +1,42 @@
|
||||
<!-- this is from: https://www.slingacademy.com/article/python-aiohttp-how-to-download-files-using-streams/ -->
|
||||
<div><pre class="wp-block-code prettyprinted" style="font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "liberation mono", "courier new", monospace;"><code class=" prettyprinted" style=""><span class="com"><span class="com"># SlingAcademy.com</span></span><span class="pln"><span class="pln">
|
||||
</span></span><span class="com"><span class="com"># This code uses Python 3.11.4</span></span><span class="pln"><span class="pln">
|
||||
|
||||
</span></span><span class="kwd"><span class="kwd">import</span></span><span class="pln"><span class="pln"> asyncio
|
||||
</span></span><span class="kwd"><span class="kwd">import</span></span><span class="pln"><span class="pln"> aiohttp
|
||||
|
||||
</span></span><span class="com"><span class="com"># This function downloads a file from a URL and saves it to a local file</span></span><span class="pln"><span class="pln">
|
||||
</span></span><span class="com"><span class="com"># The function is asynchronous and can handle large files because it uses aiohttp streams</span></span><span class="pln"><span class="pln">
|
||||
</span></span><span class="kwd"><span class="kwd">async</span></span><span class="pln"><span class="pln"> </span></span><span class="kwd"><span class="kwd">def</span></span><span class="pln"><span class="pln"> download_file</span></span><span class="pun"><span class="pun">(</span></span><span class="pln"><span class="pln">url</span></span><span class="pun"><span class="pun">,</span></span><span class="pln"><span class="pln"> filename</span></span><span class="pun"><span class="pun">):</span></span><span class="pln"><span class="pln">
|
||||
</span></span><span class="kwd"><span class="kwd">async</span></span><span class="pln"><span class="pln"> </span></span><span class="kwd"><span class="kwd">with</span></span><span class="pln"><span class="pln"> aiohttp</span></span><span class="pun"><span class="pun">.</span></span><span class="typ"><span class="typ">ClientSession</span></span><span class="pun"><span class="pun">()</span></span><span class="pln"><span class="pln"> </span></span><span class="kwd"><span class="kwd">as</span></span><span class="pln"><span class="pln"> session</span></span><span class="pun"><span class="pun">:</span></span><span class="pln"><span class="pln">
|
||||
</span></span><span class="kwd"><span class="kwd">print</span></span><span class="pun"><span class="pun">(</span></span><span class="pln"><span class="pln">f</span></span><span class="str"><span class="str">"Starting download file from {url}"</span></span><span class="pun"><span class="pun">)</span></span><span class="pln"><span class="pln">
|
||||
</span></span><span class="kwd"><span class="kwd">async</span></span><span class="pln"><span class="pln"> </span></span><span class="kwd"><span class="kwd">with</span></span><span class="pln"><span class="pln"> session</span></span><span class="pun"><span class="pun">.</span></span><span class="kwd"><span class="kwd">get</span></span><span class="pun"><span class="pun">(</span></span><span class="pln"><span class="pln">url</span></span><span class="pun"><span class="pun">)</span></span><span class="pln"><span class="pln"> </span></span><span class="kwd"><span class="kwd">as</span></span><span class="pln"><span class="pln"> response</span></span><span class="pun"><span class="pun">:</span></span><span class="pln"><span class="pln">
|
||||
</span></span><span class="kwd"><span class="kwd">assert</span></span><span class="pln"><span class="pln"> response</span></span><span class="pun"><span class="pun">.</span></span><span class="pln"><span class="pln">status </span></span><span class="pun"><span class="pun">==</span></span><span class="pln"><span class="pln"> </span></span><span class="lit"><span class="lit">200</span></span><span class="pln"><span class="pln">
|
||||
</span></span><span class="kwd"><span class="kwd">with</span></span><span class="pln"><span class="pln"> open</span></span><span class="pun"><span class="pun">(</span></span><span class="pln"><span class="pln">filename</span></span><span class="pun"><span class="pun">,</span></span><span class="pln"><span class="pln"> </span></span><span class="str"><span class="str">"wb"</span></span><span class="pun"><span class="pun">)</span></span><span class="pln"><span class="pln"> </span></span><span class="kwd"><span class="kwd">as</span></span><span class="pln"><span class="pln"> f</span></span><span class="pun"><span class="pun">:</span></span><span class="pln"><span class="pln">
|
||||
</span></span><span class="kwd"><span class="kwd">while</span></span><span class="pln"><span class="pln"> </span></span><span class="kwd"><span class="kwd">True</span></span><span class="pun"><span class="pun">:</span></span><span class="pln"><span class="pln">
|
||||
chunk </span></span><span class="pun"><span class="pun">=</span></span><span class="pln"><span class="pln"> </span></span><span class="kwd"><span class="kwd">await</span></span><span class="pln"><span class="pln"> response</span></span><span class="pun"><span class="pun">.</span></span><span class="pln"><span class="pln">content</span></span><span class="pun"><span class="pun">.</span></span><span class="pln"><span class="pln">readany</span></span><span class="pun"><span class="pun">()</span></span><span class="pln"><span class="pln">
|
||||
</span></span><span class="kwd"><span class="kwd">if</span></span><span class="pln"><span class="pln"> </span></span><span class="kwd"><span class="kwd">not</span></span><span class="pln"><span class="pln"> chunk</span></span><span class="pun"><span class="pun">:</span></span><span class="pln"><span class="pln">
|
||||
</span></span><span class="kwd"><span class="kwd">break</span></span><span class="pln"><span class="pln">
|
||||
f</span></span><span class="pun"><span class="pun">.</span></span><span class="pln"><span class="pln">write</span></span><span class="pun"><span class="pun">(</span></span><span class="pln"><span class="pln">chunk</span></span><span class="pun"><span class="pun">)</span></span><span class="pln"><span class="pln">
|
||||
</span></span><span class="kwd"><span class="kwd">print</span></span><span class="pun"><span class="pun">(</span></span><span class="pln"><span class="pln">f</span></span><span class="str"><span class="str">"Downloaded {filename} from {url}"</span></span><span class="pun"><span class="pun">)</span></span><span class="pln"><span class="pln">
|
||||
|
||||
|
||||
</span></span><span class="com"><span class="com"># This function downloads two files at the same time</span></span><span class="pln"><span class="pln">
|
||||
</span></span><span class="kwd"><span class="kwd">async</span></span><span class="pln"><span class="pln"> </span></span><span class="kwd"><span class="kwd">def</span></span><span class="pln"><span class="pln"> main</span></span><span class="pun"><span class="pun">():</span></span><span class="pln"><span class="pln">
|
||||
</span></span><span class="kwd"><span class="kwd">await</span></span><span class="pln"><span class="pln"> asyncio</span></span><span class="pun"><span class="pun">.</span></span><span class="pln"><span class="pln">gather</span></span><span class="pun"><span class="pun">(</span></span><span class="pln"><span class="pln">
|
||||
</span></span><span class="com"><span class="com"># download a CSV file</span></span><span class="pln"><span class="pln">
|
||||
download_file</span></span><span class="pun"><span class="pun">(</span></span><span class="pln"><span class="pln">
|
||||
</span></span><span class="str"><span class="str">"https://api.slingacademy.com/v1/sample-data/files/student-scores.csv"</span></span><span class="pun"><span class="pun">,</span></span><span class="pln"><span class="pln">
|
||||
</span></span><span class="str"><span class="str">"test.csv"</span></span><span class="pun"><span class="pun">,</span></span><span class="pln"><span class="pln">
|
||||
</span></span><span class="pun"><span class="pun">),</span></span><span class="pln"><span class="pln">
|
||||
|
||||
</span></span><span class="com"><span class="com"># download a PDF file</span></span><span class="pln"><span class="pln">
|
||||
download_file</span></span><span class="pun"><span class="pun">(</span></span><span class="pln"><span class="pln">
|
||||
</span></span><span class="str"><span class="str">"https://api.slingacademy.com/v1/sample-data/files/text-and-table.pdf"</span></span><span class="pun"><span class="pun">,</span></span><span class="pln"><span class="pln">
|
||||
</span></span><span class="str"><span class="str">"test.pdf"</span></span><span class="pun"><span class="pun">,</span></span><span class="pln"><span class="pln">
|
||||
</span></span><span class="pun"><span class="pun">),</span></span><span class="pln"><span class="pln">
|
||||
</span></span><span class="pun"><span class="pun">)</span></span><span class="pln"><span class="pln">
|
||||
|
||||
</span></span><span class="com"><span class="com"># Run the main function</span></span><span class="pln"><span class="pln">
|
||||
asyncio</span></span><span class="pun"><span class="pun">.</span></span><span class="pln"><span class="pln">run</span></span><span class="pun"><span class="pun">(</span></span><span class="pln"><span class="pln">main</span></span><span class="pun"><span class="pun">())</span></span></code></pre><span class="ezoic-autoinsert-video ezoic-mid_content"></span>
|
||||
<p></p></div>
|
42
packages/app-cli/tests/html_to_md/code_multiline_5.md
Normal file
42
packages/app-cli/tests/html_to_md/code_multiline_5.md
Normal file
@@ -0,0 +1,42 @@
|
||||
```
|
||||
# SlingAcademy.com
|
||||
# This code uses Python 3.11.4
|
||||
|
||||
import asyncio
|
||||
import aiohttp
|
||||
|
||||
# This function downloads a file from a URL and saves it to a local file
|
||||
# The function is asynchronous and can handle large files because it uses aiohttp streams
|
||||
async def download_file(url, filename):
|
||||
async with aiohttp.ClientSession() as session:
|
||||
print(f"Starting download file from {url}")
|
||||
async with session.get(url) as response:
|
||||
assert response.status == 200
|
||||
with open(filename, "wb") as f:
|
||||
while True:
|
||||
chunk = await response.content.readany()
|
||||
if not chunk:
|
||||
break
|
||||
f.write(chunk)
|
||||
print(f"Downloaded {filename} from {url}")
|
||||
|
||||
|
||||
# This function downloads two files at the same time
|
||||
async def main():
|
||||
await asyncio.gather(
|
||||
# download a CSV file
|
||||
download_file(
|
||||
"https://api.slingacademy.com/v1/sample-data/files/student-scores.csv",
|
||||
"test.csv",
|
||||
),
|
||||
|
||||
# download a PDF file
|
||||
download_file(
|
||||
"https://api.slingacademy.com/v1/sample-data/files/text-and-table.pdf",
|
||||
"test.pdf",
|
||||
),
|
||||
)
|
||||
|
||||
# Run the main function
|
||||
asyncio.run(main())
|
||||
```
|
1
packages/app-cli/tests/md_to_html/sanitize_19.html
Normal file
1
packages/app-cli/tests/md_to_html/sanitize_19.html
Normal file
@@ -0,0 +1 @@
|
||||
<div><span class="jop-noMdConv">This is a comment we would like to keep</div></form>
|
1
packages/app-cli/tests/md_to_html/sanitize_19.md
Normal file
1
packages/app-cli/tests/md_to_html/sanitize_19.md
Normal file
@@ -0,0 +1 @@
|
||||
<form><span>This is a comment we would like to keep</span></form>
|
@@ -1,5 +1,5 @@
|
||||
import PluginRunner from '../../../app/services/plugins/PluginRunner';
|
||||
import PluginService, { PluginSettings } from '@joplin/lib/services/plugins/PluginService';
|
||||
import PluginService, { PluginSettings, defaultPluginSetting } from '@joplin/lib/services/plugins/PluginService';
|
||||
import { ContentScriptType } from '@joplin/lib/services/plugins/api/types';
|
||||
import MdToHtml from '@joplin/renderer/MdToHtml';
|
||||
import shim from '@joplin/lib/shim';
|
||||
@@ -9,6 +9,7 @@ import Note from '@joplin/lib/models/Note';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
import { expectNotThrow, setupDatabaseAndSynchronizer, switchClient, expectThrow, createTempDir, supportDir, mockMobilePlatform } from '@joplin/lib/testing/test-utils';
|
||||
import { newPluginScript } from '../../testUtils';
|
||||
import { join } from 'path';
|
||||
|
||||
const testPluginDir = `${supportDir}/plugins`;
|
||||
|
||||
@@ -283,10 +284,11 @@ describe('services_PluginService', () => {
|
||||
shouldRun: true,
|
||||
},
|
||||
{
|
||||
// Should default to desktop-only
|
||||
manifestPlatforms: [],
|
||||
isDesktop: false,
|
||||
appVersion: '3.0.8',
|
||||
shouldRun: true,
|
||||
shouldRun: false,
|
||||
},
|
||||
])('should enable and disable plugins depending on what platform(s) they support (case %#: %j)', async ({ manifestPlatforms, isDesktop, appVersion, shouldRun }) => {
|
||||
const pluginScript = `
|
||||
@@ -396,4 +398,78 @@ describe('services_PluginService', () => {
|
||||
expect(newPluginSettings[pluginId1]).toBe(undefined);
|
||||
expect(newPluginSettings[pluginId2]).toBe(undefined);
|
||||
});
|
||||
|
||||
it('re-running loadAndRunPlugins should reload plugins that have changed but keep unchanged plugins running', async () => {
|
||||
const testDir = await createTempDir();
|
||||
try {
|
||||
const loadCounterNote = await Note.save({ title: 'Log of plugin loads' });
|
||||
const readLoadCounterNote = async () => {
|
||||
return (await Note.load(loadCounterNote.id)).body;
|
||||
};
|
||||
expect(await readLoadCounterNote()).toBe('');
|
||||
|
||||
const writePluginScript = async (version: string, id: string) => {
|
||||
const script = `
|
||||
/* joplin-manifest:
|
||||
{
|
||||
"id": ${JSON.stringify(id)},
|
||||
"manifest_version": 1,
|
||||
"app_min_version": "1.0.0",
|
||||
"name": "JS Bundle test",
|
||||
"version": ${JSON.stringify(version)}
|
||||
}
|
||||
*/
|
||||
|
||||
joplin.plugins.register({
|
||||
onStart: async function() {
|
||||
const noteId = ${JSON.stringify(loadCounterNote.id)};
|
||||
const pluginId = ${JSON.stringify(id)};
|
||||
const note = await joplin.data.get(['notes', noteId], { fields: ['body'] });
|
||||
const newBody = note.body + '\\n' + pluginId;
|
||||
await joplin.data.put(['notes', noteId], null, { body: newBody.trim() });
|
||||
},
|
||||
});
|
||||
`;
|
||||
await fs.writeFile(join(testDir, `${id}.bundle.js`), script);
|
||||
};
|
||||
|
||||
const service = newPluginService();
|
||||
const pluginId1 = 'org.joplinapp.testPlugin1';
|
||||
await writePluginScript('0.0.1', pluginId1);
|
||||
const pluginId2 = 'org.joplinapp.testPlugin2';
|
||||
await writePluginScript('0.0.1', pluginId2);
|
||||
|
||||
let pluginSettings: PluginSettings = {
|
||||
[pluginId1]: defaultPluginSetting(),
|
||||
[pluginId2]: defaultPluginSetting(),
|
||||
};
|
||||
await service.loadAndRunPlugins(testDir, pluginSettings);
|
||||
|
||||
// Plugins should initially load once
|
||||
expect(service.pluginIds).toHaveLength(2);
|
||||
expect(service.pluginById(pluginId1).running).toBe(true);
|
||||
expect(service.pluginById(pluginId2).running).toBe(true);
|
||||
expect(await readLoadCounterNote()).toBe(`${pluginId1}\n${pluginId2}`);
|
||||
|
||||
// Updating just plugin 1 reload just plugin 1.
|
||||
await writePluginScript('0.0.2', pluginId1);
|
||||
await service.loadAndRunPlugins(testDir, pluginSettings);
|
||||
|
||||
expect(service.pluginById(pluginId1).running).toBe(true);
|
||||
expect(service.pluginById(pluginId2).running).toBe(true);
|
||||
expect(await readLoadCounterNote()).toBe(`${pluginId1}\n${pluginId2}\n${pluginId1}`);
|
||||
|
||||
// Disabling plugin 1 should not reload plugin 2
|
||||
pluginSettings = { ...pluginSettings, [pluginId1]: { ...defaultPluginSetting(), enabled: false } };
|
||||
await service.loadAndRunPlugins(testDir, pluginSettings);
|
||||
|
||||
expect(service.pluginById(pluginId1).running).toBe(false);
|
||||
expect(service.pluginById(pluginId2).running).toBe(true);
|
||||
expect(await readLoadCounterNote()).toBe(`${pluginId1}\n${pluginId2}\n${pluginId1}`);
|
||||
|
||||
await service.destroy();
|
||||
} finally {
|
||||
await fs.remove(testDir);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
@@ -47,6 +47,42 @@ const registerMakeThumbnailCommand = async () => {
|
||||
await joplin.views.toolbarButtons.create('makeThumbnailButton', 'makeThumbnail', ToolbarButtonLocation.EditorToolbar);
|
||||
};
|
||||
|
||||
const registerMakeThumbnailFromUrlCommand = async () => {
|
||||
await joplin.commands.register({
|
||||
name: 'makeThumbnailFromUrl',
|
||||
execute: async () => {
|
||||
const urls = [
|
||||
'https://github.com/laurent22/joplin/blob/dev/Assets/ImageSources/RoundedCornersMac_1024x1024.png?raw=true',
|
||||
'https://github.com/laurent22/joplin/blob/dev/packages/app-cli/tests/ocr_samples/multi_page__embedded_text.pdf?raw=true',
|
||||
]
|
||||
|
||||
for (const url of urls) {
|
||||
// ---------------------------------------------------------------
|
||||
// Create an image from URLs
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
const imageHandle = await joplin.imaging.createFromPath(url);
|
||||
const resizedImageHandle = await joplin.imaging.resize(imageHandle, { width: 100 });
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Convert the image to a resource and add it to the note
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
const newResource = await joplin.imaging.toJpgResource(resizedImageHandle, { title: "Thumbnail" });
|
||||
await joplin.commands.execute('insertText', '\n');
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Free up the image objects at the end
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
await joplin.imaging.free(imageHandle);
|
||||
await joplin.imaging.free(resizedImageHandle);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
await joplin.views.toolbarButtons.create('makeThumbnailFromUrlButton', 'makeThumbnailFromUrl', ToolbarButtonLocation.EditorToolbar);
|
||||
};
|
||||
|
||||
const registerInlinePdfCommand = async () => {
|
||||
await joplin.commands.register({
|
||||
@@ -106,5 +142,6 @@ joplin.plugins.register({
|
||||
onStart: async function() {
|
||||
await registerMakeThumbnailCommand();
|
||||
await registerInlinePdfCommand();
|
||||
await registerMakeThumbnailFromUrlCommand();
|
||||
},
|
||||
});
|
||||
|
@@ -10,5 +10,5 @@ document.addEventListener('click', async (event) => {
|
||||
})
|
||||
|
||||
console.info('webview.js: registering message listener');
|
||||
webviewApi.onMessage((message) => console.info('webview.js: got message:', message));
|
||||
webviewApi.onMessage((event) => console.info('webview.js: got message:', event.message));
|
||||
|
||||
|
@@ -390,6 +390,8 @@ class Application extends BaseApplication {
|
||||
|
||||
argv = await super.start(argv, startOptions);
|
||||
|
||||
bridge().setLogFilePath(Logger.globalLogger.logFilePath());
|
||||
|
||||
await this.applySettingsSideEffects();
|
||||
|
||||
if (Setting.value('sync.upgradeState') === Setting.SYNC_UPGRADE_STATE_MUST_DO) {
|
||||
@@ -465,7 +467,7 @@ class Application extends BaseApplication {
|
||||
Setting.dispatchUpdateAll();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
await refreshFolders((action: any) => this.dispatch(action));
|
||||
await refreshFolders((action: any) => this.dispatch(action), '');
|
||||
|
||||
const tags = await Tag.allWithNotes();
|
||||
|
||||
|
@@ -6,11 +6,14 @@ import { dirname, toSystemSlashes } from '@joplin/lib/path-utils';
|
||||
import { fileUriToPath } from '@joplin/utils/url';
|
||||
import { urlDecode } from '@joplin/lib/string-utils';
|
||||
import * as Sentry from '@sentry/electron/main';
|
||||
import { ErrorEvent } from '@sentry/types/types';
|
||||
import { homedir } from 'os';
|
||||
import { msleep } from '@joplin/utils/time';
|
||||
import { pathExists, writeFileSync } from 'fs-extra';
|
||||
import { pathExists, pathExistsSync, writeFileSync } from 'fs-extra';
|
||||
import { extname, normalize } from 'path';
|
||||
import isSafeToOpen from './utils/isSafeToOpen';
|
||||
import { closeSync, openSync, readSync, statSync } from 'fs';
|
||||
import { KB } from '@joplin/utils/bytes';
|
||||
|
||||
interface LastSelectedPath {
|
||||
file: string;
|
||||
@@ -38,6 +41,7 @@ export class Bridge {
|
||||
private rootProfileDir_: string;
|
||||
private appName_: string;
|
||||
private appId_: string;
|
||||
private logFilePath_ = '';
|
||||
|
||||
private extraAllowedExtensions_: string[] = [];
|
||||
private onAllowedExtensionsChangeListener_: OnAllowedExtensionsChange = ()=>{};
|
||||
@@ -56,12 +60,53 @@ export class Bridge {
|
||||
this.sentryInit();
|
||||
}
|
||||
|
||||
public setLogFilePath(v: string) {
|
||||
this.logFilePath_ = v;
|
||||
}
|
||||
|
||||
private sentryInit() {
|
||||
const getLogLines = () => {
|
||||
try {
|
||||
if (!this.logFilePath_ || !pathExistsSync(this.logFilePath_)) return '';
|
||||
const { size } = statSync(this.logFilePath_);
|
||||
if (!size) return '';
|
||||
|
||||
const bytesToRead = Math.min(size, 100 * KB);
|
||||
const handle = openSync(this.logFilePath_, 'r');
|
||||
const position = size - bytesToRead;
|
||||
const buffer = Buffer.alloc(bytesToRead);
|
||||
readSync(handle, buffer, 0, bytesToRead, position);
|
||||
closeSync(handle);
|
||||
return buffer.toString('utf-8');
|
||||
} catch (error) {
|
||||
// Can't do anything in this context
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const getLogAttachment = () => {
|
||||
const lines = getLogLines();
|
||||
if (!lines) return null;
|
||||
return { filename: 'joplin-log.txt', data: lines };
|
||||
};
|
||||
|
||||
const options: Sentry.ElectronMainOptions = {
|
||||
beforeSend: event => {
|
||||
beforeSend: (event, hint) => {
|
||||
try {
|
||||
const logAttachment = getLogAttachment();
|
||||
if (logAttachment) hint.attachments = [logAttachment];
|
||||
const date = (new Date()).toISOString().replace(/[:-]/g, '').split('.')[0];
|
||||
writeFileSync(`${homedir()}/joplin_crash_dump_${date}.json`, JSON.stringify(event, null, '\t'), 'utf-8');
|
||||
|
||||
interface ErrorEventWithLog extends ErrorEvent {
|
||||
log: string[];
|
||||
}
|
||||
|
||||
const errorEventWithLog: ErrorEventWithLog = {
|
||||
...event,
|
||||
log: logAttachment ? logAttachment.data.trim().split('\n') : [],
|
||||
};
|
||||
|
||||
writeFileSync(`${homedir()}/joplin_crash_dump_${date}.json`, JSON.stringify(errorEventWithLog, null, '\t'), 'utf-8');
|
||||
} catch (error) {
|
||||
// Ignore the error since we can't handle it here
|
||||
}
|
||||
|
@@ -19,7 +19,7 @@ const generateChecksumFile = () => {
|
||||
}
|
||||
}
|
||||
if (appImageName === '') {
|
||||
throw 'AppImage not found!';
|
||||
throw new Error('AppImage not found!');
|
||||
}
|
||||
const appImagePath = path.join(distPath, appImageName);
|
||||
const appImageContent = fs.readFileSync(appImagePath);
|
||||
|
@@ -22,6 +22,7 @@ interface Props {
|
||||
title?: string;
|
||||
iconName?: string;
|
||||
level?: ButtonLevel;
|
||||
iconLabel?: string;
|
||||
className?: string;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
|
||||
onClick?: Function;
|
||||
@@ -219,7 +220,7 @@ const Button = React.forwardRef((props: Props, ref: any) => {
|
||||
|
||||
function renderIcon() {
|
||||
if (!props.iconName) return null;
|
||||
return <StyledIcon animation={props.iconAnimation} mr={iconOnly ? '0' : '6px'} color={props.color} className={props.iconName}/>;
|
||||
return <StyledIcon aria-label={props.iconLabel} animation={props.iconAnimation} mr={iconOnly ? '0' : '6px'} color={props.color} className={props.iconName}/>;
|
||||
}
|
||||
|
||||
function renderTitle() {
|
||||
|
@@ -4,7 +4,7 @@ import ButtonBar from './ButtonBar';
|
||||
import Button, { ButtonLevel, ButtonSize } from '../Button/Button';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import bridge from '../../services/bridge';
|
||||
import Setting, { AppType, SyncStartupOperation } from '@joplin/lib/models/Setting';
|
||||
import Setting, { AppType, SettingItemSubType, SyncStartupOperation } from '@joplin/lib/models/Setting';
|
||||
import control_PluginsStates from './controls/plugins/PluginsStates';
|
||||
import EncryptionConfigScreen from '../EncryptionConfigScreen/EncryptionConfigScreen';
|
||||
import { reg } from '@joplin/lib/registry';
|
||||
@@ -20,12 +20,23 @@ import ToggleAdvancedSettingsButton from './controls/ToggleAdvancedSettingsButto
|
||||
import shouldShowMissingPasswordWarning from '@joplin/lib/components/shared/config/shouldShowMissingPasswordWarning';
|
||||
import MacOSMissingPasswordHelpLink from './controls/MissingPasswordHelpLink';
|
||||
const { KeymapConfigScreen } = require('../KeymapConfig/KeymapConfigScreen');
|
||||
import FontSearch from './FontSearch';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const settingKeyToControl: any = {
|
||||
'plugins.states': control_PluginsStates,
|
||||
};
|
||||
|
||||
interface Font {
|
||||
family: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
queryLocalFonts(): Promise<Font[]>;
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
class ConfigScreenComponent extends React.Component<any, any> {
|
||||
|
||||
@@ -44,6 +55,7 @@ class ConfigScreenComponent extends React.Component<any, any> {
|
||||
screenName: '',
|
||||
changedSettingKeys: [],
|
||||
needRestart: false,
|
||||
fonts: [],
|
||||
};
|
||||
|
||||
this.rowStyle_ = {
|
||||
@@ -78,12 +90,16 @@ class ConfigScreenComponent extends React.Component<any, any> {
|
||||
this.setState({ settings: this.props.settings });
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
public async componentDidMount() {
|
||||
if (this.props.defaultSection) {
|
||||
this.setState({ selectedSectionName: this.props.defaultSection }, () => {
|
||||
void this.switchSection(this.props.defaultSection);
|
||||
});
|
||||
}
|
||||
|
||||
const fonts = (await window.queryLocalFonts()).map((font: Font) => font.family);
|
||||
const uniqueFonts = [...new Set(fonts)];
|
||||
this.setState({ fonts: uniqueFonts });
|
||||
}
|
||||
|
||||
private async handleSettingButton(key: string) {
|
||||
@@ -591,22 +607,32 @@ class ConfigScreenComponent extends React.Component<any, any> {
|
||||
const onTextChange = (event: any) => {
|
||||
updateSettingValue(key, event.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={key} style={rowStyle}>
|
||||
<div style={labelStyle}>
|
||||
<label>{md.label()}</label>
|
||||
</div>
|
||||
<input
|
||||
type={inputType}
|
||||
style={inputStyle}
|
||||
value={this.state.settings[key]}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
onChange={(event: any) => {
|
||||
onTextChange(event);
|
||||
}}
|
||||
spellCheck={false}
|
||||
/>
|
||||
{
|
||||
md.subType === SettingItemSubType.FontFamily || md.subType === SettingItemSubType.MonospaceFontFamily ?
|
||||
<FontSearch
|
||||
type={inputType}
|
||||
style={inputStyle}
|
||||
value={this.state.settings[key]}
|
||||
availableFonts={this.state.fonts}
|
||||
onChange={fontFamily => updateSettingValue(key, fontFamily)}
|
||||
subtype={md.subType}
|
||||
/> :
|
||||
<input
|
||||
type={inputType}
|
||||
style={inputStyle}
|
||||
value={this.state.settings[key]}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
onChange={(event: any) => {
|
||||
onTextChange(event);
|
||||
}}
|
||||
spellCheck={false}
|
||||
/>
|
||||
}
|
||||
<div style={{ width: inputStyle.width, minWidth: inputStyle.minWidth }}>
|
||||
{descriptionComp}
|
||||
</div>
|
||||
|
232
packages/app-desktop/gui/ConfigScreen/FontSearch.tsx
Normal file
232
packages/app-desktop/gui/ConfigScreen/FontSearch.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
import React = require('react');
|
||||
import { useMemo, useState, useCallback, CSSProperties, useEffect, useRef } from 'react';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { SettingItemSubType } from '@joplin/lib/models/Setting';
|
||||
import { focus } from '@joplin/lib/utils/focusHandler';
|
||||
|
||||
interface Props {
|
||||
type: string;
|
||||
style: CSSProperties;
|
||||
value: string;
|
||||
availableFonts: string[];
|
||||
onChange: (font: string)=> void;
|
||||
subtype: string;
|
||||
}
|
||||
|
||||
const FontSearch = (props: Props) => {
|
||||
const { type, style, value, availableFonts, onChange, subtype } = props;
|
||||
const [filteredAvailableFonts, setFilteredAvailableFonts] = useState(availableFonts);
|
||||
const [inputText, setInputText] = useState(value);
|
||||
const [showList, setShowList] = useState(false);
|
||||
const [isListHovered, setIsListHovered] = useState(false);
|
||||
const [isFontSelected, setIsFontSelected] = useState(value !== '');
|
||||
const [visibleFonts, setVisibleFonts] = useState<string[]>([]);
|
||||
const [isMonoBoxChecked, setIsMonoBoxChecked] = useState(false);
|
||||
const isLoadingFonts = filteredAvailableFonts.length === 0;
|
||||
const fontInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (subtype === SettingItemSubType.MonospaceFontFamily) {
|
||||
setIsMonoBoxChecked(true);
|
||||
}
|
||||
}, [subtype]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMonoBoxChecked) return setFilteredAvailableFonts(availableFonts);
|
||||
const localMonospacedFonts = availableFonts.filter((font: string) =>
|
||||
monospaceKeywords.some((word: string) => font.toLowerCase().includes(word)) ||
|
||||
knownMonospacedFonts.includes(font.toLowerCase()),
|
||||
);
|
||||
setFilteredAvailableFonts(localMonospacedFonts);
|
||||
}, [isMonoBoxChecked, availableFonts]);
|
||||
|
||||
const displayedFonts = useMemo(() => {
|
||||
if (isFontSelected) return filteredAvailableFonts;
|
||||
return filteredAvailableFonts.filter((font: string) =>
|
||||
font.toLowerCase().startsWith(inputText.toLowerCase()),
|
||||
);
|
||||
}, [filteredAvailableFonts, inputText, isFontSelected]);
|
||||
|
||||
useEffect(() => {
|
||||
setVisibleFonts(displayedFonts.slice(0, 20));
|
||||
}, [displayedFonts]);
|
||||
|
||||
// Lazy loading
|
||||
const handleListScroll: React.UIEventHandler<HTMLDivElement> = useCallback((event) => {
|
||||
const scrollTop = (event.target as HTMLDivElement).scrollTop;
|
||||
const scrollHeight = (event.target as HTMLDivElement).scrollHeight;
|
||||
const clientHeight = (event.target as HTMLDivElement).clientHeight;
|
||||
|
||||
// Check if the user has scrolled to the bottom of the container
|
||||
// A small buffer of 20 pixels is subtracted from the total scrollHeight to ensure new content starts loading slightly before the user reaches the absolute bottom, providing a smoother experience.
|
||||
if (scrollTop + clientHeight >= scrollHeight - 20) {
|
||||
// Load the next 20 fonts
|
||||
const remainingFonts = displayedFonts.slice(visibleFonts.length, visibleFonts.length + 20);
|
||||
|
||||
setVisibleFonts([...visibleFonts, ...remainingFonts]);
|
||||
}
|
||||
}, [displayedFonts, visibleFonts]);
|
||||
|
||||
const handleTextChange: React.ChangeEventHandler<HTMLInputElement> = useCallback((event) => {
|
||||
setIsFontSelected(false);
|
||||
setInputText(event.target.value);
|
||||
onChange(event.target.value);
|
||||
}, [onChange]);
|
||||
|
||||
const handleFocus: React.FocusEventHandler<HTMLInputElement> = useCallback(() => setShowList(true), []);
|
||||
|
||||
const handleBlur: React.FocusEventHandler<HTMLInputElement> = useCallback(() => {
|
||||
if (!isListHovered) {
|
||||
setShowList(false);
|
||||
}
|
||||
}, [isListHovered]);
|
||||
|
||||
const handleFontClick: React.MouseEventHandler<HTMLDivElement> = useCallback((event) => {
|
||||
const font = (event.target as HTMLDivElement).innerText;
|
||||
setInputText(font);
|
||||
setShowList(false);
|
||||
onChange(font);
|
||||
setIsFontSelected(true);
|
||||
}, [onChange]);
|
||||
|
||||
const handleListHover: React.MouseEventHandler<HTMLDivElement> = useCallback(() => setIsListHovered(true), []);
|
||||
|
||||
const handleListLeave: React.MouseEventHandler<HTMLDivElement> = useCallback(() => setIsListHovered(false), []);
|
||||
|
||||
const handleMonoBoxCheck: React.ChangeEventHandler<HTMLInputElement> = useCallback(() => {
|
||||
setIsMonoBoxChecked(!isMonoBoxChecked);
|
||||
focus('FontSearch::fontInputRef', fontInputRef.current);
|
||||
}, [isMonoBoxChecked]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
type={type}
|
||||
style={style}
|
||||
value={inputText}
|
||||
onChange={handleTextChange}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
spellCheck={false}
|
||||
ref={fontInputRef}
|
||||
/>
|
||||
<div
|
||||
className={'font-search-list'}
|
||||
style={{ display: showList ? 'block' : 'none' }}
|
||||
onMouseEnter={handleListHover}
|
||||
onMouseLeave={handleListLeave}
|
||||
onScroll={handleListScroll}
|
||||
>
|
||||
{
|
||||
isLoadingFonts ? <div>{_('Loading...')}</div> :
|
||||
<>
|
||||
<div className='monospace-checkbox'>
|
||||
<input
|
||||
type='checkbox'
|
||||
checked={isMonoBoxChecked}
|
||||
onChange={handleMonoBoxCheck}
|
||||
id={`show-monospace-fonts_${subtype}`}
|
||||
/>
|
||||
<label htmlFor={`show-monospace-fonts_${subtype}`}>{_('Show monospace fonts only.')}</label>
|
||||
</div>
|
||||
{
|
||||
visibleFonts.map((font: string) =>
|
||||
<div
|
||||
key={font}
|
||||
style={{ fontFamily: `"${font}"` }}
|
||||
onClick={handleFontClick}
|
||||
className='font-search-item'
|
||||
>
|
||||
{font}
|
||||
</div>,
|
||||
)
|
||||
}
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FontSearch;
|
||||
|
||||
// Known monospaced fonts from wikipedia
|
||||
// https://en.wikipedia.org/wiki/List_of_monospaced_typefaces
|
||||
// https://en.wikipedia.org/wiki/Category:Monospaced_typefaces
|
||||
// Make sure to add the fonts in lower case
|
||||
// cSpell:disable
|
||||
const knownMonospacedFonts = [
|
||||
'andalé mono',
|
||||
'anonymous pro',
|
||||
'bitstream vera sans mono',
|
||||
'cascadia code',
|
||||
'century schoolbook monospace',
|
||||
'comic mono',
|
||||
'computer modern mono/typewriter',
|
||||
'consolas',
|
||||
'courier',
|
||||
'courier final draft',
|
||||
'courier new',
|
||||
'courier prime',
|
||||
'courier screenplay',
|
||||
'cousine',
|
||||
'dejavu sans mono',
|
||||
'droid sans mono',
|
||||
'envy code r',
|
||||
'everson mono',
|
||||
'fantasque sans mono',
|
||||
'fira code',
|
||||
'fira mono',
|
||||
'fixed',
|
||||
'fixedsys',
|
||||
'freemono',
|
||||
'go mono',
|
||||
'hack',
|
||||
'hyperfont',
|
||||
'ibm courier',
|
||||
'ibm plex mono',
|
||||
'inconsolata',
|
||||
'input',
|
||||
'iosevka',
|
||||
'jetbrains mono',
|
||||
'juliamono',
|
||||
'letter gothic',
|
||||
'liberation mono',
|
||||
'lucida console',
|
||||
'menlo',
|
||||
'monaco',
|
||||
'monofur',
|
||||
'monospace (unicode)',
|
||||
'nimbus mono l',
|
||||
'nk57 monospace',
|
||||
'noto mono',
|
||||
'ocr-a',
|
||||
'ocr-b',
|
||||
'operator mono',
|
||||
'overpass mono',
|
||||
'oxygen mono',
|
||||
'pragmatapro',
|
||||
'profont',
|
||||
'pt mono',
|
||||
'recursive mono',
|
||||
'roboto mono',
|
||||
'sf mono',
|
||||
'source code pro',
|
||||
'spleen',
|
||||
'terminal',
|
||||
'terminus',
|
||||
'tex gyre cursor',
|
||||
'ubuntu mono',
|
||||
'victor mono',
|
||||
'wumpus mono',
|
||||
];
|
||||
|
||||
const monospaceKeywords = [
|
||||
'mono',
|
||||
'code',
|
||||
'courier',
|
||||
'console',
|
||||
'source code',
|
||||
'terminal',
|
||||
'fixed',
|
||||
];
|
@@ -12,3 +12,35 @@
|
||||
.config-screen-content > .section:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.font-search-list {
|
||||
background-color: var(--joplin-background-color);
|
||||
max-height: 200px;
|
||||
width: 50%;
|
||||
min-width: 20em;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--joplin-border-color4);
|
||||
border-radius: 5px;
|
||||
display: none;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.font-search-list > div {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.font-search-item {
|
||||
border-bottom: 1px solid var(--joplin-border-color4);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.font-search-item:hover {
|
||||
color: var(--joplin-background-color);
|
||||
background-color: var(--joplin-color);
|
||||
}
|
||||
|
||||
.monospace-checkbox {
|
||||
background-color: var(--joplin-background-color3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
@@ -1,5 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import { useCallback, useState, useRef, useEffect } from 'react';
|
||||
import { useCallback, useState, useRef, useEffect, useId } from 'react';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import DialogButtonRow, { ClickEvent } from '../DialogButtonRow';
|
||||
import Dialog from '../Dialog';
|
||||
@@ -127,13 +127,14 @@ export default function(props: Props) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const formTitleInputId = useId();
|
||||
function renderForm() {
|
||||
return (
|
||||
<div>
|
||||
<div className="form">
|
||||
<div className="form-input-group">
|
||||
<label>{_('Title')}</label>
|
||||
<StyledInput type="text" ref={titleInputRef} value={folderTitle} onChange={onFolderTitleChange}/>
|
||||
<label htmlFor={formTitleInputId}>{_('Title')}</label>
|
||||
<StyledInput id={formTitleInputId} type="text" ref={titleInputRef} value={folderTitle} onChange={onFolderTitleChange}/>
|
||||
</div>
|
||||
|
||||
<div className="form-input-group">
|
||||
|
@@ -1,19 +1,15 @@
|
||||
import * as React from 'react';
|
||||
import { DragEventHandler, KeyboardEventHandler, UIEventHandler } from 'react';
|
||||
|
||||
interface Props {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
style: any;
|
||||
interface Props<ItemType> {
|
||||
style: React.CSSProperties & { height: number };
|
||||
itemHeight: number;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
items: any[];
|
||||
items: ItemType[];
|
||||
disabled?: boolean;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
|
||||
onKeyDown?: Function;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
|
||||
itemRenderer: Function;
|
||||
onKeyDown?: KeyboardEventHandler<HTMLElement>;
|
||||
itemRenderer: (item: ItemType, index: number)=> React.JSX.Element;
|
||||
className?: string;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
|
||||
onNoteDrop?: Function;
|
||||
onItemDrop?: DragEventHandler<HTMLElement>;
|
||||
}
|
||||
|
||||
interface State {
|
||||
@@ -21,13 +17,12 @@ interface State {
|
||||
bottomItemIndex: number;
|
||||
}
|
||||
|
||||
class ItemList extends React.Component<Props, State> {
|
||||
class ItemList<ItemType> extends React.Component<Props<ItemType>, State> {
|
||||
|
||||
private scrollTop_: number;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
private listRef: any;
|
||||
private listRef: React.MutableRefObject<HTMLDivElement>;
|
||||
|
||||
public constructor(props: Props) {
|
||||
public constructor(props: Props<ItemType>) {
|
||||
super(props);
|
||||
|
||||
this.scrollTop_ = 0;
|
||||
@@ -39,12 +34,12 @@ class ItemList extends React.Component<Props, State> {
|
||||
this.onDrop = this.onDrop.bind(this);
|
||||
}
|
||||
|
||||
public visibleItemCount(props: Props = undefined) {
|
||||
public visibleItemCount(props: Props<ItemType> = undefined) {
|
||||
if (typeof props === 'undefined') props = this.props;
|
||||
return Math.ceil(props.style.height / props.itemHeight);
|
||||
}
|
||||
|
||||
public updateStateItemIndexes(props: Props = undefined) {
|
||||
public updateStateItemIndexes(props: Props<ItemType> = undefined) {
|
||||
if (typeof props === 'undefined') props = this.props;
|
||||
|
||||
const topItemIndex = Math.floor(this.scrollTop_ / props.itemHeight);
|
||||
@@ -67,35 +62,47 @@ class ItemList extends React.Component<Props, State> {
|
||||
return this.scrollTop_;
|
||||
}
|
||||
|
||||
public get container() {
|
||||
return this.listRef.current;
|
||||
}
|
||||
|
||||
public UNSAFE_componentWillMount() {
|
||||
this.updateStateItemIndexes();
|
||||
}
|
||||
|
||||
public UNSAFE_componentWillReceiveProps(newProps: Props) {
|
||||
public UNSAFE_componentWillReceiveProps(newProps: Props<ItemType>) {
|
||||
this.updateStateItemIndexes(newProps);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
public onScroll(event: any) {
|
||||
this.scrollTop_ = event.target.scrollTop;
|
||||
public onScroll: UIEventHandler<HTMLDivElement> = event => {
|
||||
this.scrollTop_ = (event.target as HTMLElement).scrollTop;
|
||||
this.updateStateItemIndexes();
|
||||
}
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
public onKeyDown(event: any) {
|
||||
public onKeyDown: KeyboardEventHandler<HTMLElement> = event => {
|
||||
if (this.props.onKeyDown) this.props.onKeyDown(event);
|
||||
};
|
||||
|
||||
public onDrop: DragEventHandler<HTMLElement> = event => {
|
||||
if (this.props.onItemDrop) this.props.onItemDrop(event);
|
||||
};
|
||||
|
||||
public get firstVisibleIndex() {
|
||||
return Math.min(this.props.items.length - 1, this.state.topItemIndex);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
public onDrop(event: any) {
|
||||
if (this.props.onNoteDrop) this.props.onNoteDrop(event);
|
||||
public get lastVisibleIndex() {
|
||||
return Math.max(0, this.state.bottomItemIndex);
|
||||
}
|
||||
|
||||
public isIndexVisible(itemIndex: number) {
|
||||
return itemIndex >= this.firstVisibleIndex && itemIndex <= this.lastVisibleIndex;
|
||||
}
|
||||
|
||||
public makeItemIndexVisible(itemIndex: number) {
|
||||
const top = Math.min(this.props.items.length - 1, this.state.topItemIndex);
|
||||
const bottom = Math.max(0, this.state.bottomItemIndex);
|
||||
if (this.isIndexVisible(itemIndex)) return;
|
||||
|
||||
if (itemIndex >= top && itemIndex <= bottom) return;
|
||||
const top = this.firstVisibleIndex;
|
||||
|
||||
let scrollTop = 0;
|
||||
if (itemIndex < top) {
|
||||
@@ -130,8 +137,11 @@ class ItemList extends React.Component<Props, State> {
|
||||
|
||||
public render() {
|
||||
const items = this.props.items;
|
||||
const style = { ...this.props.style, overflowX: 'hidden',
|
||||
overflowY: 'auto' };
|
||||
const style: React.CSSProperties = {
|
||||
...this.props.style,
|
||||
overflowX: 'hidden',
|
||||
overflowY: 'auto',
|
||||
};
|
||||
|
||||
// if (this.props.disabled) style.opacity = 0.5;
|
||||
|
||||
|
@@ -1,3 +1,12 @@
|
||||
.inbox-email-value {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.alert-warn {
|
||||
background-color: var(--joplin-background-color);
|
||||
width: fit-content;
|
||||
|
||||
p {
|
||||
padding: calc(var(--joplin-font-size) * 0.8);
|
||||
}
|
||||
}
|
@@ -3,9 +3,11 @@ import { AppState } from '../app.reducer';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { clipboard } from 'electron';
|
||||
import Button from './Button/Button';
|
||||
import { Fragment } from 'react';
|
||||
|
||||
type JoplinCloudConfigScreenProps = {
|
||||
inboxEmail: string;
|
||||
joplinCloudAccountType: number;
|
||||
};
|
||||
|
||||
const JoplinCloudConfigScreen = (props: JoplinCloudConfigScreenProps) => {
|
||||
@@ -13,12 +15,21 @@ const JoplinCloudConfigScreen = (props: JoplinCloudConfigScreenProps) => {
|
||||
clipboard.writeText(props.inboxEmail);
|
||||
};
|
||||
|
||||
const isEmailToNoteAvailableInAccount = props.joplinCloudAccountType !== 1;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>{_('Email to note')}</h2>
|
||||
<p>{_('Any email sent to this address will be converted into a note and added to your collection. The note will be saved into the Inbox notebook')}</p>
|
||||
<p className='inbox-email-value'>{props.inboxEmail}</p>
|
||||
<Button onClick={copyToClipboard} title={_('Copy to clipboard')} />
|
||||
{
|
||||
isEmailToNoteAvailableInAccount ? <Fragment>
|
||||
<p className='inbox-email-value'>{props.inboxEmail}</p>
|
||||
<Button onClick={copyToClipboard} title={_('Copy to clipboard')} />
|
||||
</Fragment>
|
||||
: <div className='alert-warn'>
|
||||
<p>{_('Your account doesn\'t have access to this feature')}</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -26,6 +37,7 @@ const JoplinCloudConfigScreen = (props: JoplinCloudConfigScreenProps) => {
|
||||
const mapStateToProps = (state: AppState) => {
|
||||
return {
|
||||
inboxEmail: state.settings['sync.10.inboxEmail'],
|
||||
joplinCloudAccountType: state.settings['sync.10.accountType'],
|
||||
};
|
||||
};
|
||||
|
||||
|
@@ -1,4 +1,4 @@
|
||||
// Helper commands added to the the CodeMirror instance
|
||||
// Helper commands added to the CodeMirror instance
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
export default function useJoplinCommands(CodeMirror: any) {
|
||||
|
||||
|
@@ -36,7 +36,7 @@ import { focus } from '@joplin/lib/utils/focusHandler';
|
||||
const md5 = require('md5');
|
||||
const { clipboard } = require('electron');
|
||||
const supportedLocales = require('./supportedLocales');
|
||||
import { isLink } from '@joplin/utils/url';
|
||||
import { hasProtocol } from '@joplin/utils/url';
|
||||
|
||||
const logger = Logger.create('TinyMCE');
|
||||
|
||||
@@ -1195,7 +1195,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {
|
||||
editor.insertContent(result.html);
|
||||
}
|
||||
} else {
|
||||
if (BaseItem.isMarkdownTag(pastedText) || isLink(pastedText)) { // Paste a link to a note
|
||||
if (BaseItem.isMarkdownTag(pastedText) || hasProtocol(pastedText, ['https', 'joplin', 'file'])) { // Paste a link to a note
|
||||
logger.info('onPaste: pasting as a Markdown tag');
|
||||
const result = await markupToHtml.current(MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN, pastedText, markupRenderOptions({ bodyOnly: true }));
|
||||
editor.insertContent(result.html);
|
||||
|
@@ -95,7 +95,7 @@ export default function openEditDialog(editor: any, markupToHtml: any, dispatchD
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
onSubmit: async (dialogApi: any) => {
|
||||
const newSource = newBlockSource(dialogApi.getData().languageInput, dialogApi.getData().codeTextArea, source);
|
||||
const md = `${newSource.openCharacters}${newSource.content.trim()}${newSource.closeCharacters}`;
|
||||
const md = `${newSource.openCharacters}${newSource.content}${newSource.closeCharacters}`;
|
||||
const result = await markupToHtml.current(MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN, md, { bodyOnly: true });
|
||||
|
||||
// markupToHtml will return the complete editable HTML, but we only
|
||||
|
@@ -5,16 +5,18 @@ import bridge from '../../../services/bridge';
|
||||
import { ContextMenuItemType, ContextMenuOptions, ContextMenuItems, resourceInfo, textToDataUri, svgUriToPng, svgDimensions } from './contextMenuUtils';
|
||||
const Menu = bridge().Menu;
|
||||
const MenuItem = bridge().MenuItem;
|
||||
import Resource from '@joplin/lib/models/Resource';
|
||||
import Resource, { resourceOcrStatusToString } from '@joplin/lib/models/Resource';
|
||||
import BaseItem from '@joplin/lib/models/BaseItem';
|
||||
import BaseModel, { ModelType } from '@joplin/lib/BaseModel';
|
||||
import { processPastedHtml } from './resourceHandling';
|
||||
import { NoteEntity, ResourceEntity } from '@joplin/lib/services/database/types';
|
||||
import { NoteEntity, ResourceEntity, ResourceOcrStatus } from '@joplin/lib/services/database/types';
|
||||
import { TinyMceEditorEvents } from '../NoteBody/TinyMCE/utils/types';
|
||||
import { itemIsReadOnlySync, ItemSlice } from '@joplin/lib/models/utils/readOnly';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import ItemChange from '@joplin/lib/models/ItemChange';
|
||||
import { HtmlToMarkdownHandler, MarkupToHtmlHandler } from './types';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import { openFileWithExternalEditor } from '@joplin/lib/services/ExternalEditWatcher/utils';
|
||||
const fs = require('fs-extra');
|
||||
const { writeFile } = require('fs-extra');
|
||||
const { clipboard } = require('electron');
|
||||
@@ -135,6 +137,21 @@ export function menuItems(dispatch: Function, htmlToMd: HtmlToMarkdownHandler, m
|
||||
},
|
||||
isActive: (itemType: ContextMenuItemType, options: ContextMenuOptions) => !options.textToCopy && itemType === ContextMenuItemType.Image || itemType === ContextMenuItemType.Resource,
|
||||
},
|
||||
copyOcrText: {
|
||||
label: _('View OCR text'),
|
||||
onAction: async (options: ContextMenuOptions) => {
|
||||
const { resource } = await resourceInfo(options);
|
||||
|
||||
if (resource.ocr_status === ResourceOcrStatus.Done) {
|
||||
const tempFilePath = `${Setting.value('tempDir')}/${resource.id}_ocr.txt`;
|
||||
await shim.fsDriver().writeFile(tempFilePath, resource.ocr_text, 'utf8');
|
||||
await openFileWithExternalEditor(tempFilePath, bridge());
|
||||
} else {
|
||||
bridge().showInfoMessageBox(_('This attachment does not have OCR data (Status: %s)', resourceOcrStatusToString(resource.ocr_status)));
|
||||
}
|
||||
},
|
||||
isActive: (itemType: ContextMenuItemType, _options: ContextMenuOptions) => itemType === ContextMenuItemType.Resource,
|
||||
},
|
||||
copyPathToClipboard: {
|
||||
label: _('Copy path to clipboard'),
|
||||
onAction: async (options: ContextMenuOptions) => {
|
||||
|
@@ -1,5 +1,4 @@
|
||||
import * as React from 'react';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { useMemo, useRef, useEffect } from 'react';
|
||||
import { AppState } from '../../app.reducer';
|
||||
import BaseModel, { ModelType } from '@joplin/lib/BaseModel';
|
||||
@@ -22,6 +21,7 @@ import * as focusElementNoteList from './commands/focusElementNoteList';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import useDragAndDrop from './utils/useDragAndDrop';
|
||||
import { itemIsInTrash } from '@joplin/lib/services/trash';
|
||||
import getEmptyFolderMessage from '@joplin/lib/components/shared/NoteList/getEmptyFolderMessage';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
const { connect } = require('react-redux');
|
||||
|
||||
@@ -187,7 +187,7 @@ const NoteList = (props: Props) => {
|
||||
|
||||
const renderEmptyList = () => {
|
||||
if (props.notes.length) return null;
|
||||
return <div className="emptylist">{props.folders.length ? _('No notes in here. Create one by clicking on "New note".') : _('There is currently no notebook. Create one by clicking on "New notebook".')}</div>;
|
||||
return <div className="emptylist">{getEmptyFolderMessage(props.folders, props.selectedFolderId)}</div>;
|
||||
};
|
||||
|
||||
const renderFiller = (key: string, style: React.CSSProperties) => {
|
||||
|
@@ -187,6 +187,8 @@ function NoteListControls(props: Props) {
|
||||
}
|
||||
|
||||
function sortOrderFieldIcon() {
|
||||
const defaultIcon = 'fas fa-cog';
|
||||
|
||||
const field = props.sortOrderField;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const iconMap: any = {
|
||||
@@ -194,8 +196,10 @@ function NoteListControls(props: Props) {
|
||||
user_created_time: 'far fa-calendar-plus',
|
||||
title: 'fas fa-font',
|
||||
order: 'fas fa-wrench',
|
||||
todo_due: 'fas fa-calendar-check',
|
||||
todo_completed: 'fas fa-check',
|
||||
};
|
||||
return `${iconMap[field] || iconMap['title']} ${field}`;
|
||||
return `${iconMap[field] || defaultIcon} ${field}`;
|
||||
}
|
||||
|
||||
function sortOrderReverseIcon() {
|
||||
|
100
packages/app-desktop/gui/Sidebar/FolderAndTagList.tsx
Normal file
100
packages/app-desktop/gui/Sidebar/FolderAndTagList.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import * as React from 'react';
|
||||
import { AppState } from '../../app.reducer';
|
||||
import { FolderEntity, TagsWithNoteCountEntity } from '@joplin/lib/services/database/types';
|
||||
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
|
||||
import { connect } from 'react-redux';
|
||||
import { Dispatch } from 'redux';
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import ItemList from '../ItemList';
|
||||
import useElementHeight from '../hooks/useElementHeight';
|
||||
import useSidebarListData from './hooks/useSidebarListData';
|
||||
import useSelectedSidebarIndex from './hooks/useSelectedSidebarIndex';
|
||||
import useOnSidebarKeyDownHandler from './hooks/useOnSidebarKeyDownHandler';
|
||||
import useFocusHandler from './hooks/useFocusHandler';
|
||||
import useOnRenderItem from './hooks/useOnRenderItem';
|
||||
import { ListItem } from './types';
|
||||
import useSidebarCommandHandler from './hooks/useSidebarCommandHandler';
|
||||
|
||||
interface Props {
|
||||
dispatch: Dispatch;
|
||||
themeId: number;
|
||||
plugins: PluginStates;
|
||||
|
||||
tags: TagsWithNoteCountEntity[];
|
||||
folders: FolderEntity[];
|
||||
notesParentType: string;
|
||||
selectedTagId: string;
|
||||
selectedFolderId: string;
|
||||
selectedSmartFilterId: string;
|
||||
collapsedFolderIds: string[];
|
||||
folderHeaderIsExpanded: boolean;
|
||||
tagHeaderIsExpanded: boolean;
|
||||
}
|
||||
|
||||
|
||||
const FolderAndTagList: React.FC<Props> = props => {
|
||||
const listItems = useSidebarListData(props);
|
||||
const { selectedIndex, updateSelectedIndex } = useSelectedSidebarIndex({
|
||||
...props,
|
||||
listItems: listItems,
|
||||
});
|
||||
|
||||
const [selectedListElement, setSelectedListElement] = useState<HTMLElement|null>(null);
|
||||
const onRenderItem = useOnRenderItem({
|
||||
...props,
|
||||
selectedIndex,
|
||||
onSelectedElementShown: setSelectedListElement,
|
||||
});
|
||||
|
||||
const onKeyEventHandler = useOnSidebarKeyDownHandler({
|
||||
dispatch: props.dispatch,
|
||||
listItems: listItems,
|
||||
selectedIndex,
|
||||
updateSelectedIndex,
|
||||
});
|
||||
|
||||
const itemListRef = useRef<ItemList<ListItem>>();
|
||||
const { focusSidebar } = useFocusHandler({ itemListRef, selectedListElement, selectedIndex, listItems });
|
||||
|
||||
useSidebarCommandHandler({ focusSidebar });
|
||||
|
||||
const [itemListContainer, setItemListContainer] = useState<HTMLDivElement|null>(null);
|
||||
const listHeight = useElementHeight(itemListContainer);
|
||||
const listStyle = useMemo(() => ({ height: listHeight }), [listHeight]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className='folder-and-tag-list'
|
||||
ref={setItemListContainer}
|
||||
>
|
||||
<ItemList
|
||||
className='items'
|
||||
ref={itemListRef}
|
||||
style={listStyle}
|
||||
items={listItems}
|
||||
itemRenderer={onRenderItem}
|
||||
onKeyDown={onKeyEventHandler}
|
||||
|
||||
itemHeight={30}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const mapStateToProps = (state: AppState) => {
|
||||
return {
|
||||
themeId: state.settings.theme,
|
||||
tags: state.tags,
|
||||
folders: state.folders,
|
||||
notesParentType: state.notesParentType,
|
||||
selectedFolderId: state.selectedFolderId,
|
||||
selectedTagId: state.selectedTagId,
|
||||
collapsedFolderIds: state.collapsedFolderIds,
|
||||
selectedSmartFilterId: state.selectedSmartFilterId,
|
||||
plugins: state.pluginService.plugins,
|
||||
tagHeaderIsExpanded: state.settings.tagHeaderIsExpanded,
|
||||
folderHeaderIsExpanded: state.settings.folderHeaderIsExpanded,
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps)(FolderAndTagList);
|
@@ -1,766 +1,28 @@
|
||||
import * as React from 'react';
|
||||
import { useEffect, useRef, useCallback, useMemo, DragEventHandler, MouseEventHandler, RefObject } from 'react';
|
||||
import { StyledRoot, StyledAddButton, StyledShareIcon, StyledHeader, StyledHeaderIcon, StyledAllNotesIcon, StyledHeaderLabel, StyledListItem, StyledListItemAnchor, StyledExpandLink, StyledNoteCount, StyledSyncReportText, StyledSyncReport, StyledSynchronizeButton, StyledSpanFix } from './styles';
|
||||
import { StyledRoot, StyledSyncReportText, StyledSyncReport, StyledSynchronizeButton } from './styles';
|
||||
import { ButtonLevel } from '../Button/Button';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import InteropService from '@joplin/lib/services/interop/InteropService';
|
||||
import Synchronizer from '@joplin/lib/Synchronizer';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import MenuUtils from '@joplin/lib/services/commands/MenuUtils';
|
||||
import InteropServiceHelper from '../../InteropServiceHelper';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { PluginStates, utils as pluginUtils } from '@joplin/lib/services/plugins/reducer';
|
||||
import { MenuItemLocation } from '@joplin/lib/services/plugins/api/types';
|
||||
import { AppState } from '../../app.reducer';
|
||||
import { ModelType } from '@joplin/lib/BaseModel';
|
||||
import BaseModel from '@joplin/lib/BaseModel';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
import Tag from '@joplin/lib/models/Tag';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import { FolderEntity, FolderIcon, FolderIconType, TagEntity } from '@joplin/lib/services/database/types';
|
||||
import stateToWhenClauseContext from '../../services/commands/stateToWhenClauseContext';
|
||||
import { StateDecryptionWorker, StateResourceFetcher, store } from '@joplin/lib/reducer';
|
||||
import PerFolderSortOrderService from '../../services/sortOrder/PerFolderSortOrderService';
|
||||
import { getFolderCallbackUrl, getTagCallbackUrl } from '@joplin/lib/callbackUrlUtils';
|
||||
import FolderIconBox from '../FolderIconBox';
|
||||
import onFolderDrop from '@joplin/lib/models/utils/onFolderDrop';
|
||||
import { RuntimeProps } from './commands/focusElementSideBar';
|
||||
import { StateDecryptionWorker, StateResourceFetcher } from '@joplin/lib/reducer';
|
||||
import { connect } from 'react-redux';
|
||||
import { renderFolders, renderTags } from '@joplin/lib/components/shared/side-menu-shared';
|
||||
import { getTrashFolderIcon, getTrashFolderId } from '@joplin/lib/services/trash';
|
||||
import { focus } from '@joplin/lib/utils/focusHandler';
|
||||
import { ThemeStyle, themeStyle } from '@joplin/lib/theme';
|
||||
import { themeStyle } from '@joplin/lib/theme';
|
||||
import { Dispatch } from 'redux';
|
||||
import bridge from '../../services/bridge';
|
||||
const Menu = bridge().Menu;
|
||||
const MenuItem = bridge().MenuItem;
|
||||
import { substrWithEllipsis } from '@joplin/lib/string-utils';
|
||||
const { ALL_NOTES_FILTER_ID } = require('@joplin/lib/reserved-ids');
|
||||
import { clipboard } from 'electron';
|
||||
import FolderAndTagList from './FolderAndTagList';
|
||||
|
||||
const logger = Logger.create('Sidebar');
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
dispatch: Dispatch;
|
||||
folders: FolderEntity[];
|
||||
collapsedFolderIds: string[];
|
||||
notesParentType: string;
|
||||
selectedFolderId: string;
|
||||
selectedTagId: string;
|
||||
selectedSmartFilterId: string;
|
||||
decryptionWorker: StateDecryptionWorker;
|
||||
resourceFetcher: StateResourceFetcher;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
syncReport: any;
|
||||
tags: TagEntity[];
|
||||
syncStarted: boolean;
|
||||
plugins: PluginStates;
|
||||
folderHeaderIsExpanded: boolean;
|
||||
tagHeaderIsExpanded: boolean;
|
||||
}
|
||||
|
||||
const commands = [
|
||||
require('./commands/focusElementSideBar'),
|
||||
];
|
||||
|
||||
interface ExpandIconProps {
|
||||
themeId: number;
|
||||
isExpanded: boolean;
|
||||
isVisible: boolean;
|
||||
}
|
||||
|
||||
function ExpandIcon(props: ExpandIconProps) {
|
||||
const theme = themeStyle(props.themeId);
|
||||
const style: React.CSSProperties = {
|
||||
width: 16, maxWidth: 16, opacity: 0.5, fontSize: Math.round(theme.toolbarIconSize * 0.8), display: 'flex', justifyContent: 'center',
|
||||
};
|
||||
if (!props.isVisible) style.visibility = 'hidden';
|
||||
return <i className={props.isExpanded ? 'fas fa-caret-down' : 'fas fa-caret-right'} style={style}></i>;
|
||||
}
|
||||
|
||||
interface ExpandLinkProps {
|
||||
themeId: number;
|
||||
folderId: string;
|
||||
hasChildren: boolean;
|
||||
isExpanded: boolean;
|
||||
onClick: MouseEventHandler<HTMLElement>;
|
||||
}
|
||||
|
||||
function ExpandLink(props: ExpandLinkProps) {
|
||||
return props.hasChildren ? (
|
||||
<StyledExpandLink href="#" data-folder-id={props.folderId} onClick={props.onClick}>
|
||||
<ExpandIcon themeId={props.themeId} isVisible={true} isExpanded={props.isExpanded}/>
|
||||
</StyledExpandLink>
|
||||
) : (
|
||||
<StyledExpandLink><ExpandIcon themeId={props.themeId} isVisible={false} isExpanded={false}/></StyledExpandLink>
|
||||
);
|
||||
}
|
||||
|
||||
const renderFolderIcon = (folderIcon: FolderIcon) => {
|
||||
if (!folderIcon) {
|
||||
const defaultFolderIcon: FolderIcon = {
|
||||
dataUrl: '',
|
||||
emoji: '',
|
||||
name: 'far fa-folder',
|
||||
type: FolderIconType.FontAwesome,
|
||||
};
|
||||
return <div style={{ marginRight: 7, display: 'flex' }}><FolderIconBox opacity={0.7} folderIcon={defaultFolderIcon}/></div>;
|
||||
}
|
||||
|
||||
return <div style={{ marginRight: 7, display: 'flex' }}><FolderIconBox folderIcon={folderIcon}/></div>;
|
||||
};
|
||||
|
||||
type ItemDragListener = DragEventHandler<HTMLElement>;
|
||||
type ItemContextMenuListener = MouseEventHandler<HTMLElement>;
|
||||
type ItemClickListener = MouseEventHandler<HTMLElement>;
|
||||
|
||||
interface FolderItemProps {
|
||||
themeId: number;
|
||||
hasChildren: boolean;
|
||||
showFolderIcon: boolean;
|
||||
isExpanded: boolean;
|
||||
parentId: string;
|
||||
depth: number;
|
||||
selected: boolean;
|
||||
folderId: string;
|
||||
folderTitle: string;
|
||||
folderIcon: FolderIcon;
|
||||
anchorRef: RefObject<HTMLElement>;
|
||||
noteCount: number;
|
||||
onFolderDragStart_: ItemDragListener;
|
||||
onFolderDragOver_: ItemDragListener;
|
||||
onFolderDrop_: ItemDragListener;
|
||||
itemContextMenu: ItemContextMenuListener;
|
||||
folderItem_click: (folderId: string)=> void;
|
||||
onFolderToggleClick_: ItemClickListener;
|
||||
shareId: string;
|
||||
}
|
||||
|
||||
function FolderItem(props: FolderItemProps) {
|
||||
const { hasChildren, showFolderIcon, isExpanded, parentId, depth, selected, folderId, folderTitle, folderIcon, anchorRef, noteCount, onFolderDragStart_, onFolderDragOver_, onFolderDrop_, itemContextMenu, folderItem_click, onFolderToggleClick_, shareId } = props;
|
||||
|
||||
const noteCountComp = noteCount ? <StyledNoteCount className="note-count-label">{noteCount}</StyledNoteCount> : null;
|
||||
const shareIcon = shareId && !parentId ? <StyledShareIcon className="fas fa-share-alt"></StyledShareIcon> : null;
|
||||
const draggable = ![getTrashFolderId(), Folder.conflictFolderId()].includes(folderId);
|
||||
|
||||
const doRenderFolderIcon = () => {
|
||||
if (folderId === getTrashFolderId()) {
|
||||
return renderFolderIcon(getTrashFolderIcon(FolderIconType.FontAwesome));
|
||||
}
|
||||
|
||||
if (!showFolderIcon) return null;
|
||||
return renderFolderIcon(folderIcon);
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledListItem depth={depth} selected={selected} className={`list-item-container list-item-depth-${depth} ${selected ? 'selected' : ''}`} onDragStart={onFolderDragStart_} onDragOver={onFolderDragOver_} onDrop={onFolderDrop_} draggable={draggable} data-folder-id={folderId}>
|
||||
<ExpandLink themeId={props.themeId} hasChildren={hasChildren} folderId={folderId} onClick={onFolderToggleClick_} isExpanded={isExpanded}/>
|
||||
<StyledListItemAnchor
|
||||
ref={anchorRef}
|
||||
className="list-item"
|
||||
isConflictFolder={folderId === Folder.conflictFolderId()}
|
||||
href="#"
|
||||
selected={selected}
|
||||
shareId={shareId}
|
||||
data-id={folderId}
|
||||
data-type={BaseModel.TYPE_FOLDER}
|
||||
onContextMenu={itemContextMenu}
|
||||
data-folder-id={folderId}
|
||||
onClick={() => {
|
||||
folderItem_click(folderId);
|
||||
}}
|
||||
onDoubleClick={onFolderToggleClick_}
|
||||
>
|
||||
{doRenderFolderIcon()}<StyledSpanFix className="title" style={{ lineHeight: 0 }}>{folderTitle}</StyledSpanFix>
|
||||
{shareIcon} {noteCountComp}
|
||||
</StyledListItemAnchor>
|
||||
</StyledListItem>
|
||||
);
|
||||
}
|
||||
|
||||
const menuUtils = new MenuUtils(CommandService.instance());
|
||||
|
||||
const SidebarComponent = (props: Props) => {
|
||||
|
||||
const folderItemsOrder_ = useRef<string[]>();
|
||||
folderItemsOrder_.current = [];
|
||||
const tagItemsOrder_ = useRef<string[]>();
|
||||
tagItemsOrder_.current = [];
|
||||
|
||||
const rootRef = useRef(null);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const anchorItemRefs = useRef<Record<string, any>>({});
|
||||
|
||||
// This whole component is a bit of a mess and rather than passing
|
||||
// a plugins prop around, not knowing how it's going to affect
|
||||
// re-rendering, we just keep a ref to it. Currently that's enough
|
||||
// as plugins are only accessed from context menus. However if want
|
||||
// to do more complex things with plugins in the sidebar, it will
|
||||
// probably have to be refactored using React Hooks first.
|
||||
const pluginsRef = useRef<PluginStates>(null);
|
||||
pluginsRef.current = props.plugins;
|
||||
|
||||
// If at least one of the folder has an icon, then we display icons for all
|
||||
// folders (those without one will get the default icon). This is so that
|
||||
// visual alignment is correct for all folders, otherwise the folder tree
|
||||
// looks messy.
|
||||
const showFolderIcons = useMemo(() => {
|
||||
return Folder.shouldShowFolderIcons(props.folders);
|
||||
}, [props.folders]);
|
||||
|
||||
const getSelectedItem = useCallback(() => {
|
||||
if (props.notesParentType === 'Folder' && props.selectedFolderId) {
|
||||
return { type: 'folder', id: props.selectedFolderId };
|
||||
} else if (props.notesParentType === 'Tag' && props.selectedTagId) {
|
||||
return { type: 'tag', id: props.selectedTagId };
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [props.notesParentType, props.selectedFolderId, props.selectedTagId]);
|
||||
|
||||
const getFirstAnchorItemRef = useCallback((type: string) => {
|
||||
const refs = anchorItemRefs.current[type];
|
||||
if (!refs) return null;
|
||||
|
||||
const p = type === 'folder' ? props.folders : props.tags;
|
||||
const item = p && p.length ? p[0] : null;
|
||||
if (!item) return null;
|
||||
|
||||
return refs[item.id];
|
||||
}, [anchorItemRefs, props.folders, props.tags]);
|
||||
|
||||
useEffect(() => {
|
||||
const runtimeProps: RuntimeProps = {
|
||||
getSelectedItem,
|
||||
anchorItemRefs,
|
||||
getFirstAnchorItemRef,
|
||||
};
|
||||
|
||||
CommandService.instance().componentRegisterCommands(runtimeProps, commands);
|
||||
|
||||
return () => {
|
||||
CommandService.instance().componentUnregisterCommands(commands);
|
||||
};
|
||||
}, [
|
||||
getSelectedItem,
|
||||
anchorItemRefs,
|
||||
getFirstAnchorItemRef,
|
||||
]);
|
||||
|
||||
const onFolderDragStart_: ItemDragListener = useCallback(event => {
|
||||
const folderId = event.currentTarget.getAttribute('data-folder-id');
|
||||
if (!folderId) return;
|
||||
|
||||
event.dataTransfer.setDragImage(new Image(), 1, 1);
|
||||
event.dataTransfer.clearData();
|
||||
event.dataTransfer.setData('text/x-jop-folder-ids', JSON.stringify([folderId]));
|
||||
}, []);
|
||||
|
||||
const onFolderDragOver_: ItemDragListener = useCallback(event => {
|
||||
if (event.dataTransfer.types.indexOf('text/x-jop-note-ids') >= 0) event.preventDefault();
|
||||
if (event.dataTransfer.types.indexOf('text/x-jop-folder-ids') >= 0) event.preventDefault();
|
||||
}, []);
|
||||
|
||||
const onFolderDrop_: ItemDragListener = useCallback(async event => {
|
||||
const folderId = event.currentTarget.getAttribute('data-folder-id');
|
||||
const dt = event.dataTransfer;
|
||||
if (!dt) return;
|
||||
|
||||
// folderId can be NULL when dropping on the sidebar Notebook header. In that case, it's used
|
||||
// to put the dropped folder at the root. But for notes, folderId needs to always be defined
|
||||
// since there's no such thing as a root note.
|
||||
|
||||
try {
|
||||
if (dt.types.indexOf('text/x-jop-note-ids') >= 0) {
|
||||
event.preventDefault();
|
||||
if (!folderId) return;
|
||||
const noteIds = JSON.parse(dt.getData('text/x-jop-note-ids'));
|
||||
await onFolderDrop(noteIds, [], folderId);
|
||||
} else if (dt.types.indexOf('text/x-jop-folder-ids') >= 0) {
|
||||
event.preventDefault();
|
||||
const folderIds = JSON.parse(dt.getData('text/x-jop-folder-ids'));
|
||||
await onFolderDrop([], folderIds, folderId);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
alert(error.message);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onTagDrop_: ItemDragListener = useCallback(async event => {
|
||||
const tagId = event.currentTarget.getAttribute('data-tag-id');
|
||||
const dt = event.dataTransfer;
|
||||
if (!dt) return;
|
||||
|
||||
if (dt.types.indexOf('text/x-jop-note-ids') >= 0) {
|
||||
event.preventDefault();
|
||||
|
||||
const noteIds = JSON.parse(dt.getData('text/x-jop-note-ids'));
|
||||
for (let i = 0; i < noteIds.length; i++) {
|
||||
await Tag.addNote(tagId, noteIds[i]);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onFolderToggleClick_: ItemClickListener = useCallback(event => {
|
||||
const folderId = event.currentTarget.getAttribute('data-folder-id');
|
||||
|
||||
props.dispatch({
|
||||
type: 'FOLDER_TOGGLE',
|
||||
id: folderId,
|
||||
});
|
||||
}, [props.dispatch]);
|
||||
|
||||
const header_contextMenu = useCallback(async () => {
|
||||
const menu = new Menu();
|
||||
|
||||
menu.append(
|
||||
new MenuItem(menuUtils.commandToStatefulMenuItem('newFolder')),
|
||||
);
|
||||
|
||||
menu.popup({ window: bridge().window() });
|
||||
}, []);
|
||||
|
||||
const itemContextMenu: ItemContextMenuListener = useCallback(async event => {
|
||||
const itemId = event.currentTarget.getAttribute('data-id');
|
||||
if (itemId === Folder.conflictFolderId()) return;
|
||||
|
||||
const itemType = Number(event.currentTarget.getAttribute('data-type'));
|
||||
if (!itemId || !itemType) throw new Error('No data on element');
|
||||
|
||||
const state: AppState = store().getState();
|
||||
|
||||
let deleteMessage = '';
|
||||
const deleteButtonLabel = _('Remove');
|
||||
|
||||
if (itemType === BaseModel.TYPE_TAG) {
|
||||
const tag = await Tag.load(itemId);
|
||||
deleteMessage = _('Remove tag "%s" from all notes?', substrWithEllipsis(tag.title, 0, 32));
|
||||
} else if (itemType === BaseModel.TYPE_SEARCH) {
|
||||
deleteMessage = _('Remove this search from the sidebar?');
|
||||
}
|
||||
|
||||
const menu = new Menu();
|
||||
|
||||
if (itemId === getTrashFolderId()) {
|
||||
menu.append(
|
||||
new MenuItem(menuUtils.commandToStatefulMenuItem('emptyTrash')),
|
||||
);
|
||||
menu.popup({ window: bridge().window() });
|
||||
return;
|
||||
}
|
||||
|
||||
let item = null;
|
||||
if (itemType === BaseModel.TYPE_FOLDER) {
|
||||
item = BaseModel.byId(props.folders, itemId);
|
||||
}
|
||||
|
||||
const isDeleted = item ? !!item.deleted_time : false;
|
||||
|
||||
if (!isDeleted) {
|
||||
if (itemType === BaseModel.TYPE_FOLDER && !item.encryption_applied) {
|
||||
menu.append(
|
||||
new MenuItem(menuUtils.commandToStatefulMenuItem('newFolder', itemId)),
|
||||
);
|
||||
}
|
||||
|
||||
if (itemType === BaseModel.TYPE_FOLDER) {
|
||||
menu.append(
|
||||
new MenuItem(menuUtils.commandToStatefulMenuItem('deleteFolder', itemId)),
|
||||
);
|
||||
} else {
|
||||
menu.append(
|
||||
new MenuItem({
|
||||
label: deleteButtonLabel,
|
||||
click: async () => {
|
||||
const ok = bridge().showConfirmMessageBox(deleteMessage, {
|
||||
buttons: [deleteButtonLabel, _('Cancel')],
|
||||
defaultId: 1,
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
if (itemType === BaseModel.TYPE_TAG) {
|
||||
await Tag.untagAll(itemId);
|
||||
} else if (itemType === BaseModel.TYPE_SEARCH) {
|
||||
props.dispatch({
|
||||
type: 'SEARCH_DELETE',
|
||||
id: itemId,
|
||||
});
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (itemType === BaseModel.TYPE_FOLDER && !item.encryption_applied) {
|
||||
menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem('openFolderDialog', { folderId: itemId })));
|
||||
|
||||
menu.append(new MenuItem({ type: 'separator' }));
|
||||
|
||||
const exportMenu = new Menu();
|
||||
const ioService = InteropService.instance();
|
||||
const ioModules = ioService.modules();
|
||||
for (let i = 0; i < ioModules.length; i++) {
|
||||
const module = ioModules[i];
|
||||
if (module.type !== 'exporter') continue;
|
||||
|
||||
exportMenu.append(
|
||||
new MenuItem({
|
||||
label: module.fullLabel(),
|
||||
click: async () => {
|
||||
await InteropServiceHelper.export(props.dispatch, module, { sourceFolderIds: [itemId], plugins: pluginsRef.current });
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// We don't display the "Share notebook" menu item for sub-notebooks
|
||||
// that are within a shared notebook. If user wants to do this,
|
||||
// they'd have to move the notebook out of the shared notebook
|
||||
// first.
|
||||
const whenClause = stateToWhenClauseContext(state, { commandFolderId: itemId });
|
||||
|
||||
if (CommandService.instance().isEnabled('showShareFolderDialog', whenClause)) {
|
||||
menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem('showShareFolderDialog', itemId)));
|
||||
}
|
||||
|
||||
if (CommandService.instance().isEnabled('leaveSharedFolder', whenClause)) {
|
||||
menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem('leaveSharedFolder', itemId)));
|
||||
}
|
||||
|
||||
menu.append(
|
||||
new MenuItem({
|
||||
label: _('Export'),
|
||||
submenu: exportMenu,
|
||||
}),
|
||||
);
|
||||
if (Setting.value('notes.perFolderSortOrderEnabled')) {
|
||||
menu.append(new MenuItem({
|
||||
...menuUtils.commandToStatefulMenuItem('togglePerFolderSortOrder', itemId),
|
||||
type: 'checkbox',
|
||||
checked: PerFolderSortOrderService.isSet(itemId),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if (itemType === BaseModel.TYPE_FOLDER) {
|
||||
menu.append(
|
||||
new MenuItem({
|
||||
label: _('Copy external link'),
|
||||
click: () => {
|
||||
clipboard.writeText(getFolderCallbackUrl(itemId));
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (itemType === BaseModel.TYPE_TAG) {
|
||||
menu.append(new MenuItem(
|
||||
menuUtils.commandToStatefulMenuItem('renameTag', itemId),
|
||||
));
|
||||
menu.append(
|
||||
new MenuItem({
|
||||
label: _('Copy external link'),
|
||||
click: () => {
|
||||
clipboard.writeText(getTagCallbackUrl(itemId));
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const pluginViews = pluginUtils.viewsByType(pluginsRef.current, 'menuItem');
|
||||
|
||||
for (const view of pluginViews) {
|
||||
const location = view.location;
|
||||
|
||||
if (itemType === ModelType.Tag && location === MenuItemLocation.TagContextMenu ||
|
||||
itemType === ModelType.Folder && location === MenuItemLocation.FolderContextMenu
|
||||
) {
|
||||
menu.append(
|
||||
new MenuItem(menuUtils.commandToStatefulMenuItem(view.commandName, itemId)),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (itemType === BaseModel.TYPE_FOLDER) {
|
||||
menu.append(
|
||||
new MenuItem(menuUtils.commandToStatefulMenuItem('restoreFolder', itemId)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
menu.popup({ window: bridge().window() });
|
||||
}, [props.folders, props.dispatch, pluginsRef]);
|
||||
|
||||
const folderItem_click = useCallback((folderId: string) => {
|
||||
props.dispatch({
|
||||
type: 'FOLDER_SELECT',
|
||||
id: folderId ? folderId : null,
|
||||
});
|
||||
}, [props.dispatch]);
|
||||
|
||||
const tagItem_click = useCallback((tag: TagEntity|undefined) => {
|
||||
props.dispatch({
|
||||
type: 'TAG_SELECT',
|
||||
id: tag ? tag.id : null,
|
||||
});
|
||||
}, [props.dispatch]);
|
||||
|
||||
const onHeaderClick_ = useCallback((key: string) => {
|
||||
const isExpanded = key === 'tagHeader' ? props.tagHeaderIsExpanded : props.folderHeaderIsExpanded;
|
||||
Setting.setValue(key === 'tagHeader' ? 'tagHeaderIsExpanded' : 'folderHeaderIsExpanded', !isExpanded);
|
||||
}, [props.folderHeaderIsExpanded, props.tagHeaderIsExpanded]);
|
||||
|
||||
const onAllNotesClick_ = useCallback(() => {
|
||||
props.dispatch({
|
||||
type: 'SMART_FILTER_SELECT',
|
||||
id: ALL_NOTES_FILTER_ID,
|
||||
});
|
||||
folderItem_click(ALL_NOTES_FILTER_ID);
|
||||
}, [props.dispatch, folderItem_click]);
|
||||
|
||||
const anchorItemRef = (type: string, id: string) => {
|
||||
if (!anchorItemRefs.current[type]) anchorItemRefs.current[type] = {};
|
||||
if (anchorItemRefs.current[type][id]) return anchorItemRefs.current[type][id];
|
||||
anchorItemRefs.current[type][id] = React.createRef();
|
||||
return anchorItemRefs.current[type][id];
|
||||
};
|
||||
|
||||
const renderNoteCount = (count: number) => {
|
||||
return count ? <StyledNoteCount className="note-count-label">{count}</StyledNoteCount> : null;
|
||||
};
|
||||
|
||||
const renderExpandIcon = (theme: ThemeStyle, isExpanded: boolean, isVisible: boolean) => {
|
||||
const style: React.CSSProperties = {
|
||||
width: 16, maxWidth: 16, opacity: 0.5, fontSize: Math.round(theme.toolbarIconSize * 0.8), display: 'flex', justifyContent: 'center',
|
||||
};
|
||||
if (!isVisible) style.visibility = 'hidden';
|
||||
return <i className={isExpanded ? 'fas fa-caret-down' : 'fas fa-caret-right'} style={style}></i>;
|
||||
};
|
||||
|
||||
const toggleAllNotesContextMenu = useCallback(() => {
|
||||
const menu = new Menu();
|
||||
|
||||
if (Setting.value('notes.perFolderSortOrderEnabled')) {
|
||||
menu.append(new MenuItem({
|
||||
...menuUtils.commandToStatefulMenuItem('togglePerFolderSortOrder', ALL_NOTES_FILTER_ID),
|
||||
type: 'checkbox',
|
||||
checked: PerFolderSortOrderService.isSet(ALL_NOTES_FILTER_ID),
|
||||
}));
|
||||
}
|
||||
|
||||
menu.popup({ window: bridge().window() });
|
||||
}, []);
|
||||
|
||||
const renderAllNotesItem = (theme: ThemeStyle, selected: boolean) => {
|
||||
return (
|
||||
<StyledListItem key="allNotesHeader" selected={selected} className={'list-item-container list-item-depth-0 all-notes'} isSpecialItem={true}>
|
||||
<StyledExpandLink>{renderExpandIcon(theme, false, false)}</StyledExpandLink>
|
||||
<StyledAllNotesIcon className="icon-notes"/>
|
||||
<StyledListItemAnchor
|
||||
className="list-item"
|
||||
isSpecialItem={true}
|
||||
href="#"
|
||||
selected={selected}
|
||||
onClick={onAllNotesClick_}
|
||||
onContextMenu={toggleAllNotesContextMenu}
|
||||
>
|
||||
{_('All notes')}
|
||||
</StyledListItemAnchor>
|
||||
</StyledListItem>
|
||||
);
|
||||
};
|
||||
|
||||
const renderFolderItem = (folder: FolderEntity, selected: boolean, hasChildren: boolean, depth: number) =>{
|
||||
const anchorRef = anchorItemRef('folder', folder.id);
|
||||
const isExpanded = props.collapsedFolderIds.indexOf(folder.id) < 0;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
let noteCount = (folder as any).note_count;
|
||||
|
||||
// For now hide the count for folders in the trash because it doesn't work and getting it to
|
||||
// work would be tricky.
|
||||
if (folder.deleted_time || folder.id === getTrashFolderId()) noteCount = 0;
|
||||
|
||||
// Thunderbird count: Subtract children note_count from parent folder if it expanded.
|
||||
if (isExpanded) {
|
||||
for (let i = 0; i < props.folders.length; i++) {
|
||||
if (props.folders[i].parent_id === folder.id) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
noteCount -= (props.folders[i] as any).note_count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return <FolderItem
|
||||
key={folder.id}
|
||||
folderId={folder.id}
|
||||
folderTitle={Folder.displayTitle(folder)}
|
||||
folderIcon={Folder.unserializeIcon(folder.icon)}
|
||||
themeId={props.themeId}
|
||||
depth={depth}
|
||||
selected={selected}
|
||||
isExpanded={isExpanded}
|
||||
hasChildren={hasChildren}
|
||||
anchorRef={anchorRef}
|
||||
noteCount={noteCount}
|
||||
onFolderDragStart_={onFolderDragStart_}
|
||||
onFolderDragOver_={onFolderDragOver_}
|
||||
onFolderDrop_={onFolderDrop_}
|
||||
itemContextMenu={itemContextMenu}
|
||||
folderItem_click={folderItem_click}
|
||||
onFolderToggleClick_={onFolderToggleClick_}
|
||||
shareId={folder.share_id}
|
||||
parentId={folder.parent_id}
|
||||
showFolderIcon={showFolderIcons}
|
||||
/>;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const renderTag = (tag: any, selected: boolean) => {
|
||||
const anchorRef = anchorItemRef('tag', tag.id);
|
||||
let noteCount = null;
|
||||
if (Setting.value('showNoteCounts')) {
|
||||
if (Setting.value('showCompletedTodos')) noteCount = renderNoteCount(tag.note_count);
|
||||
else noteCount = renderNoteCount(tag.note_count - tag.todo_completed_count);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledListItem selected={selected}
|
||||
className={`list-item-container ${selected ? 'selected' : ''}`}
|
||||
key={tag.id}
|
||||
onDrop={onTagDrop_}
|
||||
data-tag-id={tag.id}
|
||||
>
|
||||
<StyledExpandLink>{renderExpandIcon(theme, false, false)}</StyledExpandLink>
|
||||
<StyledListItemAnchor
|
||||
ref={anchorRef}
|
||||
className="list-item"
|
||||
href="#"
|
||||
selected={selected}
|
||||
data-id={tag.id}
|
||||
data-type={BaseModel.TYPE_TAG}
|
||||
onContextMenu={itemContextMenu}
|
||||
onClick={() => {
|
||||
tagItem_click(tag);
|
||||
}}
|
||||
>
|
||||
<StyledSpanFix className="tag-label">{Tag.displayTitle(tag)}</StyledSpanFix>
|
||||
{noteCount}
|
||||
</StyledListItemAnchor>
|
||||
</StyledListItem>
|
||||
);
|
||||
};
|
||||
|
||||
const renderHeader = (
|
||||
key: string,
|
||||
label: string,
|
||||
iconName: string,
|
||||
contextMenuHandler: ItemContextMenuListener|null = null,
|
||||
onPlusButtonClick: ItemClickListener|null = null,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
extraProps: any = {},
|
||||
) => {
|
||||
const headerClick = extraProps.onClick || null;
|
||||
delete extraProps.onClick;
|
||||
const ref = anchorItemRef('headers', key);
|
||||
|
||||
return (
|
||||
<div key={key} style={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}>
|
||||
<StyledHeader
|
||||
ref={ref}
|
||||
{...extraProps}
|
||||
onContextMenu={contextMenuHandler}
|
||||
onClick={(event: MouseEvent) => {
|
||||
// if a custom click event is attached, trigger that.
|
||||
if (headerClick) {
|
||||
headerClick(key, event);
|
||||
}
|
||||
onHeaderClick_(key);
|
||||
}}
|
||||
>
|
||||
<StyledHeaderIcon className={iconName}/>
|
||||
<StyledHeaderLabel>{label}</StyledHeaderLabel>
|
||||
</StyledHeader>
|
||||
{ onPlusButtonClick && <StyledAddButton onClick={onPlusButtonClick} iconName="fas fa-plus" level={ButtonLevel.SidebarSecondary}/> }
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const onKeyDown = useCallback((event: KeyboardEvent) => {
|
||||
const keyCode = event.keyCode;
|
||||
const selectedItem = getSelectedItem();
|
||||
|
||||
if (keyCode === 40 || keyCode === 38) {
|
||||
// DOWN / UP
|
||||
event.preventDefault();
|
||||
|
||||
const focusItems = [];
|
||||
|
||||
for (let i = 0; i < folderItemsOrder_.current.length; i++) {
|
||||
const id = folderItemsOrder_.current[i];
|
||||
focusItems.push({ id: id, ref: anchorItemRefs.current['folder'][id], type: 'folder' });
|
||||
}
|
||||
|
||||
for (let i = 0; i < tagItemsOrder_.current.length; i++) {
|
||||
const id = tagItemsOrder_.current[i];
|
||||
focusItems.push({ id: id, ref: anchorItemRefs.current['tag'][id], type: 'tag' });
|
||||
}
|
||||
|
||||
let currentIndex = 0;
|
||||
for (let i = 0; i < focusItems.length; i++) {
|
||||
if (!selectedItem || focusItems[i].id === selectedItem.id) {
|
||||
currentIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const inc = keyCode === 38 ? -1 : +1;
|
||||
let newIndex = currentIndex + inc;
|
||||
|
||||
if (newIndex < 0) newIndex = 0;
|
||||
if (newIndex > focusItems.length - 1) newIndex = focusItems.length - 1;
|
||||
|
||||
const focusItem = focusItems[newIndex];
|
||||
|
||||
const actionName = `${focusItem.type.toUpperCase()}_SELECT`;
|
||||
|
||||
props.dispatch({
|
||||
type: actionName,
|
||||
id: focusItem.id,
|
||||
});
|
||||
|
||||
focus('SideBar::onKeyDown', focusItem.ref.current);
|
||||
}
|
||||
|
||||
if (keyCode === 9) {
|
||||
// TAB
|
||||
event.preventDefault();
|
||||
|
||||
if (event.shiftKey) {
|
||||
void CommandService.instance().execute('focusElement', 'noteBody');
|
||||
} else {
|
||||
void CommandService.instance().execute('focusElement', 'noteList');
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedItem && selectedItem.type === 'folder' && keyCode === 32) {
|
||||
// SPACE
|
||||
event.preventDefault();
|
||||
|
||||
props.dispatch({
|
||||
type: 'FOLDER_TOGGLE',
|
||||
id: selectedItem.id,
|
||||
});
|
||||
}
|
||||
|
||||
if (keyCode === 65 && (event.ctrlKey || event.metaKey)) {
|
||||
// Ctrl+A key
|
||||
event.preventDefault();
|
||||
}
|
||||
}, [getSelectedItem, props.dispatch]);
|
||||
|
||||
const renderSynchronizeButton = (type: string) => {
|
||||
const label = type === 'sync' ? _('Synchronise') : _('Cancel');
|
||||
const iconAnimation = type !== 'sync' ? 'icon-infinite-rotation 1s linear infinite' : '';
|
||||
@@ -779,61 +41,8 @@ const SidebarComponent = (props: Props) => {
|
||||
);
|
||||
};
|
||||
|
||||
const onAddFolderButtonClick = useCallback(() => {
|
||||
void CommandService.instance().execute('newFolder');
|
||||
}, []);
|
||||
|
||||
const theme = themeStyle(props.themeId);
|
||||
|
||||
const items = [];
|
||||
|
||||
items.push(
|
||||
renderHeader('folderHeader', _('Notebooks'), 'icon-notebooks', header_contextMenu, onAddFolderButtonClick, {
|
||||
onDrop: onFolderDrop_,
|
||||
['data-folder-id']: '',
|
||||
toggleblock: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
const foldersStyle = useMemo(() => {
|
||||
return { display: props.folderHeaderIsExpanded ? 'block' : 'none', paddingBottom: 10 };
|
||||
}, [props.folderHeaderIsExpanded]);
|
||||
|
||||
|
||||
if (props.folders.length) {
|
||||
const allNotesSelected = props.selectedFolderId === ALL_NOTES_FILTER_ID;
|
||||
const result = renderFolders(props, renderFolderItem);
|
||||
const folderItems = [renderAllNotesItem(theme, allNotesSelected)].concat(result.items);
|
||||
folderItemsOrder_.current = result.order;
|
||||
items.push(
|
||||
<div
|
||||
className={`folders ${props.folderHeaderIsExpanded ? 'expanded' : ''}`}
|
||||
key="folder_items"
|
||||
style={foldersStyle}
|
||||
>
|
||||
{folderItems}
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
|
||||
items.push(
|
||||
renderHeader('tagHeader', _('Tags'), 'icon-tags', null, null, {
|
||||
toggleblock: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
if (props.tags.length) {
|
||||
const result = renderTags(props, renderTag);
|
||||
const tagItems = result.items;
|
||||
tagItemsOrder_.current = result.order;
|
||||
|
||||
items.push(
|
||||
<div className="tags" key="tag_items" style={{ display: props.tagHeaderIsExpanded ? 'block' : 'none' }}>
|
||||
{tagItems}
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
|
||||
let decryptionReportText = '';
|
||||
if (props.decryptionWorker && props.decryptionWorker.state !== 'idle' && props.decryptionWorker.itemCount) {
|
||||
decryptionReportText = _('Decrypting items: %d/%d', props.decryptionWorker.itemIndex + 1, props.decryptionWorker.itemCount);
|
||||
@@ -865,8 +74,8 @@ const SidebarComponent = (props: Props) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledRoot ref={rootRef} onKeyDown={onKeyDown} className="sidebar">
|
||||
<div style={{ flex: 1, overflowX: 'hidden', overflowY: 'auto' }}>{items}</div>
|
||||
<StyledRoot className="sidebar">
|
||||
<div style={{ flex: 1 }}><FolderAndTagList/></div>
|
||||
<div style={{ flex: 0, padding: theme.mainPadding }}>
|
||||
{syncReportComp}
|
||||
{syncButton}
|
||||
@@ -877,24 +86,16 @@ const SidebarComponent = (props: Props) => {
|
||||
|
||||
const mapStateToProps = (state: AppState) => {
|
||||
return {
|
||||
folders: state.folders,
|
||||
tags: state.tags,
|
||||
searches: state.searches,
|
||||
syncStarted: state.syncStarted,
|
||||
syncReport: state.syncReport,
|
||||
selectedFolderId: state.selectedFolderId,
|
||||
selectedTagId: state.selectedTagId,
|
||||
selectedSearchId: state.selectedSearchId,
|
||||
selectedSmartFilterId: state.selectedSmartFilterId,
|
||||
notesParentType: state.notesParentType,
|
||||
locale: state.settings.locale,
|
||||
themeId: state.settings.theme,
|
||||
collapsedFolderIds: state.collapsedFolderIds,
|
||||
decryptionWorker: state.decryptionWorker,
|
||||
resourceFetcher: state.resourceFetcher,
|
||||
plugins: state.pluginService.plugins,
|
||||
tagHeaderIsExpanded: state.settings.tagHeaderIsExpanded,
|
||||
folderHeaderIsExpanded: state.settings.folderHeaderIsExpanded,
|
||||
};
|
||||
};
|
||||
|
||||
|
@@ -2,7 +2,7 @@ import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import layoutItemProp from '../../ResizableLayout/utils/layoutItemProp';
|
||||
import { AppState } from '../../../app.reducer';
|
||||
import { focus } from '@joplin/lib/utils/focusHandler';
|
||||
import { SidebarCommandRuntimeProps } from '../types';
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'focusElementSideBar',
|
||||
@@ -10,29 +10,13 @@ export const declaration: CommandDeclaration = {
|
||||
parentLabel: () => _('Focus'),
|
||||
};
|
||||
|
||||
export interface RuntimeProps {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
getSelectedItem(): any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
getFirstAnchorItemRef(type: string): any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
anchorItemRefs: any;
|
||||
}
|
||||
|
||||
export const runtime = (props: RuntimeProps): CommandRuntime => {
|
||||
export const runtime = (props: SidebarCommandRuntimeProps): CommandRuntime => {
|
||||
return {
|
||||
execute: async (context: CommandContext) => {
|
||||
const sidebarVisible = layoutItemProp((context.state as AppState).mainLayout, 'sideBar', 'visible');
|
||||
|
||||
if (sidebarVisible) {
|
||||
const item = props.getSelectedItem();
|
||||
if (item) {
|
||||
const anchorRef = props.anchorItemRefs.current[item.type][item.id];
|
||||
if (anchorRef) focus('focusElementSideBar1', anchorRef.current);
|
||||
} else {
|
||||
const anchorRef = props.getFirstAnchorItemRef('folder');
|
||||
if (anchorRef) focus('focusElementSideBar2', anchorRef.current);
|
||||
}
|
||||
props.focusSidebar();
|
||||
}
|
||||
},
|
||||
|
||||
|
82
packages/app-desktop/gui/Sidebar/hooks/useFocusHandler.ts
Normal file
82
packages/app-desktop/gui/Sidebar/hooks/useFocusHandler.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { MutableRefObject, RefObject, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { ListItem } from '../types';
|
||||
import ItemList from '../../ItemList';
|
||||
import { focus } from '@joplin/lib/utils/focusHandler';
|
||||
|
||||
interface Props {
|
||||
itemListRef: RefObject<ItemList<ListItem>>;
|
||||
selectedListElement: HTMLElement|null;
|
||||
selectedIndex: number;
|
||||
listItems: ListItem[];
|
||||
}
|
||||
|
||||
const useFocusAfterNextRenderHandler = (
|
||||
shouldFocusAfterNextRender: MutableRefObject<boolean>,
|
||||
selectedListElement: HTMLElement|null,
|
||||
) => {
|
||||
useEffect(() => {
|
||||
if (!shouldFocusAfterNextRender.current || !selectedListElement) return;
|
||||
focus('FolderAndTagList/useFocusHandler/afterRender', selectedListElement);
|
||||
shouldFocusAfterNextRender.current = false;
|
||||
}, [selectedListElement, shouldFocusAfterNextRender]);
|
||||
};
|
||||
|
||||
const useRefocusOnSelectionChangeHandler = (
|
||||
itemListRef: RefObject<ItemList<ListItem>>,
|
||||
shouldFocusAfterNextRender: MutableRefObject<boolean>,
|
||||
listItems: ListItem[],
|
||||
selectedIndex: number,
|
||||
) => {
|
||||
// We keep track of the key to avoid scrolling unnecessarily. For example, when the
|
||||
// selection's index changes because a notebook is expanded/collapsed, we don't necessarily
|
||||
// want to scroll the selection into view.
|
||||
const lastSelectedItemKey = useRef('');
|
||||
const selectedItemKey = useMemo(() => {
|
||||
if (selectedIndex >= 0 && selectedIndex < listItems.length) {
|
||||
return listItems[selectedIndex].key;
|
||||
} else {
|
||||
// When nothing is selected, re-use the key from before.
|
||||
// This prevents the view from scrolling when a dropdown containing the
|
||||
// selection is closed, then opened again.
|
||||
return lastSelectedItemKey.current;
|
||||
}
|
||||
}, [listItems, selectedIndex]);
|
||||
lastSelectedItemKey.current = selectedItemKey;
|
||||
|
||||
const selectedIndexRef = useRef(selectedIndex);
|
||||
selectedIndexRef.current = selectedIndex;
|
||||
|
||||
useEffect(() => {
|
||||
if (!itemListRef.current || !selectedItemKey) return;
|
||||
|
||||
const hasFocus = !!itemListRef.current.container.querySelector(':scope :focus');
|
||||
shouldFocusAfterNextRender.current = hasFocus;
|
||||
|
||||
if (hasFocus) {
|
||||
itemListRef.current.makeItemIndexVisible(selectedIndexRef.current);
|
||||
}
|
||||
}, [selectedItemKey, itemListRef, shouldFocusAfterNextRender]);
|
||||
};
|
||||
|
||||
const useFocusHandler = (props: Props) => {
|
||||
const { itemListRef, selectedListElement, selectedIndex, listItems } = props;
|
||||
|
||||
// When set to true, when selectedListElement next changes, select it.
|
||||
const shouldFocusAfterNextRender = useRef(false);
|
||||
|
||||
useRefocusOnSelectionChangeHandler(itemListRef, shouldFocusAfterNextRender, listItems, selectedIndex);
|
||||
useFocusAfterNextRenderHandler(shouldFocusAfterNextRender, selectedListElement);
|
||||
|
||||
const focusSidebar = useCallback(() => {
|
||||
if (!selectedListElement || !itemListRef.current.isIndexVisible(selectedIndex)) {
|
||||
itemListRef.current.makeItemIndexVisible(selectedIndex);
|
||||
shouldFocusAfterNextRender.current = true;
|
||||
} else {
|
||||
focus('FolderAndTagList/useFocusHandler/focusSidebar', selectedListElement);
|
||||
}
|
||||
}, [selectedListElement, selectedIndex, itemListRef]);
|
||||
|
||||
return { focusSidebar };
|
||||
};
|
||||
|
||||
export default useFocusHandler;
|
421
packages/app-desktop/gui/Sidebar/hooks/useOnRenderItem.tsx
Normal file
421
packages/app-desktop/gui/Sidebar/hooks/useOnRenderItem.tsx
Normal file
@@ -0,0 +1,421 @@
|
||||
import * as React from 'react';
|
||||
import { DragEventHandler, MouseEventHandler, useCallback, useMemo, useRef } from 'react';
|
||||
import { ItemClickListener, ItemDragListener, ListItem, ListItemType } from '../types';
|
||||
import TagItem, { TagLinkClickEvent } from '../listItemComponents/TagItem';
|
||||
import { Dispatch } from 'redux';
|
||||
import { clipboard } from 'electron';
|
||||
import { getTrashFolderId } from '@joplin/lib/services/trash';
|
||||
import BaseModel, { ModelType } from '@joplin/lib/BaseModel';
|
||||
import Tag from '@joplin/lib/models/Tag';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { substrWithEllipsis } from '@joplin/lib/string-utils';
|
||||
import { AppState } from '../../../app.reducer';
|
||||
import { store } from '@joplin/lib/reducer';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
import bridge from '../../../services/bridge';
|
||||
import MenuUtils from '@joplin/lib/services/commands/MenuUtils';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import { FolderEntity } from '@joplin/lib/services/database/types';
|
||||
import InteropService from '@joplin/lib/services/interop/InteropService';
|
||||
import InteropServiceHelper from '../../../InteropServiceHelper';
|
||||
import stateToWhenClauseContext from '@joplin/lib/services/commands/stateToWhenClauseContext';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import PerFolderSortOrderService from '../../../services/sortOrder/PerFolderSortOrderService';
|
||||
import { getFolderCallbackUrl, getTagCallbackUrl } from '@joplin/lib/callbackUrlUtils';
|
||||
import { PluginStates, utils as pluginUtils } from '@joplin/lib/services/plugins/reducer';
|
||||
import { MenuItemLocation } from '@joplin/lib/services/plugins/api/types';
|
||||
import FolderItem from '../listItemComponents/FolderItem';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import onFolderDrop from '@joplin/lib/models/utils/onFolderDrop';
|
||||
import HeaderItem from '../listItemComponents/HeaderItem';
|
||||
import AllNotesItem from '../listItemComponents/AllNotesItem';
|
||||
|
||||
const Menu = bridge().Menu;
|
||||
const MenuItem = bridge().MenuItem;
|
||||
|
||||
const logger = Logger.create('useOnRenderItem');
|
||||
|
||||
interface Props {
|
||||
dispatch: Dispatch;
|
||||
themeId: number;
|
||||
plugins: PluginStates;
|
||||
folders: FolderEntity[];
|
||||
collapsedFolderIds: string[];
|
||||
|
||||
selectedIndex: number;
|
||||
onSelectedElementShown: (element: HTMLElement)=> void;
|
||||
}
|
||||
|
||||
type ItemContextMenuListener = MouseEventHandler<HTMLElement>;
|
||||
|
||||
const menuUtils = new MenuUtils(CommandService.instance());
|
||||
|
||||
const useOnRenderItem = (props: Props) => {
|
||||
|
||||
const pluginsRef = useRef<PluginStates>(null);
|
||||
pluginsRef.current = props.plugins;
|
||||
const foldersRef = useRef<FolderEntity[]>(null);
|
||||
foldersRef.current = props.folders;
|
||||
|
||||
const tagItem_click = useCallback(({ tag }: TagLinkClickEvent) => {
|
||||
props.dispatch({
|
||||
type: 'TAG_SELECT',
|
||||
id: tag ? tag.id : null,
|
||||
});
|
||||
}, [props.dispatch]);
|
||||
|
||||
const onTagDrop_: DragEventHandler<HTMLElement> = useCallback(async event => {
|
||||
const tagId = event.currentTarget.getAttribute('data-tag-id');
|
||||
const dt = event.dataTransfer;
|
||||
if (!dt) return;
|
||||
|
||||
if (dt.types.indexOf('text/x-jop-note-ids') >= 0) {
|
||||
event.preventDefault();
|
||||
|
||||
const noteIds = JSON.parse(dt.getData('text/x-jop-note-ids'));
|
||||
for (let i = 0; i < noteIds.length; i++) {
|
||||
await Tag.addNote(tagId, noteIds[i]);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onItemContextMenu: ItemContextMenuListener = useCallback(async event => {
|
||||
const itemId = event.currentTarget.getAttribute('data-id');
|
||||
if (itemId === Folder.conflictFolderId()) return;
|
||||
|
||||
const itemType = Number(event.currentTarget.getAttribute('data-type'));
|
||||
if (!itemId || !itemType) throw new Error('No data on element');
|
||||
|
||||
const state: AppState = store().getState();
|
||||
|
||||
let deleteMessage = '';
|
||||
const deleteButtonLabel = _('Remove');
|
||||
|
||||
if (itemType === BaseModel.TYPE_TAG) {
|
||||
const tag = await Tag.load(itemId);
|
||||
deleteMessage = _('Remove tag "%s" from all notes?', substrWithEllipsis(tag.title, 0, 32));
|
||||
} else if (itemType === BaseModel.TYPE_SEARCH) {
|
||||
deleteMessage = _('Remove this search from the sidebar?');
|
||||
}
|
||||
|
||||
const menu = new Menu();
|
||||
|
||||
if (itemId === getTrashFolderId()) {
|
||||
menu.append(
|
||||
new MenuItem(menuUtils.commandToStatefulMenuItem('emptyTrash')),
|
||||
);
|
||||
menu.popup({ window: bridge().window() });
|
||||
return;
|
||||
}
|
||||
|
||||
let item = null;
|
||||
if (itemType === BaseModel.TYPE_FOLDER) {
|
||||
item = BaseModel.byId(foldersRef.current, itemId);
|
||||
}
|
||||
|
||||
const isDeleted = item ? !!item.deleted_time : false;
|
||||
|
||||
if (!isDeleted) {
|
||||
if (itemType === BaseModel.TYPE_FOLDER && !item.encryption_applied) {
|
||||
menu.append(
|
||||
new MenuItem(menuUtils.commandToStatefulMenuItem('newFolder', itemId)),
|
||||
);
|
||||
}
|
||||
|
||||
if (itemType === BaseModel.TYPE_FOLDER) {
|
||||
menu.append(
|
||||
new MenuItem(menuUtils.commandToStatefulMenuItem('deleteFolder', itemId)),
|
||||
);
|
||||
} else {
|
||||
menu.append(
|
||||
new MenuItem({
|
||||
label: deleteButtonLabel,
|
||||
click: async () => {
|
||||
const ok = bridge().showConfirmMessageBox(deleteMessage, {
|
||||
buttons: [deleteButtonLabel, _('Cancel')],
|
||||
defaultId: 1,
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
if (itemType === BaseModel.TYPE_TAG) {
|
||||
await Tag.untagAll(itemId);
|
||||
} else if (itemType === BaseModel.TYPE_SEARCH) {
|
||||
props.dispatch({
|
||||
type: 'SEARCH_DELETE',
|
||||
id: itemId,
|
||||
});
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (itemType === BaseModel.TYPE_FOLDER && !item.encryption_applied) {
|
||||
menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem('openFolderDialog', { folderId: itemId })));
|
||||
|
||||
menu.append(new MenuItem({ type: 'separator' }));
|
||||
|
||||
const exportMenu = new Menu();
|
||||
const ioService = InteropService.instance();
|
||||
const ioModules = ioService.modules();
|
||||
for (let i = 0; i < ioModules.length; i++) {
|
||||
const module = ioModules[i];
|
||||
if (module.type !== 'exporter') continue;
|
||||
|
||||
exportMenu.append(
|
||||
new MenuItem({
|
||||
label: module.fullLabel(),
|
||||
click: async () => {
|
||||
await InteropServiceHelper.export(props.dispatch, module, { sourceFolderIds: [itemId], plugins: pluginsRef.current });
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// We don't display the "Share notebook" menu item for sub-notebooks
|
||||
// that are within a shared notebook. If user wants to do this,
|
||||
// they'd have to move the notebook out of the shared notebook
|
||||
// first.
|
||||
const whenClause = stateToWhenClauseContext(state, { commandFolderId: itemId });
|
||||
|
||||
if (CommandService.instance().isEnabled('showShareFolderDialog', whenClause)) {
|
||||
menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem('showShareFolderDialog', itemId)));
|
||||
}
|
||||
|
||||
if (CommandService.instance().isEnabled('leaveSharedFolder', whenClause)) {
|
||||
menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem('leaveSharedFolder', itemId)));
|
||||
}
|
||||
|
||||
menu.append(
|
||||
new MenuItem({
|
||||
label: _('Export'),
|
||||
submenu: exportMenu,
|
||||
}),
|
||||
);
|
||||
if (Setting.value('notes.perFolderSortOrderEnabled')) {
|
||||
menu.append(new MenuItem({
|
||||
...menuUtils.commandToStatefulMenuItem('togglePerFolderSortOrder', itemId),
|
||||
type: 'checkbox',
|
||||
checked: PerFolderSortOrderService.isSet(itemId),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if (itemType === BaseModel.TYPE_FOLDER) {
|
||||
menu.append(
|
||||
new MenuItem({
|
||||
label: _('Copy external link'),
|
||||
click: () => {
|
||||
clipboard.writeText(getFolderCallbackUrl(itemId));
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (itemType === BaseModel.TYPE_TAG) {
|
||||
menu.append(new MenuItem(
|
||||
menuUtils.commandToStatefulMenuItem('renameTag', itemId),
|
||||
));
|
||||
menu.append(
|
||||
new MenuItem({
|
||||
label: _('Copy external link'),
|
||||
click: () => {
|
||||
clipboard.writeText(getTagCallbackUrl(itemId));
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const pluginViews = pluginUtils.viewsByType(pluginsRef.current, 'menuItem');
|
||||
|
||||
for (const view of pluginViews) {
|
||||
const location = view.location;
|
||||
|
||||
if (itemType === ModelType.Tag && location === MenuItemLocation.TagContextMenu ||
|
||||
itemType === ModelType.Folder && location === MenuItemLocation.FolderContextMenu
|
||||
) {
|
||||
menu.append(
|
||||
new MenuItem(menuUtils.commandToStatefulMenuItem(view.commandName, itemId)),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (itemType === BaseModel.TYPE_FOLDER) {
|
||||
menu.append(
|
||||
new MenuItem(menuUtils.commandToStatefulMenuItem('restoreFolder', itemId)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
menu.popup({ window: bridge().window() });
|
||||
}, [props.dispatch, pluginsRef]);
|
||||
|
||||
|
||||
|
||||
const onFolderDragStart_: ItemDragListener = useCallback(event => {
|
||||
const folderId = event.currentTarget.getAttribute('data-folder-id');
|
||||
if (!folderId) return;
|
||||
|
||||
event.dataTransfer.setDragImage(new Image(), 1, 1);
|
||||
event.dataTransfer.clearData();
|
||||
event.dataTransfer.setData('text/x-jop-folder-ids', JSON.stringify([folderId]));
|
||||
}, []);
|
||||
|
||||
const onFolderDragOver_: ItemDragListener = useCallback(event => {
|
||||
if (event.dataTransfer.types.indexOf('text/x-jop-note-ids') >= 0) event.preventDefault();
|
||||
if (event.dataTransfer.types.indexOf('text/x-jop-folder-ids') >= 0) event.preventDefault();
|
||||
}, []);
|
||||
|
||||
const onFolderDrop_: ItemDragListener = useCallback(async event => {
|
||||
const folderId = event.currentTarget.getAttribute('data-folder-id');
|
||||
const dt = event.dataTransfer;
|
||||
if (!dt) return;
|
||||
|
||||
// folderId can be NULL when dropping on the sidebar Notebook header. In that case, it's used
|
||||
// to put the dropped folder at the root. But for notes, folderId needs to always be defined
|
||||
// since there's no such thing as a root note.
|
||||
|
||||
try {
|
||||
if (dt.types.indexOf('text/x-jop-note-ids') >= 0) {
|
||||
event.preventDefault();
|
||||
if (!folderId) return;
|
||||
const noteIds = JSON.parse(dt.getData('text/x-jop-note-ids'));
|
||||
await onFolderDrop(noteIds, [], folderId);
|
||||
} else if (dt.types.indexOf('text/x-jop-folder-ids') >= 0) {
|
||||
event.preventDefault();
|
||||
const folderIds = JSON.parse(dt.getData('text/x-jop-folder-ids'));
|
||||
await onFolderDrop([], folderIds, folderId);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
alert(error.message);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onFolderToggleClick_: ItemClickListener = useCallback(event => {
|
||||
const folderId = event.currentTarget.getAttribute('data-folder-id');
|
||||
|
||||
props.dispatch({
|
||||
type: 'FOLDER_TOGGLE',
|
||||
id: folderId,
|
||||
});
|
||||
}, [props.dispatch]);
|
||||
|
||||
const folderItem_click = useCallback((folderId: string) => {
|
||||
props.dispatch({
|
||||
type: 'FOLDER_SELECT',
|
||||
id: folderId ? folderId : null,
|
||||
});
|
||||
}, [props.dispatch]);
|
||||
|
||||
// If at least one of the folder has an icon, then we display icons for all
|
||||
// folders (those without one will get the default icon). This is so that
|
||||
// visual alignment is correct for all folders, otherwise the folder tree
|
||||
// looks messy.
|
||||
const showFolderIcons = useMemo(() => {
|
||||
return Folder.shouldShowFolderIcons(props.folders);
|
||||
}, [props.folders]);
|
||||
|
||||
const selectedIndexRef = useRef(props.selectedIndex);
|
||||
selectedIndexRef.current = props.selectedIndex;
|
||||
|
||||
return useCallback((item: ListItem, index: number) => {
|
||||
const selected = props.selectedIndex === index;
|
||||
const anchorRefCallback = selected ? (
|
||||
(element: HTMLElement) => {
|
||||
if (selectedIndexRef.current === index) {
|
||||
props.onSelectedElementShown(element);
|
||||
}
|
||||
}
|
||||
) : null;
|
||||
|
||||
if (item.kind === ListItemType.Tag) {
|
||||
const tag = item.tag;
|
||||
return <TagItem
|
||||
key={item.key}
|
||||
anchorRef={anchorRefCallback}
|
||||
selected={selected}
|
||||
onClick={tagItem_click}
|
||||
onTagDrop={onTagDrop_}
|
||||
onContextMenu={onItemContextMenu}
|
||||
tag={tag}
|
||||
/>;
|
||||
} else if (item.kind === ListItemType.Folder) {
|
||||
const folder = item.folder;
|
||||
const isExpanded = props.collapsedFolderIds.indexOf(folder.id) < 0;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
let noteCount = (folder as any).note_count;
|
||||
|
||||
// For now hide the count for folders in the trash because it doesn't work and getting it to
|
||||
// work would be tricky.
|
||||
if (folder.deleted_time || folder.id === getTrashFolderId()) noteCount = 0;
|
||||
|
||||
// Thunderbird count: Subtract children note_count from parent folder if it expanded.
|
||||
if (isExpanded) {
|
||||
for (let i = 0; i < props.folders.length; i++) {
|
||||
if (props.folders[i].parent_id === folder.id) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
noteCount -= (props.folders[i] as any).note_count;
|
||||
}
|
||||
}
|
||||
}
|
||||
return <FolderItem
|
||||
key={item.key}
|
||||
anchorRef={anchorRefCallback}
|
||||
selected={selected}
|
||||
folderId={folder.id}
|
||||
folderTitle={Folder.displayTitle(folder)}
|
||||
folderIcon={Folder.unserializeIcon(folder.icon)}
|
||||
depth={item.depth}
|
||||
isExpanded={isExpanded}
|
||||
hasChildren={item.hasChildren}
|
||||
noteCount={noteCount}
|
||||
onFolderDragStart_={onFolderDragStart_}
|
||||
onFolderDragOver_={onFolderDragOver_}
|
||||
onFolderDrop_={onFolderDrop_}
|
||||
itemContextMenu={onItemContextMenu}
|
||||
folderItem_click={folderItem_click}
|
||||
onFolderToggleClick_={onFolderToggleClick_}
|
||||
shareId={folder.share_id}
|
||||
parentId={folder.parent_id}
|
||||
showFolderIcon={showFolderIcons}
|
||||
/>;
|
||||
} else if (item.kind === ListItemType.Header) {
|
||||
return <HeaderItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
anchorRef={anchorRefCallback}
|
||||
onDrop={item.supportsFolderDrop ? onFolderDrop_ : null}
|
||||
/>;
|
||||
} else if (item.kind === ListItemType.AllNotes) {
|
||||
return <AllNotesItem
|
||||
key={item.key}
|
||||
selected={selected}
|
||||
anchorRef={anchorRefCallback}
|
||||
/>;
|
||||
} else if (item.kind === ListItemType.Spacer) {
|
||||
return (
|
||||
<a key={item.key} className='sidebar-spacer-item' ref={anchorRefCallback} aria-label={_('Spacer')}></a>
|
||||
);
|
||||
} else {
|
||||
const exhaustivenessCheck: never = item;
|
||||
return exhaustivenessCheck;
|
||||
}
|
||||
}, [
|
||||
folderItem_click,
|
||||
onFolderDragOver_,
|
||||
onFolderDragStart_,
|
||||
onFolderDrop_,
|
||||
onFolderToggleClick_,
|
||||
onItemContextMenu,
|
||||
onTagDrop_,
|
||||
props.collapsedFolderIds,
|
||||
props.folders,
|
||||
showFolderIcons,
|
||||
tagItem_click,
|
||||
props.selectedIndex,
|
||||
props.onSelectedElementShown,
|
||||
]);
|
||||
};
|
||||
|
||||
export default useOnRenderItem;
|
@@ -0,0 +1,51 @@
|
||||
import { Dispatch } from 'redux';
|
||||
import { ListItem, ListItemType, SetSelectedIndexCallback } from '../types';
|
||||
import { KeyboardEventHandler, useCallback } from 'react';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
|
||||
interface Props {
|
||||
dispatch: Dispatch;
|
||||
listItems: ListItem[];
|
||||
selectedIndex: number;
|
||||
updateSelectedIndex: SetSelectedIndexCallback;
|
||||
}
|
||||
|
||||
const useOnSidebarKeyDownHandler = (props: Props) => {
|
||||
const { updateSelectedIndex, listItems, selectedIndex, dispatch } = props;
|
||||
|
||||
return useCallback<KeyboardEventHandler<HTMLElement>>((event) => {
|
||||
const selectedItem = listItems[selectedIndex];
|
||||
if (selectedItem && selectedItem.kind === ListItemType.Folder && event.code === 'Space') {
|
||||
event.preventDefault();
|
||||
|
||||
dispatch({
|
||||
type: 'FOLDER_TOGGLE',
|
||||
id: selectedItem.folder.id,
|
||||
});
|
||||
} else if ((event.ctrlKey || event.metaKey) && event.code === 'KeyA') { // ctrl+a or cmd+a
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
let indexChange = 0;
|
||||
if (event.code === 'ArrowUp') {
|
||||
indexChange = -1;
|
||||
} else if (event.code === 'ArrowDown') {
|
||||
indexChange = 1;
|
||||
} else if (event.code === 'Tab') {
|
||||
event.preventDefault();
|
||||
|
||||
if (event.shiftKey) {
|
||||
void CommandService.instance().execute('focusElement', 'noteBody');
|
||||
} else {
|
||||
void CommandService.instance().execute('focusElement', 'noteList');
|
||||
}
|
||||
}
|
||||
|
||||
if (indexChange !== 0) {
|
||||
event.preventDefault();
|
||||
updateSelectedIndex(selectedIndex + indexChange);
|
||||
}
|
||||
}, [selectedIndex, listItems, updateSelectedIndex, dispatch]);
|
||||
};
|
||||
|
||||
export default useOnSidebarKeyDownHandler;
|
@@ -0,0 +1,88 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { ListItem, ListItemType } from '../types';
|
||||
import { isFolderSelected, isTagSelected } from '@joplin/lib/components/shared/side-menu-shared';
|
||||
import { Dispatch } from 'redux';
|
||||
const { ALL_NOTES_FILTER_ID } = require('@joplin/lib/reserved-ids');
|
||||
|
||||
|
||||
interface Props {
|
||||
dispatch: Dispatch;
|
||||
listItems: ListItem[];
|
||||
|
||||
notesParentType: string;
|
||||
selectedTagId: string;
|
||||
selectedFolderId: string;
|
||||
selectedSmartFilterId: string;
|
||||
}
|
||||
|
||||
const useSelectedSidebarIndex = (props: Props) => {
|
||||
const appStateSelectedIndex = useMemo(() => {
|
||||
for (let i = 0; i < props.listItems.length; i++) {
|
||||
const listItem = props.listItems[i];
|
||||
|
||||
let selected = false;
|
||||
if (listItem.kind === ListItemType.AllNotes) {
|
||||
selected = props.selectedSmartFilterId === ALL_NOTES_FILTER_ID && props.notesParentType === 'SmartFilter';
|
||||
} else if (listItem.kind === ListItemType.Header || listItem.kind === ListItemType.Spacer) {
|
||||
selected = false;
|
||||
} else if (listItem.kind === ListItemType.Folder) {
|
||||
selected = isFolderSelected(listItem.folder, { selectedFolderId: props.selectedFolderId, notesParentType: props.notesParentType });
|
||||
} else if (listItem.kind === ListItemType.Tag) {
|
||||
selected = isTagSelected(listItem.tag, { selectedTagId: props.selectedTagId, notesParentType: props.notesParentType });
|
||||
} else {
|
||||
const exhaustivenessCheck: never = listItem;
|
||||
return exhaustivenessCheck;
|
||||
}
|
||||
|
||||
if (selected) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}, [props.listItems, props.selectedFolderId, props.selectedTagId, props.selectedSmartFilterId, props.notesParentType]);
|
||||
|
||||
// Not all list items correspond with selectable Joplin folders/tags, but we want to
|
||||
// be able to select them anyway. This is handled with selectedIndexOverride.
|
||||
//
|
||||
// When selectedIndexOverride >= 0, it corresponds to the index of a selected item with no
|
||||
// specific note parent item (e.g. a header).
|
||||
const [selectedIndexOverride, setSelectedIndexOverride] = useState(-1);
|
||||
useEffect(() => {
|
||||
setSelectedIndexOverride(-1);
|
||||
}, [appStateSelectedIndex]);
|
||||
|
||||
const updateSelectedIndex = useCallback((newIndex: number) => {
|
||||
if (newIndex < 0) {
|
||||
newIndex = 0;
|
||||
} else if (newIndex >= props.listItems.length) {
|
||||
newIndex = props.listItems.length - 1;
|
||||
}
|
||||
|
||||
const newItem = props.listItems[newIndex];
|
||||
let newOverrideIndex = -1;
|
||||
if (newItem.kind === ListItemType.AllNotes) {
|
||||
props.dispatch({
|
||||
type: 'SMART_FILTER_SELECT',
|
||||
id: ALL_NOTES_FILTER_ID,
|
||||
});
|
||||
} else if (newItem.kind === ListItemType.Folder) {
|
||||
props.dispatch({
|
||||
type: 'FOLDER_SELECT',
|
||||
id: newItem.folder.id,
|
||||
});
|
||||
} else if (newItem.kind === ListItemType.Tag) {
|
||||
props.dispatch({
|
||||
type: 'TAG_SELECT',
|
||||
id: newItem.tag.id,
|
||||
});
|
||||
} else {
|
||||
newOverrideIndex = newIndex;
|
||||
}
|
||||
setSelectedIndexOverride(newOverrideIndex);
|
||||
}, [props.listItems, props.dispatch]);
|
||||
|
||||
const selectedIndex = selectedIndexOverride === -1 ? appStateSelectedIndex : selectedIndexOverride;
|
||||
return { selectedIndex, updateSelectedIndex };
|
||||
};
|
||||
|
||||
export default useSelectedSidebarIndex;
|
@@ -0,0 +1,24 @@
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import commands from '../commands';
|
||||
import { SidebarCommandRuntimeProps } from '../types';
|
||||
|
||||
interface Props {
|
||||
focusSidebar: ()=> void;
|
||||
}
|
||||
const useSidebarCommandHandler = ({ focusSidebar }: Props) => {
|
||||
useEffect(() => {
|
||||
const runtimeProps: SidebarCommandRuntimeProps = {
|
||||
focusSidebar,
|
||||
};
|
||||
|
||||
CommandService.instance().componentRegisterCommands(runtimeProps, commands);
|
||||
|
||||
return () => {
|
||||
CommandService.instance().componentUnregisterCommands(commands);
|
||||
};
|
||||
}, [focusSidebar]);
|
||||
};
|
||||
|
||||
export default useSidebarCommandHandler;
|
98
packages/app-desktop/gui/Sidebar/hooks/useSidebarListData.ts
Normal file
98
packages/app-desktop/gui/Sidebar/hooks/useSidebarListData.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { useMemo } from 'react';
|
||||
import { FolderListItem, HeaderId, HeaderListItem, ListItem, ListItemType, TagListItem } from '../types';
|
||||
import { FolderEntity, TagsWithNoteCountEntity } from '@joplin/lib/services/database/types';
|
||||
import { renderFolders, renderTags } from '@joplin/lib/components/shared/side-menu-shared';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
|
||||
interface Props {
|
||||
tags: TagsWithNoteCountEntity[];
|
||||
folders: FolderEntity[];
|
||||
collapsedFolderIds: string[];
|
||||
folderHeaderIsExpanded: boolean;
|
||||
tagHeaderIsExpanded: boolean;
|
||||
}
|
||||
|
||||
const onAddFolderButtonClick = () => {
|
||||
void CommandService.instance().execute('newFolder');
|
||||
};
|
||||
|
||||
const onHeaderClick = (headerId: HeaderId) => {
|
||||
const settingKey = headerId === HeaderId.TagHeader ? 'tagHeaderIsExpanded' : 'folderHeaderIsExpanded';
|
||||
const current = Setting.value(settingKey);
|
||||
Setting.setValue(settingKey, !current);
|
||||
};
|
||||
|
||||
const useSidebarListData = (props: Props): ListItem[] => {
|
||||
const tagItems = useMemo(() => {
|
||||
return renderTags<ListItem>(props.tags, (tag): TagListItem => {
|
||||
return {
|
||||
kind: ListItemType.Tag,
|
||||
tag,
|
||||
key: tag.id,
|
||||
};
|
||||
});
|
||||
}, [props.tags]);
|
||||
|
||||
|
||||
const folderItems = useMemo(() => {
|
||||
const renderProps = {
|
||||
folders: props.folders,
|
||||
collapsedFolderIds: props.collapsedFolderIds,
|
||||
};
|
||||
return renderFolders<ListItem>(renderProps, (folder, hasChildren, depth): FolderListItem => {
|
||||
return {
|
||||
kind: ListItemType.Folder,
|
||||
folder,
|
||||
hasChildren,
|
||||
depth,
|
||||
key: folder.id,
|
||||
};
|
||||
});
|
||||
}, [props.folders, props.collapsedFolderIds]);
|
||||
|
||||
return useMemo(() => {
|
||||
const foldersHeader: HeaderListItem = {
|
||||
kind: ListItemType.Header,
|
||||
label: _('Notebooks'),
|
||||
iconName: 'icon-notebooks',
|
||||
id: HeaderId.FolderHeader,
|
||||
key: HeaderId.FolderHeader,
|
||||
onClick: onHeaderClick,
|
||||
onPlusButtonClick: onAddFolderButtonClick,
|
||||
extraProps: {
|
||||
['data-folder-id']: '',
|
||||
},
|
||||
supportsFolderDrop: true,
|
||||
};
|
||||
const foldersSectionContent: ListItem[] = props.folderHeaderIsExpanded ? [
|
||||
{ kind: ListItemType.AllNotes, key: 'all-notes' },
|
||||
...folderItems.items,
|
||||
{ kind: ListItemType.Spacer, key: 'after-folders-spacer' },
|
||||
] : [];
|
||||
|
||||
const tagsHeader: HeaderListItem = {
|
||||
kind: ListItemType.Header,
|
||||
label: _('Tags'),
|
||||
iconName: 'icon-tags',
|
||||
id: HeaderId.TagHeader,
|
||||
key: HeaderId.TagHeader,
|
||||
onClick: onHeaderClick,
|
||||
onPlusButtonClick: null,
|
||||
extraProps: { },
|
||||
supportsFolderDrop: false,
|
||||
};
|
||||
const tagsSectionContent: ListItem[] = props.tagHeaderIsExpanded ? tagItems.items : [];
|
||||
|
||||
const items: ListItem[] = [
|
||||
foldersHeader,
|
||||
...foldersSectionContent,
|
||||
tagsHeader,
|
||||
...tagsSectionContent,
|
||||
];
|
||||
return items;
|
||||
}, [tagItems, folderItems, props.folderHeaderIsExpanded, props.tagHeaderIsExpanded]);
|
||||
};
|
||||
|
||||
export default useSidebarListData;
|
@@ -0,0 +1,67 @@
|
||||
import * as React from 'react';
|
||||
import { StyledAllNotesIcon, StyledListItem, StyledListItemAnchor } from '../styles';
|
||||
import { useCallback } from 'react';
|
||||
import { Dispatch } from 'redux';
|
||||
import bridge from '../../../services/bridge';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import MenuUtils from '@joplin/lib/services/commands/MenuUtils';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import PerFolderSortOrderService from '../../../services/sortOrder/PerFolderSortOrderService';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { connect } from 'react-redux';
|
||||
import EmptyExpandLink from './EmptyExpandLink';
|
||||
const { ALL_NOTES_FILTER_ID } = require('@joplin/lib/reserved-ids');
|
||||
|
||||
const Menu = bridge().Menu;
|
||||
const MenuItem = bridge().MenuItem;
|
||||
|
||||
interface Props {
|
||||
dispatch: Dispatch;
|
||||
selected: boolean;
|
||||
anchorRef: React.Ref<HTMLAnchorElement>;
|
||||
}
|
||||
|
||||
const menuUtils = new MenuUtils(CommandService.instance());
|
||||
|
||||
const AllNotesItem: React.FC<Props> = props => {
|
||||
const onAllNotesClick_ = useCallback(() => {
|
||||
props.dispatch({
|
||||
type: 'SMART_FILTER_SELECT',
|
||||
id: ALL_NOTES_FILTER_ID,
|
||||
});
|
||||
}, [props.dispatch]);
|
||||
|
||||
const toggleAllNotesContextMenu = useCallback(() => {
|
||||
const menu = new Menu();
|
||||
|
||||
if (Setting.value('notes.perFolderSortOrderEnabled')) {
|
||||
menu.append(new MenuItem({
|
||||
...menuUtils.commandToStatefulMenuItem('togglePerFolderSortOrder', ALL_NOTES_FILTER_ID),
|
||||
type: 'checkbox',
|
||||
checked: PerFolderSortOrderService.isSet(ALL_NOTES_FILTER_ID),
|
||||
}));
|
||||
}
|
||||
|
||||
menu.popup({ window: bridge().window() });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<StyledListItem key="allNotesHeader" selected={props.selected} className={'list-item-container list-item-depth-0 all-notes'} isSpecialItem={true}>
|
||||
<EmptyExpandLink/>
|
||||
<StyledAllNotesIcon className="icon-notes"/>
|
||||
<StyledListItemAnchor
|
||||
ref={props.anchorRef}
|
||||
className="list-item"
|
||||
isSpecialItem={true}
|
||||
href="#"
|
||||
selected={props.selected}
|
||||
onClick={onAllNotesClick_}
|
||||
onContextMenu={toggleAllNotesContextMenu}
|
||||
>
|
||||
{_('All notes')}
|
||||
</StyledListItemAnchor>
|
||||
</StyledListItem>
|
||||
);
|
||||
};
|
||||
|
||||
export default connect()(AllNotesItem);
|
@@ -0,0 +1,11 @@
|
||||
import * as React from 'react';
|
||||
import ExpandIcon from './ExpandIcon';
|
||||
|
||||
interface Props {
|
||||
}
|
||||
|
||||
const EmptyExpandLink: React.FC<Props> = _props => {
|
||||
return <a className='sidebar-expand-link'><ExpandIcon isVisible={false} isExpanded={false}/></a>;
|
||||
};
|
||||
|
||||
export default EmptyExpandLink;
|
@@ -0,0 +1,33 @@
|
||||
import * as React from 'react';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
|
||||
type ExpandIconProps = {
|
||||
isExpanded: boolean;
|
||||
isVisible: true;
|
||||
targetTitle: string;
|
||||
}|{
|
||||
isExpanded: boolean;
|
||||
isVisible: false;
|
||||
targetTitle?: string;
|
||||
};
|
||||
|
||||
const ExpandIcon: React.FC<ExpandIconProps> = props => {
|
||||
const classNames = ['sidebar-expand-icon'];
|
||||
if (props.isVisible) classNames.push('-visible');
|
||||
classNames.push(props.isExpanded ? 'fas fa-caret-down' : 'fas fa-caret-right');
|
||||
|
||||
// Referencing the name of the item we expand/collapse is both good for accessibility
|
||||
// and makes writing tests easier.
|
||||
const getLabel = () => {
|
||||
if (!props.isVisible) {
|
||||
return undefined;
|
||||
}
|
||||
if (props.isExpanded) {
|
||||
return _('Collapse %s', props.targetTitle);
|
||||
}
|
||||
return _('Expand %s', props.targetTitle);
|
||||
};
|
||||
return <i className={classNames.join(' ')} aria-label={getLabel()}></i>;
|
||||
};
|
||||
|
||||
export default ExpandIcon;
|
@@ -0,0 +1,24 @@
|
||||
import * as React from 'react';
|
||||
import { MouseEventHandler } from 'react';
|
||||
import ExpandIcon from './ExpandIcon';
|
||||
import EmptyExpandLink from './EmptyExpandLink';
|
||||
|
||||
interface ExpandLinkProps {
|
||||
folderId: string;
|
||||
folderTitle: string;
|
||||
hasChildren: boolean;
|
||||
isExpanded: boolean;
|
||||
onClick: MouseEventHandler<HTMLElement>;
|
||||
}
|
||||
|
||||
const ExpandLink: React.FC<ExpandLinkProps> = props => {
|
||||
return props.hasChildren ? (
|
||||
<a className='sidebar-expand-link' href="#" data-folder-id={props.folderId} onClick={props.onClick}>
|
||||
<ExpandIcon isVisible={true} isExpanded={props.isExpanded} targetTitle={props.folderTitle}/>
|
||||
</a>
|
||||
) : (
|
||||
<EmptyExpandLink/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExpandLink;
|
@@ -0,0 +1,89 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { FolderIcon, FolderIconType } from '@joplin/lib/services/database/types';
|
||||
import ExpandLink from './ExpandLink';
|
||||
import { StyledListItem, StyledListItemAnchor, StyledNoteCount, StyledShareIcon, StyledSpanFix } from '../styles';
|
||||
import { ItemClickListener, ItemContextMenuListener, ItemDragListener } from '../types';
|
||||
import FolderIconBox from '../../FolderIconBox';
|
||||
import { getTrashFolderIcon, getTrashFolderId } from '@joplin/lib/services/trash';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
import { ModelType } from '@joplin/lib/BaseModel';
|
||||
|
||||
const renderFolderIcon = (folderIcon: FolderIcon) => {
|
||||
if (!folderIcon) {
|
||||
const defaultFolderIcon: FolderIcon = {
|
||||
dataUrl: '',
|
||||
emoji: '',
|
||||
name: 'far fa-folder',
|
||||
type: FolderIconType.FontAwesome,
|
||||
};
|
||||
return <div style={{ marginRight: 7, display: 'flex' }}><FolderIconBox opacity={0.7} folderIcon={defaultFolderIcon}/></div>;
|
||||
}
|
||||
|
||||
return <div style={{ marginRight: 7, display: 'flex' }}><FolderIconBox folderIcon={folderIcon}/></div>;
|
||||
};
|
||||
|
||||
interface FolderItemProps {
|
||||
hasChildren: boolean;
|
||||
showFolderIcon: boolean;
|
||||
isExpanded: boolean;
|
||||
parentId: string;
|
||||
depth: number;
|
||||
folderId: string;
|
||||
folderTitle: string;
|
||||
folderIcon: FolderIcon;
|
||||
noteCount: number;
|
||||
onFolderDragStart_: ItemDragListener;
|
||||
onFolderDragOver_: ItemDragListener;
|
||||
onFolderDrop_: ItemDragListener;
|
||||
itemContextMenu: ItemContextMenuListener;
|
||||
folderItem_click: (folderId: string)=> void;
|
||||
onFolderToggleClick_: ItemClickListener;
|
||||
shareId: string;
|
||||
selected: boolean;
|
||||
anchorRef: React.Ref<HTMLElement>;
|
||||
}
|
||||
|
||||
function FolderItem(props: FolderItemProps) {
|
||||
const { hasChildren, showFolderIcon, isExpanded, parentId, depth, selected, folderId, folderTitle, folderIcon, noteCount, onFolderDragStart_, onFolderDragOver_, onFolderDrop_, itemContextMenu, folderItem_click, onFolderToggleClick_, shareId } = props;
|
||||
|
||||
const noteCountComp = noteCount ? <StyledNoteCount className="note-count-label">{noteCount}</StyledNoteCount> : null;
|
||||
const shareIcon = shareId && !parentId ? <StyledShareIcon className="fas fa-share-alt"></StyledShareIcon> : null;
|
||||
const draggable = ![getTrashFolderId(), Folder.conflictFolderId()].includes(folderId);
|
||||
|
||||
const doRenderFolderIcon = () => {
|
||||
if (folderId === getTrashFolderId()) {
|
||||
return renderFolderIcon(getTrashFolderIcon(FolderIconType.FontAwesome));
|
||||
}
|
||||
|
||||
if (!showFolderIcon) return null;
|
||||
return renderFolderIcon(folderIcon);
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledListItem depth={depth} selected={selected} className={`list-item-container list-item-depth-${depth} ${selected ? 'selected' : ''}`} onDragStart={onFolderDragStart_} onDragOver={onFolderDragOver_} onDrop={onFolderDrop_} draggable={draggable} data-folder-id={folderId}>
|
||||
<ExpandLink hasChildren={hasChildren} folderTitle={folderTitle} folderId={folderId} onClick={onFolderToggleClick_} isExpanded={isExpanded}/>
|
||||
<StyledListItemAnchor
|
||||
ref={props.anchorRef}
|
||||
className="list-item"
|
||||
isConflictFolder={folderId === Folder.conflictFolderId()}
|
||||
href="#"
|
||||
selected={selected}
|
||||
shareId={shareId}
|
||||
data-id={folderId}
|
||||
data-type={ModelType.Folder}
|
||||
onContextMenu={itemContextMenu}
|
||||
data-folder-id={folderId}
|
||||
onClick={() => {
|
||||
folderItem_click(folderId);
|
||||
}}
|
||||
onDoubleClick={onFolderToggleClick_}
|
||||
>
|
||||
{doRenderFolderIcon()}<StyledSpanFix className="title">{folderTitle}</StyledSpanFix>
|
||||
{shareIcon} {noteCountComp}
|
||||
</StyledListItemAnchor>
|
||||
</StyledListItem>
|
||||
);
|
||||
}
|
||||
|
||||
export default FolderItem;
|
@@ -0,0 +1,72 @@
|
||||
import * as React from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { ButtonLevel } from '../../Button/Button';
|
||||
import { StyledAddButton, StyledHeader, StyledHeaderIcon, StyledHeaderLabel } from '../styles';
|
||||
import { HeaderId, HeaderListItem } from '../types';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import bridge from '../../../services/bridge';
|
||||
import MenuUtils from '@joplin/lib/services/commands/MenuUtils';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
|
||||
const Menu = bridge().Menu;
|
||||
const MenuItem = bridge().MenuItem;
|
||||
const menuUtils = new MenuUtils(CommandService.instance());
|
||||
|
||||
|
||||
interface Props {
|
||||
item: HeaderListItem;
|
||||
onDrop: React.DragEventHandler|null;
|
||||
anchorRef: React.Ref<HTMLElement>;
|
||||
}
|
||||
|
||||
const HeaderItem: React.FC<Props> = props => {
|
||||
const item = props.item;
|
||||
const onItemClick = item.onClick;
|
||||
const itemId = item.id;
|
||||
|
||||
const onClick: React.MouseEventHandler<HTMLElement> = useCallback(event => {
|
||||
if (onItemClick) {
|
||||
onItemClick(itemId, event);
|
||||
}
|
||||
}, [onItemClick, itemId]);
|
||||
|
||||
const onContextMenu = useCallback(async () => {
|
||||
if (itemId === HeaderId.FolderHeader) {
|
||||
const menu = new Menu();
|
||||
|
||||
menu.append(
|
||||
new MenuItem(menuUtils.commandToStatefulMenuItem('newFolder')),
|
||||
);
|
||||
|
||||
menu.popup({ window: bridge().window() });
|
||||
}
|
||||
}, [itemId]);
|
||||
|
||||
const addButton = <StyledAddButton
|
||||
iconLabel={_('New')}
|
||||
onClick={item.onPlusButtonClick}
|
||||
iconName='fas fa-plus'
|
||||
level={ButtonLevel.SidebarSecondary}
|
||||
/>;
|
||||
|
||||
return (
|
||||
<div
|
||||
className='sidebar-header-container'
|
||||
{...item.extraProps}
|
||||
onDrop={props.onDrop}
|
||||
>
|
||||
<StyledHeader
|
||||
onContextMenu={onContextMenu}
|
||||
onClick={onClick}
|
||||
tabIndex={0}
|
||||
ref={props.anchorRef}
|
||||
>
|
||||
<StyledHeaderIcon className={item.iconName}/>
|
||||
<StyledHeaderLabel>{item.label}</StyledHeaderLabel>
|
||||
</StyledHeader>
|
||||
{ item.onPlusButtonClick && addButton }
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeaderItem;
|
@@ -0,0 +1,14 @@
|
||||
import * as React from 'react';
|
||||
import { StyledNoteCount } from '../styles';
|
||||
|
||||
|
||||
interface Props {
|
||||
count: number;
|
||||
}
|
||||
|
||||
const NoteCount: React.FC<Props> = props => {
|
||||
const count = props.count;
|
||||
return count ? <StyledNoteCount className="note-count-label">{count}</StyledNoteCount> : null;
|
||||
};
|
||||
|
||||
export default NoteCount;
|
@@ -0,0 +1,59 @@
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import * as React from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { StyledListItem, StyledListItemAnchor, StyledSpanFix } from '../styles';
|
||||
import { TagsWithNoteCountEntity } from '@joplin/lib/services/database/types';
|
||||
import BaseModel from '@joplin/lib/BaseModel';
|
||||
import NoteCount from './NoteCount';
|
||||
import Tag from '@joplin/lib/models/Tag';
|
||||
import EmptyExpandLink from './EmptyExpandLink';
|
||||
|
||||
export type TagLinkClickEvent = { tag: TagsWithNoteCountEntity|undefined };
|
||||
|
||||
interface Props {
|
||||
selected: boolean;
|
||||
anchorRef: React.Ref<HTMLElement>;
|
||||
tag: TagsWithNoteCountEntity;
|
||||
onTagDrop: React.DragEventHandler<HTMLElement>;
|
||||
onContextMenu: React.MouseEventHandler<HTMLElement>;
|
||||
onClick: (event: TagLinkClickEvent)=> void;
|
||||
}
|
||||
|
||||
const TagItem = (props: Props) => {
|
||||
const { tag, selected } = props;
|
||||
|
||||
let noteCount = null;
|
||||
if (Setting.value('showNoteCounts')) {
|
||||
const count = Setting.value('showCompletedTodos') ? tag.note_count : tag.note_count - tag.todo_completed_count;
|
||||
noteCount = <NoteCount count={count}/>;
|
||||
}
|
||||
|
||||
const onClickHandler = useCallback(() => {
|
||||
props.onClick({ tag });
|
||||
}, [props.onClick, tag]);
|
||||
|
||||
return (
|
||||
<StyledListItem selected={selected}
|
||||
className={`list-item-container ${selected ? 'selected' : ''}`}
|
||||
onDrop={props.onTagDrop}
|
||||
data-tag-id={tag.id}
|
||||
>
|
||||
<EmptyExpandLink/>
|
||||
<StyledListItemAnchor
|
||||
ref={props.anchorRef}
|
||||
className="list-item"
|
||||
href="#"
|
||||
selected={selected}
|
||||
data-id={tag.id}
|
||||
data-type={BaseModel.TYPE_TAG}
|
||||
onContextMenu={props.onContextMenu}
|
||||
onClick={onClickHandler}
|
||||
>
|
||||
<StyledSpanFix className="tag-label">{Tag.displayTitle(tag)}</StyledSpanFix>
|
||||
{noteCount}
|
||||
</StyledListItemAnchor>
|
||||
</StyledListItem>
|
||||
);
|
||||
};
|
||||
|
||||
export default TagItem;
|
5
packages/app-desktop/gui/Sidebar/style.scss
Normal file
5
packages/app-desktop/gui/Sidebar/style.scss
Normal file
@@ -0,0 +1,5 @@
|
||||
@use 'styles/folder-and-tag-list.scss';
|
||||
@use 'styles/sidebar-expand-icon.scss';
|
||||
@use 'styles/sidebar-expand-link.scss';
|
||||
@use 'styles/sidebar-header-container.scss';
|
||||
@use 'styles/sidebar-spacer-item.scss';
|
@@ -0,0 +1,14 @@
|
||||
|
||||
.folder-and-tag-list {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
flex-grow: 1;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
> .items {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
}
|
@@ -90,20 +90,6 @@ export const StyledShareIcon = styled.i`
|
||||
margin-left: 8px;
|
||||
`;
|
||||
|
||||
export const StyledExpandLink = styled.a`
|
||||
color: ${(props: StyleProps) => props.theme.color2};
|
||||
cursor: default;
|
||||
opacity: 0.8;
|
||||
text-decoration: none;
|
||||
padding-right: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 16px;
|
||||
max-width: 16px;
|
||||
min-width: 16px;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
export const StyledNoteCount = styled.div`
|
||||
color: ${(props: StyleProps) => props.theme.colorFaded2};
|
||||
padding-left: 8px;
|
||||
|
@@ -0,0 +1,13 @@
|
||||
|
||||
.sidebar-expand-icon {
|
||||
width: 16px;
|
||||
max-width: 16px;
|
||||
opacity: 0.5px;
|
||||
font-size: calc(var(--joplin-toolbar-icon-size) * 0.8);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
&:not(.-visible) {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
@@ -0,0 +1,14 @@
|
||||
|
||||
.sidebar-expand-link {
|
||||
color: var(--joplin-color2);
|
||||
cursor: default;
|
||||
opacity: 0.8;
|
||||
text-decoration: none;
|
||||
padding-right: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 16px;
|
||||
max-width: 16px;
|
||||
min-width: 16px;
|
||||
height: 100%;
|
||||
}
|
@@ -0,0 +1,6 @@
|
||||
|
||||
.sidebar-header-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
@@ -0,0 +1,5 @@
|
||||
|
||||
.sidebar-spacer-item {
|
||||
display: block;
|
||||
height: 30px;
|
||||
}
|
64
packages/app-desktop/gui/Sidebar/types.ts
Normal file
64
packages/app-desktop/gui/Sidebar/types.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { FolderEntity, TagsWithNoteCountEntity } from '@joplin/lib/services/database/types';
|
||||
import { DragEventHandler, MouseEventHandler, MouseEvent as ReactMouseEvent } from 'react';
|
||||
|
||||
export enum HeaderId {
|
||||
TagHeader = 'tagHeader',
|
||||
FolderHeader = 'folderHeader',
|
||||
}
|
||||
|
||||
export enum ListItemType {
|
||||
Header = 'header',
|
||||
Tag = 'tag',
|
||||
Folder = 'folder',
|
||||
AllNotes = 'all-notes',
|
||||
Spacer = 'spacer',
|
||||
}
|
||||
|
||||
interface BaseListItem {
|
||||
key: string;
|
||||
}
|
||||
|
||||
export interface HeaderListItem extends BaseListItem {
|
||||
kind: ListItemType.Header;
|
||||
label: string;
|
||||
iconName: string;
|
||||
id: HeaderId;
|
||||
onClick: ((headerId: HeaderId, event: ReactMouseEvent<HTMLElement>)=> void)|null;
|
||||
onPlusButtonClick: MouseEventHandler<HTMLElement>|null;
|
||||
extraProps: Record<string, string>;
|
||||
supportsFolderDrop: boolean;
|
||||
}
|
||||
|
||||
export interface AllNotesListItem extends BaseListItem {
|
||||
kind: ListItemType.AllNotes;
|
||||
}
|
||||
|
||||
export interface TagListItem extends BaseListItem {
|
||||
kind: ListItemType.Tag;
|
||||
tag: TagsWithNoteCountEntity;
|
||||
}
|
||||
|
||||
export interface FolderListItem extends BaseListItem {
|
||||
kind: ListItemType.Folder;
|
||||
folder: FolderEntity;
|
||||
hasChildren: boolean;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
export interface SpacerListItem extends BaseListItem {
|
||||
kind: ListItemType.Spacer;
|
||||
}
|
||||
|
||||
export type ListItem = HeaderListItem|AllNotesListItem|TagListItem|FolderListItem|SpacerListItem;
|
||||
|
||||
|
||||
export type SetSelectedIndexCallback = (newIndex: number)=> void;
|
||||
|
||||
|
||||
export type ItemDragListener = DragEventHandler<HTMLElement>;
|
||||
export type ItemContextMenuListener = MouseEventHandler<HTMLElement>;
|
||||
export type ItemClickListener = MouseEventHandler<HTMLElement>;
|
||||
|
||||
export interface SidebarCommandRuntimeProps {
|
||||
focusSidebar: ()=> void;
|
||||
}
|
23
packages/app-desktop/gui/hooks/useElementHeight.ts
Normal file
23
packages/app-desktop/gui/hooks/useElementHeight.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
// This uses a ResizeObserver -- be careful to prevent infinite loops (should be stopped
|
||||
// early and print a warning). See https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver#observation_errors
|
||||
const useElementHeight = (container: HTMLElement|null) => {
|
||||
const [height, setHeight] = useState(container?.clientHeight ?? 0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!container) return () => {};
|
||||
const observer = new ResizeObserver(() => {
|
||||
setHeight(container.clientHeight);
|
||||
});
|
||||
observer.observe(container);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [container]);
|
||||
|
||||
return height;
|
||||
};
|
||||
|
||||
export default useElementHeight;
|
@@ -1,21 +1,25 @@
|
||||
import { Page, Locator, ElectronApplication } from '@playwright/test';
|
||||
import NoteEditorScreen from './NoteEditorScreen';
|
||||
import activateMainMenuItem from '../util/activateMainMenuItem';
|
||||
import Sidebar from './Sidebar';
|
||||
|
||||
export default class MainScreen {
|
||||
public readonly newNoteButton: Locator;
|
||||
public readonly noteListContainer: Locator;
|
||||
public readonly sidebar: Sidebar;
|
||||
public readonly dialog: Locator;
|
||||
public readonly noteEditor: NoteEditorScreen;
|
||||
|
||||
public constructor(private page: Page) {
|
||||
this.newNoteButton = page.locator('.new-note-button');
|
||||
this.noteListContainer = page.locator('.rli-noteList');
|
||||
this.sidebar = new Sidebar(page, this);
|
||||
this.dialog = page.locator('.dialog-root');
|
||||
this.noteEditor = new NoteEditorScreen(page);
|
||||
}
|
||||
|
||||
public async waitFor() {
|
||||
await this.newNoteButton.waitFor();
|
||||
await this.noteEditor.waitFor();
|
||||
await this.noteListContainer.waitFor();
|
||||
}
|
||||
|
||||
|
46
packages/app-desktop/integration-tests/models/Sidebar.ts
Normal file
46
packages/app-desktop/integration-tests/models/Sidebar.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import activateMainMenuItem from '../util/activateMainMenuItem';
|
||||
import type MainScreen from './MainScreen';
|
||||
import { ElectronApplication, Locator, Page } from '@playwright/test';
|
||||
|
||||
export default class Sidebar {
|
||||
public readonly container: Locator;
|
||||
|
||||
public constructor(page: Page, private mainScreen: MainScreen) {
|
||||
this.container = page.locator('.rli-sideBar');
|
||||
}
|
||||
|
||||
public async createNewFolder(title: string) {
|
||||
const newFolderButton = this.container.getByRole('button', { name: 'New' });
|
||||
await newFolderButton.click();
|
||||
|
||||
const titleInput = this.mainScreen.dialog.getByLabel('Title');
|
||||
await titleInput.fill(title);
|
||||
|
||||
const submitButton = this.mainScreen.dialog.getByRole('button', { name: 'OK' });
|
||||
await submitButton.click();
|
||||
|
||||
return this.container.getByText(title);
|
||||
}
|
||||
|
||||
private async sortBy(electronApp: ElectronApplication, option: string) {
|
||||
const success = await activateMainMenuItem(electronApp, option, 'Sort notebooks by');
|
||||
if (!success) {
|
||||
throw new Error(`Failed to find menu item: ${option}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async sortByDate(electronApp: ElectronApplication) {
|
||||
return this.sortBy(electronApp, 'Updated date');
|
||||
}
|
||||
|
||||
public async sortByTitle(electronApp: ElectronApplication) {
|
||||
return this.sortBy(electronApp, 'Title');
|
||||
}
|
||||
|
||||
public async forceUpdateSorting(electronApp: ElectronApplication) {
|
||||
// By default, notebooks will not be in the correct position in the list for about 1 second.
|
||||
// Change the notebook list sort order to force an immediate refresh.
|
||||
await this.sortByDate(electronApp);
|
||||
await this.sortByTitle(electronApp);
|
||||
}
|
||||
}
|
104
packages/app-desktop/integration-tests/sidebar.spec.ts
Normal file
104
packages/app-desktop/integration-tests/sidebar.spec.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { test, expect } from './util/test';
|
||||
import MainScreen from './models/MainScreen';
|
||||
|
||||
test.describe('sidebar', () => {
|
||||
test('should be able to create new folders', async ({ mainWindow }) => {
|
||||
const mainScreen = new MainScreen(mainWindow);
|
||||
const sidebar = mainScreen.sidebar;
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const title = `Test folder ${i}`;
|
||||
await sidebar.createNewFolder(title);
|
||||
await expect(sidebar.container.getByText(title)).toBeAttached();
|
||||
}
|
||||
|
||||
// The first folder should still be visible
|
||||
await expect(sidebar.container.getByText('Test folder 0')).toBeAttached();
|
||||
});
|
||||
|
||||
test('should allow changing the focused folder with the arrow keys', async ({ electronApp, mainWindow }) => {
|
||||
const mainScreen = new MainScreen(mainWindow);
|
||||
const sidebar = mainScreen.sidebar;
|
||||
|
||||
const folderAHeader = await sidebar.createNewFolder('Folder A');
|
||||
await expect(folderAHeader).toBeVisible();
|
||||
|
||||
const folderBHeader = await sidebar.createNewFolder('Folder B');
|
||||
await expect(folderBHeader).toBeVisible();
|
||||
await folderBHeader.click();
|
||||
|
||||
await sidebar.forceUpdateSorting(electronApp);
|
||||
|
||||
await folderBHeader.click();
|
||||
await mainWindow.keyboard.press('ArrowUp');
|
||||
await expect(mainWindow.locator(':focus')).toHaveText('Folder A');
|
||||
await mainWindow.keyboard.press('ArrowDown');
|
||||
await expect(mainWindow.locator(':focus')).toHaveText('Folder B');
|
||||
await mainWindow.keyboard.press('ArrowUp');
|
||||
await expect(mainWindow.locator(':focus')).toHaveText('Folder A');
|
||||
await mainWindow.keyboard.press('ArrowUp');
|
||||
await expect(mainWindow.locator(':focus')).toHaveText('All notes');
|
||||
await mainWindow.keyboard.press('ArrowUp');
|
||||
await expect(mainWindow.locator(':focus')).toHaveText(/NOTEBOOKS/i);
|
||||
await mainWindow.keyboard.press('ArrowDown');
|
||||
await expect(mainWindow.locator(':focus')).toHaveText('All notes');
|
||||
});
|
||||
|
||||
test('should allow changing the parent of a folder by drag-and-drop', async ({ electronApp, mainWindow }) => {
|
||||
const mainScreen = new MainScreen(mainWindow);
|
||||
const sidebar = mainScreen.sidebar;
|
||||
|
||||
const parentFolderHeader = await sidebar.createNewFolder('Parent folder');
|
||||
await expect(parentFolderHeader).toBeVisible();
|
||||
|
||||
const childFolderHeader = await sidebar.createNewFolder('Child folder');
|
||||
await expect(childFolderHeader).toBeVisible();
|
||||
|
||||
await sidebar.forceUpdateSorting(electronApp);
|
||||
|
||||
await childFolderHeader.dragTo(parentFolderHeader);
|
||||
|
||||
// Verify that it's now a child folder -- expand and collapse the parent
|
||||
const collapseButton = sidebar.container.getByRole('link', { name: 'Collapse Parent folder' });
|
||||
await expect(collapseButton).toBeVisible();
|
||||
await collapseButton.click();
|
||||
|
||||
// Should be collapsed
|
||||
await expect(childFolderHeader).not.toBeAttached();
|
||||
|
||||
const expandButton = sidebar.container.getByRole('link', { name: 'Expand Parent folder' });
|
||||
await expandButton.click();
|
||||
|
||||
// Should be possible to move back to the root
|
||||
const rootFolderHeader = sidebar.container.getByText('Notebooks');
|
||||
await childFolderHeader.dragTo(rootFolderHeader);
|
||||
await expect(collapseButton).not.toBeVisible();
|
||||
await expect(expandButton).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('all notes section should list all notes', async ({ electronApp, mainWindow }) => {
|
||||
const mainScreen = new MainScreen(mainWindow);
|
||||
const sidebar = mainScreen.sidebar;
|
||||
|
||||
const testFolderA = await sidebar.createNewFolder('Folder A');
|
||||
await expect(testFolderA).toBeAttached();
|
||||
|
||||
await sidebar.forceUpdateSorting(electronApp);
|
||||
|
||||
await mainScreen.createNewNote('A note in Folder A');
|
||||
await expect(mainWindow.getByText('A note in Folder A')).toBeAttached();
|
||||
await mainScreen.createNewNote('Another note in Folder A');
|
||||
|
||||
const testFolderB = await sidebar.createNewFolder('Folder B');
|
||||
await expect(testFolderB).toBeAttached();
|
||||
|
||||
await mainScreen.createNewNote('A note in Folder B');
|
||||
|
||||
const allNotesButton = sidebar.container.getByText('All notes');
|
||||
await allNotesButton.click();
|
||||
|
||||
await expect(mainWindow.getByText('A note in Folder A')).toBeAttached();
|
||||
await expect(mainWindow.getByText('Another note in Folder A')).toBeAttached();
|
||||
await expect(mainWindow.getByText('A note in Folder B')).toBeAttached();
|
||||
});
|
||||
});
|
@@ -6,17 +6,22 @@ import type { MenuItem } from 'electron';
|
||||
// Roughly based on
|
||||
// https://github.com/spaceagetv/electron-playwright-helpers/blob/main/src/menu_helpers.ts
|
||||
|
||||
// `menuItemPath` should be a list of menu labels (e.g. [["&JoplinMainMenu", "&File"], "Synchronise"]).
|
||||
const activateMainMenuItem = (electronApp: ElectronApplication, menuItemLabel: string) => {
|
||||
return electronApp.evaluate(async ({ Menu }, menuItemLabel) => {
|
||||
const activateItemInSubmenu = (submenu: MenuItem[]) => {
|
||||
// If given, `parentMenuLabel` should be the label of the menu containing the target item.
|
||||
const activateMainMenuItem = (
|
||||
electronApp: ElectronApplication,
|
||||
targetItemLabel: string,
|
||||
parentMenuLabel?: string,
|
||||
) => {
|
||||
return electronApp.evaluate(async ({ Menu }, [targetItemLabel, parentMenuLabel]) => {
|
||||
const activateItemInSubmenu = (submenu: MenuItem[], parentLabel: string) => {
|
||||
for (const item of submenu) {
|
||||
if (item.label === menuItemLabel && item.visible) {
|
||||
const matchesParent = !parentMenuLabel || parentLabel === parentMenuLabel;
|
||||
if (item.label === targetItemLabel && matchesParent && item.visible) {
|
||||
// Found!
|
||||
item.click();
|
||||
return true;
|
||||
} else if (item.submenu) {
|
||||
const foundItem = activateItemInSubmenu(item.submenu.items);
|
||||
const foundItem = activateItemInSubmenu(item.submenu.items, item.label);
|
||||
|
||||
if (foundItem) {
|
||||
return true;
|
||||
@@ -29,8 +34,8 @@ const activateMainMenuItem = (electronApp: ElectronApplication, menuItemLabel: s
|
||||
};
|
||||
|
||||
const appMenu = Menu.getApplicationMenu();
|
||||
return activateItemInSubmenu(appMenu.items);
|
||||
}, menuItemLabel);
|
||||
return activateItemInSubmenu(appMenu.items, '');
|
||||
}, [targetItemLabel, parentMenuLabel]);
|
||||
};
|
||||
|
||||
export default activateMainMenuItem;
|
||||
|
@@ -26,7 +26,7 @@ const shim = require('@joplin/lib/shim').default;
|
||||
const { shimInit } = require('@joplin/lib/shim-init-node.js');
|
||||
const bridge = require('@electron/remote').require('./bridge').default;
|
||||
const EncryptionService = require('@joplin/lib/services/e2ee/EncryptionService').default;
|
||||
const { FileApiDriverLocal } = require('@joplin/lib/file-api-driver-local');
|
||||
const FileApiDriverLocal = require('@joplin/lib/file-api-driver-local').default;
|
||||
const React = require('react');
|
||||
const nodeSqlite = require('sqlite3');
|
||||
const initLib = require('@joplin/lib/initLib').default;
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/app-desktop",
|
||||
"version": "3.0.3",
|
||||
"version": "3.0.4",
|
||||
"description": "Joplin for Desktop",
|
||||
"main": "main.js",
|
||||
"private": true,
|
||||
@@ -126,7 +126,7 @@
|
||||
"@playwright/test": "1.42.1",
|
||||
"@testing-library/react-hooks": "8.0.1",
|
||||
"@types/jest": "29.5.8",
|
||||
"@types/node": "18.19.8",
|
||||
"@types/node": "18.19.10",
|
||||
"@types/react": "18.2.48",
|
||||
"@types/react-redux": "7.1.33",
|
||||
"@types/styled-components": "5.1.32",
|
||||
|
@@ -8,7 +8,9 @@ import BasePlatformImplementation, { Joplin } from '@joplin/lib/services/plugins
|
||||
import { CreateFromPdfOptions, Implementation as ImagingImplementation } from '@joplin/lib/services/plugins/api/JoplinImaging';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import { join } from 'path';
|
||||
import uuid from '@joplin/lib/uuid';
|
||||
import uuid, { uuidgen } from '@joplin/lib/uuid';
|
||||
import { hasProtocol } from '@joplin/utils/url';
|
||||
import { fileExtension } from '@joplin/utils/path';
|
||||
const { clipboard, nativeImage } = require('electron');
|
||||
const packageInfo = require('../../packageInfo');
|
||||
|
||||
@@ -97,24 +99,33 @@ export default class PlatformImplementation extends BasePlatformImplementation {
|
||||
await shim.fsDriver().remove(tempDir);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
nativeImage: {
|
||||
async createFromPath(path: string) {
|
||||
if (path.toLowerCase().endsWith('.pdf')) {
|
||||
const images = await createFromPdf(path, { minPage: 1, maxPage: 1 });
|
||||
createFromPath: async (path: string) => {
|
||||
let pathToProcess = path;
|
||||
let ext = fileExtension(path).toLowerCase();
|
||||
|
||||
if (images.length === 0) {
|
||||
// Match the behavior or Electron's nativeImage when reading an invalid image.
|
||||
return nativeImage.createEmpty();
|
||||
}
|
||||
if (hasProtocol(path, ['http', 'https', 'file'])) {
|
||||
ext = fileExtension((new URL(path)).pathname);
|
||||
const tempFilePath = `${Setting.value('tempDir')}/${uuidgen()}${ext ? `.${ext}` : ''}`;
|
||||
await shim.fetchBlob(path, { path: tempFilePath });
|
||||
pathToProcess = tempFilePath;
|
||||
}
|
||||
|
||||
return images[0];
|
||||
} else {
|
||||
return nativeImage.createFromPath(path);
|
||||
if (ext === 'pdf') {
|
||||
const images = await createFromPdf(pathToProcess, { minPage: 1, maxPage: 1 });
|
||||
|
||||
if (images.length === 0) {
|
||||
// Match the behavior or Electron's nativeImage when reading an invalid image.
|
||||
return nativeImage.createEmpty();
|
||||
}
|
||||
},
|
||||
createFromPdf,
|
||||
|
||||
return images[0];
|
||||
} else {
|
||||
return nativeImage.createFromPath(pathToProcess);
|
||||
}
|
||||
},
|
||||
createFromPdf,
|
||||
getPdfInfo(path: string) {
|
||||
return shim.pdfInfo(path);
|
||||
},
|
||||
|
@@ -1,18 +1,49 @@
|
||||
import PerFolderSortOrderService from './PerFolderSortOrderService';
|
||||
import { setNotesSortOrder } from './notesSortOrderUtils';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { AppState, createAppDefaultState } from '../../app.reducer';
|
||||
import eventManager from '@joplin/lib/eventManager';
|
||||
const { shimInit } = require('@joplin/lib/shim-init-node.js');
|
||||
const { ALL_NOTES_FILTER_ID } = require('@joplin/lib/reserved-ids');
|
||||
|
||||
const folderId1 = 'aa012345678901234567890123456789';
|
||||
const folderId2 = 'bb012345678901234567890123456789';
|
||||
|
||||
let appState: AppState|null = null;
|
||||
const updateAppState = (update: Partial<AppState>) => {
|
||||
appState = { ...appState, ...update };
|
||||
eventManager.appStateEmit(appState);
|
||||
};
|
||||
|
||||
const switchToFolder = (id: string) => {
|
||||
updateAppState({
|
||||
notesParentType: 'Folder',
|
||||
selectedFolderId: id,
|
||||
});
|
||||
};
|
||||
|
||||
const switchToAllNotes = () => {
|
||||
updateAppState({
|
||||
notesParentType: 'SmartFilter',
|
||||
selectedSmartFilterId: ALL_NOTES_FILTER_ID,
|
||||
});
|
||||
};
|
||||
|
||||
describe('PerFolderSortOrderService', () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
shimInit();
|
||||
Setting.autoSaveEnabled = false;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
PerFolderSortOrderService.initialize();
|
||||
Setting.setValue('notes.perFolderSortOrderEnabled', true);
|
||||
updateAppState(createAppDefaultState({}, {}));
|
||||
switchToFolder(folderId1);
|
||||
});
|
||||
afterEach(() => {
|
||||
Setting.setValue('notes.perFolderSortOrders', {});
|
||||
});
|
||||
|
||||
test('get(), isSet() and set()', async () => {
|
||||
@@ -39,4 +70,71 @@ describe('PerFolderSortOrderService', () => {
|
||||
// Folder without per-folder sort order has no per-folder sort order
|
||||
expect(PerFolderSortOrderService.get(folderId2)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should allow specifying a sort order specific to a folder', () => {
|
||||
switchToFolder(folderId1);
|
||||
|
||||
expect(PerFolderSortOrderService.isSet(folderId1)).toBe(false);
|
||||
expect(PerFolderSortOrderService.isSet(folderId2)).toBe(false);
|
||||
|
||||
setNotesSortOrder('user_created_time', false);
|
||||
expect(Setting.value('notes.sortOrder.field')).toBe('user_created_time');
|
||||
expect(Setting.value('notes.sortOrder.reverse')).toBe(false);
|
||||
|
||||
// Folder 2 should use the shared sort order.
|
||||
switchToFolder(folderId2);
|
||||
expect(Setting.value('notes.sortOrder.field')).toBe('user_created_time');
|
||||
expect(Setting.value('notes.sortOrder.reverse')).toBe(false);
|
||||
|
||||
// If changing the per-folder sort order for folder 1, folder 2 should continue
|
||||
// to use the shared sort order.
|
||||
switchToFolder(folderId1);
|
||||
PerFolderSortOrderService.set(folderId1, true);
|
||||
|
||||
setNotesSortOrder('title', true);
|
||||
expect(Setting.value('notes.sortOrder.field')).toBe('title');
|
||||
expect(Setting.value('notes.sortOrder.reverse')).toBe(true);
|
||||
|
||||
switchToFolder(folderId2);
|
||||
expect(Setting.value('notes.sortOrder.field')).toBe('user_created_time');
|
||||
expect(Setting.value('notes.sortOrder.reverse')).toBe(false);
|
||||
});
|
||||
|
||||
test('should allow setting a sort order specific to All Notes', () => {
|
||||
switchToFolder(folderId1);
|
||||
|
||||
expect(PerFolderSortOrderService.isSet(ALL_NOTES_FILTER_ID)).toBe(false);
|
||||
expect(PerFolderSortOrderService.isSet(folderId1)).toBe(false);
|
||||
|
||||
// Set default shared sort order
|
||||
setNotesSortOrder('user_created_time', false);
|
||||
expect(Setting.value('notes.sortOrder.field')).toBe('user_created_time');
|
||||
expect(Setting.value('notes.sortOrder.reverse')).toBe(false);
|
||||
|
||||
// Switching to all notes should not change the default sort order.
|
||||
switchToAllNotes();
|
||||
|
||||
expect(Setting.value('notes.sortOrder.field')).toBe('user_created_time');
|
||||
expect(Setting.value('notes.sortOrder.reverse')).toBe(false);
|
||||
|
||||
// It should be possible to enable per-folder sorting for all notes.
|
||||
PerFolderSortOrderService.set(ALL_NOTES_FILTER_ID, true);
|
||||
expect(PerFolderSortOrderService.isSet(ALL_NOTES_FILTER_ID)).toBe(true);
|
||||
|
||||
setNotesSortOrder('user_updated_time', true);
|
||||
|
||||
// Per-folder sorting should be respected for all notes
|
||||
switchToFolder(folderId1);
|
||||
|
||||
expect(Setting.value('notes.sortOrder.field')).toBe('user_created_time');
|
||||
expect(Setting.value('notes.sortOrder.reverse')).toBe(false);
|
||||
|
||||
// Shared sort order should be overriden by per-folder sorting
|
||||
setNotesSortOrder('title', false);
|
||||
|
||||
switchToAllNotes();
|
||||
|
||||
expect(Setting.value('notes.sortOrder.field')).toBe('user_updated_time');
|
||||
expect(Setting.value('notes.sortOrder.reverse')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
@@ -13,6 +13,7 @@ export interface SortOrder {
|
||||
interface FolderState {
|
||||
notesParentType: string;
|
||||
selectedFolderId: string;
|
||||
selectedSmartFilterId: string;
|
||||
}
|
||||
|
||||
interface SortOrderPool {
|
||||
@@ -21,8 +22,10 @@ interface SortOrderPool {
|
||||
|
||||
export default class PerFolderSortOrderService {
|
||||
|
||||
// To support a custom sort order in "all notebooks", previousFolderId
|
||||
// can also be a smart filter ID.
|
||||
private static previousFolderId: string = null;
|
||||
private static folderState: FolderState = { notesParentType: '', selectedFolderId: '' };
|
||||
private static folderState: FolderState = { notesParentType: '', selectedFolderId: '', selectedSmartFilterId: '' };
|
||||
// Since perFolderSortOrders and sharedSortOrder is persisted using Setting,
|
||||
// their structures are not nested.
|
||||
private static perFolderSortOrders: SortOrderPool = null;
|
||||
@@ -40,6 +43,7 @@ export default class PerFolderSortOrderService {
|
||||
this.loadSharedSortOrder();
|
||||
eventManager.appStateOn('notesParentType', this.onFolderSelectionMayChange.bind(this, 'notesParentType'));
|
||||
eventManager.appStateOn('selectedFolderId', this.onFolderSelectionMayChange.bind(this, 'selectedFolderId'));
|
||||
eventManager.appStateOn('selectedSmartFilterId', this.onFolderSelectionMayChange.bind(this, 'selectedSmartFilterId'));
|
||||
this.previousFolderId = Setting.value('activeFolderId');
|
||||
}
|
||||
|
||||
@@ -93,7 +97,9 @@ export default class PerFolderSortOrderService {
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
private static onFolderSelectionMayChange(cause: string, event: any) {
|
||||
if (cause !== 'notesParentType' && cause !== 'selectedFolderId') return;
|
||||
if (cause !== 'notesParentType' && cause !== 'selectedFolderId' && cause !== 'selectedSmartFilterId') {
|
||||
return;
|
||||
}
|
||||
this.folderState[cause] = event.value;
|
||||
const selectedId = this.getSelectedFolderId();
|
||||
if (this.previousFolderId === selectedId) return;
|
||||
@@ -127,6 +133,8 @@ export default class PerFolderSortOrderService {
|
||||
private static getSelectedFolderId(): string {
|
||||
if (this.folderState.notesParentType === 'Folder') {
|
||||
return this.folderState.selectedFolderId;
|
||||
} else if (this.folderState.notesParentType === 'SmartFilter') {
|
||||
return this.folderState.selectedSmartFilterId;
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
|
@@ -9,4 +9,5 @@
|
||||
@use 'gui/JoplinCloudLoginScreen.scss' as joplin-cloud-login-screen;
|
||||
@use 'gui/NoteListHeader/style.scss' as note-list-header;
|
||||
@use 'gui/TrashNotification/style.scss' as trash-notification;
|
||||
@use 'gui/Sidebar/style.scss' as sidebar-styles;
|
||||
@use 'main.scss' as main;
|
@@ -6,7 +6,7 @@
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
|
||||
TEMP_PATH=~/src/plugin-tests
|
||||
NEED_COMPILING=1
|
||||
PLUGIN_PATH=~/src/plugin-abc
|
||||
PLUGIN_PATH=~/src/joplin/packages/app-cli/tests/support/plugins/imaging
|
||||
|
||||
if [[ $NEED_COMPILING == 1 ]]; then
|
||||
mkdir -p "$TEMP_PATH"
|
||||
@@ -18,7 +18,7 @@ if [[ $NEED_COMPILING == 1 ]]; then
|
||||
|
||||
rsync -a --delete "$PLUGIN_PATH/" "$TEMP_PLUGIN_PATH/"
|
||||
|
||||
npm install --prefix="$TEMP_PLUGIN_PATH" && yarn start --dev-plugins "$TEMP_PLUGIN_PATH"
|
||||
NODE_OPTIONS=--openssl-legacy-provider npm install --prefix="$TEMP_PLUGIN_PATH" && yarn start --dev-plugins "$TEMP_PLUGIN_PATH"
|
||||
else
|
||||
yarn start --dev-plugins "$PLUGIN_PATH"
|
||||
fi
|
||||
|
@@ -110,8 +110,8 @@ android {
|
||||
applicationId "net.cozic.joplin"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 2097740
|
||||
versionName "3.0.1"
|
||||
versionCode 2097742
|
||||
versionName "3.0.3"
|
||||
ndk {
|
||||
abiFilters "armeabi-v7a", "x86", "arm64-v8a", "x86_64"
|
||||
}
|
||||
|
83
packages/app-mobile/components/DismissibleDialog.tsx
Normal file
83
packages/app-mobile/components/DismissibleDialog.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import * as React from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { StyleSheet, View, ViewStyle, useWindowDimensions } from 'react-native';
|
||||
import { IconButton, Surface } from 'react-native-paper';
|
||||
import { themeStyle } from './global-style';
|
||||
import Modal from './Modal';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
visible: boolean;
|
||||
onDismiss: ()=> void;
|
||||
containerStyle?: ViewStyle;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const useStyles = (themeId: number, containerStyle: ViewStyle) => {
|
||||
const windowSize = useWindowDimensions();
|
||||
|
||||
return useMemo(() => {
|
||||
const theme = themeStyle(themeId);
|
||||
|
||||
return StyleSheet.create({
|
||||
webView: {
|
||||
backgroundColor: 'transparent',
|
||||
display: 'flex',
|
||||
},
|
||||
webViewContainer: {
|
||||
flexGrow: 1,
|
||||
flexShrink: 1,
|
||||
},
|
||||
closeButtonContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
dialog: {
|
||||
backgroundColor: theme.backgroundColor,
|
||||
borderRadius: 12,
|
||||
padding: 10,
|
||||
|
||||
height: windowSize.height * 0.9,
|
||||
width: windowSize.width * 0.97,
|
||||
|
||||
// Center
|
||||
marginLeft: 'auto',
|
||||
marginRight: 'auto',
|
||||
|
||||
...containerStyle,
|
||||
},
|
||||
});
|
||||
}, [themeId, windowSize.width, windowSize.height, containerStyle]);
|
||||
};
|
||||
|
||||
const DismissibleDialog: React.FC<Props> = props => {
|
||||
const styles = useStyles(props.themeId, props.containerStyle);
|
||||
|
||||
const closeButton = (
|
||||
<View style={styles.closeButtonContainer}>
|
||||
<IconButton
|
||||
icon='close'
|
||||
accessibilityLabel={_('Close')}
|
||||
onPress={props.onDismiss}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={props.visible}
|
||||
onDismiss={props.onDismiss}
|
||||
onRequestClose={props.onDismiss}
|
||||
animationType='fade'
|
||||
transparent={true}
|
||||
>
|
||||
<Surface style={styles.dialog} elevation={1}>
|
||||
{closeButton}
|
||||
{props.children}
|
||||
</Surface>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default DismissibleDialog;
|
@@ -29,7 +29,7 @@ export default function useOnResourceLongPress(callbacks: Callbacks, dialogBoxRe
|
||||
return;
|
||||
}
|
||||
|
||||
const name = resource.title ? resource.title : resource.file_name;
|
||||
const name = resource.title ? resource.title : resource.filename;
|
||||
const mime: string|undefined = resource.mime;
|
||||
|
||||
const actions = [];
|
||||
|
@@ -133,7 +133,7 @@ export const createJsDrawEditor = (
|
||||
const request = new XMLHttpRequest();
|
||||
|
||||
const onError = () => {
|
||||
reject(`Failed to load initial SVG data: ${request.status}, ${request.statusText}, ${request.responseText}`);
|
||||
reject(new Error(`Failed to load initial SVG data: ${request.status}, ${request.statusText}, ${request.responseText}`));
|
||||
};
|
||||
|
||||
request.addEventListener('load', _ => {
|
||||
|
@@ -6,6 +6,7 @@ import { connect } from 'react-redux';
|
||||
import { FlatList, Text, StyleSheet, Button, View } from 'react-native';
|
||||
import { FolderEntity, NoteEntity } from '@joplin/lib/services/database/types';
|
||||
import { AppState } from '../utils/types';
|
||||
import getEmptyFolderMessage from '@joplin/lib/components/shared/NoteList/getEmptyFolderMessage';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
|
||||
const { _ } = require('@joplin/lib/locale');
|
||||
@@ -20,7 +21,7 @@ interface NoteListProps {
|
||||
items: NoteEntity[];
|
||||
folders: FolderEntity[];
|
||||
noteSelectionEnabled?: boolean;
|
||||
selectedFolderId?: string;
|
||||
selectedFolderId: string|null;
|
||||
}
|
||||
|
||||
class NoteListComponent extends Component<NoteListProps> {
|
||||
@@ -102,8 +103,9 @@ class NoteListComponent extends Component<NoteListProps> {
|
||||
</View>
|
||||
);
|
||||
} else {
|
||||
const noItemMessage = _('There are currently no notes. Create one by clicking on the (+) button.');
|
||||
return <Text style={this.styles().noItemMessage}>{noItemMessage}</Text>;
|
||||
return <Text style={this.styles().noItemMessage}>
|
||||
{getEmptyFolderMessage(this.props.folders, this.props.selectedFolderId)}
|
||||
</Text>;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,6 +119,7 @@ const NoteList = connect((state: AppState) => {
|
||||
notesSource: state.notesSource,
|
||||
themeId: state.settings.theme,
|
||||
noteSelectionEnabled: state.noteSelectionEnabled,
|
||||
selectedFolderId: state.selectedFolderId,
|
||||
};
|
||||
})(NoteListComponent);
|
||||
|
||||
|
@@ -30,7 +30,7 @@ export interface ValueMap {
|
||||
|
||||
export default function getResponsiveValue(valueMap: ValueMap): number {
|
||||
if (Object.keys(valueMap).length === 0) {
|
||||
throw 'valueMap cannot be an empty object!';
|
||||
throw new Error('valueMap cannot be an empty object!');
|
||||
}
|
||||
|
||||
const width = Dimensions.get('window').width;
|
||||
|
@@ -8,7 +8,6 @@ import setIgnoreTlsErrors from '../../../utils/TlsUtils';
|
||||
import { reg } from '@joplin/lib/registry';
|
||||
import { State } from '@joplin/lib/reducer';
|
||||
const { BackButtonService } = require('../../../services/back-button.js');
|
||||
const VersionInfo = require('react-native-version-info').default;
|
||||
import { connect } from 'react-redux';
|
||||
import ScreenHeader from '../../ScreenHeader';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
@@ -33,6 +32,8 @@ import PluginStates, { getSearchText as getPluginStatesSearchText } from './plug
|
||||
import PluginUploadButton, { canInstallPluginsFromFile, buttonLabel as pluginUploadButtonSearchText } from './plugins/PluginUploadButton';
|
||||
import NoteImportButton, { importButtonDefaultTitle, importButtonDescription } from './NoteExportSection/NoteImportButton';
|
||||
import SectionDescription from './SectionDescription';
|
||||
import getPackageInfo from '../../../utils/getPackageInfo';
|
||||
import versionInfo from '@joplin/lib/versionInfo';
|
||||
|
||||
interface ConfigScreenState {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
@@ -345,6 +346,7 @@ class ConfigScreenComponent extends BaseScreenComponent<ConfigScreenProps, Confi
|
||||
description={options?.description}
|
||||
statusComponent={options?.statusComp}
|
||||
styles={this.styles()}
|
||||
disabled={options?.disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -513,18 +515,27 @@ class ConfigScreenComponent extends BaseScreenComponent<ConfigScreenProps, Confi
|
||||
if (section.name === 'joplinCloud') {
|
||||
const label = _('Email to note');
|
||||
const description = _('Any email sent to this address will be converted into a note and added to your collection. The note will be saved into the Inbox notebook');
|
||||
const isEmailToNoteAvailableInAccount = this.props.settings['sync.10.accountType'] !== 1;
|
||||
const inboxEmailValue = isEmailToNoteAvailableInAccount ? this.props.settings['sync.10.inboxEmail'] : '-';
|
||||
addSettingComponent(
|
||||
<View key="joplinCloud">
|
||||
<View style={this.styles().styleSheet.settingContainerNoBottomBorder}>
|
||||
<Text style={this.styles().styleSheet.settingText}>{label}</Text>
|
||||
<Text style={this.styles().styleSheet.settingTextEmphasis}>{this.props.settings['sync.10.inboxEmail']}</Text>
|
||||
<Text style={this.styles().styleSheet.settingTextEmphasis}>{inboxEmailValue}</Text>
|
||||
</View>
|
||||
{
|
||||
!isEmailToNoteAvailableInAccount && (
|
||||
<View style={this.styles().styleSheet.settingContainerNoBottomBorder}>
|
||||
<Text style={this.styles().styleSheet.descriptionAlert}>{_('Your account doesn\'t have access to this feature')}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
{
|
||||
this.renderButton(
|
||||
'sync.10.inboxEmail',
|
||||
_('Copy to clipboard'),
|
||||
() => Clipboard.setString(this.props.settings['sync.10.inboxEmail']),
|
||||
{ description },
|
||||
() => isEmailToNoteAvailableInAccount && Clipboard.setString(this.props.settings['sync.10.inboxEmail']),
|
||||
{ description, disabled: !isEmailToNoteAvailableInAccount },
|
||||
)
|
||||
}
|
||||
</View>,
|
||||
@@ -592,13 +603,20 @@ class ConfigScreenComponent extends BaseScreenComponent<ConfigScreenProps, Confi
|
||||
addSettingLink('website_link', _('Joplin website'), 'https://joplinapp.org/');
|
||||
addSettingLink('privacy_link', _('Privacy Policy'), 'https://joplinapp.org/privacy/');
|
||||
|
||||
addSettingText('version_info_app', `Joplin ${VersionInfo.appVersion}`);
|
||||
addSettingText('version_info_db', _('Database v%s', reg.db().version()));
|
||||
addSettingText('version_info_sync', _('Sync Version: %s', Setting.value('syncVersion')));
|
||||
addSettingText('version_info_client_id', _('Client ID: %s', Setting.value('clientId')));
|
||||
addSettingText('version_info_fts', _('FTS enabled: %d', this.props.settings['db.ftsEnabled']));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
addSettingText('version_info_hermes', _('Hermes enabled: %d', (global as any).HermesInternal ? 1 : 0));
|
||||
const packageInfo = getPackageInfo();
|
||||
const appInfo = versionInfo(packageInfo, PluginService.instance().enabledPlugins(settings['plugins.states']));
|
||||
const versionInfoText = [
|
||||
appInfo.body,
|
||||
'',
|
||||
_('FTS enabled: %d', this.props.settings['db.ftsEnabled']),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
_('Hermes enabled: %d', (global as any).HermesInternal ? 1 : 0),
|
||||
].join('\n');
|
||||
|
||||
addSettingText('version_info', versionInfoText);
|
||||
addSettingButton('copy_app_info', _('Copy version info'), () => {
|
||||
Clipboard.setString(versionInfoText);
|
||||
});
|
||||
|
||||
const featureFlagKeys = Setting.featureFlagKeys(AppType.Mobile);
|
||||
if (featureFlagKeys.length) {
|
||||
|
@@ -15,6 +15,7 @@ export interface ConfigScreenStyleSheet {
|
||||
settingTextEmphasis: TextStyle;
|
||||
linkText: TextStyle;
|
||||
descriptionText: TextStyle;
|
||||
descriptionAlert: TextStyle;
|
||||
warningText: TextStyle;
|
||||
|
||||
sliderUnits: TextStyle;
|
||||
@@ -119,6 +120,11 @@ const configScreenStyles = (themeId: number): ConfigScreenStyles => {
|
||||
fontSize: theme.fontSizeSmaller,
|
||||
flex: 1,
|
||||
},
|
||||
descriptionAlert: {
|
||||
color: theme.color,
|
||||
fontSize: theme.fontSizeSmaller,
|
||||
flex: 1,
|
||||
},
|
||||
linkText: {
|
||||
...settingTextStyle,
|
||||
borderBottomWidth: 1,
|
||||
|
@@ -0,0 +1,115 @@
|
||||
import { PluginItem } from '@joplin/lib/components/shared/config/plugins/types';
|
||||
import * as React from 'react';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Button, IconButton, List, Portal, Text } from 'react-native-paper';
|
||||
import getPluginIssueReportUrl from '@joplin/lib/services/plugins/utils/getPluginIssueReportUrl';
|
||||
import { Linking, ScrollView, StyleSheet, View } from 'react-native';
|
||||
import DismissibleDialog from '../../../../DismissibleDialog';
|
||||
import openWebsiteForPlugin from '../utils/openWebsiteForPlugin';
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
size: number;
|
||||
item: PluginItem;
|
||||
onModalDismiss?: ()=> void;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
aboutPluginContainer: {
|
||||
paddingLeft: 10,
|
||||
paddingRight: 10,
|
||||
paddingBottom: 10,
|
||||
},
|
||||
descriptionText: {
|
||||
marginTop: 5,
|
||||
marginBottom: 5,
|
||||
},
|
||||
fraudulentPluginButton: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
});
|
||||
|
||||
const PluginInfoModal: React.FC<Props> = props => {
|
||||
const aboutPlugin = (
|
||||
<View style={styles.aboutPluginContainer}>
|
||||
<Text variant='titleLarge'>{props.item.manifest.name}</Text>
|
||||
<Text variant='bodyLarge'>{props.item.manifest.author ? _('by %s', props.item.manifest.author) : ''}</Text>
|
||||
<Text style={styles.descriptionText}>{props.item.manifest.description ?? _('No description')}</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
const onAboutPress = useCallback(() => {
|
||||
void openWebsiteForPlugin({ item: props.item });
|
||||
}, [props.item]);
|
||||
|
||||
const reportIssueUrl = useMemo(() => {
|
||||
return getPluginIssueReportUrl(props.item.manifest);
|
||||
}, [props.item]);
|
||||
|
||||
const onReportIssuePress = useCallback(() => {
|
||||
void Linking.openURL(reportIssueUrl);
|
||||
}, [reportIssueUrl]);
|
||||
|
||||
const reportIssueButton = (
|
||||
<List.Item
|
||||
left={props => <List.Icon {...props} icon='bug'/>}
|
||||
title={_('Report an issue')}
|
||||
onPress={onReportIssuePress}
|
||||
/>
|
||||
);
|
||||
|
||||
const onReportFraudulentPress = useCallback(() => {
|
||||
void Linking.openURL('https://github.com/laurent22/joplin/security/advisories/new');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<DismissibleDialog
|
||||
themeId={props.themeId}
|
||||
visible={true}
|
||||
onDismiss={props.onModalDismiss}
|
||||
>
|
||||
<ScrollView>
|
||||
{aboutPlugin}
|
||||
<List.Item
|
||||
left={props => <List.Icon {...props} icon='web'/>}
|
||||
title={_('About')}
|
||||
onPress={onAboutPress}
|
||||
/>
|
||||
{ reportIssueUrl ? reportIssueButton : null }
|
||||
</ScrollView>
|
||||
<Button
|
||||
icon='shield-bug'
|
||||
style={styles.fraudulentPluginButton}
|
||||
onPress={onReportFraudulentPress}
|
||||
>{_('Report fraudulent plugin')}</Button>
|
||||
</DismissibleDialog>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
||||
const PluginInfoButton: React.FC<Props> = props => {
|
||||
const [showInfoModal, setShowInfoModal] = useState(false);
|
||||
const onInfoButtonPress = useCallback(() => {
|
||||
setShowInfoModal(true);
|
||||
}, []);
|
||||
|
||||
const onModalDismiss = useCallback(() => {
|
||||
setShowInfoModal(false);
|
||||
props.onModalDismiss?.();
|
||||
}, [props.onModalDismiss]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showInfoModal ? <PluginInfoModal {...props} onModalDismiss={onModalDismiss} /> : null}
|
||||
<IconButton
|
||||
size={props.size}
|
||||
icon='information'
|
||||
onPress={onInfoButtonPress}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PluginInfoButton;
|
@@ -6,6 +6,7 @@ import { PluginItem } from '@joplin/lib/components/shared/config/plugins/types';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import PluginService from '@joplin/lib/services/plugins/PluginService';
|
||||
import ActionButton, { PluginCallback } from './ActionButton';
|
||||
import PluginInfoButton from './PluginInfoButton';
|
||||
|
||||
export enum InstallState {
|
||||
NotInstalled,
|
||||
@@ -21,6 +22,7 @@ export enum UpdateState {
|
||||
}
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
item: PluginItem;
|
||||
isCompatible: boolean;
|
||||
|
||||
@@ -28,11 +30,11 @@ interface Props {
|
||||
installState?: InstallState;
|
||||
updateState?: UpdateState;
|
||||
|
||||
onAboutPress?: PluginCallback;
|
||||
onInstall?: PluginCallback;
|
||||
onUpdate?: PluginCallback;
|
||||
onDelete?: PluginCallback;
|
||||
onToggle?: PluginCallback;
|
||||
onAboutPress?: PluginCallback;
|
||||
onShowPluginLog?: PluginCallback;
|
||||
}
|
||||
|
||||
@@ -113,7 +115,7 @@ const PluginBox: React.FC<Props> = props => {
|
||||
);
|
||||
const disableButton = <ActionButton item={item} onPress={props.onToggle} title={_('Disable')}/>;
|
||||
const enableButton = <ActionButton item={item} onPress={props.onToggle} title={_('Enable')}/>;
|
||||
const aboutButton = <ActionButton icon='web' item={item} onPress={props.onAboutPress} title={_('About')}/>;
|
||||
const aboutButton = <ActionButton item={item} onPress={props.onAboutPress} icon='web' title={_('About')}/>;
|
||||
|
||||
const renderErrorsChip = () => {
|
||||
if (!props.hasErrors) return null;
|
||||
@@ -165,6 +167,13 @@ const PluginBox: React.FC<Props> = props => {
|
||||
);
|
||||
};
|
||||
|
||||
const renderRightEdgeButton = (buttonProps: { size: number }) => {
|
||||
// If .onAboutPress is given (e.g. when searching), there's another way to get information
|
||||
// about the plugin. In this case, we don't show the right-side information link.
|
||||
if (props.onAboutPress) return null;
|
||||
return <PluginInfoButton {...buttonProps} themeId={props.themeId} item={props.item}/>;
|
||||
};
|
||||
|
||||
const updateStateIsIdle = props.updateState !== UpdateState.Idle;
|
||||
|
||||
const titleComponent = <>
|
||||
@@ -177,6 +186,7 @@ const PluginBox: React.FC<Props> = props => {
|
||||
titleStyle={styles.title}
|
||||
subtitle={manifest.description}
|
||||
left={PluginIcon}
|
||||
right={renderRightEdgeButton}
|
||||
/>
|
||||
<Card.Content>
|
||||
<View style={{ flexDirection: 'row' }}>
|
||||
|
@@ -24,13 +24,12 @@ const shouldShowBasedOnSettingSearchQuery = ()=>true;
|
||||
const PluginStatesWrapper = (props: WrapperProps) => {
|
||||
const styles = configScreenStyles(Setting.THEME_LIGHT);
|
||||
|
||||
const [pluginStates, setPluginStates] = useState(() => {
|
||||
return PluginService.instance().serializePluginSettings(props.initialPluginSettings ?? {});
|
||||
const [pluginSettings, setPluginSettings] = useState(() => {
|
||||
return props.initialPluginSettings ?? {};
|
||||
});
|
||||
|
||||
const updatePluginStates = useCallback((newStates: PluginSettings) => {
|
||||
const serialized = PluginService.instance().serializePluginSettings(newStates);
|
||||
setPluginStates(serialized);
|
||||
setPluginSettings(newStates);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@@ -38,7 +37,7 @@ const PluginStatesWrapper = (props: WrapperProps) => {
|
||||
themeId={Setting.THEME_LIGHT}
|
||||
styles={styles}
|
||||
updatePluginStates={updatePluginStates}
|
||||
pluginSettings={pluginStates}
|
||||
pluginSettings={pluginSettings}
|
||||
shouldShowBasedOnSearchQuery={shouldShowBasedOnSettingSearchQuery}
|
||||
/>
|
||||
);
|
||||
|
@@ -1,21 +1,23 @@
|
||||
import * as React from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { ConfigScreenStyles } from '../configScreenStyles';
|
||||
import { View } from 'react-native';
|
||||
import { Banner, Button, Text } from 'react-native-paper';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import PluginService, { PluginSettings } from '@joplin/lib/services/plugins/PluginService';
|
||||
import PluginService, { PluginSettings, SerializedPluginSettings } from '@joplin/lib/services/plugins/PluginService';
|
||||
import PluginToggle from './PluginToggle';
|
||||
import SearchPlugins from './SearchPlugins';
|
||||
import { ItemEvent } from '@joplin/lib/components/shared/config/plugins/types';
|
||||
import NavService from '@joplin/lib/services/NavService';
|
||||
import useRepoApi from './utils/useRepoApi';
|
||||
import RepositoryApi from '@joplin/lib/services/plugins/RepositoryApi';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
styles: ConfigScreenStyles;
|
||||
pluginSettings: string;
|
||||
pluginSettings: SerializedPluginSettings;
|
||||
settingsSearchQuery?: string;
|
||||
|
||||
updatePluginStates: (settingValue: PluginSettings)=> void;
|
||||
@@ -35,6 +37,42 @@ export const getSearchText = () => {
|
||||
return searchText;
|
||||
};
|
||||
|
||||
const logger = Logger.create('PluginStates');
|
||||
|
||||
// Loaded plugins: All plugins with available manifests.
|
||||
const useLoadedPluginIds = (pluginSettings: SerializedPluginSettings) => {
|
||||
const allPluginIds = useMemo(() => {
|
||||
return Object.keys(
|
||||
PluginService.instance().unserializePluginSettings(pluginSettings),
|
||||
);
|
||||
}, [pluginSettings]);
|
||||
|
||||
const [pluginReloadCounter, setPluginReloadCounter] = useState(0);
|
||||
const loadedPluginIds = useMemo(() => {
|
||||
if (pluginReloadCounter > 0) {
|
||||
logger.debug(`Not all plugins were loaded in the last render. Re-loading (try ${pluginReloadCounter})`);
|
||||
}
|
||||
|
||||
const pluginService = PluginService.instance();
|
||||
return allPluginIds.filter(id => !!pluginService.plugins[id]);
|
||||
}, [allPluginIds, pluginReloadCounter]);
|
||||
const hasLoadingPlugins = loadedPluginIds.length !== allPluginIds.length;
|
||||
|
||||
// Force a re-render if not all plugins have available metadata. This can happen
|
||||
// if plugins are still loading.
|
||||
const pluginReloadCounterRef = useRef(0);
|
||||
pluginReloadCounterRef.current = pluginReloadCounter;
|
||||
const timeoutRef = useRef(null);
|
||||
if (hasLoadingPlugins && !timeoutRef.current) {
|
||||
timeoutRef.current = shim.setTimeout(() => {
|
||||
timeoutRef.current = null;
|
||||
setPluginReloadCounter(pluginReloadCounterRef.current + 1);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
return loadedPluginIds;
|
||||
};
|
||||
|
||||
const PluginStates: React.FC<Props> = props => {
|
||||
const [repoApiError, setRepoApiError] = useState(null);
|
||||
const [repoApiLoaded, setRepoApiLoaded] = useState(false);
|
||||
@@ -91,14 +129,17 @@ const PluginStates: React.FC<Props> = props => {
|
||||
|
||||
const installedPluginCards = [];
|
||||
const pluginService = PluginService.instance();
|
||||
for (const key in pluginService.plugins) {
|
||||
const plugin = pluginService.plugins[key];
|
||||
|
||||
const pluginIds = useLoadedPluginIds(props.pluginSettings);
|
||||
for (const pluginId of pluginIds) {
|
||||
const plugin = pluginService.plugins[pluginId];
|
||||
|
||||
if (!props.shouldShowBasedOnSearchQuery || props.shouldShowBasedOnSearchQuery(plugin.manifest.name)) {
|
||||
installedPluginCards.push(
|
||||
<PluginToggle
|
||||
key={`plugin-${key}`}
|
||||
pluginId={plugin.id}
|
||||
key={`plugin-${pluginId}`}
|
||||
themeId={props.themeId}
|
||||
pluginId={pluginId}
|
||||
styles={props.styles}
|
||||
pluginSettings={props.pluginSettings}
|
||||
updatablePluginIds={updatablePluginIds}
|
||||
|
@@ -1,7 +1,7 @@
|
||||
|
||||
import * as React from 'react';
|
||||
import { ConfigScreenStyles } from '../configScreenStyles';
|
||||
import PluginService, { PluginSettings, defaultPluginSetting } from '@joplin/lib/services/plugins/PluginService';
|
||||
import PluginService, { PluginSettings, defaultPluginSetting, SerializedPluginSettings } from '@joplin/lib/services/plugins/PluginService';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import PluginBox, { UpdateState } from './PluginBox';
|
||||
import useOnDeleteHandler from '@joplin/lib/components/shared/config/plugins/useOnDeleteHandler';
|
||||
@@ -11,8 +11,9 @@ import RepositoryApi from '@joplin/lib/services/plugins/RepositoryApi';
|
||||
|
||||
interface Props {
|
||||
pluginId: string;
|
||||
themeId: number;
|
||||
styles: ConfigScreenStyles;
|
||||
pluginSettings: string;
|
||||
pluginSettings: SerializedPluginSettings;
|
||||
updatablePluginIds: Record<string, boolean>;
|
||||
repoApi: RepositoryApi;
|
||||
|
||||
@@ -91,6 +92,7 @@ const PluginToggle: React.FC<Props> = props => {
|
||||
|
||||
return (
|
||||
<PluginBox
|
||||
themeId={props.themeId}
|
||||
item={pluginItem}
|
||||
isCompatible={isCompatible}
|
||||
hasErrors={plugin.hasErrors}
|
||||
|
@@ -1,6 +1,6 @@
|
||||
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import PluginService, { PluginSettings, defaultPluginSetting } from '@joplin/lib/services/plugins/PluginService';
|
||||
import PluginService, { PluginSettings, SerializedPluginSettings, defaultPluginSetting } from '@joplin/lib/services/plugins/PluginService';
|
||||
import * as React from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Button } from 'react-native-paper';
|
||||
@@ -14,7 +14,7 @@ import Setting from '@joplin/lib/models/Setting';
|
||||
|
||||
interface Props {
|
||||
updatePluginStates: (settingValue: PluginSettings)=> void;
|
||||
pluginSettings: string;
|
||||
pluginSettings: SerializedPluginSettings;
|
||||
}
|
||||
|
||||
const logger = Logger.create('PluginUploadButton');
|
||||
|
@@ -7,8 +7,7 @@ import '@testing-library/react-native/extend-expect';
|
||||
|
||||
import SearchPlugins from './SearchPlugins';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import PluginService, { PluginSettings } from '@joplin/lib/services/plugins/PluginService';
|
||||
import { useMemo } from 'react';
|
||||
import { PluginSettings } from '@joplin/lib/services/plugins/PluginService';
|
||||
import pluginServiceSetup from './testUtils/pluginServiceSetup';
|
||||
import newRepoApi from './testUtils/newRepoApi';
|
||||
|
||||
@@ -22,14 +21,10 @@ interface WrapperProps {
|
||||
const noOpFunction = ()=>{};
|
||||
|
||||
const SearchWrapper = (props: WrapperProps) => {
|
||||
const serializedPluginSettings = useMemo(() => {
|
||||
return PluginService.instance().serializePluginSettings(props.pluginSettings ?? {});
|
||||
}, [props.pluginSettings]);
|
||||
|
||||
return (
|
||||
<SearchPlugins
|
||||
themeId={Setting.THEME_LIGHT}
|
||||
pluginSettings={serializedPluginSettings}
|
||||
pluginSettings={props.pluginSettings ?? {}}
|
||||
repoApiInitialized={props.repoApiInitialized ?? true}
|
||||
repoApi={props.repoApi}
|
||||
onUpdatePluginStates={props.onUpdatePluginStates ?? noOpFunction}
|
||||
|
@@ -7,15 +7,15 @@ import { useCallback, useMemo, useState } from 'react';
|
||||
import { FlatList, View } from 'react-native';
|
||||
import { Searchbar } from 'react-native-paper';
|
||||
import PluginBox, { InstallState } from './PluginBox';
|
||||
import PluginService, { PluginSettings } from '@joplin/lib/services/plugins/PluginService';
|
||||
import PluginService, { PluginSettings, SerializedPluginSettings } from '@joplin/lib/services/plugins/PluginService';
|
||||
import useInstallHandler from '@joplin/lib/components/shared/config/plugins/useOnInstallHandler';
|
||||
import { OnPluginSettingChangeEvent, PluginItem } from '@joplin/lib/components/shared/config/plugins/types';
|
||||
import onOpenWebsiteForPluginPress from './utils/openWebsiteForPlugin';
|
||||
import RepositoryApi from '@joplin/lib/services/plugins/RepositoryApi';
|
||||
import openWebsiteForPlugin from './utils/openWebsiteForPlugin';
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
pluginSettings: string;
|
||||
pluginSettings: SerializedPluginSettings;
|
||||
repoApiInitialized: boolean;
|
||||
onUpdatePluginStates: (states: PluginSettings)=> void;
|
||||
repoApi: RepositoryApi;
|
||||
@@ -90,15 +90,16 @@ const PluginSearch: React.FC<Props> = props => {
|
||||
|
||||
return (
|
||||
<PluginBox
|
||||
themeId={props.themeId}
|
||||
key={manifest.id}
|
||||
item={item.item}
|
||||
installState={item.installState}
|
||||
isCompatible={PluginService.instance().isCompatible(manifest)}
|
||||
onInstall={installPlugin}
|
||||
onAboutPress={onOpenWebsiteForPluginPress}
|
||||
onAboutPress={openWebsiteForPlugin}
|
||||
/>
|
||||
);
|
||||
}, [installPlugin]);
|
||||
}, [installPlugin, props.themeId]);
|
||||
|
||||
return (
|
||||
<View style={{ flexDirection: 'column' }}>
|
||||
|
@@ -8,7 +8,7 @@ import Synchronizer from '@joplin/lib/Synchronizer';
|
||||
import NavService from '@joplin/lib/services/NavService';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { ThemeStyle, themeStyle } from './global-style';
|
||||
import { renderFolders } from '@joplin/lib/components/shared/side-menu-shared';
|
||||
import { isFolderSelected, renderFolders } from '@joplin/lib/components/shared/side-menu-shared';
|
||||
import { FolderEntity, FolderIcon, FolderIconType } from '@joplin/lib/services/database/types';
|
||||
import { AppState } from '../utils/types';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
@@ -93,6 +93,8 @@ const SideMenuContentComponent = (props: Props) => {
|
||||
fontSize: 22,
|
||||
color: theme.color,
|
||||
width: 26,
|
||||
textAlign: 'center',
|
||||
textAlignVertical: 'center',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -391,7 +393,7 @@ const SideMenuContentComponent = (props: Props) => {
|
||||
}
|
||||
};
|
||||
|
||||
const renderFolderItem = (folder: FolderEntity, selected: boolean, hasChildren: boolean, depth: number) => {
|
||||
const renderFolderItem = (folder: FolderEntity, hasChildren: boolean, depth: number) => {
|
||||
const theme = themeStyle(props.themeId);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
@@ -403,6 +405,7 @@ const SideMenuContentComponent = (props: Props) => {
|
||||
paddingRight: theme.marginRight,
|
||||
paddingLeft: 10,
|
||||
};
|
||||
const selected = isFolderSelected(folder, { selectedFolderId: props.selectedFolderId, notesParentType: props.notesParentType });
|
||||
if (selected) folderButtonStyle.backgroundColor = theme.selectedColor;
|
||||
folderButtonStyle.paddingLeft = depth * 10 + theme.marginLeft;
|
||||
|
||||
|
@@ -523,13 +523,13 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 113;
|
||||
CURRENT_PROJECT_VERSION = 114;
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Joplin/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 13.0.0;
|
||||
MARKETING_VERSION = 13.0.1;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
@@ -552,12 +552,12 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 113;
|
||||
CURRENT_PROJECT_VERSION = 114;
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
INFOPLIST_FILE = Joplin/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 13.0.0;
|
||||
MARKETING_VERSION = 13.0.1;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
@@ -704,14 +704,14 @@
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 113;
|
||||
CURRENT_PROJECT_VERSION = 114;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
INFOPLIST_FILE = ShareExtension/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
||||
MARKETING_VERSION = 13.0.0;
|
||||
MARKETING_VERSION = 13.0.1;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = net.cozic.joplin.ShareExtension;
|
||||
@@ -735,14 +735,14 @@
|
||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 113;
|
||||
CURRENT_PROJECT_VERSION = 114;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
INFOPLIST_FILE = ShareExtension/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
||||
MARKETING_VERSION = 13.0.0;
|
||||
MARKETING_VERSION = 13.0.1;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = net.cozic.joplin.ShareExtension;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
@@ -333,7 +333,7 @@ PODS:
|
||||
- React-jsinspector (0.71.10)
|
||||
- React-logger (0.71.10):
|
||||
- glog
|
||||
- react-native-alarm-notification (2.14.0):
|
||||
- react-native-alarm-notification (3.0.0):
|
||||
- React
|
||||
- react-native-camera (4.2.1):
|
||||
- React-Core
|
||||
@@ -360,7 +360,7 @@ PODS:
|
||||
- React-Core
|
||||
- react-native-rsa-native (2.0.5):
|
||||
- React
|
||||
- react-native-saf-x (2.14.0):
|
||||
- react-native-saf-x (3.0.0):
|
||||
- React-Core
|
||||
- react-native-safe-area-context (4.8.2):
|
||||
- React-Core
|
||||
@@ -780,7 +780,7 @@ SPEC CHECKSUMS:
|
||||
React-jsiexecutor: 4bb480a183a354e4dbfb1012936b1a2bb9357de7
|
||||
React-jsinspector: cdc854f8b13abd202afa54bc12578e5afb9cfae1
|
||||
React-logger: ef2269b3afa6ba868da90496c3e17a4ec4f4cee0
|
||||
react-native-alarm-notification: f27565d7b14c206fd1b0caac4aee82d2e7939b7d
|
||||
react-native-alarm-notification: efb7227e85a0ae1ccd615fb81ce92510aaf740a9
|
||||
react-native-camera: 3eae183c1d111103963f3dd913b65d01aef8110f
|
||||
react-native-document-picker: b4f4a23b73f864ce17965b284c0757648993805b
|
||||
react-native-fingerprint-scanner: ac6656f18c8e45a7459302b84da41a44ad96dbbe
|
||||
@@ -790,7 +790,7 @@ SPEC CHECKSUMS:
|
||||
react-native-image-resizer: 681f7607418b97c084ba2d0999b153b103040d8a
|
||||
react-native-netinfo: 8a7fd3f7130ef4ad2fb4276d5c9f8d3f28d2df3d
|
||||
react-native-rsa-native: 12132eb627797529fdb1f0d22fd0f8f9678df64a
|
||||
react-native-saf-x: e0d30813901e54dad462dacda96555f725c2284f
|
||||
react-native-saf-x: 672d22e9912d34e6a2c0ebcf7da2af67b2c177dc
|
||||
react-native-safe-area-context: 0ee144a6170530ccc37a0fd9388e28d06f516a89
|
||||
react-native-slider: ae9441a884f8465762f1f68d31eafad42663adb4
|
||||
react-native-sqlite-storage: f6d515e1c446d1e6d026aa5352908a25d4de3261
|
||||
@@ -830,4 +830,4 @@ SPEC CHECKSUMS:
|
||||
|
||||
PODFILE CHECKSUM: 3b2cace838120977b5b54871752c9dddf5a11cea
|
||||
|
||||
COCOAPODS: 1.12.1
|
||||
COCOAPODS: 1.15.2
|
||||
|
@@ -3,7 +3,7 @@ import { Implementation as WindowImplementation } from '@joplin/lib/services/plu
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { reg } from '@joplin/lib/registry';
|
||||
import BasePlatformImplementation, { Joplin } from '@joplin/lib/services/plugins/BasePlatformImplementation';
|
||||
import { Implementation as ImagingImplementation } from '@joplin/lib/services/plugins/api/JoplinImaging';
|
||||
import { CreateFromPdfOptions, Implementation as ImagingImplementation } from '@joplin/lib/services/plugins/api/JoplinImaging';
|
||||
import RNVersionInfo from 'react-native-version-info';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import shim from '@joplin/lib/shim';
|
||||
@@ -76,7 +76,12 @@ export default class PlatformImplementation extends BasePlatformImplementation {
|
||||
|
||||
public get imaging(): ImagingImplementation {
|
||||
return {
|
||||
nativeImage: null,
|
||||
createFromPath: async (_path: string) => {
|
||||
throw new Error('Not implemented: createFromPath');
|
||||
},
|
||||
createFromPdf: (_path: string, _options: CreateFromPdfOptions) => {
|
||||
throw new Error('Not implemented: createFromPdf');
|
||||
},
|
||||
getPdfInfo: async () => {
|
||||
throw new Error('Not implemented: getPdfInfo');
|
||||
},
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user