You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-12-29 23:48:19 +02:00
Compare commits
1 Commits
v3.5.7
...
join_serve
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c44aad544e |
@@ -6,7 +6,6 @@ _releases/
|
||||
*.min.js
|
||||
**/commands/index.ts
|
||||
**/node_modules/
|
||||
**/abcjs-basic-min.js
|
||||
packages/generator-joplin/generators/app/templates/api/
|
||||
Assets/
|
||||
docs/
|
||||
@@ -97,7 +96,7 @@ packages/onenote-converter/renderer/pkg/*
|
||||
packages/app-cli/app/LinkSelector.js
|
||||
packages/app-cli/app/app.js
|
||||
packages/app-cli/app/base-command.js
|
||||
packages/app-cli/app/cli-integration-tests.test.js
|
||||
packages/app-cli/app/cli-integration-tests.js
|
||||
packages/app-cli/app/command-apidoc.js
|
||||
packages/app-cli/app/command-attach.js
|
||||
packages/app-cli/app/command-batch.js
|
||||
@@ -425,11 +424,10 @@ 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/useOnItemClick.js
|
||||
packages/app-desktop/gui/Sidebar/hooks/useOnRenderItem.js
|
||||
packages/app-desktop/gui/Sidebar/hooks/useOnRenderListWrapper.js
|
||||
packages/app-desktop/gui/Sidebar/hooks/useOnSidebarKeyDownHandler.js
|
||||
packages/app-desktop/gui/Sidebar/hooks/useSelectedSidebarIndexes.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/hooks/utils/toggleHeader.js
|
||||
@@ -1146,7 +1144,6 @@ packages/editor/ProseMirror/utils/dom/showModal.js
|
||||
packages/editor/ProseMirror/utils/extractSelectedLinesTo.test.js
|
||||
packages/editor/ProseMirror/utils/extractSelectedLinesTo.js
|
||||
packages/editor/ProseMirror/utils/forEachHeading.js
|
||||
packages/editor/ProseMirror/utils/insertRenderedMarkdown.js
|
||||
packages/editor/ProseMirror/utils/jumpToHash.js
|
||||
packages/editor/ProseMirror/utils/makeLinksClickableInElement.js
|
||||
packages/editor/ProseMirror/utils/postprocessEditorOutput.test.js
|
||||
@@ -1364,7 +1361,6 @@ packages/lib/models/utils/getCanBeCollapsedFolderIds.js
|
||||
packages/lib/models/utils/getCollator.js
|
||||
packages/lib/models/utils/getConflictFolderId.js
|
||||
packages/lib/models/utils/isItemId.js
|
||||
packages/lib/models/utils/isJoplinServerVariant.js
|
||||
packages/lib/models/utils/itemCanBeEncrypted.js
|
||||
packages/lib/models/utils/onFolderDrop.test.js
|
||||
packages/lib/models/utils/onFolderDrop.js
|
||||
@@ -1400,7 +1396,6 @@ packages/lib/services/KeymapService_keysRegExp.js
|
||||
packages/lib/services/KvStore.js
|
||||
packages/lib/services/MigrationService.js
|
||||
packages/lib/services/NavService.js
|
||||
packages/lib/services/NotePositionService.js
|
||||
packages/lib/services/PostMessageService.js
|
||||
packages/lib/services/ReportService.test.js
|
||||
packages/lib/services/ReportService.js
|
||||
@@ -1416,7 +1411,6 @@ packages/lib/services/UndoRedoService.js
|
||||
packages/lib/services/WhenClause.test.js
|
||||
packages/lib/services/WhenClause.js
|
||||
packages/lib/services/commands/MenuUtils.js
|
||||
packages/lib/services/commands/ToolbarButtonUtils.test.js
|
||||
packages/lib/services/commands/ToolbarButtonUtils.js
|
||||
packages/lib/services/commands/commandsToMarkdownTable.js
|
||||
packages/lib/services/commands/focusEditorIfEditorCommand.js
|
||||
@@ -1785,7 +1779,6 @@ packages/renderer/MdToHtml/createEventHandlingAttrs.js
|
||||
packages/renderer/MdToHtml/linkReplacement.test.js
|
||||
packages/renderer/MdToHtml/linkReplacement.js
|
||||
packages/renderer/MdToHtml/renderMedia.js
|
||||
packages/renderer/MdToHtml/rules/abc.js
|
||||
packages/renderer/MdToHtml/rules/checkbox.js
|
||||
packages/renderer/MdToHtml/rules/code_inline.js
|
||||
packages/renderer/MdToHtml/rules/fence.js
|
||||
@@ -1828,18 +1821,14 @@ packages/tools/fuzzer/Client.js
|
||||
packages/tools/fuzzer/ClientPool.js
|
||||
packages/tools/fuzzer/Server.js
|
||||
packages/tools/fuzzer/constants.js
|
||||
packages/tools/fuzzer/doRandomAction.js
|
||||
packages/tools/fuzzer/model/FolderRecord.js
|
||||
packages/tools/fuzzer/sync-fuzzer.js
|
||||
packages/tools/fuzzer/types.js
|
||||
packages/tools/fuzzer/utils/ProgressBar.js
|
||||
packages/tools/fuzzer/utils/SeededRandom.js
|
||||
packages/tools/fuzzer/utils/getNumberProperty.js
|
||||
packages/tools/fuzzer/utils/getProperty.js
|
||||
packages/tools/fuzzer/utils/getStringProperty.js
|
||||
packages/tools/fuzzer/utils/logDiffDebug.js
|
||||
packages/tools/fuzzer/utils/openDebugSession.js
|
||||
packages/tools/fuzzer/utils/randomString.js
|
||||
packages/tools/fuzzer/utils/retryWithCount.js
|
||||
packages/tools/generate-database-types.js
|
||||
packages/tools/generate-images.js
|
||||
|
||||
13
.github/workflows/build-android.yml
vendored
13
.github/workflows/build-android.yml
vendored
@@ -21,24 +21,19 @@ jobs:
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
node-version: '18'
|
||||
cache: 'yarn'
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install Yarn
|
||||
run: |
|
||||
corepack enable
|
||||
|
||||
- name: Install
|
||||
run: yarn install
|
||||
env:
|
||||
SKIP_ONENOTE_CONVERTER_BUILD: 1
|
||||
|
||||
- name: Free disk space
|
||||
run: |
|
||||
sudo rm -rf /usr/share/dotnet || true
|
||||
sudo rm -rf /opt/ghc || true
|
||||
|
||||
- name: Assemble Android Release
|
||||
run: |
|
||||
|
||||
6
.github/workflows/build-macos-m1.yml
vendored
6
.github/workflows/build-macos-m1.yml
vendored
@@ -9,9 +9,11 @@ jobs:
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
- uses: olegtarasov/get-tag@v2.1.4
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
# We need to pin the version to 18.15, because 18.16+ fails with this error:
|
||||
# https://github.com/facebook/react-native/issues/36440
|
||||
node-version: '18.20.8'
|
||||
cache: 'yarn'
|
||||
|
||||
- name: Install Yarn
|
||||
|
||||
4
.github/workflows/github-actions-main.yml
vendored
4
.github/workflows/github-actions-main.yml
vendored
@@ -147,9 +147,9 @@ jobs:
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
node-version: '18'
|
||||
|
||||
- name: Free disk space
|
||||
if: runner.os == 'Linux'
|
||||
|
||||
@@ -51,9 +51,9 @@ runs:
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
if: ${{ runner.os != 'Windows' }}
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
node-version: '18.20.8'
|
||||
# Disable the cache on ARM runners. For now, we don't run "yarn install" on these
|
||||
# environments and this breaks actions/setup-node.
|
||||
# See https://github.com/laurent22/joplin/commit/47d0d3eb9e89153a609fb5441344da10904c6308#commitcomment-159577783.
|
||||
|
||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -69,7 +69,7 @@ docs/**/*.mustache
|
||||
packages/app-cli/app/LinkSelector.js
|
||||
packages/app-cli/app/app.js
|
||||
packages/app-cli/app/base-command.js
|
||||
packages/app-cli/app/cli-integration-tests.test.js
|
||||
packages/app-cli/app/cli-integration-tests.js
|
||||
packages/app-cli/app/command-apidoc.js
|
||||
packages/app-cli/app/command-attach.js
|
||||
packages/app-cli/app/command-batch.js
|
||||
@@ -397,11 +397,10 @@ 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/useOnItemClick.js
|
||||
packages/app-desktop/gui/Sidebar/hooks/useOnRenderItem.js
|
||||
packages/app-desktop/gui/Sidebar/hooks/useOnRenderListWrapper.js
|
||||
packages/app-desktop/gui/Sidebar/hooks/useOnSidebarKeyDownHandler.js
|
||||
packages/app-desktop/gui/Sidebar/hooks/useSelectedSidebarIndexes.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/hooks/utils/toggleHeader.js
|
||||
@@ -1118,7 +1117,6 @@ packages/editor/ProseMirror/utils/dom/showModal.js
|
||||
packages/editor/ProseMirror/utils/extractSelectedLinesTo.test.js
|
||||
packages/editor/ProseMirror/utils/extractSelectedLinesTo.js
|
||||
packages/editor/ProseMirror/utils/forEachHeading.js
|
||||
packages/editor/ProseMirror/utils/insertRenderedMarkdown.js
|
||||
packages/editor/ProseMirror/utils/jumpToHash.js
|
||||
packages/editor/ProseMirror/utils/makeLinksClickableInElement.js
|
||||
packages/editor/ProseMirror/utils/postprocessEditorOutput.test.js
|
||||
@@ -1336,7 +1334,6 @@ packages/lib/models/utils/getCanBeCollapsedFolderIds.js
|
||||
packages/lib/models/utils/getCollator.js
|
||||
packages/lib/models/utils/getConflictFolderId.js
|
||||
packages/lib/models/utils/isItemId.js
|
||||
packages/lib/models/utils/isJoplinServerVariant.js
|
||||
packages/lib/models/utils/itemCanBeEncrypted.js
|
||||
packages/lib/models/utils/onFolderDrop.test.js
|
||||
packages/lib/models/utils/onFolderDrop.js
|
||||
@@ -1372,7 +1369,6 @@ packages/lib/services/KeymapService_keysRegExp.js
|
||||
packages/lib/services/KvStore.js
|
||||
packages/lib/services/MigrationService.js
|
||||
packages/lib/services/NavService.js
|
||||
packages/lib/services/NotePositionService.js
|
||||
packages/lib/services/PostMessageService.js
|
||||
packages/lib/services/ReportService.test.js
|
||||
packages/lib/services/ReportService.js
|
||||
@@ -1388,7 +1384,6 @@ packages/lib/services/UndoRedoService.js
|
||||
packages/lib/services/WhenClause.test.js
|
||||
packages/lib/services/WhenClause.js
|
||||
packages/lib/services/commands/MenuUtils.js
|
||||
packages/lib/services/commands/ToolbarButtonUtils.test.js
|
||||
packages/lib/services/commands/ToolbarButtonUtils.js
|
||||
packages/lib/services/commands/commandsToMarkdownTable.js
|
||||
packages/lib/services/commands/focusEditorIfEditorCommand.js
|
||||
@@ -1757,7 +1752,6 @@ packages/renderer/MdToHtml/createEventHandlingAttrs.js
|
||||
packages/renderer/MdToHtml/linkReplacement.test.js
|
||||
packages/renderer/MdToHtml/linkReplacement.js
|
||||
packages/renderer/MdToHtml/renderMedia.js
|
||||
packages/renderer/MdToHtml/rules/abc.js
|
||||
packages/renderer/MdToHtml/rules/checkbox.js
|
||||
packages/renderer/MdToHtml/rules/code_inline.js
|
||||
packages/renderer/MdToHtml/rules/fence.js
|
||||
@@ -1800,18 +1794,14 @@ packages/tools/fuzzer/Client.js
|
||||
packages/tools/fuzzer/ClientPool.js
|
||||
packages/tools/fuzzer/Server.js
|
||||
packages/tools/fuzzer/constants.js
|
||||
packages/tools/fuzzer/doRandomAction.js
|
||||
packages/tools/fuzzer/model/FolderRecord.js
|
||||
packages/tools/fuzzer/sync-fuzzer.js
|
||||
packages/tools/fuzzer/types.js
|
||||
packages/tools/fuzzer/utils/ProgressBar.js
|
||||
packages/tools/fuzzer/utils/SeededRandom.js
|
||||
packages/tools/fuzzer/utils/getNumberProperty.js
|
||||
packages/tools/fuzzer/utils/getProperty.js
|
||||
packages/tools/fuzzer/utils/getStringProperty.js
|
||||
packages/tools/fuzzer/utils/logDiffDebug.js
|
||||
packages/tools/fuzzer/utils/openDebugSession.js
|
||||
packages/tools/fuzzer/utils/randomString.js
|
||||
packages/tools/fuzzer/utils/retryWithCount.js
|
||||
packages/tools/generate-database-types.js
|
||||
packages/tools/generate-images.js
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 24 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 52 KiB |
@@ -2,7 +2,7 @@
|
||||
# Build stage
|
||||
# =============================================================================
|
||||
|
||||
FROM node:24 AS builder
|
||||
FROM node:18 AS builder
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y \
|
||||
@@ -58,7 +58,7 @@ RUN --mount=type=cache,target=/build/.yarn/cache --mount=type=cache,target=/buil
|
||||
# from a smaller base image.
|
||||
# =============================================================================
|
||||
|
||||
FROM node:24-slim
|
||||
FROM node:18-slim
|
||||
|
||||
ARG user=joplin
|
||||
RUN useradd --create-home --shell /bin/bash $user
|
||||
|
||||
@@ -67,45 +67,6 @@ showHelp() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Accepts two versions in symver (a.b.c).
|
||||
# Echos -1 if the first version is less than the second,
|
||||
# 0 if they're equal,
|
||||
# 1 if the first version is greater than second.
|
||||
compareVersions() {
|
||||
V_MAJOR1=$(echo "$1"|cut -d. -f1)
|
||||
V_MAJOR2=$(echo "$2"|cut -d. -f1)
|
||||
|
||||
if [[ $V_MAJOR1 -lt $V_MAJOR2 ]] ; then
|
||||
echo -1
|
||||
return
|
||||
elif [[ $V_MAJOR1 -gt $V_MAJOR2 ]] ; then
|
||||
echo 1
|
||||
return
|
||||
fi
|
||||
|
||||
V_MINOR1=$(echo "$1"|cut -d. -f2)
|
||||
V_MINOR2=$(echo "$2"|cut -d. -f2)
|
||||
|
||||
if [[ $V_MINOR1 -lt $V_MINOR2 ]] ; then
|
||||
echo -1
|
||||
return
|
||||
elif [[ $V_MINOR1 -gt $V_MINOR2 ]] ; then
|
||||
echo 1
|
||||
return
|
||||
fi
|
||||
|
||||
V_PATCH1=$(echo "$1"|cut -d. -f3)
|
||||
V_PATCH2=$(echo "$2"|cut -d. -f3)
|
||||
|
||||
if [[ $V_PATCH1 -lt $V_PATCH2 ]] ; then
|
||||
echo -1
|
||||
elif [[ $V_PATCH1 -gt $V_PATCH2 ]] ; then
|
||||
echo 1
|
||||
else
|
||||
echo 0
|
||||
fi
|
||||
}
|
||||
|
||||
#-----------------------------------------------------
|
||||
# Setup Download Helper: DL
|
||||
#-----------------------------------------------------
|
||||
@@ -297,15 +258,6 @@ fi
|
||||
if [[ $DESKTOP =~ .*gnome.*|.*kde.*|.*xfce.*|.*mate.*|.*lxqt.*|.*unity.*|.*x-cinnamon.*|.*deepin.*|.*pantheon.*|.*lxde.*|.*i3.*|.*sway.* ]] || [[ `command -v update-desktop-database` ]]; then
|
||||
DATA_HOME=${XDG_DATA_HOME:-~/.local/share}
|
||||
DESKTOP_FILE_LOCATION="$DATA_HOME/applications"
|
||||
|
||||
# Only later versions of Joplin default to Wayland
|
||||
IS_WAYLAND_BY_DEFAULT=$(compareVersions "$RELEASE_VERSION" "3.5.6")
|
||||
# Joplin has a different startup WM class on Wayland and X11:
|
||||
STARTUP_WM_CLASS=Joplin
|
||||
if [[ $XDG_SESSION_TYPE != "x11" && $IS_WAYLAND_BY_DEFAULT == "1" ]]; then
|
||||
STARTUP_WM_CLASS=@joplin/app-desktop
|
||||
fi
|
||||
|
||||
# Only delete the desktop file if it will be replaced
|
||||
rm -f "$DESKTOP_FILE_LOCATION/appimagekit-joplin.desktop"
|
||||
|
||||
@@ -320,9 +272,7 @@ Name=Joplin
|
||||
Comment=Joplin for Desktop
|
||||
Exec=env APPIMAGELAUNCHER_DISABLE=TRUE "${INSTALL_DIR}/Joplin.AppImage" ${SANDBOXPARAM} %u
|
||||
Icon=joplin
|
||||
# This will be different between Wayland and X11. On Wayland, the startup
|
||||
# WM class is "@joplin/app-desktop". On X11, it's "Joplin".
|
||||
StartupWMClass=${STARTUP_WM_CLASS}
|
||||
StartupWMClass=Joplin
|
||||
Type=Application
|
||||
Categories=Office;
|
||||
MimeType=x-scheme-handler/joplin;
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"version": "latest",
|
||||
"excluded_platforms": ["aarch64-darwin", "x86_64-darwin"],
|
||||
},
|
||||
"git": "2.49.0",
|
||||
"git": "2.48.1",
|
||||
},
|
||||
"shell": {
|
||||
"init_hook": [
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
services:
|
||||
|
||||
postgresql-master:
|
||||
image: 'bitnamilegacy/postgresql:17.5.0'
|
||||
image: 'bitnamilegacy/postgresql:17.4.0'
|
||||
ports:
|
||||
- '5432:5432'
|
||||
environment:
|
||||
@@ -36,7 +36,7 @@ services:
|
||||
- POSTGRESQL_EXTRA_FLAGS=-c work_mem=100000 -c log_statement=all
|
||||
|
||||
postgresql-slave:
|
||||
image: 'bitnamilegacy/postgresql:17.5.0'
|
||||
image: 'bitnamilegacy/postgresql:17.4.0'
|
||||
ports:
|
||||
- '5433:5432'
|
||||
depends_on:
|
||||
|
||||
@@ -1,270 +0,0 @@
|
||||
import * as fs from 'fs-extra';
|
||||
import Logger, { TargetType } from '@joplin/utils/Logger';
|
||||
import { dirname } from '@joplin/lib/path-utils';
|
||||
const { DatabaseDriverNode } = require('@joplin/lib/database-driver-node.js');
|
||||
import JoplinDatabase from '@joplin/lib/JoplinDatabase';
|
||||
import BaseModel from '@joplin/lib/BaseModel';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { node } from 'execa';
|
||||
import { splitCommandString } from '@joplin/utils';
|
||||
const nodeSqlite = require('sqlite3');
|
||||
const { loadKeychainServiceAndSettings } = require('@joplin/lib/services/SettingUtils');
|
||||
const { default: shimInitCli } = require('./utils/shimInitCli');
|
||||
|
||||
const baseDir = `${dirname(__dirname)}/tests/cli-integration`;
|
||||
const joplinAppPath = `${__dirname}/main.js`;
|
||||
|
||||
shimInitCli({ nodeSqlite, appVersion: () => require('../package.json').version, keytar: null });
|
||||
require('@joplin/lib/testing/test-utils');
|
||||
|
||||
|
||||
interface Client {
|
||||
id: number;
|
||||
profileDir: string;
|
||||
}
|
||||
|
||||
function createClient(id: number): Client {
|
||||
return {
|
||||
id: id,
|
||||
profileDir: `${baseDir}/client${id}`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
async function execCommand(client: Client, command: string) {
|
||||
const result = await node(
|
||||
joplinAppPath,
|
||||
['--update-geolocation-disabled', '--env', 'dev', '--profile', client.profileDir, ...splitCommandString(command)],
|
||||
);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(`Command failed: ${command}:\nstderr: ${result.stderr}\nstdout: ${result.stdout}`);
|
||||
}
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
async function clearDatabase(db: JoplinDatabase) {
|
||||
await db.transactionExecBatch(['DELETE FROM folders', 'DELETE FROM notes', 'DELETE FROM tags', 'DELETE FROM note_tags', 'DELETE FROM resources', 'DELETE FROM deleted_items']);
|
||||
}
|
||||
|
||||
|
||||
describe('cli-integration-tests', () => {
|
||||
let client: Client;
|
||||
let db: JoplinDatabase;
|
||||
|
||||
beforeAll(async () => {
|
||||
await fs.remove(baseDir);
|
||||
await fs.mkdir(baseDir);
|
||||
|
||||
client = createClient(1);
|
||||
// Initialize the database by running a client command and exiting.
|
||||
await execCommand(client, 'version');
|
||||
|
||||
const dbLogger = new Logger();
|
||||
dbLogger.addTarget(TargetType.Console);
|
||||
dbLogger.setLevel(Logger.LEVEL_WARN);
|
||||
|
||||
db = new JoplinDatabase(new DatabaseDriverNode());
|
||||
db.setLogger(dbLogger);
|
||||
|
||||
await db.open({ name: `${client.profileDir}/database.sqlite` });
|
||||
BaseModel.setDb(db);
|
||||
Setting.setConstant('rootProfileDir', client.profileDir);
|
||||
Setting.setConstant('profileDir', client.profileDir);
|
||||
await loadKeychainServiceAndSettings([]);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await clearDatabase(db);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'version',
|
||||
'help',
|
||||
])('should run command %j without crashing', async (command) => {
|
||||
await execCommand(client, command);
|
||||
});
|
||||
|
||||
it('should support the \'ls\' command', async () => {
|
||||
await execCommand(client, 'mkbook nb1');
|
||||
await execCommand(client, 'mknote note1');
|
||||
await execCommand(client, 'mknote note2');
|
||||
const r = await execCommand(client, 'ls');
|
||||
|
||||
expect(r.indexOf('note1') >= 0).toBe(true);
|
||||
expect(r.indexOf('note2') >= 0).toBe(true);
|
||||
});
|
||||
|
||||
it('should support the \'mv\' command', async () => {
|
||||
await execCommand(client, 'mkbook nb2');
|
||||
await execCommand(client, 'mkbook nb1');
|
||||
await execCommand(client, 'mknote n1');
|
||||
await execCommand(client, 'mv n1 nb2');
|
||||
|
||||
const f1 = await Folder.loadByTitle('nb1');
|
||||
const f2 = await Folder.loadByTitle('nb2');
|
||||
let notes1 = await Note.previews(f1.id);
|
||||
let notes2 = await Note.previews(f2.id);
|
||||
|
||||
expect(notes1.length).toBe(0);
|
||||
expect(notes2.length).toBe(1);
|
||||
|
||||
await execCommand(client, 'mknote note1');
|
||||
await execCommand(client, 'mknote note2');
|
||||
await execCommand(client, 'mknote note3');
|
||||
await execCommand(client, 'mknote blabla');
|
||||
|
||||
notes1 = await Note.previews(f1.id);
|
||||
notes2 = await Note.previews(f2.id);
|
||||
|
||||
expect(notes1.length).toBe(4);
|
||||
expect(notes2.length).toBe(1);
|
||||
|
||||
await execCommand(client, 'mv \'note*\' nb2');
|
||||
|
||||
notes2 = await Note.previews(f2.id);
|
||||
notes1 = await Note.previews(f1.id);
|
||||
|
||||
expect(notes1.length).toBe(1);
|
||||
expect(notes2.length).toBe(4);
|
||||
});
|
||||
|
||||
it('should support the \'use\' command', async () => {
|
||||
await execCommand(client, 'mkbook nb1');
|
||||
await execCommand(client, 'mkbook nb2');
|
||||
await execCommand(client, 'mknote n1');
|
||||
await execCommand(client, 'mknote n2');
|
||||
|
||||
const f1 = await Folder.loadByTitle('nb1');
|
||||
const f2 = await Folder.loadByTitle('nb2');
|
||||
let notes1 = await Note.previews(f1.id);
|
||||
let notes2 = await Note.previews(f2.id);
|
||||
|
||||
expect(notes1.length).toBe(0);
|
||||
expect(notes2.length).toBe(2);
|
||||
|
||||
await execCommand(client, 'use nb1');
|
||||
await execCommand(client, 'mknote note2');
|
||||
await execCommand(client, 'mknote note3');
|
||||
|
||||
notes1 = await Note.previews(f1.id);
|
||||
notes2 = await Note.previews(f2.id);
|
||||
|
||||
expect(notes1.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should support creating and removing folders', async () => {
|
||||
await execCommand(client, 'mkbook nb1');
|
||||
|
||||
let folders = await Folder.all();
|
||||
expect(folders.length).toBe(1);
|
||||
expect(folders[0].title).toBe('nb1');
|
||||
|
||||
await execCommand(client, 'mkbook nb1');
|
||||
|
||||
folders = await Folder.all();
|
||||
expect(folders.length).toBe(2);
|
||||
expect(folders[0].title).toBe('nb1');
|
||||
expect(folders[1].title).toBe('nb1');
|
||||
|
||||
await execCommand(client, 'rmbook -p -f nb1');
|
||||
|
||||
folders = await Folder.all();
|
||||
expect(folders.length).toBe(1);
|
||||
|
||||
await execCommand(client, 'rmbook -p -f nb1');
|
||||
|
||||
folders = await Folder.all();
|
||||
expect(folders.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should support creating and removing notes', async () => {
|
||||
await execCommand(client, 'mkbook nb1');
|
||||
await execCommand(client, 'mknote n1');
|
||||
|
||||
let notes = await Note.all();
|
||||
expect(notes.length).toBe(1);
|
||||
expect(notes[0].title).toBe('n1');
|
||||
|
||||
await execCommand(client, 'rmnote -p -f n1');
|
||||
notes = await Note.all();
|
||||
expect(notes.length).toBe(0);
|
||||
|
||||
await execCommand(client, 'mknote n1');
|
||||
await execCommand(client, 'mknote n2');
|
||||
|
||||
notes = await Note.all();
|
||||
expect(notes.length).toBe(2);
|
||||
|
||||
// Should fail to delete a non-existent note
|
||||
let failed = false;
|
||||
try {
|
||||
await execCommand(client, 'rmnote -f \'blabla*\'');
|
||||
} catch (error) {
|
||||
failed = true;
|
||||
}
|
||||
expect(failed).toBe(true);
|
||||
|
||||
notes = await Note.all();
|
||||
expect(notes.length).toBe(2);
|
||||
|
||||
await execCommand(client, 'rmnote -f -p \'n*\'');
|
||||
|
||||
notes = await Note.all();
|
||||
expect(notes.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should support listing the contents of notes', async () => {
|
||||
await execCommand(client, 'mkbook nb1');
|
||||
await execCommand(client, 'mknote mynote');
|
||||
|
||||
const folder = await Folder.loadByTitle('nb1');
|
||||
const note = await Note.loadFolderNoteByField(folder.id, 'title', 'mynote');
|
||||
|
||||
let r = await execCommand(client, 'cat mynote');
|
||||
expect(r).toContain('mynote');
|
||||
expect(r).not.toContain(note.id);
|
||||
|
||||
r = await execCommand(client, 'cat -v mynote');
|
||||
expect(r).toContain(note.id);
|
||||
});
|
||||
|
||||
it('should support changing settings with config', async () => {
|
||||
await execCommand(client, 'config editor vim');
|
||||
await Setting.reset();
|
||||
await Setting.load();
|
||||
expect(Setting.value('editor')).toBe('vim');
|
||||
|
||||
await execCommand(client, 'config editor subl');
|
||||
await Setting.reset();
|
||||
await Setting.load();
|
||||
expect(Setting.value('editor')).toBe('subl');
|
||||
|
||||
const r = await execCommand(client, 'config');
|
||||
expect(r.indexOf('editor') >= 0).toBe(true);
|
||||
expect(r.indexOf('subl') >= 0).toBe(true);
|
||||
});
|
||||
|
||||
it('should support copying folders with cp', async () => {
|
||||
await execCommand(client, 'mkbook nb2');
|
||||
await execCommand(client, 'mkbook nb1');
|
||||
await execCommand(client, 'mknote n1');
|
||||
|
||||
await execCommand(client, 'cp n1');
|
||||
|
||||
const f1 = await Folder.loadByTitle('nb1');
|
||||
const f2 = await Folder.loadByTitle('nb2');
|
||||
let notes = await Note.previews(f1.id);
|
||||
|
||||
expect(notes.length).toBe(2);
|
||||
|
||||
await execCommand(client, 'cp n1 nb2');
|
||||
const notesF1 = await Note.previews(f1.id);
|
||||
expect(notesF1.length).toBe(2);
|
||||
notes = await Note.previews(f2.id);
|
||||
expect(notes.length).toBe(1);
|
||||
expect(notes[0].title).toBe(notesF1[0].title);
|
||||
});
|
||||
});
|
||||
|
||||
300
packages/app-cli/app/cli-integration-tests.ts
Normal file
300
packages/app-cli/app/cli-integration-tests.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
'use strict';
|
||||
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import * as fs from 'fs-extra';
|
||||
import Logger, { TargetType } from '@joplin/utils/Logger';
|
||||
import { dirname } from '@joplin/lib/path-utils';
|
||||
const { DatabaseDriverNode } = require('@joplin/lib/database-driver-node.js');
|
||||
import JoplinDatabase from '@joplin/lib/JoplinDatabase';
|
||||
import BaseModel from '@joplin/lib/BaseModel';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
const { sprintf } = require('sprintf-js');
|
||||
const exec = require('child_process').exec;
|
||||
const nodeSqlite = require('sqlite3');
|
||||
const { loadKeychainServiceAndSettings } = require('@joplin/lib/services/SettingUtils');
|
||||
const { default: shimInitCli } = require('./utils/shimInitCli');
|
||||
|
||||
const baseDir = `${dirname(__dirname)}/tests/cli-integration`;
|
||||
const joplinAppPath = `${__dirname}/main.js`;
|
||||
|
||||
shimInitCli({ nodeSqlite, appVersion: () => require('../package.json').version, keytar: null });
|
||||
require('@joplin/lib/testing/test-utils');
|
||||
|
||||
const logger = new Logger();
|
||||
logger.addTarget(TargetType.Console);
|
||||
logger.setLevel(Logger.LEVEL_ERROR);
|
||||
|
||||
const dbLogger = new Logger();
|
||||
dbLogger.addTarget(TargetType.Console);
|
||||
dbLogger.setLevel(Logger.LEVEL_INFO);
|
||||
|
||||
const db = new JoplinDatabase(new DatabaseDriverNode());
|
||||
db.setLogger(dbLogger);
|
||||
|
||||
interface Client {
|
||||
id: number;
|
||||
profileDir: string;
|
||||
}
|
||||
|
||||
function createClient(id: number): Client {
|
||||
return {
|
||||
id: id,
|
||||
profileDir: `${baseDir}/client${id}`,
|
||||
};
|
||||
}
|
||||
|
||||
const client = createClient(1);
|
||||
|
||||
function execCommand(client: Client, command: string) {
|
||||
const exePath = `node ${joplinAppPath}`;
|
||||
const cmd = `${exePath} --update-geolocation-disabled --env dev --profile ${client.profileDir} ${command}`;
|
||||
logger.info(`${client.id}: ${command}`);
|
||||
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
exec(cmd, (error: string, stdout: string, stderr: string) => {
|
||||
if (error) {
|
||||
logger.error(stderr);
|
||||
reject(error);
|
||||
} else {
|
||||
resolve(stdout.trim());
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function assertTrue(v: unknown) {
|
||||
if (!v) throw new Error(sprintf('Expected "true", got "%s"."', v));
|
||||
process.stdout.write('.');
|
||||
}
|
||||
|
||||
function assertFalse(v: unknown) {
|
||||
if (v) throw new Error(sprintf('Expected "false", got "%s"."', v));
|
||||
process.stdout.write('.');
|
||||
}
|
||||
|
||||
function assertEquals(expected: unknown, real: unknown) {
|
||||
if (expected !== real) throw new Error(sprintf('Expecting "%s", got "%s"', expected, real));
|
||||
process.stdout.write('.');
|
||||
}
|
||||
|
||||
async function clearDatabase() {
|
||||
await db.transactionExecBatch(['DELETE FROM folders', 'DELETE FROM notes', 'DELETE FROM tags', 'DELETE FROM note_tags', 'DELETE FROM resources', 'DELETE FROM deleted_items']);
|
||||
}
|
||||
|
||||
const testUnits: Record<string, ()=> Promise<void>> = {};
|
||||
|
||||
testUnits.testFolders = async () => {
|
||||
await execCommand(client, 'mkbook nb1');
|
||||
|
||||
let folders = await Folder.all();
|
||||
assertEquals(1, folders.length);
|
||||
assertEquals('nb1', folders[0].title);
|
||||
|
||||
await execCommand(client, 'mkbook nb1');
|
||||
|
||||
folders = await Folder.all();
|
||||
assertEquals(2, folders.length);
|
||||
assertEquals('nb1', folders[0].title);
|
||||
assertEquals('nb1', folders[1].title);
|
||||
|
||||
await execCommand(client, 'rmbook -p -f nb1');
|
||||
|
||||
folders = await Folder.all();
|
||||
assertEquals(1, folders.length);
|
||||
|
||||
await execCommand(client, 'rmbook -p -f nb1');
|
||||
|
||||
folders = await Folder.all();
|
||||
assertEquals(0, folders.length);
|
||||
};
|
||||
|
||||
testUnits.testNotes = async () => {
|
||||
await execCommand(client, 'mkbook nb1');
|
||||
await execCommand(client, 'mknote n1');
|
||||
|
||||
let notes = await Note.all();
|
||||
assertEquals(1, notes.length);
|
||||
assertEquals('n1', notes[0].title);
|
||||
|
||||
await execCommand(client, 'rmnote -p -f n1');
|
||||
notes = await Note.all();
|
||||
assertEquals(0, notes.length);
|
||||
|
||||
await execCommand(client, 'mknote n1');
|
||||
await execCommand(client, 'mknote n2');
|
||||
|
||||
notes = await Note.all();
|
||||
assertEquals(2, notes.length);
|
||||
|
||||
// Should fail to delete a non-existent note
|
||||
let failed = false;
|
||||
try {
|
||||
await execCommand(client, 'rmnote -f \'blabla*\'');
|
||||
} catch (error) {
|
||||
failed = true;
|
||||
}
|
||||
assertEquals(failed, true);
|
||||
|
||||
notes = await Note.all();
|
||||
assertEquals(2, notes.length);
|
||||
|
||||
await execCommand(client, 'rmnote -f -p \'n*\'');
|
||||
|
||||
notes = await Note.all();
|
||||
assertEquals(0, notes.length);
|
||||
};
|
||||
|
||||
testUnits.testCat = async () => {
|
||||
await execCommand(client, 'mkbook nb1');
|
||||
await execCommand(client, 'mknote mynote');
|
||||
|
||||
const folder = await Folder.loadByTitle('nb1');
|
||||
const note = await Note.loadFolderNoteByField(folder.id, 'title', 'mynote');
|
||||
|
||||
let r = await execCommand(client, 'cat mynote');
|
||||
assertTrue(r.indexOf('mynote') >= 0);
|
||||
assertFalse(r.indexOf(note.id) >= 0);
|
||||
|
||||
r = await execCommand(client, 'cat -v mynote');
|
||||
assertTrue(r.indexOf(note.id) >= 0);
|
||||
};
|
||||
|
||||
testUnits.testConfig = async () => {
|
||||
await execCommand(client, 'config editor vim');
|
||||
await Setting.reset();
|
||||
await Setting.load();
|
||||
assertEquals('vim', Setting.value('editor'));
|
||||
|
||||
await execCommand(client, 'config editor subl');
|
||||
await Setting.reset();
|
||||
await Setting.load();
|
||||
assertEquals('subl', Setting.value('editor'));
|
||||
|
||||
const r = await execCommand(client, 'config');
|
||||
assertTrue(r.indexOf('editor') >= 0);
|
||||
assertTrue(r.indexOf('subl') >= 0);
|
||||
};
|
||||
|
||||
testUnits.testCp = async () => {
|
||||
await execCommand(client, 'mkbook nb2');
|
||||
await execCommand(client, 'mkbook nb1');
|
||||
await execCommand(client, 'mknote n1');
|
||||
|
||||
await execCommand(client, 'cp n1');
|
||||
|
||||
const f1 = await Folder.loadByTitle('nb1');
|
||||
const f2 = await Folder.loadByTitle('nb2');
|
||||
let notes = await Note.previews(f1.id);
|
||||
|
||||
assertEquals(2, notes.length);
|
||||
|
||||
await execCommand(client, 'cp n1 nb2');
|
||||
const notesF1 = await Note.previews(f1.id);
|
||||
assertEquals(2, notesF1.length);
|
||||
notes = await Note.previews(f2.id);
|
||||
assertEquals(1, notes.length);
|
||||
assertEquals(notesF1[0].title, notes[0].title);
|
||||
};
|
||||
|
||||
testUnits.testLs = async () => {
|
||||
await execCommand(client, 'mkbook nb1');
|
||||
await execCommand(client, 'mknote note1');
|
||||
await execCommand(client, 'mknote note2');
|
||||
const r = await execCommand(client, 'ls');
|
||||
|
||||
assertTrue(r.indexOf('note1') >= 0);
|
||||
assertTrue(r.indexOf('note2') >= 0);
|
||||
};
|
||||
|
||||
testUnits.testMv = async () => {
|
||||
await execCommand(client, 'mkbook nb2');
|
||||
await execCommand(client, 'mkbook nb1');
|
||||
await execCommand(client, 'mknote n1');
|
||||
await execCommand(client, 'mv n1 nb2');
|
||||
|
||||
const f1 = await Folder.loadByTitle('nb1');
|
||||
const f2 = await Folder.loadByTitle('nb2');
|
||||
let notes1 = await Note.previews(f1.id);
|
||||
let notes2 = await Note.previews(f2.id);
|
||||
|
||||
assertEquals(0, notes1.length);
|
||||
assertEquals(1, notes2.length);
|
||||
|
||||
await execCommand(client, 'mknote note1');
|
||||
await execCommand(client, 'mknote note2');
|
||||
await execCommand(client, 'mknote note3');
|
||||
await execCommand(client, 'mknote blabla');
|
||||
|
||||
notes1 = await Note.previews(f1.id);
|
||||
notes2 = await Note.previews(f2.id);
|
||||
|
||||
assertEquals(4, notes1.length);
|
||||
assertEquals(1, notes2.length);
|
||||
|
||||
await execCommand(client, 'mv \'note*\' nb2');
|
||||
|
||||
notes2 = await Note.previews(f2.id);
|
||||
notes1 = await Note.previews(f1.id);
|
||||
|
||||
assertEquals(1, notes1.length);
|
||||
assertEquals(4, notes2.length);
|
||||
};
|
||||
|
||||
testUnits.testUse = async () => {
|
||||
await execCommand(client, 'mkbook nb1');
|
||||
await execCommand(client, 'mkbook nb2');
|
||||
await execCommand(client, 'mknote n1');
|
||||
await execCommand(client, 'mknote n2');
|
||||
|
||||
const f1 = await Folder.loadByTitle('nb1');
|
||||
const f2 = await Folder.loadByTitle('nb2');
|
||||
let notes1 = await Note.previews(f1.id);
|
||||
let notes2 = await Note.previews(f2.id);
|
||||
|
||||
assertEquals(0, notes1.length);
|
||||
assertEquals(2, notes2.length);
|
||||
|
||||
await execCommand(client, 'use nb1');
|
||||
await execCommand(client, 'mknote note2');
|
||||
await execCommand(client, 'mknote note3');
|
||||
|
||||
notes1 = await Note.previews(f1.id);
|
||||
notes2 = await Note.previews(f2.id);
|
||||
|
||||
assertEquals(2, notes1.length);
|
||||
assertEquals(2, notes2.length);
|
||||
};
|
||||
|
||||
async function main() {
|
||||
await fs.remove(baseDir);
|
||||
|
||||
logger.info(await execCommand(client, 'version'));
|
||||
|
||||
await db.open({ name: `${client.profileDir}/database.sqlite` });
|
||||
BaseModel.setDb(db);
|
||||
Setting.setConstant('rootProfileDir', client.profileDir);
|
||||
Setting.setConstant('profileDir', client.profileDir);
|
||||
await loadKeychainServiceAndSettings([]);
|
||||
|
||||
let onlyThisTest = 'testMv';
|
||||
onlyThisTest = '';
|
||||
|
||||
for (const n in testUnits) {
|
||||
if (!testUnits.hasOwnProperty(n)) continue;
|
||||
if (onlyThisTest && n !== onlyThisTest) continue;
|
||||
|
||||
await clearDatabase();
|
||||
const testName = n.substr(4).toLowerCase();
|
||||
process.stdout.write(`${testName}: `);
|
||||
await testUnits[n]();
|
||||
console.info('');
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.info('');
|
||||
logger.error(error);
|
||||
});
|
||||
@@ -12,7 +12,7 @@ class Command extends BaseCommand {
|
||||
}
|
||||
|
||||
public override async action() {
|
||||
this.stdout(versionInfo(require('../package.json'), {}).message);
|
||||
this.stdout(versionInfo(require('./package.json'), {}).message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const { afterEachCleanUp } = require('@joplin/lib/testing/test-utils.js');
|
||||
const { default: shimInitCli } = require('./app/utils/shimInitCli');
|
||||
const { shimInit } = require('@joplin/lib/shim-init-node.js');
|
||||
const shim = require('@joplin/lib/shim').default;
|
||||
const sharp = require('sharp');
|
||||
const nodeSqlite = require('sqlite3');
|
||||
@@ -13,7 +13,7 @@ try {
|
||||
keytar = null;
|
||||
}
|
||||
|
||||
shimInitCli({ sharp, nodeSqlite, appVersion: () => require('./package.json').version, keytar });
|
||||
shimInit({ sharp, keytar, nodeSqlite });
|
||||
|
||||
global.afterEach(async () => {
|
||||
await afterEachCleanUp();
|
||||
|
||||
@@ -52,7 +52,7 @@ describe('MarkupToHtml', () => {
|
||||
pluginAssets: [],
|
||||
};
|
||||
|
||||
expect(await service.render(MarkupLanguage.Html, testString, {}, { })).toMatchObject(expectedOutput);
|
||||
expect(await service.render(MarkupLanguage.Markdown, testString, {}, { })).toMatchObject(expectedOutput);
|
||||
expect(await service.render(MarkupLanguage.Html, testString, {}, {})).toMatchObject(expectedOutput);
|
||||
expect(await service.render(MarkupLanguage.Markdown, testString, {}, {})).toMatchObject(expectedOutput);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,7 +23,6 @@ import { defaultWindowId } from '@joplin/lib/reducer';
|
||||
import { msleep, Second } from '@joplin/utils/time';
|
||||
import determineBaseAppDirs from '@joplin/lib/determineBaseAppDirs';
|
||||
import getAppName from '@joplin/lib/getAppName';
|
||||
import { execCommand } from '@joplin/utils';
|
||||
|
||||
interface RendererProcessQuitReply {
|
||||
canClose: boolean;
|
||||
@@ -811,33 +810,6 @@ export default class ElectronAppWrapper {
|
||||
return this.customProtocolHandler_;
|
||||
}
|
||||
|
||||
private async fixLinuxAccessibility_() {
|
||||
if (this.electronApp().accessibilitySupportEnabled) return;
|
||||
|
||||
const isOrcaRunning = async () => {
|
||||
if (!shim.isLinux()) return false;
|
||||
try {
|
||||
const matchingProcesses = await execCommand(['ps', '--no-headers', '-C', 'orca'], { quiet: true });
|
||||
return matchingProcesses.trim().length > 0;
|
||||
} catch (error) {
|
||||
if (error.stderr || error.exitCode !== 1) {
|
||||
// eslint-disable-next-line no-console -- The main logger is not available at this point.
|
||||
console.error('Failed to check for and enable accessibility support:', error.stderr);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Work around https://issues.chromium.org/issues/431257156 by force-enabling accessibility
|
||||
// when Orca (a screen reader) is running:
|
||||
if (await isOrcaRunning()) {
|
||||
// eslint-disable-next-line no-console -- The main logger is not available at this point.
|
||||
console.log('Linux accessibility: Enabling full accessibility support.');
|
||||
this.electronApp().setAccessibilitySupportEnabled(true);
|
||||
}
|
||||
}
|
||||
|
||||
public async start() {
|
||||
// Since we are doing other async things before creating the window, we might miss
|
||||
// the "ready" event. So we use the function below to make sure that the app is ready.
|
||||
@@ -846,8 +818,6 @@ export default class ElectronAppWrapper {
|
||||
const alreadyRunning = await this.ensureSingleInstance();
|
||||
if (alreadyRunning) return;
|
||||
|
||||
await this.fixLinuxAccessibility_();
|
||||
|
||||
this.customProtocolHandler_ = handleCustomProtocols();
|
||||
this.createWindow();
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import Setting from '@joplin/lib/models/Setting';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
const { friendlySafeFilename } = require('@joplin/lib/path-utils');
|
||||
import time from '@joplin/lib/time';
|
||||
import { BrowserWindow, BrowserWindowConstructorOptions } from 'electron';
|
||||
import { BrowserWindow } from 'electron';
|
||||
const md5 = require('md5');
|
||||
const url = require('url');
|
||||
|
||||
@@ -62,10 +62,8 @@ export default class InteropServiceHelper {
|
||||
|
||||
htmlFile = await this.exportNoteToHtmlFile(noteId, exportOptions);
|
||||
|
||||
const windowOptions: BrowserWindowConstructorOptions = {
|
||||
// Work around a printing issue: As of Electron 39, if the window is initially hidden, printing crashes the app.
|
||||
// This only seems to be necessary on Linux.
|
||||
show: shim.isLinux(),
|
||||
const windowOptions = {
|
||||
show: false,
|
||||
};
|
||||
|
||||
win = bridge().newBrowserWindow(windowOptions);
|
||||
@@ -122,9 +120,6 @@ export default class InteropServiceHelper {
|
||||
//
|
||||
// 2025-05-03: Windows and MacOS also need the window.print() workaround.
|
||||
// See https://github.com/electron/electron/pull/46937.
|
||||
//
|
||||
// 2025-10-30: window.print() now causes a crash on Linux -- switch back to the
|
||||
// other method.
|
||||
|
||||
const applyWorkaround = true;
|
||||
if (applyWorkaround) {
|
||||
|
||||
@@ -52,7 +52,7 @@ describe('app.reducer', () => {
|
||||
...createAppDefaultState({}),
|
||||
backgroundWindows: {
|
||||
testWindow: {
|
||||
...createAppDefaultWindowState(),
|
||||
...createAppDefaultWindowState(null),
|
||||
windowId: 'testWindow',
|
||||
|
||||
visibleDialogs: {
|
||||
|
||||
@@ -30,6 +30,17 @@ export interface NoteIdToScrollPercent {
|
||||
[noteId: string]: number;
|
||||
}
|
||||
|
||||
type RichTextEditorSelectionBookmark = unknown;
|
||||
|
||||
export interface EditorCursorLocations {
|
||||
readonly richText?: RichTextEditorSelectionBookmark;
|
||||
readonly markdown?: number;
|
||||
}
|
||||
|
||||
export interface NoteIdToEditorCursorLocations {
|
||||
[noteId: string]: EditorCursorLocations;
|
||||
}
|
||||
|
||||
export interface VisibleDialogs {
|
||||
[dialogKey: string]: boolean;
|
||||
}
|
||||
@@ -42,6 +53,9 @@ export interface AppWindowState extends WindowState {
|
||||
devToolsVisible: boolean;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
watchedResources: any;
|
||||
|
||||
lastEditorScrollPercents: NoteIdToScrollPercent;
|
||||
lastEditorCursorLocations: NoteIdToEditorCursorLocations;
|
||||
}
|
||||
|
||||
interface BackgroundWindowStates {
|
||||
@@ -65,7 +79,7 @@ export interface AppState extends State, AppWindowState {
|
||||
isResettingLayout: boolean;
|
||||
}
|
||||
|
||||
export const createAppDefaultWindowState = (): AppWindowState => {
|
||||
export const createAppDefaultWindowState = (globalState: AppState|null): AppWindowState => {
|
||||
return {
|
||||
...defaultWindowState,
|
||||
visibleDialogs: {},
|
||||
@@ -74,6 +88,12 @@ export const createAppDefaultWindowState = (): AppWindowState => {
|
||||
editorCodeView: true,
|
||||
devToolsVisible: false,
|
||||
watchedResources: {},
|
||||
|
||||
// Maintain the scroll and cursor location for secondary windows separate from the
|
||||
// main window. This prevents scrolling in a secondary window from changing/resetting
|
||||
// the default scroll position in the main window:
|
||||
lastEditorCursorLocations: globalState?.lastEditorCursorLocations ?? {},
|
||||
lastEditorScrollPercents: globalState?.lastEditorScrollPercents ?? {},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -81,7 +101,7 @@ export const createAppDefaultWindowState = (): AppWindowState => {
|
||||
export function createAppDefaultState(resourceEditWatcherDefaultState: any): AppState {
|
||||
return {
|
||||
...defaultState,
|
||||
...createAppDefaultWindowState(),
|
||||
...createAppDefaultWindowState(null),
|
||||
route: {
|
||||
type: 'NAV_GO',
|
||||
routeName: 'Main',
|
||||
@@ -287,6 +307,28 @@ export default function(state: AppState, action: any) {
|
||||
}
|
||||
break;
|
||||
|
||||
case 'EDITOR_SCROLL_PERCENT_SET':
|
||||
|
||||
{
|
||||
newState = { ...state };
|
||||
const newPercents = { ...newState.lastEditorScrollPercents };
|
||||
newPercents[action.noteId] = action.percent;
|
||||
newState.lastEditorScrollPercents = newPercents;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'EDITOR_CURSOR_POSITION_SET':
|
||||
{
|
||||
newState = { ...state };
|
||||
const newCursorLocations = { ...newState.lastEditorCursorLocations };
|
||||
newCursorLocations[action.noteId] = {
|
||||
...(newCursorLocations[action.noteId] ?? {}),
|
||||
...action.location,
|
||||
};
|
||||
newState.lastEditorCursorLocations = newCursorLocations;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'NOTE_DEVTOOLS_TOGGLE':
|
||||
newState = { ...state };
|
||||
newState.devToolsVisible = !newState.devToolsVisible;
|
||||
|
||||
@@ -280,16 +280,6 @@ class Application extends BaseApplication {
|
||||
Setting.setValue('plugins.states', pluginSettings);
|
||||
}
|
||||
|
||||
// As of Joplin 3.5.7, the ABC rendering is part of the app so we automatically disable the plugin
|
||||
pluginSettings = {
|
||||
...pluginSettings,
|
||||
['org.joplinapp.plugins.AbcSheetMusic']: {
|
||||
enabled: false,
|
||||
deleted: false,
|
||||
hasBeenUpdated: false,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
if (await shim.fsDriver().exists(Setting.value('pluginDir'))) {
|
||||
await service.loadAndRunPlugins(Setting.value('pluginDir'), pluginSettings);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { stateUtils } from '@joplin/lib/reducer';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import { createAppDefaultWindowState } from '../app.reducer';
|
||||
import { AppState, createAppDefaultWindowState } from '../app.reducer';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
@@ -25,7 +25,7 @@ export const runtime = (): CommandRuntime => {
|
||||
folderId: note.parent_id,
|
||||
windowId: `window-${noteId}-${idCounter++}`,
|
||||
defaultAppWindowState: {
|
||||
...createAppDefaultWindowState(),
|
||||
...createAppDefaultWindowState(context.state as AppState),
|
||||
noteVisiblePanes: Setting.value('noteVisiblePanes'),
|
||||
editorCodeView: Setting.value('editor.codeView'),
|
||||
},
|
||||
|
||||
@@ -261,10 +261,7 @@ class ConfigScreenComponent extends React.Component<any, any> {
|
||||
if (settings['sync.target'] === SyncTargetRegistry.nameToId('joplinServerSaml')) {
|
||||
const server = settings['sync.11.path'] as string;
|
||||
|
||||
const goToSamlLogin = async () => {
|
||||
// Save settings to allow SAML auth with the correct URL.
|
||||
await shared.saveSettings(this);
|
||||
|
||||
const goToSamlLogin = () => {
|
||||
this.props.dispatch({
|
||||
type: 'NAV_GO',
|
||||
routeName: 'JoplinServerSamlLogin',
|
||||
|
||||
@@ -22,6 +22,12 @@ interface MultiNoteActionsProps {
|
||||
function styles_(props: MultiNoteActionsProps) {
|
||||
return buildStyle('MultiNoteActions', props.themeId, (theme: ThemeStyle) => {
|
||||
return {
|
||||
root: {
|
||||
display: 'inline-flex',
|
||||
justifyContent: 'center',
|
||||
paddingTop: theme.marginTop,
|
||||
width: '100%',
|
||||
},
|
||||
itemList: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
@@ -84,7 +90,7 @@ export default function MultiNoteActions(props: MultiNoteActionsProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={styles.root} className='multi-note-actions'>
|
||||
<div style={styles.root}>
|
||||
<div style={styles.itemList}>{itemComps}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -32,7 +32,6 @@ import useRefocusOnVisiblePaneChange from './utils/useRefocusOnVisiblePaneChange
|
||||
import { WindowIdContext } from '../../../../NewWindowOrIFrame';
|
||||
import eventManager, { EventName, ResourceChangeEvent } from '@joplin/lib/eventManager';
|
||||
import useSyncEditorValue from './utils/useSyncEditorValue';
|
||||
import { getGlobalSettings } from '@joplin/renderer/types';
|
||||
|
||||
const logger = Logger.create('CodeMirror6');
|
||||
const logDebug = (message: string) => logger.debug(message);
|
||||
@@ -249,7 +248,6 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
|
||||
useCustomPdfViewer: props.useCustomPdfViewer,
|
||||
noteId: props.noteId,
|
||||
vendorDir: bridge().vendorDir(),
|
||||
globalSettings: getGlobalSettings(Setting),
|
||||
}));
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
@@ -5,8 +5,6 @@ import { MarkupToHtmlHandler } from '../../../utils/types';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import enableTextAreaTab, { TextAreaTabHandler } from './enableTextAreaTab';
|
||||
import { MarkupToHtml } from '@joplin/renderer';
|
||||
import { getGlobalSettings } from '@joplin/renderer/types';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
|
||||
interface Props {
|
||||
editor: Editor;
|
||||
@@ -92,7 +90,7 @@ function openEditDialog(
|
||||
onSubmit: async (dialogApi: any) => {
|
||||
const newSource = newBlockSource(dialogApi.getData().languageInput, dialogApi.getData().codeTextArea, source);
|
||||
const md = `${newSource.openCharacters}${newSource.content}${newSource.closeCharacters}`;
|
||||
const result = await markupToHtml.current(MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN, md, { bodyOnly: true, globalSettings: getGlobalSettings(Setting) });
|
||||
const result = await markupToHtml.current(MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN, md, { bodyOnly: true });
|
||||
|
||||
// markupToHtml will return the complete editable HTML, but we only
|
||||
// want to update the inner HTML, so as not to break additional props that
|
||||
|
||||
@@ -18,7 +18,7 @@ import { NoteEditorProps, FormNote, OnChangeEvent, AllAssetsOptions, NoteBodyEdi
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import Button, { ButtonLevel } from '../Button/Button';
|
||||
import eventManager, { EventName } from '@joplin/lib/eventManager';
|
||||
import { AppState } from '../../app.reducer';
|
||||
import { AppState, EditorCursorLocations } from '../../app.reducer';
|
||||
import ToolbarButtonUtils, { ToolbarButtonInfo } from '@joplin/lib/services/commands/ToolbarButtonUtils';
|
||||
import { _, _n } from '@joplin/lib/locale';
|
||||
import NoteTitleBar from './NoteTitle/NoteTitleBar';
|
||||
@@ -58,7 +58,6 @@ import useVisiblePluginEditorViewIds from '@joplin/lib/hooks/plugins/useVisibleP
|
||||
import useConnectToEditorPlugin from './utils/useConnectToEditorPlugin';
|
||||
import getResourceBaseUrl from './utils/getResourceBaseUrl';
|
||||
import useInitialCursorLocation from './utils/useInitialCursorLocation';
|
||||
import NotePositionService, { EditorCursorLocations } from '@joplin/lib/services/NotePositionService';
|
||||
|
||||
const debounce = require('debounce');
|
||||
|
||||
@@ -334,6 +333,7 @@ function NoteEditorContent(props: NoteEditorProps) {
|
||||
const { scrollWhenReadyRef, clearScrollWhenReady } = useScrollWhenReadyOptions({
|
||||
noteId: formNote.id,
|
||||
selectedNoteHash: props.selectedNoteHash,
|
||||
lastEditorScrollPercents: props.lastEditorScrollPercents,
|
||||
editorRef,
|
||||
editorName: props.bodyEditor,
|
||||
});
|
||||
@@ -401,14 +401,23 @@ function NoteEditorContent(props: NoteEditorProps) {
|
||||
}, [setShowRevisions]);
|
||||
|
||||
const onScroll = useCallback((event: { percent: number }) => {
|
||||
const noteId = formNoteRef.current.id;
|
||||
NotePositionService.instance().updateScrollPosition(noteId, windowId, event.percent);
|
||||
}, [windowId]);
|
||||
props.dispatch({
|
||||
type: 'EDITOR_SCROLL_PERCENT_SET',
|
||||
// In callbacks of setTimeout()/setInterval(), props/state cannot be used
|
||||
// to refer the current value, since they would be one or more generations old.
|
||||
// For the purpose, useRef value should be used.
|
||||
noteId: formNoteRef.current.id,
|
||||
percent: event.percent,
|
||||
});
|
||||
}, [props.dispatch]);
|
||||
|
||||
const onCursorMotion = useCallback((location: EditorCursorLocations) => {
|
||||
const noteId = formNoteRef.current.id;
|
||||
NotePositionService.instance().updateCursorPosition(noteId, windowId, location);
|
||||
}, [windowId]);
|
||||
props.dispatch({
|
||||
type: 'EDITOR_CURSOR_POSITION_SET',
|
||||
noteId: formNoteRef.current.id,
|
||||
location,
|
||||
});
|
||||
}, [props.dispatch]);
|
||||
|
||||
function renderNoNotes(rootStyle: React.CSSProperties) {
|
||||
const emptyDivStyle = {
|
||||
@@ -421,7 +430,7 @@ function NoteEditorContent(props: NoteEditorProps) {
|
||||
|
||||
const searchMarkers = useSearchMarkers(showLocalSearch, localSearchMarkerOptions, props.searches, props.selectedSearchId, props.highlightedWords);
|
||||
const initialCursorLocation = useInitialCursorLocation({
|
||||
noteId: props.noteId,
|
||||
lastEditorCursorLocations: props.lastEditorCursorLocations, noteId: props.noteId,
|
||||
});
|
||||
|
||||
const markupLanguage = formNote.markup_language;
|
||||
@@ -734,6 +743,8 @@ const mapStateToProps = (state: AppState, ownProps: ConnectProps) => {
|
||||
watchedNoteFiles: state.watchedNoteFiles,
|
||||
notesParentType: windowState.notesParentType,
|
||||
selectedNoteTags: windowState.selectedNoteTags,
|
||||
lastEditorScrollPercents: state.lastEditorScrollPercents,
|
||||
lastEditorCursorLocations: state.lastEditorCursorLocations,
|
||||
selectedNoteHash: windowState.selectedNoteHash,
|
||||
searches: state.searches,
|
||||
selectedSearchId: windowState.selectedSearchId,
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
import { LinkRenderingType } from '@joplin/renderer/MdToHtml';
|
||||
import { MarkupToHtmlOptions } from './types';
|
||||
import { getGlobalSettings, ResourceInfos } from '@joplin/renderer/types';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
|
||||
interface OptionOverride {
|
||||
bodyOnly: boolean;
|
||||
resourceInfos?: ResourceInfos;
|
||||
allowedFilePrefixes?: string[];
|
||||
}
|
||||
|
||||
export default (override: OptionOverride = null): MarkupToHtmlOptions => {
|
||||
export default (override: MarkupToHtmlOptions = null): MarkupToHtmlOptions => {
|
||||
return {
|
||||
plugins: {
|
||||
checkbox: {
|
||||
@@ -20,7 +12,6 @@ export default (override: OptionOverride = null): MarkupToHtmlOptions => {
|
||||
},
|
||||
},
|
||||
replaceResourceInternalToExternalLinks: true,
|
||||
globalSettings: getGlobalSettings(Setting),
|
||||
...override,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -98,10 +98,6 @@ export async function getResourcesFromPasteEvent(event: any) {
|
||||
const formatType = format.split('/')[0];
|
||||
|
||||
if (formatType === 'image') {
|
||||
// writeImageToFile can process only image/jpeg, image/jpg or image/png mime types
|
||||
if (['image/png', 'image/jpg', 'image/jpeg'].indexOf(format) < 0) {
|
||||
continue;
|
||||
}
|
||||
if (event) event.preventDefault();
|
||||
|
||||
const image = clipboard.readImage();
|
||||
|
||||
@@ -14,7 +14,7 @@ import { ScrollbarSize } from '@joplin/lib/models/settings/builtInMetadata';
|
||||
import { RefObject, SetStateAction } from 'react';
|
||||
import * as React from 'react';
|
||||
import { ResourceEntity, ResourceLocalStateEntity } from '@joplin/lib/services/database/types';
|
||||
import { EditorCursorLocations } from '@joplin/lib/services/NotePositionService';
|
||||
import { EditorCursorLocations, NoteIdToEditorCursorLocations, NoteIdToScrollPercent } from '../../../app.reducer';
|
||||
|
||||
export interface AllAssetsOptions {
|
||||
contentMaxWidthTarget?: string;
|
||||
@@ -41,6 +41,8 @@ export interface NoteEditorProps {
|
||||
notesParentType: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
selectedNoteTags: any[];
|
||||
lastEditorScrollPercents: NoteIdToScrollPercent;
|
||||
lastEditorCursorLocations: NoteIdToEditorCursorLocations;
|
||||
selectedNoteHash: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
searches: any[];
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { useContext, useMemo } from 'react';
|
||||
import { WindowIdContext } from '../../NewWindowOrIFrame';
|
||||
import NotePositionService from '@joplin/lib/services/NotePositionService';
|
||||
import { useMemo } from 'react';
|
||||
import { EditorCursorLocations, NoteIdToEditorCursorLocations } from '../../../app.reducer';
|
||||
|
||||
interface Props {
|
||||
lastEditorCursorLocations: NoteIdToEditorCursorLocations;
|
||||
noteId: string;
|
||||
}
|
||||
|
||||
const useInitialCursorLocation = ({ noteId }: Props) => {
|
||||
const windowId = useContext(WindowIdContext);
|
||||
const useInitialCursorLocation = ({ noteId, lastEditorCursorLocations }: Props) => {
|
||||
const lastCursorLocation = lastEditorCursorLocations[noteId];
|
||||
|
||||
return useMemo(() => {
|
||||
return NotePositionService.instance().getCursorPosition(noteId, windowId);
|
||||
}, [noteId, windowId]);
|
||||
return useMemo((): EditorCursorLocations => {
|
||||
return lastCursorLocation ?? { };
|
||||
}, [lastCursorLocation]);
|
||||
};
|
||||
|
||||
export default useInitialCursorLocation;
|
||||
|
||||
@@ -1,43 +1,42 @@
|
||||
import { RefObject, useCallback, useContext, useRef } from 'react';
|
||||
import { RefObject, useCallback, useRef } from 'react';
|
||||
import { NoteBodyEditorRef, ScrollOptions, ScrollOptionTypes } from './types';
|
||||
import usePrevious from '@joplin/lib/hooks/usePrevious';
|
||||
import NotePositionService from '@joplin/lib/services/NotePositionService';
|
||||
import type { NoteIdToScrollPercent } from '../../../app.reducer';
|
||||
import useNowEffect from '@joplin/lib/hooks/useNowEffect';
|
||||
import { WindowIdContext } from '../../NewWindowOrIFrame';
|
||||
|
||||
interface Props {
|
||||
noteId: string;
|
||||
editorName: string;
|
||||
selectedNoteHash: string;
|
||||
lastEditorScrollPercents: NoteIdToScrollPercent;
|
||||
editorRef: RefObject<NoteBodyEditorRef>;
|
||||
}
|
||||
|
||||
const useScrollWhenReadyOptions = ({ noteId, editorName, selectedNoteHash, editorRef }: Props) => {
|
||||
const useScrollWhenReadyOptions = ({ noteId, editorName, selectedNoteHash, lastEditorScrollPercents, editorRef }: Props) => {
|
||||
const scrollWhenReadyRef = useRef<ScrollOptions|null>(null);
|
||||
|
||||
const previousNoteId = usePrevious(noteId);
|
||||
const noteIdChanged = noteId !== previousNoteId;
|
||||
const previousEditor = usePrevious(editorName);
|
||||
const windowId = useContext(WindowIdContext);
|
||||
|
||||
const editorChanged = editorName !== previousEditor;
|
||||
const lastScrollPercentsRef = useRef<NoteIdToScrollPercent>(null);
|
||||
lastScrollPercentsRef.current = lastEditorScrollPercents;
|
||||
|
||||
// This needs to be a nowEffect to prevent race conditions
|
||||
useNowEffect(() => {
|
||||
const editorChanged = editorName !== previousEditor;
|
||||
const noteIdChanged = noteId !== previousNoteId;
|
||||
if (!editorChanged && !noteIdChanged) return () => {};
|
||||
|
||||
const lastScrollPercent = NotePositionService.instance().getScrollPercent(noteId, windowId) || 0;
|
||||
scrollWhenReadyRef.current = {
|
||||
type: selectedNoteHash ? ScrollOptionTypes.Hash : ScrollOptionTypes.Percent,
|
||||
value: selectedNoteHash ? selectedNoteHash : lastScrollPercent,
|
||||
};
|
||||
|
||||
if (editorRef.current) {
|
||||
editorRef.current.resetScroll();
|
||||
}
|
||||
|
||||
const lastScrollPercent = lastScrollPercentsRef.current[noteId] || 0;
|
||||
scrollWhenReadyRef.current = {
|
||||
type: selectedNoteHash ? ScrollOptionTypes.Hash : ScrollOptionTypes.Percent,
|
||||
value: selectedNoteHash ? selectedNoteHash : lastScrollPercent,
|
||||
};
|
||||
return () => {};
|
||||
}, [editorName, previousEditor, noteId, previousNoteId, selectedNoteHash, editorRef, windowId]);
|
||||
}, [editorChanged, noteIdChanged, noteId, selectedNoteHash, editorRef]);
|
||||
|
||||
const clearScrollWhenReady = useCallback(() => {
|
||||
scrollWhenReadyRef.current = null;
|
||||
|
||||
@@ -25,8 +25,6 @@ import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
|
||||
import { ScrollbarSize } from '@joplin/lib/models/settings/builtInMetadata';
|
||||
import { focus } from '@joplin/lib/utils/focusHandler';
|
||||
import useDeleteHistoryClick from '@joplin/lib/components/shared/NoteRevisionViewer/useDeleteHistoryClick';
|
||||
import { getGlobalSettings } from '@joplin/renderer/types';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
@@ -74,7 +72,6 @@ const useNoteContent = (
|
||||
const result = await markupToHtml(markupLanguage, noteBody, {
|
||||
resources: await shared.attachedResources(noteBody),
|
||||
whiteBackgroundNoteRendering: markupLanguage === MarkupLanguage.Html,
|
||||
globalSettings: getGlobalSettings(Setting),
|
||||
});
|
||||
|
||||
viewerRef.current.setHtml(result.html, {
|
||||
|
||||
@@ -20,7 +20,6 @@ import { reg } from '@joplin/lib/registry';
|
||||
import useAsyncEffect, { AsyncEffectEvent } from '@joplin/lib/hooks/useAsyncEffect';
|
||||
import { ChangeEvent, Dropdown, DropdownOptions, DropdownVariant } from '../Dropdown/Dropdown';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import { SettingsRecord } from '@joplin/lib/models/Setting';
|
||||
|
||||
const logger = Logger.create('ShareFolderDialog');
|
||||
|
||||
@@ -422,14 +421,10 @@ function ShareFolderDialog(props: Props) {
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: State) => {
|
||||
const getCanUseSharePermissions = (settings: Partial<SettingsRecord>) => {
|
||||
return [9, 10, 11].includes(settings['sync.target']) && !!settings['sync.10.canUseSharePermissions'];
|
||||
};
|
||||
|
||||
return {
|
||||
shares: state.shareService.shares,
|
||||
shareUsers: state.shareService.shareUsers,
|
||||
canUseSharePermissions: getCanUseSharePermissions(state.settings),
|
||||
canUseSharePermissions: state.settings['sync.target'] === 10 && state.settings['sync.10.canUseSharePermissions'],
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useMemo, useRef, useState } from 'react';
|
||||
import ItemList from '../ItemList';
|
||||
import useElementHeight from '../hooks/useElementHeight';
|
||||
import useSidebarListData from './hooks/useSidebarListData';
|
||||
import useSelectedSidebarIndexes from './hooks/useSelectedSidebarIndexes';
|
||||
import useSelectedSidebarIndex from './hooks/useSelectedSidebarIndex';
|
||||
import useOnSidebarKeyDownHandler from './hooks/useOnSidebarKeyDownHandler';
|
||||
import useFocusHandler from './hooks/useFocusHandler';
|
||||
import useOnRenderItem from './hooks/useOnRenderItem';
|
||||
@@ -26,9 +26,7 @@ interface Props {
|
||||
tags: TagsWithNoteCountEntity[];
|
||||
folders: FolderEntity[];
|
||||
notesParentType: string;
|
||||
selectedTagIds: string[];
|
||||
selectedTagId: string;
|
||||
selectedFolderIds: string[];
|
||||
selectedFolderId: string;
|
||||
selectedSmartFilterId: string;
|
||||
collapsedFolderIds: string[];
|
||||
@@ -39,7 +37,7 @@ interface Props {
|
||||
|
||||
const FolderAndTagList: React.FC<Props> = props => {
|
||||
const listItems = useSidebarListData(props);
|
||||
const { selectedIndex, selectedIndexes, updateSelectedIndex } = useSelectedSidebarIndexes({
|
||||
const { selectedIndex, updateSelectedIndex } = useSelectedSidebarIndex({
|
||||
...props,
|
||||
listItems: listItems,
|
||||
});
|
||||
@@ -52,7 +50,6 @@ const FolderAndTagList: React.FC<Props> = props => {
|
||||
const onRenderItem = useOnRenderItem({
|
||||
...props,
|
||||
selectedIndex,
|
||||
selectedIndexes,
|
||||
listItems,
|
||||
containerRef: listContainerRef,
|
||||
});
|
||||
@@ -61,7 +58,6 @@ const FolderAndTagList: React.FC<Props> = props => {
|
||||
dispatch: props.dispatch,
|
||||
listItems: listItems,
|
||||
selectedIndex,
|
||||
selectedIndexes,
|
||||
updateSelectedIndex,
|
||||
collapsedFolderIds: props.collapsedFolderIds,
|
||||
});
|
||||
@@ -111,8 +107,6 @@ const mapStateToProps = (state: AppState) => {
|
||||
tags: state.tags,
|
||||
folders: state.folders,
|
||||
notesParentType: mainWindowState.notesParentType,
|
||||
selectedFolderIds: mainWindowState.selectedFolderIds,
|
||||
selectedTagIds: mainWindowState.selectedTagIds,
|
||||
selectedFolderId: mainWindowState.selectedFolderId,
|
||||
selectedTagId: mainWindowState.selectedTagId,
|
||||
collapsedFolderIds: state.collapsedFolderIds,
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import { MouseEvent } from 'react';
|
||||
import { ModelType } from '@joplin/lib/BaseModel';
|
||||
import { RefObject, useCallback } from 'react';
|
||||
import { Dispatch } from 'redux';
|
||||
import { ListItem, ListItemType } from '../types';
|
||||
import shim from '@joplin/lib/shim';
|
||||
|
||||
export interface ItemClickEvent {
|
||||
id: string;
|
||||
type: ModelType;
|
||||
event: MouseEvent;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
itemsRef: RefObject<ListItem[]>;
|
||||
selectedIndexesRef: RefObject<number[]>;
|
||||
dispatch: Dispatch;
|
||||
}
|
||||
|
||||
const listItemToId = (item: ListItem) => {
|
||||
if (item.kind === ListItemType.Tag) return item.tag.id;
|
||||
if (item.kind === ListItemType.Folder) return item.folder.id;
|
||||
return null;
|
||||
};
|
||||
|
||||
const useOnItemClick = ({ dispatch, selectedIndexesRef, itemsRef }: Props) => {
|
||||
return useCallback(({ id, type, event }: ItemClickEvent) => {
|
||||
const action = type === ModelType.Folder ? 'FOLDER_SELECT' : 'TAG_SELECT';
|
||||
const selectedIndexes = selectedIndexesRef.current;
|
||||
const findItemIndex = () => itemsRef.current.findIndex(item => listItemToId(item) === id);
|
||||
|
||||
if (event.shiftKey && selectedIndexes.length > 0) {
|
||||
const index = findItemIndex();
|
||||
if (index === -1) throw new Error(`No item found with ID: ${id}`);
|
||||
|
||||
const lastAddedIndex = selectedIndexes[selectedIndexes.length - 1];
|
||||
const indexStart = Math.min(index, lastAddedIndex);
|
||||
const indexStop = Math.max(index, lastAddedIndex);
|
||||
const itemIds = itemsRef.current.slice(indexStart, indexStop + 1)
|
||||
.map(listItemToId)
|
||||
.filter(id => !!id);
|
||||
|
||||
dispatch({
|
||||
type: `${action}_ADD`,
|
||||
ids: itemIds,
|
||||
});
|
||||
} else if (shim.isMac() ? event.metaKey : event.ctrlKey) {
|
||||
const index = findItemIndex();
|
||||
// Don't allow unselecting all items: Keep at least one item selected
|
||||
const canDeselect = selectedIndexes.length > 1;
|
||||
const actionType = canDeselect && selectedIndexes.includes(index) ? 'REMOVE' : 'ADD';
|
||||
dispatch({
|
||||
type: `${action}_${actionType}`,
|
||||
id: id,
|
||||
});
|
||||
} else {
|
||||
dispatch({
|
||||
type: action,
|
||||
id: id,
|
||||
});
|
||||
}
|
||||
}, [dispatch, selectedIndexesRef, itemsRef]);
|
||||
};
|
||||
|
||||
export default useOnItemClick;
|
||||
@@ -1,15 +1,16 @@
|
||||
import * as React from 'react';
|
||||
import { DragEventHandler, MouseEventHandler, useCallback, useMemo, useRef } from 'react';
|
||||
import { ItemClickListener, ItemDragListener, ListItem, ListItemType } from '../types';
|
||||
import TagItem from '../listItemComponents/TagItem';
|
||||
import TagItem, { TagLinkClickEvent } from '../listItemComponents/TagItem';
|
||||
import { Dispatch } from 'redux';
|
||||
import { clipboard } from 'electron';
|
||||
import type { MenuItem as MenuItemType } 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';
|
||||
@@ -17,6 +18,7 @@ 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';
|
||||
@@ -27,13 +29,12 @@ import Logger from '@joplin/utils/Logger';
|
||||
import onFolderDrop from '@joplin/lib/models/utils/onFolderDrop';
|
||||
import HeaderItem from '../listItemComponents/HeaderItem';
|
||||
import AllNotesItem from '../listItemComponents/AllNotesItem';
|
||||
import ListItemWrapper, { ItemSelectionState } from '../listItemComponents/ListItemWrapper';
|
||||
import ListItemWrapper from '../listItemComponents/ListItemWrapper';
|
||||
import { focus } from '@joplin/lib/utils/focusHandler';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import useOnItemClick from './useOnItemClick';
|
||||
|
||||
const Menu = bridge().Menu;
|
||||
const MenuItem: typeof MenuItemType = bridge().MenuItem;
|
||||
const MenuItem = bridge().MenuItem;
|
||||
|
||||
const logger = Logger.create('useOnRenderItem');
|
||||
|
||||
@@ -46,7 +47,6 @@ interface Props {
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
|
||||
selectedIndex: number;
|
||||
selectedIndexes: number[];
|
||||
listItems: ListItem[];
|
||||
}
|
||||
|
||||
@@ -65,11 +65,6 @@ const focusListItem = (item: HTMLElement|null) => {
|
||||
|
||||
const noFocusListItem = () => {};
|
||||
|
||||
const folderCommandToMenuItem = (commandId: string, folderIds: string|string[]) => {
|
||||
const options = Array.isArray(folderIds) ? { commandFolderIds: folderIds } : { commandFolderId: folderIds };
|
||||
return new MenuItem(menuUtils.commandToStatefulMenuItem(commandId, folderIds, options));
|
||||
};
|
||||
|
||||
const useOnRenderItem = (props: Props) => {
|
||||
|
||||
const pluginsRef = useRef<PluginStates>(null);
|
||||
@@ -77,6 +72,13 @@ const useOnRenderItem = (props: Props) => {
|
||||
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;
|
||||
@@ -92,24 +94,6 @@ const useOnRenderItem = (props: Props) => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const selectedIndexesRef = useRef(props.selectedIndexes);
|
||||
selectedIndexesRef.current = props.selectedIndexes;
|
||||
const itemsRef = useRef(props.listItems);
|
||||
itemsRef.current = props.listItems;
|
||||
const getSelectedIds = useCallback(() => {
|
||||
return selectedIndexesRef.current.map(index => {
|
||||
const item = itemsRef.current[index];
|
||||
if (item.kind === ListItemType.Folder) {
|
||||
return item.folder.id;
|
||||
} else if (item.kind === ListItemType.Tag) {
|
||||
return item.tag.id;
|
||||
}
|
||||
return null;
|
||||
}).filter(id => !!id);
|
||||
}, []);
|
||||
|
||||
const onItemClick = useOnItemClick({ dispatch: props.dispatch, selectedIndexesRef, itemsRef });
|
||||
|
||||
const onItemContextMenu: ItemContextMenuListener = useCallback(async event => {
|
||||
const itemId = event.currentTarget.getAttribute('data-id');
|
||||
if (itemId === Folder.conflictFolderId()) return;
|
||||
@@ -117,22 +101,14 @@ const useOnRenderItem = (props: Props) => {
|
||||
const itemType = Number(event.currentTarget.getAttribute('data-type'));
|
||||
if (!itemId || !itemType) throw new Error('No data on element');
|
||||
|
||||
let itemIds = [itemId];
|
||||
const itemIndex = Number(event.currentTarget.getAttribute('data-index'));
|
||||
if (selectedIndexesRef.current.includes(itemIndex)) {
|
||||
itemIds = getSelectedIds();
|
||||
}
|
||||
const state: AppState = store().getState();
|
||||
|
||||
let deleteMessage = '';
|
||||
const deleteButtonLabel = _('Remove');
|
||||
|
||||
if (itemType === BaseModel.TYPE_TAG) {
|
||||
if (itemIds.length === 1) {
|
||||
const tag = await Tag.load(itemId);
|
||||
deleteMessage = _('Remove tag "%s" from all notes?', substrWithEllipsis(tag.title, 0, 32));
|
||||
} else {
|
||||
deleteMessage = _('Remove %d tags from all notes? This cannot be undone.', itemIds.length);
|
||||
}
|
||||
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?');
|
||||
}
|
||||
@@ -155,13 +131,16 @@ const useOnRenderItem = (props: Props) => {
|
||||
const isDeleted = item ? !!item.deleted_time : false;
|
||||
|
||||
if (!isDeleted) {
|
||||
const isDecryptedFolder = itemType === BaseModel.TYPE_FOLDER && !item.encryption_applied;
|
||||
if (isDecryptedFolder && itemIds.length === 1) {
|
||||
menu.append(folderCommandToMenuItem('newFolder', itemId));
|
||||
if (itemType === BaseModel.TYPE_FOLDER && !item.encryption_applied) {
|
||||
menu.append(
|
||||
new MenuItem(menuUtils.commandToStatefulMenuItem('newFolder', itemId)),
|
||||
);
|
||||
}
|
||||
|
||||
if (itemType === BaseModel.TYPE_FOLDER) {
|
||||
menu.append(folderCommandToMenuItem('deleteFolder', itemIds));
|
||||
menu.append(
|
||||
new MenuItem(menuUtils.commandToStatefulMenuItem('deleteFolder', itemId)),
|
||||
);
|
||||
} else {
|
||||
menu.append(
|
||||
new MenuItem({
|
||||
@@ -174,9 +153,7 @@ const useOnRenderItem = (props: Props) => {
|
||||
if (!ok) return;
|
||||
|
||||
if (itemType === BaseModel.TYPE_TAG) {
|
||||
for (const itemId of itemIds) {
|
||||
await Tag.untagAll(itemId);
|
||||
}
|
||||
await Tag.untagAll(itemId);
|
||||
} else if (itemType === BaseModel.TYPE_SEARCH) {
|
||||
props.dispatch({
|
||||
type: 'SEARCH_DELETE',
|
||||
@@ -188,18 +165,15 @@ const useOnRenderItem = (props: Props) => {
|
||||
);
|
||||
}
|
||||
|
||||
if (isDecryptedFolder) {
|
||||
const whenClause = CommandService.instance().currentWhenClauseContext({ commandFolderIds: itemIds });
|
||||
if (itemType === BaseModel.TYPE_FOLDER && !item.encryption_applied) {
|
||||
menu.append(new MenuItem({
|
||||
...menuUtils.commandToStatefulMenuItem('moveToFolder', itemIds),
|
||||
// By default, moveToFolder's enabled condition is based on the selected notes. However, the right-click
|
||||
// menu item applies to folders. For now, use a custom condition:
|
||||
enabled: !whenClause.foldersIncludeReadOnly,
|
||||
...menuUtils.commandToStatefulMenuItem('moveToFolder', [itemId]),
|
||||
// By default, enabled is based on the selected folder. However, the right-click
|
||||
// menu can be shown for unselected folders.
|
||||
enabled: true,
|
||||
}));
|
||||
}
|
||||
|
||||
if (isDecryptedFolder && itemIds.length === 1) {
|
||||
menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem('openFolderDialog', { folderId: itemId }, { commandFolderId: itemId })));
|
||||
menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem('openFolderDialog', { folderId: itemId })));
|
||||
|
||||
menu.append(new MenuItem({ type: 'separator' }));
|
||||
|
||||
@@ -214,17 +188,25 @@ const useOnRenderItem = (props: Props) => {
|
||||
new MenuItem({
|
||||
label: module.fullLabel(),
|
||||
click: async () => {
|
||||
await InteropServiceHelper.export(props.dispatch, module, { sourceFolderIds: itemIds, plugins: pluginsRef.current });
|
||||
await InteropServiceHelper.export(props.dispatch, module, { sourceFolderIds: [itemId], plugins: pluginsRef.current });
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Only show the share/leave share actions for top-level folders
|
||||
const shareFolderItem = folderCommandToMenuItem('showShareFolderDialog', itemId);
|
||||
if (shareFolderItem.enabled) menu.append(shareFolderItem);
|
||||
const leaveSharedFolderItem = folderCommandToMenuItem('leaveSharedFolder', itemId);
|
||||
if (leaveSharedFolderItem.enabled) menu.append(leaveSharedFolderItem);
|
||||
// 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({
|
||||
@@ -234,14 +216,14 @@ const useOnRenderItem = (props: Props) => {
|
||||
);
|
||||
if (Setting.value('notes.perFolderSortOrderEnabled')) {
|
||||
menu.append(new MenuItem({
|
||||
...menuUtils.commandToStatefulMenuItem('togglePerFolderSortOrder', itemId, { commandFolderId: itemId }),
|
||||
...menuUtils.commandToStatefulMenuItem('togglePerFolderSortOrder', itemId),
|
||||
type: 'checkbox',
|
||||
checked: PerFolderSortOrderService.isSet(itemId),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if (itemType === BaseModel.TYPE_FOLDER && itemIds.length === 1) {
|
||||
if (itemType === BaseModel.TYPE_FOLDER) {
|
||||
menu.append(
|
||||
new MenuItem({
|
||||
label: _('Copy external link'),
|
||||
@@ -252,7 +234,7 @@ const useOnRenderItem = (props: Props) => {
|
||||
);
|
||||
}
|
||||
|
||||
if (itemType === BaseModel.TYPE_TAG && itemIds.length === 1) {
|
||||
if (itemType === BaseModel.TYPE_TAG) {
|
||||
menu.append(new MenuItem(
|
||||
menuUtils.commandToStatefulMenuItem('renameTag', itemId),
|
||||
));
|
||||
@@ -271,22 +253,24 @@ const useOnRenderItem = (props: Props) => {
|
||||
for (const view of pluginViews) {
|
||||
const location = view.location;
|
||||
|
||||
if (itemType === ModelType.Tag && location === MenuItemLocation.TagContextMenu) {
|
||||
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 === ModelType.Folder && location === MenuItemLocation.FolderContextMenu) {
|
||||
menu.append(folderCommandToMenuItem(view.commandName, itemId));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (itemType === BaseModel.TYPE_FOLDER) {
|
||||
menu.append(folderCommandToMenuItem('restoreFolder', itemIds));
|
||||
menu.append(
|
||||
new MenuItem(menuUtils.commandToStatefulMenuItem('restoreFolder', itemId)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
menu.popup({ window: bridge().activeWindow() });
|
||||
}, [props.dispatch, pluginsRef, getSelectedIds]);
|
||||
}, [props.dispatch, pluginsRef]);
|
||||
|
||||
|
||||
|
||||
@@ -294,16 +278,10 @@ const useOnRenderItem = (props: Props) => {
|
||||
const folderId = event.currentTarget.getAttribute('data-folder-id');
|
||||
if (!folderId) return;
|
||||
|
||||
let itemIds = [folderId];
|
||||
const itemIndex = Number(event.currentTarget.getAttribute('data-index'));
|
||||
if (selectedIndexesRef.current.includes(itemIndex)) {
|
||||
itemIds = getSelectedIds();
|
||||
}
|
||||
|
||||
event.dataTransfer.setDragImage(new Image(), 1, 1);
|
||||
event.dataTransfer.clearData();
|
||||
event.dataTransfer.setData('text/x-jop-folder-ids', JSON.stringify(itemIds));
|
||||
}, [getSelectedIds]);
|
||||
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();
|
||||
@@ -345,6 +323,13 @@ const useOnRenderItem = (props: Props) => {
|
||||
});
|
||||
}, [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
|
||||
@@ -353,26 +338,22 @@ const useOnRenderItem = (props: Props) => {
|
||||
return Folder.shouldShowFolderIcons(props.folders);
|
||||
}, [props.folders]);
|
||||
|
||||
const selectedIndexRef = useRef(props.selectedIndex);
|
||||
selectedIndexRef.current = props.selectedIndex;
|
||||
|
||||
const itemCount = props.listItems.length;
|
||||
return useCallback((item: ListItem, index: number) => {
|
||||
const primarySelected = props.selectedIndex === index;
|
||||
const selected = primarySelected || props.selectedIndexes.includes(index);
|
||||
const selectionState: ItemSelectionState = {
|
||||
primarySelected,
|
||||
selected,
|
||||
multipleItemsSelected: props.selectedIndexes.length > 1,
|
||||
};
|
||||
|
||||
const selected = props.selectedIndex === index;
|
||||
const focusInList = document.hasFocus() && props.containerRef.current?.contains(document.activeElement);
|
||||
const anchorRef = (focusInList && primarySelected) ? focusListItem : noFocusListItem;
|
||||
const anchorRef = (focusInList && selected) ? focusListItem : noFocusListItem;
|
||||
|
||||
if (item.kind === ListItemType.Tag) {
|
||||
const tag = item.tag;
|
||||
return <TagItem
|
||||
key={item.key}
|
||||
anchorRef={anchorRef}
|
||||
selectionState={selectionState}
|
||||
onClick={onItemClick}
|
||||
selected={selected}
|
||||
onClick={tagItem_click}
|
||||
onTagDrop={onTagDrop_}
|
||||
onContextMenu={onItemContextMenu}
|
||||
label={item.label}
|
||||
@@ -402,7 +383,7 @@ const useOnRenderItem = (props: Props) => {
|
||||
return <FolderItem
|
||||
key={item.key}
|
||||
anchorRef={anchorRef}
|
||||
selectionState={selectionState}
|
||||
selected={selected}
|
||||
folderId={folder.id}
|
||||
folderTitle={item.label}
|
||||
folderIcon={Folder.unserializeIcon(folder.icon)}
|
||||
@@ -414,7 +395,7 @@ const useOnRenderItem = (props: Props) => {
|
||||
onFolderDragOver_={onFolderDragOver_}
|
||||
onFolderDrop_={onFolderDrop_}
|
||||
itemContextMenu={onItemContextMenu}
|
||||
folderItem_click={onItemClick}
|
||||
folderItem_click={folderItem_click}
|
||||
onFolderToggleClick_={onFolderToggleClick_}
|
||||
shareId={folder.share_id}
|
||||
parentId={folder.parent_id}
|
||||
@@ -427,7 +408,7 @@ const useOnRenderItem = (props: Props) => {
|
||||
key={item.id}
|
||||
anchorRef={anchorRef}
|
||||
item={item}
|
||||
selectionState={selectionState}
|
||||
isSelected={selected}
|
||||
onDrop={item.supportsFolderDrop ? onFolderDrop_ : null}
|
||||
index={index}
|
||||
itemCount={itemCount}
|
||||
@@ -436,7 +417,7 @@ const useOnRenderItem = (props: Props) => {
|
||||
return <AllNotesItem
|
||||
key={item.key}
|
||||
anchorRef={anchorRef}
|
||||
selectionState={selectionState}
|
||||
selected={selected}
|
||||
item={item}
|
||||
index={index}
|
||||
itemCount={itemCount}
|
||||
@@ -447,7 +428,7 @@ const useOnRenderItem = (props: Props) => {
|
||||
key={item.key}
|
||||
containerRef={anchorRef}
|
||||
depth={1}
|
||||
selectionState={selectionState}
|
||||
selected={selected}
|
||||
itemIndex={index}
|
||||
itemCount={itemCount}
|
||||
highlightOnHover={false}
|
||||
@@ -461,7 +442,7 @@ const useOnRenderItem = (props: Props) => {
|
||||
return exhaustivenessCheck;
|
||||
}
|
||||
}, [
|
||||
onItemClick,
|
||||
folderItem_click,
|
||||
onFolderDragOver_,
|
||||
onFolderDragStart_,
|
||||
onFolderDrop_,
|
||||
@@ -471,8 +452,8 @@ const useOnRenderItem = (props: Props) => {
|
||||
props.collapsedFolderIds,
|
||||
props.folders,
|
||||
showFolderIcons,
|
||||
tagItem_click,
|
||||
props.selectedIndex,
|
||||
props.selectedIndexes,
|
||||
props.containerRef,
|
||||
itemCount,
|
||||
]);
|
||||
|
||||
@@ -9,7 +9,6 @@ interface Props {
|
||||
listItems: ListItem[];
|
||||
collapsedFolderIds: string[];
|
||||
selectedIndex: number;
|
||||
selectedIndexes: number[];
|
||||
updateSelectedIndex: SetSelectedIndexCallback;
|
||||
}
|
||||
|
||||
@@ -69,7 +68,7 @@ const findNextTypeAheadMatch = (selectedIndex: number, query: string, listItems:
|
||||
};
|
||||
|
||||
const useOnSidebarKeyDownHandler = (props: Props) => {
|
||||
const { updateSelectedIndex, listItems, selectedIndex, selectedIndexes, collapsedFolderIds, dispatch } = props;
|
||||
const { updateSelectedIndex, listItems, selectedIndex, collapsedFolderIds, dispatch } = props;
|
||||
|
||||
return useCallback<KeyboardEventHandler<HTMLElement>>((event) => {
|
||||
const selectedItem = listItems[selectedIndex];
|
||||
@@ -105,15 +104,12 @@ const useOnSidebarKeyDownHandler = (props: Props) => {
|
||||
event.preventDefault();
|
||||
} else if (event.code === 'Home') {
|
||||
event.preventDefault();
|
||||
updateSelectedIndex(0, { extend: false });
|
||||
updateSelectedIndex(0);
|
||||
indexChange = 0;
|
||||
} else if (event.code === 'End') {
|
||||
event.preventDefault();
|
||||
updateSelectedIndex(listItems.length - 1, { extend: false });
|
||||
updateSelectedIndex(listItems.length - 1);
|
||||
indexChange = 0;
|
||||
} else if (event.code === 'Escape' && selectedIndexes.length > 1) {
|
||||
event.preventDefault();
|
||||
updateSelectedIndex(selectedIndex, { extend: false });
|
||||
} else if (event.code === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
void CommandService.instance().execute('focusElement', 'noteList');
|
||||
@@ -126,9 +122,9 @@ const useOnSidebarKeyDownHandler = (props: Props) => {
|
||||
|
||||
if (indexChange !== 0) {
|
||||
event.preventDefault();
|
||||
updateSelectedIndex(selectedIndex + indexChange, { extend: event.shiftKey });
|
||||
updateSelectedIndex(selectedIndex + indexChange);
|
||||
}
|
||||
}, [selectedIndex, selectedIndexes, collapsedFolderIds, listItems, updateSelectedIndex, dispatch]);
|
||||
}, [selectedIndex, collapsedFolderIds, 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;
|
||||
@@ -1,132 +0,0 @@
|
||||
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');
|
||||
|
||||
type UpdateSelectedIndexOptions = { extend: boolean };
|
||||
|
||||
interface Props {
|
||||
dispatch: Dispatch;
|
||||
listItems: ListItem[];
|
||||
|
||||
notesParentType: string;
|
||||
selectedTagId: string;
|
||||
selectedTagIds: string[];
|
||||
selectedFolderId: string;
|
||||
selectedFolderIds: string[];
|
||||
selectedSmartFilterId: string;
|
||||
}
|
||||
|
||||
const useSelectedSidebarIndexes = (props: Props) => {
|
||||
const isIndexInSelection = useCallback((index: number) => {
|
||||
const listItem = props.listItems[index];
|
||||
|
||||
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, {
|
||||
selectedFolderIds: props.selectedFolderIds,
|
||||
notesParentType: props.notesParentType,
|
||||
});
|
||||
} else if (listItem.kind === ListItemType.Tag) {
|
||||
selected = isTagSelected(listItem.tag, { selectedTagIds: props.selectedTagIds, notesParentType: props.notesParentType });
|
||||
} else {
|
||||
const exhaustivenessCheck: never = listItem;
|
||||
return exhaustivenessCheck;
|
||||
}
|
||||
|
||||
return selected;
|
||||
}, [props.listItems, props.selectedFolderIds, props.selectedTagIds, props.selectedSmartFilterId, props.notesParentType]);
|
||||
|
||||
const isIndexPrimarySelected = useCallback((index: number) => {
|
||||
const listItem = props.listItems[index];
|
||||
|
||||
if (listItem.kind === ListItemType.Folder) {
|
||||
return isFolderSelected(listItem.folder, {
|
||||
selectedFolderIds: [props.selectedFolderId],
|
||||
notesParentType: props.notesParentType,
|
||||
});
|
||||
} else if (listItem.kind === ListItemType.Tag) {
|
||||
return isTagSelected(listItem.tag, { selectedTagIds: [props.selectedTagId], notesParentType: props.notesParentType });
|
||||
} else {
|
||||
return isIndexInSelection(index);
|
||||
}
|
||||
}, [props.listItems, isIndexInSelection, props.selectedFolderId, props.selectedTagId, props.notesParentType]);
|
||||
|
||||
const appStateSelectedIndexes = useMemo(() => {
|
||||
const selectedIndexes = [];
|
||||
for (let i = 0; i < props.listItems.length; i++) {
|
||||
if (isIndexInSelection(i)) {
|
||||
selectedIndexes.push(i);
|
||||
}
|
||||
}
|
||||
return selectedIndexes;
|
||||
}, [props.listItems, isIndexInSelection]);
|
||||
|
||||
const appStateSelectedIndex = useMemo(() => {
|
||||
return props.listItems.findIndex((_item, index) => isIndexPrimarySelected(index));
|
||||
}, [props.listItems, isIndexPrimarySelected]);
|
||||
|
||||
// The main index of all selected indexes. This is where the focus will go.
|
||||
// Ignored if not included in appStateSelectedIndexes.
|
||||
const [primarySelectedIndex, setPrimarySelectedIndex] = useState(0);
|
||||
|
||||
// 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);
|
||||
setPrimarySelectedIndex(appStateSelectedIndex);
|
||||
}, [appStateSelectedIndex]);
|
||||
|
||||
const updateSelectedIndex = useCallback((newIndex: number, options: UpdateSelectedIndexOptions) => {
|
||||
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: options.extend ? 'FOLDER_SELECT_ADD' : 'FOLDER_SELECT',
|
||||
id: newItem.folder.id,
|
||||
});
|
||||
} else if (newItem.kind === ListItemType.Tag) {
|
||||
props.dispatch({
|
||||
type: options.extend ? 'TAG_SELECT_ADD' : 'TAG_SELECT',
|
||||
id: newItem.tag.id,
|
||||
});
|
||||
} else {
|
||||
newOverrideIndex = newIndex;
|
||||
}
|
||||
setSelectedIndexOverride(newOverrideIndex);
|
||||
setPrimarySelectedIndex(newIndex);
|
||||
}, [props.listItems, props.dispatch]);
|
||||
|
||||
const selectedIndexes = useMemo(() => {
|
||||
return selectedIndexOverride === -1 ? appStateSelectedIndexes : [selectedIndexOverride];
|
||||
}, [appStateSelectedIndexes, selectedIndexOverride]);
|
||||
const selectedIndex = selectedIndexes.includes(primarySelectedIndex) ? primarySelectedIndex : (selectedIndexes[0] ?? -1);
|
||||
|
||||
return {
|
||||
selectedIndex,
|
||||
selectedIndexes,
|
||||
updateSelectedIndex,
|
||||
};
|
||||
};
|
||||
|
||||
export default useSelectedSidebarIndexes;
|
||||
@@ -9,7 +9,7 @@ import CommandService from '@joplin/lib/services/CommandService';
|
||||
import PerFolderSortOrderService from '../../../services/sortOrder/PerFolderSortOrderService';
|
||||
import { connect } from 'react-redux';
|
||||
import EmptyExpandLink from './EmptyExpandLink';
|
||||
import ListItemWrapper, { ItemSelectionState, ListItemRef } from './ListItemWrapper';
|
||||
import ListItemWrapper, { ListItemRef } from './ListItemWrapper';
|
||||
import { ListItem } from '../types';
|
||||
const { ALL_NOTES_FILTER_ID } = require('@joplin/lib/reserved-ids');
|
||||
|
||||
@@ -19,7 +19,7 @@ const MenuItem = bridge().MenuItem;
|
||||
interface Props {
|
||||
dispatch: Dispatch;
|
||||
anchorRef: ListItemRef;
|
||||
selectionState: ItemSelectionState;
|
||||
selected: boolean;
|
||||
item: ListItem;
|
||||
index: number;
|
||||
itemCount: number;
|
||||
@@ -53,7 +53,7 @@ const AllNotesItem: React.FC<Props> = props => {
|
||||
<ListItemWrapper
|
||||
containerRef={props.anchorRef}
|
||||
key="allNotesHeader"
|
||||
selectionState={props.selectionState}
|
||||
selected={props.selected}
|
||||
depth={props.item.depth}
|
||||
className={'list-item-container list-item-depth-0 all-notes'}
|
||||
highlightOnHover={true}
|
||||
@@ -65,7 +65,7 @@ const AllNotesItem: React.FC<Props> = props => {
|
||||
<StyledListItemAnchor
|
||||
className="list-item"
|
||||
isSpecialItem={true}
|
||||
selected={props.selectionState.selected}
|
||||
selected={props.selected}
|
||||
onClick={onAllNotesClick_}
|
||||
onContextMenu={toggleAllNotesContextMenu}
|
||||
>
|
||||
|
||||
@@ -10,9 +10,8 @@ import Folder from '@joplin/lib/models/Folder';
|
||||
import { ModelType } from '@joplin/lib/BaseModel';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import NoteCount from './NoteCount';
|
||||
import ListItemWrapper, { ItemSelectionState, ListItemRef } from './ListItemWrapper';
|
||||
import ListItemWrapper, { ListItemRef } from './ListItemWrapper';
|
||||
import { useId } from 'react';
|
||||
import { ItemClickEvent } from '../hooks/useOnItemClick';
|
||||
|
||||
const renderFolderIcon = (folderIcon: FolderIcon) => {
|
||||
if (!folderIcon) {
|
||||
@@ -43,17 +42,17 @@ interface FolderItemProps {
|
||||
onFolderDragOver_: ItemDragListener;
|
||||
onFolderDrop_: ItemDragListener;
|
||||
itemContextMenu: ItemContextMenuListener;
|
||||
folderItem_click: (event: ItemClickEvent)=> void;
|
||||
folderItem_click: (folderId: string)=> void;
|
||||
onFolderToggleClick_: ItemClickListener;
|
||||
shareId: string;
|
||||
selectionState: ItemSelectionState;
|
||||
selected: boolean;
|
||||
|
||||
index: number;
|
||||
itemCount: number;
|
||||
}
|
||||
|
||||
function FolderItem(props: FolderItemProps) {
|
||||
const { hasChildren, showFolderIcon, isExpanded, parentId, depth, selectionState, folderId, folderTitle, folderIcon, noteCount, onFolderDragStart_, onFolderDragOver_, onFolderDrop_, itemContextMenu, folderItem_click, onFolderToggleClick_, shareId } = props;
|
||||
const { hasChildren, showFolderIcon, isExpanded, parentId, depth, selected, folderId, folderTitle, folderIcon, noteCount, onFolderDragStart_, onFolderDragOver_, onFolderDrop_, itemContextMenu, folderItem_click, onFolderToggleClick_, shareId } = props;
|
||||
|
||||
const shareTitle = _('Shared');
|
||||
const shareIcon = shareId && !parentId ? <StyledShareIcon aria-label={shareTitle} title={shareTitle} className="fas fa-share-alt"/> : null;
|
||||
@@ -74,11 +73,11 @@ function FolderItem(props: FolderItemProps) {
|
||||
containerRef={props.anchorRef}
|
||||
// Folders are contained within the "Notebooks" section (which has depth 0):
|
||||
depth={depth + 1}
|
||||
selectionState={selectionState}
|
||||
selected={selected}
|
||||
itemIndex={props.index}
|
||||
itemCount={props.itemCount}
|
||||
expanded={hasChildren ? props.isExpanded : undefined}
|
||||
className={`list-item-container list-item-depth-${depth} ${selectionState.selected ? 'selected' : ''}`}
|
||||
className={`list-item-container list-item-depth-${depth} ${selected ? 'selected' : ''}`}
|
||||
highlightOnHover={true}
|
||||
onDragStart={onFolderDragStart_}
|
||||
onDragOver={onFolderDragOver_}
|
||||
@@ -96,15 +95,13 @@ function FolderItem(props: FolderItemProps) {
|
||||
className="list-item"
|
||||
id={titleId}
|
||||
isConflictFolder={folderId === Folder.conflictFolderId()}
|
||||
selected={selectionState.selected}
|
||||
selected={selected}
|
||||
shareId={shareId}
|
||||
data-folder-id={folderId}
|
||||
onDoubleClick={onFolderToggleClick_}
|
||||
|
||||
onClick={(event: React.MouseEvent) => {
|
||||
folderItem_click({
|
||||
id: folderId, type: ModelType.Folder, event,
|
||||
});
|
||||
onClick={() => {
|
||||
folderItem_click(folderId);
|
||||
}}
|
||||
>
|
||||
{doRenderFolderIcon()}<StyledSpanFix className="title">{folderTitle}</StyledSpanFix>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { HeaderId, HeaderListItem } from '../types';
|
||||
import bridge from '../../../services/bridge';
|
||||
import MenuUtils from '@joplin/lib/services/commands/MenuUtils';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import ListItemWrapper, { ItemSelectionState, ListItemRef } from './ListItemWrapper';
|
||||
import ListItemWrapper, { ListItemRef } from './ListItemWrapper';
|
||||
|
||||
const Menu = bridge().Menu;
|
||||
const MenuItem = bridge().MenuItem;
|
||||
@@ -15,7 +15,7 @@ const menuUtils = new MenuUtils(CommandService.instance());
|
||||
interface Props {
|
||||
anchorRef: ListItemRef;
|
||||
item: HeaderListItem;
|
||||
selectionState: ItemSelectionState;
|
||||
isSelected: boolean;
|
||||
onDrop: React.DragEventHandler|null;
|
||||
index: number;
|
||||
itemCount: number;
|
||||
@@ -47,7 +47,7 @@ const HeaderItem: React.FC<Props> = props => {
|
||||
return (
|
||||
<ListItemWrapper
|
||||
containerRef={props.anchorRef}
|
||||
selectionState={props.selectionState}
|
||||
selected={props.isSelected}
|
||||
itemIndex={props.index}
|
||||
itemCount={props.itemCount}
|
||||
expanded={props.item.expanded}
|
||||
|
||||
@@ -4,18 +4,9 @@ import { useMemo } from 'react';
|
||||
|
||||
export type ListItemRef = React.Ref<HTMLDivElement>;
|
||||
|
||||
export interface ItemSelectionState {
|
||||
selected: boolean;
|
||||
// The item with primary selection is used for actions that support only one folder.
|
||||
// Only one item can have primary selection.
|
||||
primarySelected: boolean;
|
||||
|
||||
multipleItemsSelected: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
containerRef: ListItemRef;
|
||||
selectionState: ItemSelectionState;
|
||||
selected: boolean;
|
||||
itemIndex: number;
|
||||
itemCount: number;
|
||||
expanded?: boolean|undefined;
|
||||
@@ -44,17 +35,15 @@ const ListItemWrapper: React.FC<Props> = props => {
|
||||
} as React.CSSProperties;
|
||||
}, [props.depth]);
|
||||
|
||||
const { selected, primarySelected, multipleItemsSelected } = props.selectionState;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={props.containerRef}
|
||||
aria-posinset={props.itemIndex + 1}
|
||||
aria-setsize={props.itemCount}
|
||||
aria-selected={selected}
|
||||
aria-selected={props.selected}
|
||||
aria-expanded={props.expanded}
|
||||
aria-level={props.depth}
|
||||
tabIndex={primarySelected ? 0 : -1}
|
||||
tabIndex={props.selected ? 0 : -1}
|
||||
|
||||
onContextMenu={props.onContextMenu}
|
||||
onDrag={props.onDrag}
|
||||
@@ -64,17 +53,10 @@ const ListItemWrapper: React.FC<Props> = props => {
|
||||
draggable={props.draggable}
|
||||
|
||||
role='treeitem'
|
||||
className={[
|
||||
'list-item-wrapper',
|
||||
props.highlightOnHover ? '-highlight-on-hover' : '',
|
||||
selected ? '-selected' : '',
|
||||
primarySelected && multipleItemsSelected ? '-selected-primary' : '',
|
||||
props.className ?? '',
|
||||
].join(' ')}
|
||||
className={`list-item-wrapper ${props.highlightOnHover ? '-highlight-on-hover' : ''} ${props.selected ? '-selected' : ''} ${props.className ?? ''}`}
|
||||
style={style}
|
||||
data-folder-id={props['data-folder-id']}
|
||||
data-id={props['data-id']}
|
||||
data-index={props.itemIndex}
|
||||
data-tag-id={props['data-tag-id']}
|
||||
data-type={props['data-type']}
|
||||
aria-labelledby={props['aria-labelledby']}
|
||||
|
||||
@@ -3,27 +3,28 @@ import * as React from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { StyledListItemAnchor, StyledSpanFix } from '../styles';
|
||||
import { TagsWithNoteCountEntity } from '@joplin/lib/services/database/types';
|
||||
import BaseModel, { ModelType } from '@joplin/lib/BaseModel';
|
||||
import BaseModel from '@joplin/lib/BaseModel';
|
||||
import NoteCount from './NoteCount';
|
||||
import EmptyExpandLink from './EmptyExpandLink';
|
||||
import ListItemWrapper, { ItemSelectionState, ListItemRef } from './ListItemWrapper';
|
||||
import { ItemClickEvent } from '../hooks/useOnItemClick';
|
||||
import ListItemWrapper, { ListItemRef } from './ListItemWrapper';
|
||||
|
||||
export type TagLinkClickEvent = { tag: TagsWithNoteCountEntity|undefined };
|
||||
|
||||
interface Props {
|
||||
anchorRef: ListItemRef;
|
||||
selectionState: ItemSelectionState;
|
||||
selected: boolean;
|
||||
tag: TagsWithNoteCountEntity;
|
||||
label: string;
|
||||
onTagDrop: React.DragEventHandler<HTMLElement>;
|
||||
onContextMenu: React.MouseEventHandler<HTMLElement>;
|
||||
onClick: (event: ItemClickEvent)=> void;
|
||||
onClick: (event: TagLinkClickEvent)=> void;
|
||||
|
||||
itemCount: number;
|
||||
index: number;
|
||||
}
|
||||
|
||||
const TagItem = (props: Props) => {
|
||||
const { tag, selectionState } = props;
|
||||
const { tag, selected } = props;
|
||||
|
||||
let noteCount = null;
|
||||
if (Setting.value('showNoteCounts')) {
|
||||
@@ -31,31 +32,30 @@ const TagItem = (props: Props) => {
|
||||
noteCount = <NoteCount count={count}/>;
|
||||
}
|
||||
|
||||
const onClickHandler: React.MouseEventHandler<HTMLElement> = useCallback((event) => {
|
||||
props.onClick({ id: tag.id, type: ModelType.Tag, event });
|
||||
const onClickHandler = useCallback(() => {
|
||||
props.onClick({ tag });
|
||||
}, [props.onClick, tag]);
|
||||
|
||||
return (
|
||||
<ListItemWrapper
|
||||
containerRef={props.anchorRef}
|
||||
selectionState={selectionState}
|
||||
selected={selected}
|
||||
depth={1}
|
||||
className={`list-item-container ${selectionState.selected ? 'selected' : ''}`}
|
||||
className={`list-item-container ${selected ? 'selected' : ''}`}
|
||||
highlightOnHover={true}
|
||||
onDrop={props.onTagDrop}
|
||||
onContextMenu={props.onContextMenu}
|
||||
data-id={tag.id}
|
||||
data-tag-id={tag.id}
|
||||
data-type={ModelType.Tag}
|
||||
aria-selected={selected}
|
||||
itemIndex={props.index}
|
||||
itemCount={props.itemCount}
|
||||
>
|
||||
<EmptyExpandLink/>
|
||||
<StyledListItemAnchor
|
||||
className="list-item"
|
||||
selected={selectionState.selected}
|
||||
selected={selected}
|
||||
data-id={tag.id}
|
||||
data-type={BaseModel.TYPE_TAG}
|
||||
onContextMenu={props.onContextMenu}
|
||||
onClick={onClickHandler}
|
||||
>
|
||||
<StyledSpanFix className="tag-label">{props.label}</StyledSpanFix>
|
||||
|
||||
@@ -22,30 +22,7 @@
|
||||
background: var(--joplin-selected-color2);
|
||||
}
|
||||
|
||||
// When multiple items are selected, show an outline (similar to the focus outline) to indicate
|
||||
// which folder has the primary selection.
|
||||
&.-selected-primary {
|
||||
--outline-color: var(--joplin-focus-outline-color-dimmed);
|
||||
outline: 1px solid var(--outline-color);
|
||||
|
||||
// Also adjust the background color: This makes it clearer which item has primary focus,
|
||||
// especially when using a dimmed outline.
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--outline-color) 12%,
|
||||
var(--joplin-selected-color2) 92%
|
||||
);
|
||||
|
||||
// For accessibility, use a different style when actually focused. This makes it easier to
|
||||
// tell where the keyboard focus is.
|
||||
&:focus {
|
||||
--outline-color: var(--joplin-focus-outline-color);
|
||||
}
|
||||
}
|
||||
|
||||
// Don't highlight selected items on hover -- doing so makes it
|
||||
// difficult to tell whether the hovered item is selected or not.
|
||||
&.-highlight-on-hover:not(.-selected):hover {
|
||||
&.-highlight-on-hover:hover {
|
||||
background-color: var(--joplin-background-color-hover2);
|
||||
}
|
||||
}
|
||||
@@ -57,10 +57,8 @@ export interface SpacerListItem extends ToplevelListItem {
|
||||
|
||||
export type ListItem = HeaderListItem|AllNotesListItem|TagListItem|FolderListItem|SpacerListItem;
|
||||
|
||||
interface SetSelectedIndexOptions {
|
||||
extend: boolean;
|
||||
}
|
||||
export type SetSelectedIndexCallback = (newIndex: number, options: SetSelectedIndexOptions)=> void;
|
||||
|
||||
export type SetSelectedIndexCallback = (newIndex: number)=> void;
|
||||
|
||||
|
||||
export type ItemDragListener = DragEventHandler<HTMLElement>;
|
||||
|
||||
@@ -2,7 +2,6 @@ import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import bridge from '../../../services/bridge';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
import { getTrashFolderId } from '@joplin/lib/services/trash';
|
||||
const { substrWithEllipsis } = require('@joplin/lib/string-utils');
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
@@ -12,37 +11,22 @@ export const declaration: CommandDeclaration = {
|
||||
|
||||
export const runtime = (): CommandRuntime => {
|
||||
return {
|
||||
execute: async (context: CommandContext, folderIds: string|string[] = null) => {
|
||||
if (folderIds === null) {
|
||||
folderIds = context.state.selectedFolderIds;
|
||||
}
|
||||
if (!Array.isArray(folderIds)) {
|
||||
folderIds = [folderIds];
|
||||
execute: async (context: CommandContext, folderId: string = null) => {
|
||||
if (folderId === null) folderId = context.state.selectedFolderId;
|
||||
|
||||
const folder = await Folder.load(folderId);
|
||||
if (!folder) throw new Error(`No such folder: ${folderId}`);
|
||||
|
||||
let deleteMessage = _('Move notebook "%s" to the trash?\n\nAll notes and sub-notebooks within this notebook will also be moved to the trash.', substrWithEllipsis(folder.title, 0, 32));
|
||||
if (folderId === context.state.settings['sync.10.inboxId']) {
|
||||
deleteMessage = _('Delete the Inbox notebook?\n\nIf you delete the inbox notebook, any email that\'s recently been sent to it may be lost.');
|
||||
}
|
||||
|
||||
folderIds = folderIds.filter(id => id !== getTrashFolderId());
|
||||
if (folderIds.length === 0) {
|
||||
throw new Error('Nothing to do: At least one valid folder must be specified.');
|
||||
}
|
||||
|
||||
const folders = await Folder.loadItemsByIdsOrFail(folderIds);
|
||||
|
||||
const deleteMessage = [];
|
||||
if (folders.length === 1) {
|
||||
deleteMessage.push(_('Move notebook "%s" to the trash?\n\nAll notes and sub-notebooks within this notebook will also be moved to the trash.', substrWithEllipsis(folders[0].title, 0, 32)));
|
||||
} else {
|
||||
deleteMessage.push(_('Move %d notebooks to the trash?\n\nAll notes and sub-notebooks within these notebooks will also be moved to the trash.', folders.length));
|
||||
}
|
||||
|
||||
if (folders.some(folder => folder.id === context.state.settings['sync.10.inboxId'])) {
|
||||
deleteMessage.push(_('Delete the Inbox notebook?\n\nIf you delete the inbox notebook, any email that\'s recently been sent to it may be lost.'));
|
||||
}
|
||||
|
||||
const ok = bridge().showConfirmMessageBox(deleteMessage.join('\n\n'));
|
||||
const ok = bridge().showConfirmMessageBox(deleteMessage);
|
||||
if (!ok) return;
|
||||
|
||||
await Folder.batchDelete(folderIds, { toTrash: true, sourceDescription: 'deleteFolder command' });
|
||||
await Folder.delete(folderId, { toTrash: true, sourceDescription: 'deleteFolder command' });
|
||||
},
|
||||
enabledCondition: '!foldersIncludeReadOnly',
|
||||
enabledCondition: '!folderIsReadOnly',
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { utils, CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
|
||||
export const newNoteEnabledConditions = 'oneFolderSelected && !inConflictFolder && !folderIsReadOnly && !folderIsTrash';
|
||||
|
||||
@@ -14,7 +14,7 @@ export const declaration: CommandDeclaration = {
|
||||
export const runtime = (): CommandRuntime => {
|
||||
return {
|
||||
execute: async (_context: CommandContext, body = '', isTodo = false) => {
|
||||
const folderId = await Folder.getValidActiveFolder();
|
||||
const folderId = Setting.value('activeFolderId');
|
||||
if (!folderId) return;
|
||||
|
||||
const defaultValues = Note.previewFieldsWithDefaultValues({ includeTimestamps: false });
|
||||
|
||||
@@ -12,15 +12,13 @@ export const declaration: CommandDeclaration = {
|
||||
|
||||
export const runtime = (): CommandRuntime => {
|
||||
return {
|
||||
execute: async (context: CommandContext, folderIds: string|string[] = null) => {
|
||||
if (folderIds === null) folderIds = context.state.selectedFolderIds;
|
||||
if (!Array.isArray(folderIds)) {
|
||||
folderIds = [folderIds];
|
||||
}
|
||||
execute: async (context: CommandContext, folderId: string = null) => {
|
||||
if (folderId === null) folderId = context.state.selectedFolderId;
|
||||
|
||||
const folders = await Folder.loadItemsByIdsOrFail(folderIds);
|
||||
await restoreItems(ModelType.Folder, folders);
|
||||
const folder = await Folder.load(folderId);
|
||||
if (!folder) throw new Error(`No such folder: ${folderId}`);
|
||||
await restoreItems(ModelType.Folder, [folder]);
|
||||
},
|
||||
enabledCondition: 'foldersAreDeleted',
|
||||
enabledCondition: 'folderIsDeleted',
|
||||
};
|
||||
};
|
||||
|
||||
@@ -79,9 +79,6 @@ export default function useMarkupToHtml(deps: HookDependencies) {
|
||||
|
||||
return resourceFullPath(resources[id].item, resourceBaseUrl) + urlParameters;
|
||||
},
|
||||
globalSettings: {
|
||||
'markdown.plugin.abc.options': Setting.value('markdown.plugin.abc.options'),
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
|
||||
@@ -18,4 +18,3 @@
|
||||
@use './joplin-cloud-sign-up.scss';
|
||||
@use './popup-notification-list.scss';
|
||||
@use './popup-notification-item.scss';
|
||||
@use './multi-note-actions.scss';
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
|
||||
.multi-note-actions {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
padding-top: var(--joplin-margin-top);
|
||||
width: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
@@ -38,7 +38,7 @@ describe('NoteListUtils', () => {
|
||||
const mockStore = {
|
||||
getState: () => {
|
||||
return {
|
||||
...createAppDefaultWindowState(),
|
||||
...createAppDefaultWindowState(null),
|
||||
settings: {},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import activateMainMenuItem from '../util/activateMainMenuItem';
|
||||
import type MainScreen from './MainScreen';
|
||||
import { ElectronApplication, Locator, Page } from '@playwright/test';
|
||||
import expect from '../util/extendedExpect';
|
||||
|
||||
export default class Sidebar {
|
||||
public readonly container: Locator;
|
||||
@@ -43,14 +42,4 @@ export default class Sidebar {
|
||||
await this.sortByDate(electronApp);
|
||||
await this.sortByTitle(electronApp);
|
||||
}
|
||||
|
||||
// Checks the indentation level of each folder. Useful for determining whether folders are subfolders.
|
||||
public async expectToHaveDepths(folderToDepth: [Locator, number][]) {
|
||||
for (let i = 0; i < folderToDepth.length; i++) {
|
||||
const [folder, depth] = folderToDepth[i];
|
||||
await expect(
|
||||
folder, { message: `Folder ${i} should have depth ${depth}.` },
|
||||
).toHaveJSProperty('ariaLevel', String(depth));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,12 +81,10 @@ test.describe('sidebar', () => {
|
||||
await folderDHeader.dragTo(folderCHeader);
|
||||
|
||||
// Folders should have correct initial levels
|
||||
await sidebar.expectToHaveDepths([
|
||||
[folderAHeader, 2],
|
||||
[folderBHeader, 3],
|
||||
[folderCHeader, 3],
|
||||
[folderDHeader, 4],
|
||||
]);
|
||||
await expect(folderAHeader).toHaveJSProperty('ariaLevel', '2');
|
||||
await expect(folderBHeader).toHaveJSProperty('ariaLevel', '3');
|
||||
await expect(folderCHeader).toHaveJSProperty('ariaLevel', '3');
|
||||
await expect(folderDHeader).toHaveJSProperty('ariaLevel', '4');
|
||||
|
||||
await sidebar.forceUpdateSorting(electronApp);
|
||||
await folderBHeader.click();
|
||||
@@ -188,87 +186,4 @@ test.describe('sidebar', () => {
|
||||
await testFolderA.dblclick();
|
||||
await expect(testFolderB).toBeVisible();
|
||||
});
|
||||
|
||||
test('should be possible to select, then deselect, multiple folders with cmd-click', async ({ mainWindow, electronApp }) => {
|
||||
const mainScreen = await new MainScreen(mainWindow).setup();
|
||||
const sidebar = mainScreen.sidebar;
|
||||
|
||||
const folderA = await sidebar.createNewFolder('Folder A');
|
||||
const folderB = await sidebar.createNewFolder('Folder B');
|
||||
const folderC = await sidebar.createNewFolder('Folder C');
|
||||
const folderD = await sidebar.createNewFolder('Folder D');
|
||||
|
||||
await sidebar.forceUpdateSorting(electronApp);
|
||||
|
||||
await folderA.click();
|
||||
await folderB.click({ modifiers: ['ControlOrMeta'] });
|
||||
await folderC.click({ modifiers: ['ControlOrMeta'] });
|
||||
|
||||
await expect(folderA).toBeSelected();
|
||||
await expect(folderB).toBeSelected();
|
||||
await expect(folderC).toBeSelected();
|
||||
await expect(folderD).toHaveJSProperty('ariaSelected', 'false');
|
||||
|
||||
// Should be able to deselect up to two folders
|
||||
await folderA.click({ modifiers: ['ControlOrMeta'] });
|
||||
await expect(folderA).toHaveJSProperty('ariaSelected', 'false');
|
||||
await folderB.click({ modifiers: ['ControlOrMeta'] });
|
||||
await expect(folderB).toHaveJSProperty('ariaSelected', 'false');
|
||||
// Should not be possible to deselect the last folder
|
||||
await folderC.click({ modifiers: ['ControlOrMeta'] });
|
||||
await expect(folderC).toBeSelected();
|
||||
});
|
||||
|
||||
test('should be possible to move multiple folders at once with drag and drop', async ({ mainWindow, electronApp }) => {
|
||||
const mainScreen = await new MainScreen(mainWindow).setup();
|
||||
const sidebar = mainScreen.sidebar;
|
||||
|
||||
const folderA = await sidebar.createNewFolder('Folder A');
|
||||
const folderB = await sidebar.createNewFolder('Folder B');
|
||||
const folderC = await sidebar.createNewFolder('Folder C');
|
||||
const folderD = await sidebar.createNewFolder('Folder D');
|
||||
|
||||
await sidebar.forceUpdateSorting(electronApp);
|
||||
|
||||
await folderB.click();
|
||||
await folderC.click({ modifiers: ['ControlOrMeta'] });
|
||||
|
||||
await expect(folderB).toBeSelected();
|
||||
await expect(folderC).toBeSelected();
|
||||
|
||||
await folderB.dragTo(folderA);
|
||||
|
||||
// Should have made folder B **and folder C** subfolders of testFolderA
|
||||
await sidebar.expectToHaveDepths([
|
||||
[folderA, 2],
|
||||
[folderB, 3],
|
||||
[folderC, 3],
|
||||
[folderD, 2],
|
||||
]);
|
||||
});
|
||||
|
||||
test('should not move selected folders when dragging an unselected folder', async ({ mainWindow, electronApp }) => {
|
||||
const mainScreen = await new MainScreen(mainWindow).setup();
|
||||
const sidebar = mainScreen.sidebar;
|
||||
|
||||
const testFolderA = await sidebar.createNewFolder('Folder A');
|
||||
const testFolderB = await sidebar.createNewFolder('Folder B');
|
||||
const testFolderC = await sidebar.createNewFolder('Folder C');
|
||||
|
||||
await sidebar.forceUpdateSorting(electronApp);
|
||||
|
||||
await testFolderB.click();
|
||||
await testFolderC.click({ modifiers: ['ControlOrMeta'] });
|
||||
|
||||
await expect(testFolderB).toBeSelected();
|
||||
await expect(testFolderC).toBeSelected();
|
||||
|
||||
await testFolderA.dragTo(testFolderB);
|
||||
|
||||
await sidebar.expectToHaveDepths([
|
||||
[testFolderB, 2],
|
||||
[testFolderA, 3],
|
||||
[testFolderC, 2],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,26 +54,6 @@ const extendedExpect = expect.extend({
|
||||
name: assertionName,
|
||||
};
|
||||
},
|
||||
|
||||
async toBeSelected(locator: Locator) {
|
||||
let pass = true;
|
||||
|
||||
const assertionName = 'toBeSelected';
|
||||
let resultMessage = () => `${assertionName}: Passed`;
|
||||
|
||||
try {
|
||||
await extendedExpect(locator).toHaveJSProperty('ariaSelected', 'true');
|
||||
} catch (error) {
|
||||
pass = false;
|
||||
resultMessage = () => error.toString();
|
||||
}
|
||||
|
||||
return {
|
||||
pass,
|
||||
message: () => `${assertionName}: ${resultMessage()}`,
|
||||
name: assertionName,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default extendedExpect;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/app-desktop",
|
||||
"version": "3.5.7",
|
||||
"version": "3.5.6",
|
||||
"description": "Joplin for Desktop",
|
||||
"main": "main.bundle.js",
|
||||
"private": true,
|
||||
@@ -119,8 +119,7 @@
|
||||
"category": "Office",
|
||||
"desktop": {
|
||||
"Icon": "joplin",
|
||||
"MimeType": "x-scheme-handler/joplin;",
|
||||
"StartupWMClass": "@joplin/app-desktop"
|
||||
"MimeType": "x-scheme-handler/joplin;"
|
||||
},
|
||||
"target": [
|
||||
"AppImage",
|
||||
@@ -145,7 +144,7 @@
|
||||
"@joplin/renderer": "~3.5",
|
||||
"@joplin/tools": "~3.5",
|
||||
"@joplin/utils": "~3.5",
|
||||
"@playwright/test": "1.54.2",
|
||||
"@playwright/test": "1.53.2",
|
||||
"@sentry/electron": "4.24.0",
|
||||
"@testing-library/react-hooks": "8.0.1",
|
||||
"@types/jest": "29.5.14",
|
||||
@@ -160,8 +159,9 @@
|
||||
"codemirror": "5.65.9",
|
||||
"color": "3.2.1",
|
||||
"compare-versions": "6.1.1",
|
||||
"countable": "3.0.1",
|
||||
"debounce": "1.2.1",
|
||||
"electron": "39.0.0",
|
||||
"electron": "37.7.0",
|
||||
"electron-builder": "24.13.3",
|
||||
"electron-updater": "6.6.2",
|
||||
"electron-window-state": "5.0.3",
|
||||
@@ -179,7 +179,7 @@
|
||||
"md5": "2.3.0",
|
||||
"moment": "2.30.1",
|
||||
"mustache": "4.2.0",
|
||||
"nan": "2.23.0",
|
||||
"nan": "2.22.2",
|
||||
"node-notifier": "10.0.1",
|
||||
"node-rsa": "1.1.1",
|
||||
"pdfjs-dist": "3.11.174",
|
||||
|
||||
@@ -25,7 +25,7 @@ async function main() {
|
||||
// wrong one. However it means it will have to be manually upgraded for each
|
||||
// new Electron release. Some ABI map there:
|
||||
// https://github.com/electron/node-abi/tree/master/test
|
||||
const forceAbiArgs = '--force-abi 142';
|
||||
const forceAbiArgs = '--force-abi 138';
|
||||
|
||||
if (isWindows()) {
|
||||
// Cannot run this in parallel, or the 64-bit version might end up
|
||||
|
||||
@@ -2,7 +2,7 @@ import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import goToNote, { GotoNoteOptions } from './util/goToNote';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
|
||||
const logger = Logger.create('newNoteCommand');
|
||||
|
||||
@@ -13,7 +13,7 @@ export const declaration: CommandDeclaration = {
|
||||
export const runtime = (): CommandRuntime => {
|
||||
return {
|
||||
execute: async (_context: CommandContext, body = '', todo = false, options: GotoNoteOptions = null) => {
|
||||
const folderId = await Folder.getValidActiveFolder();
|
||||
const folderId = Setting.value('activeFolderId');
|
||||
if (!folderId) {
|
||||
logger.warn('Not creating new note -- no active folder ID.');
|
||||
return;
|
||||
|
||||
@@ -73,7 +73,6 @@ const useSettingButtonInfo = (setSettingsVisible: SetSettingsVisible) => {
|
||||
name: 'showToolbarSettings',
|
||||
tooltip: _('Settings'),
|
||||
iconName: 'material cogs',
|
||||
visible: true,
|
||||
enabled: true,
|
||||
onClick: () => setSettingsVisible(true),
|
||||
title: '',
|
||||
|
||||
@@ -20,8 +20,6 @@ const builtInCommandNames = [
|
||||
EditorCommandType.ToggleBulletedList,
|
||||
EditorCommandType.ToggleCheckList,
|
||||
'-',
|
||||
`editor.${EditorCommandType.InsertTable}`,
|
||||
'-',
|
||||
EditorCommandType.IndentLess,
|
||||
EditorCommandType.IndentMore,
|
||||
`editor.${EditorCommandType.SwapLineDown}`,
|
||||
|
||||
@@ -122,7 +122,6 @@ const useStyles = (theme: Theme) => {
|
||||
backgroundColor: theme.backgroundColor4,
|
||||
color: theme.color4,
|
||||
margin: 2,
|
||||
width: 90, // Reduce the min width for mobile screens in portrait
|
||||
},
|
||||
buttonText: buttonTextStyle,
|
||||
activeButtonText: {
|
||||
@@ -344,7 +343,7 @@ export const SearchPanel = (props: SearchPanelProps) => {
|
||||
);
|
||||
|
||||
const simpleLayout = (
|
||||
<View style={{ flexDirection: 'row', flexShrink: 1 }}>
|
||||
<View style={{ flexDirection: 'row' }}>
|
||||
{ closeButton }
|
||||
{ searchTextInput }
|
||||
{ showDetailsButton }
|
||||
@@ -354,7 +353,7 @@ export const SearchPanel = (props: SearchPanelProps) => {
|
||||
);
|
||||
|
||||
const advancedLayout = (
|
||||
<View style={{ flexDirection: 'column', flexShrink: 1 }}>
|
||||
<View style={{ flexDirection: 'column' }}>
|
||||
<View style={{ flexDirection: 'row' }}>
|
||||
{ closeButton }
|
||||
{ labeledSearchInput }
|
||||
|
||||
@@ -9,30 +9,16 @@ const markdownEditorOnlyCommands = [
|
||||
EditorCommandType.SwapLineDown,
|
||||
].map(command => `editor.${command}`);
|
||||
|
||||
|
||||
|
||||
const richTextEditorOnlyCommands = [
|
||||
EditorCommandType.InsertTable,
|
||||
].map(command => `editor.${command}`);
|
||||
|
||||
export const visibleCondition = (commandName: string) => {
|
||||
const output = [];
|
||||
export const enabledCondition = (commandName: string) => {
|
||||
const output = [
|
||||
'!noteIsReadOnly',
|
||||
];
|
||||
|
||||
if (markdownEditorOnlyCommands.includes(commandName)) {
|
||||
output.push('!richTextEditorVisible');
|
||||
}
|
||||
|
||||
if (richTextEditorOnlyCommands.includes(commandName)) {
|
||||
output.push('!markdownEditorPaneVisible');
|
||||
}
|
||||
|
||||
return output.join(' && ');
|
||||
};
|
||||
|
||||
export const enabledCondition = (commandName: string) => {
|
||||
return [
|
||||
visibleCondition(commandName), '!noteIsReadOnly',
|
||||
].filter(c => !!c).join('&&');
|
||||
return output.filter(c => !!c).join(' && ');
|
||||
};
|
||||
|
||||
const headerDeclarations = () => {
|
||||
@@ -112,11 +98,6 @@ const declarations: CommandDeclaration[] = [
|
||||
label: () => _('Task list'),
|
||||
iconName: 'material format-list-checks',
|
||||
},
|
||||
{
|
||||
name: `editor.${EditorCommandType.InsertTable}`,
|
||||
label: () => _('Table'),
|
||||
iconName: 'material table',
|
||||
},
|
||||
{
|
||||
name: EditorCommandType.IndentLess,
|
||||
label: () => _('Decrease indent level'),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import CommandService, { CommandContext, CommandDeclaration } from '@joplin/lib/services/CommandService';
|
||||
import { EditorControl } from '@joplin/editor/types';
|
||||
import useNowEffect from '@joplin/lib/hooks/useNowEffect';
|
||||
import commandDeclarations, { enabledCondition, visibleCondition } from '../commandDeclarations';
|
||||
import commandDeclarations, { enabledCondition } from '../commandDeclarations';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
|
||||
const logger = Logger.create('useEditorCommandHandler');
|
||||
@@ -30,7 +30,6 @@ const commandRuntime = (declaration: CommandDeclaration, editor: EditorControl)
|
||||
return await editor.execCommand(commandName, ...args);
|
||||
},
|
||||
enabledCondition: enabledCondition(declaration.name),
|
||||
visibleCondition: visibleCondition(declaration.name),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -43,7 +43,8 @@ interface Props {
|
||||
folders: FolderEntity[];
|
||||
profileConfig: ProfileConfig;
|
||||
inboxJopId: string;
|
||||
selectedFolderIds: string[];
|
||||
selectedFolderId: string;
|
||||
selectedTagId: string;
|
||||
}
|
||||
|
||||
const syncIconRotationValue = new Animated.Value(0);
|
||||
@@ -563,7 +564,7 @@ const SideMenuContentComponent = (props: Props) => {
|
||||
hasChildren={hasChildren}
|
||||
depth={depth}
|
||||
collapsed={props.collapsedFolderIds.includes(folder.id)}
|
||||
selected={isFolderSelected(folder, { selectedFolderIds: props.selectedFolderIds, notesParentType: props.notesParentType })}
|
||||
selected={isFolderSelected(folder, { selectedFolderId: props.selectedFolderId, notesParentType: props.notesParentType })}
|
||||
styles={styles_}
|
||||
folder={folder}
|
||||
alwaysShowFolderIcons={alwaysShowFolderIcons}
|
||||
@@ -729,7 +730,8 @@ export default connect((state: AppState) => {
|
||||
folders: state.folders,
|
||||
syncStarted: state.syncStarted,
|
||||
syncReport: state.syncReport,
|
||||
selectedFolderIds: state.selectedFolderIds,
|
||||
selectedFolderId: state.selectedFolderId,
|
||||
selectedTagId: state.selectedTagId,
|
||||
notesParentType: state.notesParentType,
|
||||
locale: state.settings.locale,
|
||||
themeId: state.settings.theme,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { MarkupLanguage, MarkupToHtml } from '@joplin/renderer';
|
||||
import type { MarkupToHtmlConverter, RenderOptions, RenderOptionsGlobalSettings, FsDriver as RendererFsDriver, ResourceInfos } from '@joplin/renderer/types';
|
||||
import type { MarkupToHtmlConverter, RenderOptions, FsDriver as RendererFsDriver, ResourceInfos } from '@joplin/renderer/types';
|
||||
import makeResourceModel from './utils/makeResourceModel';
|
||||
import addPluginAssets from './utils/addPluginAssets';
|
||||
import { ExtraContentScriptSource, ForwardedJoplinSettings, MarkupRecord } from '../types';
|
||||
@@ -32,7 +32,6 @@ export interface RenderSettings {
|
||||
destroyEditPopupSyntax: string;
|
||||
|
||||
pluginSettings: Record<string, unknown>;
|
||||
globalSettings?: RenderOptionsGlobalSettings;
|
||||
requestPluginSetting: (pluginId: string, settingKey: string)=> void;
|
||||
readAssetBlob: (assetPath: string)=> Promise<Blob>;
|
||||
}
|
||||
@@ -136,7 +135,6 @@ export default class Renderer {
|
||||
splitted: settings.splitted,
|
||||
mapsToLine: settings.mapsToLine,
|
||||
whiteBackgroundNoteRendering: markup.language === MarkupLanguage.Html,
|
||||
globalSettings: settings.globalSettings,
|
||||
};
|
||||
|
||||
const pluginSettingsCacheKey = JSON.stringify(settings.pluginSettings);
|
||||
|
||||
@@ -150,7 +150,7 @@ const addPluginAssets = async (assets: RenderResultPluginAsset[], options: Optio
|
||||
} catch (error) {
|
||||
// We don't throw an exception but we log it since
|
||||
// it shouldn't happen
|
||||
console.warn('Tried to remove an asset but got an error. On asset:', asset, error);
|
||||
console.warn('Tried to remove an asset but got an error', error);
|
||||
}
|
||||
pluginAssetsAdded_[assetId] = null;
|
||||
}
|
||||
|
||||
@@ -228,9 +228,6 @@ const useWebViewSetup = (props: Props): Result => {
|
||||
return shim.fsDriver().fileAtPath(resolvedPath);
|
||||
},
|
||||
removeUnusedPluginAssets: options.removeUnusedPluginAssets,
|
||||
globalSettings: {
|
||||
'markdown.plugin.abc.options': Setting.value('markdown.plugin.abc.options'),
|
||||
},
|
||||
});
|
||||
|
||||
await transferResources(options.resources);
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"@joplin/renderer": "~3.5",
|
||||
"@joplin/utils": "~3.5",
|
||||
"@react-native-clipboard/clipboard": "1.16.3",
|
||||
"@react-native-community/datetimepicker": "8.4.4",
|
||||
"@react-native-community/datetimepicker": "8.4.3",
|
||||
"@react-native-community/geolocation": "3.4.0",
|
||||
"@react-native-community/netinfo": "11.4.1",
|
||||
"@react-native-community/push-notification-ios": "1.11.0",
|
||||
@@ -53,13 +53,13 @@
|
||||
"react": "19.0.0",
|
||||
"react-native": "0.79.2",
|
||||
"react-native-device-info": "14.0.4",
|
||||
"react-native-dropdownalert": "5.2.0",
|
||||
"react-native-dropdownalert": "5.1.0",
|
||||
"react-native-exit-app": "2.0.0",
|
||||
"react-native-file-viewer": "2.1.5",
|
||||
"react-native-fs": "2.20.0",
|
||||
"react-native-get-random-values": "1.11.0",
|
||||
"react-native-image-picker": "8.2.1",
|
||||
"react-native-localize": "3.5.2",
|
||||
"react-native-localize": "3.4.2",
|
||||
"react-native-modal-datetime-picker": "18.0.0",
|
||||
"react-native-paper": "5.14.5",
|
||||
"react-native-popup-menu": "0.17.0",
|
||||
@@ -109,13 +109,13 @@
|
||||
"@types/node": "18.19.130",
|
||||
"@types/react": "19.0.14",
|
||||
"@types/react-redux": "7.1.33",
|
||||
"@types/serviceworker": "0.0.150",
|
||||
"@types/serviceworker": "0.0.149",
|
||||
"@types/tar-stream": "3.1.4",
|
||||
"babel-jest": "29.7.0",
|
||||
"babel-loader": "9.1.3",
|
||||
"babel-plugin-module-resolver": "4.1.0",
|
||||
"babel-plugin-react-native-web": "0.20.0",
|
||||
"esbuild": "0.25.9",
|
||||
"esbuild": "0.25.8",
|
||||
"fast-deep-equal": "3.1.3",
|
||||
"fs-extra": "11.2.0",
|
||||
"gulp": "4.0.2",
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
module.exports = `KCgpID0+IHsKCWxldCBpbml0RG9uZV8gPSBmYWxzZTsKCgljb25zdCBnZXRMaWJyYXJ5ID0gKCkgPT4gewoJCXJldHVybiB3aW5kb3c/LkFCQ0pTOwoJfTsKCgljb25zdCBnZXRPcHRpb25zID0gKGVsZW1lbnQpID0+IHsKCQljb25zdCBvcHRpb25zID0gZWxlbWVudC5nZXRBdHRyaWJ1dGUoJ2RhdGEtYWJjLW9wdGlvbnMnKTsKCgkJaWYgKG9wdGlvbnMpIHsKCQkJdHJ5IHsKCQkJCXJldHVybiBKU09OLnBhcnNlKG9wdGlvbnMpOwoJCQl9IGNhdGNoIChlcnJvcikgewoJCQkJY29uc29sZS5lcnJvcignQ291bGQgbm90IHBhcnNlIEFCQyBvcHRpb25zOicsIG9wdGlvbnMsIGVycm9yKTsKCQkJfQoJCX0KCgkJcmV0dXJuIHt9OwoJfTsKCgljb25zdCBpbml0aWFsaXplID0gKCkgPT4gewoJCWlmIChpbml0RG9uZV8pIHJldHVybiB0cnVlOwoKCQljb25zdCBsaWIgPSBnZXRMaWJyYXJ5KCk7CgkJaWYgKCFsaWIpIHJldHVybiBmYWxzZTsKCgkJaW5pdERvbmVfID0gdHJ1ZTsKCgkJY29uc3QgZWxlbWVudHMgPSBkb2N1bWVudC5nZXRFbGVtZW50c0J5Q2xhc3NOYW1lKCdqb3BsaW4tYWJjLW5vdGF0aW9uJyk7CgoJCWZvciAoY29uc3QgZWxlbWVudCBvZiBlbGVtZW50cykgewoJCQljb25zdCBzb3VyY2VFbGVtZW50ID0gZWxlbWVudC5xdWVyeVNlbGVjdG9yKCcuam9wbGluLXNvdXJjZScpOwoJCQljb25zdCByZW5kZXJlZEVsZW1lbnQgPSBlbGVtZW50LnF1ZXJ5U2VsZWN0b3IoJy5qb3BsaW4tcmVuZGVyZWQnKTsKCQkJY29uc3Qgb3B0aW9ucyA9IGdldE9wdGlvbnMoc291cmNlRWxlbWVudCk7CgkJCWxpYi5yZW5kZXJBYmMocmVuZGVyZWRFbGVtZW50LCBzb3VyY2VFbGVtZW50LnRleHRDb250ZW50LCB7IC4uLm9wdGlvbnMgfSk7CgkJfQoKCQlyZXR1cm4gdHJ1ZTsKCX07CgoJZG9jdW1lbnQuYWRkRXZlbnRMaXN0ZW5lcignam9wbGluLW5vdGVEaWRVcGRhdGUnLCAoKSA9PiB7CgkJaW5pdERvbmVfID0gZmFsc2U7CgkJaW5pdGlhbGl6ZSgpOwoJfSk7CgoJY29uc3QgaW5pdElJRF8gPSBzZXRJbnRlcnZhbCgoKSA9PiB7CgkJaWYgKGluaXRpYWxpemUoKSkgY2xlYXJJbnRlcnZhbChpbml0SUlEXyk7Cgl9LCAxMDApOwoKCWRvY3VtZW50LmFkZEV2ZW50TGlzdGVuZXIoJ0RPTUNvbnRlbnRMb2FkZWQnLCAoKSA9PiB7CgkJaWYgKGluaXRpYWxpemUoKSkgY2xlYXJJbnRlcnZhbChpbml0SUlEXyk7Cgl9KTsKfSkoKTsKCg==`;
|
||||
File diff suppressed because one or more lines are too long
@@ -1,7 +1,5 @@
|
||||
module.exports = {
|
||||
hash:"ae606ff7fac0daba38235dc8e1c205ba", files: {
|
||||
'abc/abc_render.js': { data: require('./abc/abc_render.js.base64.js'), mime: 'application/javascript', encoding: 'base64' },
|
||||
'abc/abcjs-basic-min.js': { data: require('./abc/abcjs-basic-min.js.base64.js'), mime: 'application/javascript', encoding: 'base64' },
|
||||
hash:"987efee92a78bb62fe4f889c5b38aceb", files: {
|
||||
'highlight.js/atom-one-dark-reasonable.css': { data: require('./highlight.js/atom-one-dark-reasonable.css.base64.js'), mime: 'text/css', encoding: 'base64' },
|
||||
'highlight.js/atom-one-light.css': { data: require('./highlight.js/atom-one-light.css.base64.js'), mime: 'text/css', encoding: 'base64' },
|
||||
'katex/fonts/KaTeX_AMS-Regular.woff2': { data: require('./katex/fonts/KaTeX_AMS-Regular.woff2.base64.js'), mime: 'application/octet-stream', encoding: 'base64' },
|
||||
|
||||
@@ -1 +1 @@
|
||||
module.exports = {"hash":"ae606ff7fac0daba38235dc8e1c205ba","files":["abc/abc_render.js","abc/abcjs-basic-min.js","highlight.js/atom-one-dark-reasonable.css","highlight.js/atom-one-light.css","katex/fonts/KaTeX_AMS-Regular.woff2","katex/fonts/KaTeX_Caligraphic-Bold.woff2","katex/fonts/KaTeX_Caligraphic-Regular.woff2","katex/fonts/KaTeX_Fraktur-Bold.woff2","katex/fonts/KaTeX_Fraktur-Regular.woff2","katex/fonts/KaTeX_Main-Bold.woff2","katex/fonts/KaTeX_Main-BoldItalic.woff2","katex/fonts/KaTeX_Main-Italic.woff2","katex/fonts/KaTeX_Main-Regular.woff2","katex/fonts/KaTeX_Math-BoldItalic.woff2","katex/fonts/KaTeX_Math-Italic.woff2","katex/fonts/KaTeX_SansSerif-Bold.woff2","katex/fonts/KaTeX_SansSerif-Italic.woff2","katex/fonts/KaTeX_SansSerif-Regular.woff2","katex/fonts/KaTeX_Script-Regular.woff2","katex/fonts/KaTeX_Size1-Regular.woff2","katex/fonts/KaTeX_Size2-Regular.woff2","katex/fonts/KaTeX_Size3-Regular.woff2","katex/fonts/KaTeX_Size4-Regular.woff2","katex/fonts/KaTeX_Typewriter-Regular.woff2","katex/katex.css","mermaid/mermaid.min.js","mermaid/mermaid_render.js"]}
|
||||
module.exports = {"hash":"987efee92a78bb62fe4f889c5b38aceb","files":["highlight.js/atom-one-dark-reasonable.css","highlight.js/atom-one-light.css","katex/fonts/KaTeX_AMS-Regular.woff2","katex/fonts/KaTeX_Caligraphic-Bold.woff2","katex/fonts/KaTeX_Caligraphic-Regular.woff2","katex/fonts/KaTeX_Fraktur-Bold.woff2","katex/fonts/KaTeX_Fraktur-Regular.woff2","katex/fonts/KaTeX_Main-Bold.woff2","katex/fonts/KaTeX_Main-BoldItalic.woff2","katex/fonts/KaTeX_Main-Italic.woff2","katex/fonts/KaTeX_Main-Regular.woff2","katex/fonts/KaTeX_Math-BoldItalic.woff2","katex/fonts/KaTeX_Math-Italic.woff2","katex/fonts/KaTeX_SansSerif-Bold.woff2","katex/fonts/KaTeX_SansSerif-Italic.woff2","katex/fonts/KaTeX_SansSerif-Regular.woff2","katex/fonts/KaTeX_Script-Regular.woff2","katex/fonts/KaTeX_Size1-Regular.woff2","katex/fonts/KaTeX_Size2-Regular.woff2","katex/fonts/KaTeX_Size3-Regular.woff2","katex/fonts/KaTeX_Size4-Regular.woff2","katex/fonts/KaTeX_Typewriter-Regular.woff2","katex/katex.css","mermaid/mermaid.min.js","mermaid/mermaid_render.js"]}
|
||||
File diff suppressed because one or more lines are too long
@@ -82,13 +82,11 @@ const appReducer = (state = appDefaultState, action: any) => {
|
||||
|
||||
if ('folderId' in action) {
|
||||
newState.selectedFolderId = action.folderId;
|
||||
newState.selectedFolderIds = [action.folderId];
|
||||
newState.notesParentType = 'Folder';
|
||||
}
|
||||
|
||||
if ('tagId' in action) {
|
||||
newState.selectedTagId = action.tagId;
|
||||
newState.selectedTagIds = [action.tagId];
|
||||
newState.notesParentType = 'Tag';
|
||||
}
|
||||
|
||||
|
||||
@@ -45,15 +45,6 @@ const editorCommands: Record<EditorCommandType, EditorCommandFunction> = {
|
||||
[EditorCommandType.ToggleHeading4]: toggleHeaderLevel(4),
|
||||
[EditorCommandType.ToggleHeading5]: toggleHeaderLevel(5),
|
||||
[EditorCommandType.InsertHorizontalRule]: insertHorizontalRule,
|
||||
[EditorCommandType.InsertTable]: editor => {
|
||||
replaceSelectionCommand(editor, [
|
||||
'',
|
||||
'| | |',
|
||||
'|----|----|',
|
||||
'| | |',
|
||||
'',
|
||||
].join('\n'));
|
||||
},
|
||||
|
||||
[EditorCommandType.ScrollSelectionIntoView]: editor => {
|
||||
editor.dispatch(editor.state.update({
|
||||
|
||||
@@ -22,11 +22,11 @@ const createEditor = async (initialMarkdown: string, hasImage: boolean) => {
|
||||
};
|
||||
|
||||
const findImages = (editor: EditorView) => {
|
||||
return editor.dom.querySelectorAll<HTMLImageElement>('div.cm-md-image > .image');
|
||||
return editor.dom.querySelectorAll<HTMLDivElement>('div.cm-md-image > .image');
|
||||
};
|
||||
|
||||
const getImageUrls = (editor: EditorView) => {
|
||||
return [...findImages(editor)].map(image => image.getAttribute('src'));
|
||||
const getImageBackgroundUrls = (editor: EditorView) => {
|
||||
return [...findImages(editor)].map(image => image.style.backgroundImage);
|
||||
};
|
||||
|
||||
describe('renderBlockImages', () => {
|
||||
@@ -60,9 +60,9 @@ describe('renderBlockImages', () => {
|
||||
const editor = await createEditor('\n', true);
|
||||
|
||||
// Should have the expected original image URLs
|
||||
expect(getImageUrls(editor)).toMatchObject([
|
||||
':/a123456789abcdef0123456789abcdef?r=0',
|
||||
':/b123456789abcdef0123456789abcde2?r=0',
|
||||
expect(getImageBackgroundUrls(editor)).toMatchObject([
|
||||
'url(:/a123456789abcdef0123456789abcdef?r=0)',
|
||||
'url(:/b123456789abcdef0123456789abcde2?r=0)',
|
||||
]);
|
||||
|
||||
editor.dispatch({
|
||||
@@ -70,9 +70,9 @@ describe('renderBlockImages', () => {
|
||||
});
|
||||
await allowImageUrlsToBeFetched();
|
||||
|
||||
expect(getImageUrls(editor)).toMatchObject([
|
||||
':/a123456789abcdef0123456789abcdef?r=1',
|
||||
':/b123456789abcdef0123456789abcde2?r=0',
|
||||
expect(getImageBackgroundUrls(editor)).toMatchObject([
|
||||
'url(:/a123456789abcdef0123456789abcdef?r=1)',
|
||||
'url(:/b123456789abcdef0123456789abcde2?r=0)',
|
||||
]);
|
||||
|
||||
editor.dispatch({
|
||||
@@ -83,80 +83,9 @@ describe('renderBlockImages', () => {
|
||||
});
|
||||
await allowImageUrlsToBeFetched();
|
||||
|
||||
expect(getImageUrls(editor)).toMatchObject([
|
||||
':/a123456789abcdef0123456789abcdef?r=2',
|
||||
':/b123456789abcdef0123456789abcde2?r=1',
|
||||
expect(getImageBackgroundUrls(editor)).toMatchObject([
|
||||
'url(:/a123456789abcdef0123456789abcdef?r=2)',
|
||||
'url(:/b123456789abcdef0123456789abcde2?r=1)',
|
||||
]);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ spaceBefore: '', spaceAfter: '\n\n', alt: 'test', width: null },
|
||||
{ spaceBefore: '', spaceAfter: '', alt: 'This is a test!', width: null },
|
||||
{ spaceBefore: ' ', spaceAfter: ' ', alt: 'test', width: null },
|
||||
{ spaceBefore: '', spaceAfter: '', alt: '!!!!', width: '500' },
|
||||
])('should render HTML img tags (case %#)', async ({ spaceBefore, spaceAfter, alt, width }) => {
|
||||
const widthAttr = width ? ` width="${width}"` : '';
|
||||
const editor = await createEditor(
|
||||
`${spaceBefore}<img src=":/0123456789abcdef0123456789abcdef" alt="${alt}"${widthAttr} />${spaceAfter}`,
|
||||
false,
|
||||
);
|
||||
|
||||
const images = findImages(editor);
|
||||
expect(images).toHaveLength(1);
|
||||
expect(images[0].role).toBe('image');
|
||||
expect(images[0].ariaLabel).toBe(alt);
|
||||
|
||||
if (width) {
|
||||
expect(images[0].style.width).toBe(`${width}px`);
|
||||
expect(images[0].style.height).toBe('auto');
|
||||
} else {
|
||||
expect(images[0].style.width).toBe('');
|
||||
}
|
||||
});
|
||||
|
||||
test('should render non-self-closing HTML img tags', async () => {
|
||||
const editor = await createEditor(
|
||||
'<img src=":/0123456789abcdef0123456789abcdef" alt="test" width="300">',
|
||||
false,
|
||||
);
|
||||
|
||||
const images = findImages(editor);
|
||||
expect(images).toHaveLength(1);
|
||||
expect(images[0].style.width).toBe('300px');
|
||||
});
|
||||
|
||||
test('should not render HTML img tags with web URLs', async () => {
|
||||
const editor = await createEditor(
|
||||
'<img src="https://example.com/test.png" alt="test" />',
|
||||
false,
|
||||
);
|
||||
|
||||
const images = findImages(editor);
|
||||
expect(images).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('should render both markdown and HTML images in same document', async () => {
|
||||
const editor = await createEditor(
|
||||
'\n\n<img src=":/b123456789abcdef0123456789abcde2" alt="html" width="400" />',
|
||||
true,
|
||||
);
|
||||
|
||||
const images = findImages(editor);
|
||||
expect(images).toHaveLength(2);
|
||||
expect(images[0].style.width).toBe(''); // markdown - no width
|
||||
expect(images[1].style.width).toBe('400px'); // HTML with width
|
||||
});
|
||||
|
||||
test('should render HTML img tags with single-quoted attributes', async () => {
|
||||
const editor = await createEditor(
|
||||
// eslint-disable-next-line quotes
|
||||
"<img src=':/0123456789abcdef0123456789abcdef' alt='test' width='250' />",
|
||||
false,
|
||||
);
|
||||
|
||||
const images = findImages(editor);
|
||||
expect(images).toHaveLength(1);
|
||||
expect(images[0].ariaLabel).toBe('test');
|
||||
expect(images[0].style.width).toBe('250px');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,9 @@ import { RenderedContentContext } from './types';
|
||||
import makeBlockReplaceExtension from './utils/makeBlockReplaceExtension';
|
||||
|
||||
const imageClassName = 'cm-md-image';
|
||||
// Pre-set the image height for performance (allows CodeMirror to better calculate
|
||||
// the document height while scrolling).
|
||||
const imageHeight = 200;
|
||||
|
||||
class ImageWidget extends WidgetType {
|
||||
private resolvedSrc_: string;
|
||||
@@ -14,36 +17,26 @@ class ImageWidget extends WidgetType {
|
||||
private readonly src_: string,
|
||||
private readonly alt_: string,
|
||||
private readonly reloadCounter_ = 0,
|
||||
private readonly width_: string | null = null,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
public eq(other: ImageWidget) {
|
||||
return this.src_ === other.src_ && this.alt_ === other.alt_ && this.reloadCounter_ === other.reloadCounter_ && this.width_ === other.width_;
|
||||
return this.src_ === other.src_ && this.alt_ === other.alt_ && this.reloadCounter_ === other.reloadCounter_;
|
||||
}
|
||||
|
||||
public updateDOM(dom: HTMLElement): boolean {
|
||||
const image = dom.querySelector<HTMLImageElement>('img.image');
|
||||
const image = dom.querySelector<HTMLDivElement>('div.image');
|
||||
if (!image) return false;
|
||||
|
||||
image.ariaLabel = this.alt_;
|
||||
image.role = 'image';
|
||||
|
||||
// Apply width if specified, otherwise clear it
|
||||
if (this.width_) {
|
||||
image.style.width = `${this.width_}px`;
|
||||
image.style.height = 'auto';
|
||||
} else {
|
||||
image.style.width = '';
|
||||
image.style.height = '';
|
||||
}
|
||||
|
||||
const updateImageUrl = () => {
|
||||
if (this.resolvedSrc_) {
|
||||
// Use a background-image style property rather than img[src=]. This
|
||||
// simplifies setting the image to the correct size/position.
|
||||
image.src = this.resolvedSrc_;
|
||||
image.style.backgroundImage = `url(${JSON.stringify(this.resolvedSrc_)})`;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -63,7 +56,7 @@ class ImageWidget extends WidgetType {
|
||||
const container = document.createElement('div');
|
||||
container.classList.add(imageClassName);
|
||||
|
||||
const image = document.createElement('img');
|
||||
const image = document.createElement('div');
|
||||
image.classList.add('image');
|
||||
|
||||
container.appendChild(image);
|
||||
@@ -73,7 +66,7 @@ class ImageWidget extends WidgetType {
|
||||
}
|
||||
|
||||
public get estimatedHeight() {
|
||||
return -1;
|
||||
return imageHeight;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,39 +93,6 @@ const getImageAlt = (node: SyntaxNodeRef, state: EditorState) => {
|
||||
}
|
||||
};
|
||||
|
||||
interface HtmlImageInfo {
|
||||
src: string;
|
||||
alt: string | null;
|
||||
width: string | null;
|
||||
}
|
||||
|
||||
const parseHtmlImage = (node: SyntaxNodeRef, state: EditorState): HtmlImageInfo | null => {
|
||||
const nodeText = state.sliceDoc(node.from, node.to);
|
||||
|
||||
// Check if this is an img tag (handles both /> and > closing styles)
|
||||
if (!nodeText.match(/<img\s/i)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract src (only Joplin resource images, accepts single or double quotes)
|
||||
const srcMatch = nodeText.match(/src=(["'])(:\/[a-zA-Z0-9]{32})\1/i);
|
||||
if (!srcMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract alt attribute (optional, accepts single or double quotes)
|
||||
const altMatch = nodeText.match(/alt=(["'])([^"']*)\1/i);
|
||||
|
||||
// Extract width attribute (optional, accepts single or double quotes)
|
||||
const widthMatch = nodeText.match(/width=(["'])(\d+)\1/i);
|
||||
|
||||
return {
|
||||
src: srcMatch[2],
|
||||
alt: altMatch ? altMatch[2] : null,
|
||||
width: widthMatch ? widthMatch[2] : null,
|
||||
};
|
||||
};
|
||||
|
||||
// In Electron: To work around browser caching, these counters should continue to increase even if an old
|
||||
// editor is destroyed and a new one is created in the same window.
|
||||
const imageToRefreshCounters = new Map<string, number>();
|
||||
@@ -145,50 +105,29 @@ export const testing__resetImageRefreshCounterCache = () => {
|
||||
|
||||
const renderBlockImages = (context: RenderedContentContext) => [
|
||||
EditorView.theme({
|
||||
[`& .${imageClassName} > .image`]: {
|
||||
maxWidth: '100%',
|
||||
minWidth: 0,
|
||||
[`& .${imageClassName} > div`]: {
|
||||
height: `${imageHeight}px`,
|
||||
backgroundSize: 'contain',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: 'center',
|
||||
display: 'block',
|
||||
|
||||
// Center
|
||||
marginLeft: 'auto',
|
||||
marginRight: 'auto',
|
||||
},
|
||||
}),
|
||||
makeBlockReplaceExtension({
|
||||
createDecoration: (node, state) => {
|
||||
// Handle both markdown images and HTML img tags
|
||||
if (node.name === 'Image' || node.name === 'HTMLTag' || node.name === 'HTMLBlock') {
|
||||
if (node.name === 'Image') {
|
||||
const lineFrom = state.doc.lineAt(node.from);
|
||||
const lineTo = state.doc.lineAt(node.to);
|
||||
const textBefore = state.sliceDoc(lineFrom.from, node.from);
|
||||
const textAfter = state.sliceDoc(node.to, lineTo.to);
|
||||
|
||||
// Only render images on their own line
|
||||
if (textBefore.trim() === '' && textAfter.trim() === '') {
|
||||
let src: string | null = null;
|
||||
let alt: string | null = null;
|
||||
let width: string | null = null;
|
||||
|
||||
// Parse image data based on node type
|
||||
if (node.name === 'Image') {
|
||||
// Markdown image: 
|
||||
src = getImageSrc(node, state);
|
||||
alt = getImageAlt(node, state);
|
||||
} else {
|
||||
// HTML img tag: <img src="..." alt="..." width="..." />
|
||||
const imageInfo = parseHtmlImage(node, state);
|
||||
if (imageInfo) {
|
||||
src = imageInfo.src;
|
||||
alt = imageInfo.alt;
|
||||
width = imageInfo.width;
|
||||
}
|
||||
}
|
||||
const src = getImageSrc(node, state);
|
||||
const alt = getImageAlt(node, state);
|
||||
|
||||
if (src) {
|
||||
const isLastLine = lineTo.number === state.doc.lines;
|
||||
return Decoration.widget({
|
||||
widget: new ImageWidget(context, src, alt, imageToRefreshCounters.get(src) ?? 0, width),
|
||||
widget: new ImageWidget(context, src, alt, imageToRefreshCounters.get(src) ?? 0),
|
||||
// "side: -1": In general, when the cursor is at the widget's location, it should be at
|
||||
// the start of the next line (and so "side" should be -1).
|
||||
//
|
||||
@@ -203,7 +142,6 @@ const renderBlockImages = (context: RenderedContentContext) => [
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
getDecorationRange: (node, state) => {
|
||||
|
||||
@@ -77,20 +77,4 @@ describe('ProseMirror/commands', () => {
|
||||
expect(jumpToHash('test-heading-2')).toBe(true);
|
||||
expect(editor.state.selection.$anchor.parent.textContent).toBe('Test heading 2');
|
||||
});
|
||||
|
||||
test('textTable should insert a table', () => {
|
||||
const editor = createTestEditor({ html: '<p></p>' });
|
||||
|
||||
commands[EditorCommandType.InsertTable](editor.state, editor.dispatch, editor);
|
||||
|
||||
expect(editor.state.doc.toJSON()).toMatchObject({
|
||||
content: [{
|
||||
content: [
|
||||
{ type: 'table_row' },
|
||||
{ type: 'table_row' },
|
||||
],
|
||||
type: 'table',
|
||||
}],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,9 +12,8 @@ import { EditorEventType } from '../../events';
|
||||
import extractSelectedLinesTo from '../utils/extractSelectedLinesTo';
|
||||
import { EditorView } from 'prosemirror-view';
|
||||
import jumpToHash from '../utils/jumpToHash';
|
||||
import focusEditor from './focusEditor';
|
||||
import insertRenderedMarkdown from '../utils/insertRenderedMarkdown';
|
||||
import canReplaceSelectionWith from '../utils/canReplaceSelectionWith';
|
||||
import focusEditor from './focusEditor';
|
||||
|
||||
type Dispatch = (tr: Transaction)=> void;
|
||||
type ExtendedCommand = (state: EditorState, dispatch: Dispatch, view?: EditorView, options?: string[])=> boolean;
|
||||
@@ -75,6 +74,7 @@ const toggleCode: Command = (state, dispatch, view) => {
|
||||
return toggleMark(schema.marks.code)(state, dispatch, view) || setBlockType(schema.nodes.paragraph)(state, dispatch, view);
|
||||
};
|
||||
|
||||
|
||||
const listItemTypes = [schema.nodes.list_item, schema.nodes.task_list_item];
|
||||
|
||||
const commands: Record<EditorCommandType, ExtendedCommand|null> = {
|
||||
@@ -86,17 +86,24 @@ const commands: Record<EditorCommandType, ExtendedCommand|null> = {
|
||||
[EditorCommandType.ToggleItalicized]: toggleMark(schema.marks.emphasis),
|
||||
[EditorCommandType.ToggleCode]: toggleCode,
|
||||
[EditorCommandType.ToggleMath]: (state, _dispatch, view) => {
|
||||
const renderer = getEditorApi(state).renderer;
|
||||
const selectedText = state.doc.textBetween(state.selection.from, state.selection.to);
|
||||
|
||||
const block = selectedText.includes('\n');
|
||||
const nodeType = block ? schema.nodes.joplinEditableBlock : schema.nodes.joplinEditableInline;
|
||||
|
||||
if (canReplaceSelectionWith(state.selection, nodeType)) {
|
||||
if (view) {
|
||||
void (async () => {
|
||||
const separator = block ? '$$' : '$';
|
||||
void insertRenderedMarkdown(view,
|
||||
`${separator}${selectedText}${separator}`,
|
||||
);
|
||||
}
|
||||
const rendered = await renderer.renderMarkupToHtml(`${separator}${selectedText}${separator}`, {
|
||||
forceMarkdown: true,
|
||||
isFullPageRender: false,
|
||||
});
|
||||
|
||||
if (view) {
|
||||
view.pasteHTML(rendered.html);
|
||||
}
|
||||
})();
|
||||
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -114,29 +121,6 @@ const commands: Record<EditorCommandType, ExtendedCommand|null> = {
|
||||
[EditorCommandType.ToggleHeading4]: toggleHeading(4),
|
||||
[EditorCommandType.ToggleHeading5]: toggleHeading(5),
|
||||
[EditorCommandType.InsertHorizontalRule]: null,
|
||||
[EditorCommandType.InsertTable]: (state, dispatch, view) => {
|
||||
if (view) {
|
||||
// See https://github.com/ProseMirror/prosemirror-tables/issues/91
|
||||
const tr = state.tr.replaceSelectionWith(
|
||||
schema.nodes.table.create(null, [
|
||||
schema.nodes.table_row.create(null, [
|
||||
schema.nodes.table_header.createAndFill(),
|
||||
schema.nodes.table_header.createAndFill(),
|
||||
]),
|
||||
schema.nodes.table_row.create(null, [
|
||||
schema.nodes.table_cell.createAndFill(),
|
||||
schema.nodes.table_cell.createAndFill(),
|
||||
]),
|
||||
]),
|
||||
);
|
||||
|
||||
if (dispatch) {
|
||||
dispatch(tr);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
[EditorCommandType.ToggleSearch]: (state, dispatch, view) => {
|
||||
const command = setSearchVisible(!getSearchVisible(state));
|
||||
return command(state, dispatch, view);
|
||||
|
||||
@@ -9,8 +9,3 @@ table .selectedCell {
|
||||
th > p:only-child, td > p:only-child {
|
||||
margin: unset;
|
||||
}
|
||||
|
||||
/* Tables used with ProseMirror do not have a thead, so zebra striping needs be styled separately to the global noteStyle css */
|
||||
table tr:nth-child(odd) {
|
||||
background-color: var(--joplin-table-background-color);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { EditorView } from 'prosemirror-view';
|
||||
import { getEditorApi } from '../plugins/joplinEditorApiPlugin';
|
||||
|
||||
const insertRenderedMarkdown = async (
|
||||
view: EditorView,
|
||||
markdown: string,
|
||||
) => {
|
||||
const renderer = getEditorApi(view.state).renderer;
|
||||
|
||||
const rendered = await renderer.renderMarkupToHtml(markdown, {
|
||||
forceMarkdown: true,
|
||||
isFullPageRender: false,
|
||||
});
|
||||
view.pasteHTML(rendered.html);
|
||||
};
|
||||
|
||||
export default insertRenderedMarkdown;
|
||||
@@ -30,7 +30,6 @@ export enum EditorCommandType {
|
||||
ToggleHeading5 = 'textHeading5',
|
||||
|
||||
InsertHorizontalRule = 'textHorizontalRule',
|
||||
InsertTable = 'textTable',
|
||||
|
||||
// Find commands
|
||||
ToggleSearch = 'textSearch',
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"author": "Laurent Cozic",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@adobe/css-tools": "4.4.4",
|
||||
"@adobe/css-tools": "4.4.3",
|
||||
"@joplin/fork-htmlparser2": "^4.1.60",
|
||||
"datauri": "4.1.0",
|
||||
"fs-extra": "11.2.0",
|
||||
|
||||
@@ -474,7 +474,7 @@ export default class BaseApplication {
|
||||
refreshNotesUseSelectedNoteId = true;
|
||||
}
|
||||
|
||||
if (action.type === 'HISTORY_BACKWARD' || action.type === 'HISTORY_FORWARD' || action.type === 'FOLDER_SELECT' || action.type === 'FOLDER_SELECT_ADD' || action.type === 'FOLDER_SELECT_REMOVE' || action.type === 'FOLDER_DELETE' || action.type === 'FOLDER_AND_NOTE_SELECT' || (action.type === 'SEARCH_UPDATE' && newState.notesParentType === 'Folder')) {
|
||||
if (action.type === 'HISTORY_BACKWARD' || action.type === 'HISTORY_FORWARD' || action.type === 'FOLDER_SELECT' || action.type === 'FOLDER_DELETE' || action.type === 'FOLDER_AND_NOTE_SELECT' || (action.type === 'SEARCH_UPDATE' && newState.notesParentType === 'Folder')) {
|
||||
Setting.setValue('activeFolderId', newState.selectedFolderId);
|
||||
this.currentFolder_ = newState.selectedFolderId ? await Folder.load(newState.selectedFolderId) : null;
|
||||
refreshNotes = true;
|
||||
|
||||
@@ -80,7 +80,6 @@ export default class JoplinServerApi {
|
||||
const optionSession = this.options_.session();
|
||||
|
||||
if (optionSession) {
|
||||
this.session_ = optionSession;
|
||||
return optionSession;
|
||||
}
|
||||
|
||||
|
||||
@@ -369,7 +369,7 @@ class WebDavApi {
|
||||
// The "solution", an ugly one, is to send a purposely invalid string as eTag, which will bypass the If-None-Match check - Seafile
|
||||
// finds out that no resource has this ID and simply sends the requested data.
|
||||
// Also add a random value to make sure the eTag is unique for each call.
|
||||
if (['GET', 'HEAD'].indexOf(method) < 0) headers['If-None-Match'] = `"JoplinIgnore-${Math.floor(Math.random() * 100000)}"`;
|
||||
if (['GET', 'HEAD'].indexOf(method) < 0) headers['If-None-Match'] = `JoplinIgnore-${Math.floor(Math.random() * 100000)}`;
|
||||
if (!headers['User-Agent']) headers['User-Agent'] = 'Joplin/1.0';
|
||||
|
||||
const fetchOptions = {};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { _ } from '../../locale';
|
||||
import Setting from '../../models/Setting';
|
||||
import shim from '../../shim';
|
||||
import { authenticateWithCode } from '../../SyncTargetJoplinServerSAML';
|
||||
@@ -7,15 +6,7 @@ import SsoScreenShared from './SsoScreenShared';
|
||||
|
||||
export default class SamlShared implements SsoScreenShared {
|
||||
public openLoginPage() {
|
||||
const samlUrl = Setting.value('sync.11.path');
|
||||
if (!samlUrl) {
|
||||
const message = _('No URL for SAML authentication set.');
|
||||
void shim.showErrorDialog(message);
|
||||
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
shim.openUrl(`${prefixWithHttps(samlUrl)}/login/sso-saml-app`);
|
||||
shim.openUrl(`${prefixWithHttps(Setting.value('sync.11.path'))}/login/sso-saml-app`);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
|
||||
@@ -6,11 +6,11 @@ export type RenderFolderItem<T> = (folder: FolderEntity, hasChildren: boolean, d
|
||||
export type RenderTagItem<T> = (tag: TagsWithNoteCountEntity)=> T;
|
||||
|
||||
interface FolderSelectedContext {
|
||||
selectedFolderIds: string[];
|
||||
selectedFolderId: string;
|
||||
notesParentType: string;
|
||||
}
|
||||
export const isFolderSelected = (folder: FolderEntity, context: FolderSelectedContext) => {
|
||||
return context.selectedFolderIds.includes(folder.id) && context.notesParentType === 'Folder';
|
||||
return context.selectedFolderId === folder.id && context.notesParentType === 'Folder';
|
||||
};
|
||||
|
||||
|
||||
@@ -105,11 +105,11 @@ const sortTags = (tags: TagEntity[]) => {
|
||||
};
|
||||
|
||||
interface TagSelectedContext {
|
||||
selectedTagIds: string[];
|
||||
selectedTagId: string;
|
||||
notesParentType: string;
|
||||
}
|
||||
export const isTagSelected = (tag: TagEntity, context: TagSelectedContext) => {
|
||||
return context.selectedTagIds.includes(tag.id) && context.notesParentType === 'Tag';
|
||||
return context.selectedTagId === tag.id && context.notesParentType === 'Tag';
|
||||
};
|
||||
|
||||
export const renderTags = <T> (unsortedTags: TagsWithNoteCountEntity[], renderItem: RenderTagItem<T>): ItemsWithOrder<T> => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const shim = require('./shim').default;
|
||||
const Promise = require('promise');
|
||||
|
||||
class DatabaseDriverNode {
|
||||
open(options) {
|
||||
|
||||
@@ -275,18 +275,6 @@ export default class BaseItem extends BaseModel {
|
||||
return output;
|
||||
}
|
||||
|
||||
public static async loadItemsByIdsOrFail(ids: string[]) {
|
||||
const items = await this.loadItemsByIds(ids);
|
||||
if (items.length < ids.length) {
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
if (items[i]?.id !== ids[i]) {
|
||||
throw new Error(`No such item: ${ids[i]}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
public static async loadItemsByTypeAndIds(itemType: ModelType, ids: string[], options: LoadOptions = null): Promise<any[]> {
|
||||
if (!ids.length) return [];
|
||||
|
||||
@@ -3,7 +3,6 @@ import { FolderEntity } from '../services/database/types';
|
||||
import { createNTestNotes, setupDatabaseAndSynchronizer, sleep, switchClient, checkThrowAsync, createFolderTree, simulateReadOnlyShareEnv, expectThrow, withWarningSilenced } from '../testing/test-utils';
|
||||
import Folder from './Folder';
|
||||
import Note from './Note';
|
||||
import Setting from './Setting';
|
||||
|
||||
async function allItems() {
|
||||
const folders = await Folder.all();
|
||||
@@ -391,22 +390,6 @@ describe('models/Folder', () => {
|
||||
expect((await Note.load(note3.id)).deleted_time).toBe(0);
|
||||
});
|
||||
|
||||
it('should allow deleting multiple folders to trash', async () => {
|
||||
const folder1 = await Folder.save({});
|
||||
const folder2 = await Folder.save({});
|
||||
const folder3 = await Folder.save({ parent_id: folder2.id });
|
||||
const folder4 = await Folder.save({ parent_id: folder2.id });
|
||||
|
||||
const beforeTime = Date.now();
|
||||
await Folder.batchDelete([folder1.id, folder2.id, folder3.id], { toTrash: true, deleteChildren: true });
|
||||
|
||||
const folders = await Folder.loadItemsByIds([folder1.id, folder2.id, folder3.id, folder4.id]);
|
||||
expect(folders[0].deleted_time).toBeGreaterThanOrEqual(beforeTime);
|
||||
expect(folders[1].deleted_time).toBeGreaterThanOrEqual(beforeTime);
|
||||
expect(folders[2].deleted_time).toBeGreaterThanOrEqual(beforeTime);
|
||||
expect(folders[3].deleted_time).toBeGreaterThanOrEqual(beforeTime);
|
||||
});
|
||||
|
||||
it('should delete and set the parent ID', async () => {
|
||||
const folder1 = await Folder.save({});
|
||||
const folder2 = await Folder.save({});
|
||||
@@ -457,40 +440,4 @@ describe('models/Folder', () => {
|
||||
expect(Folder.atLeastOneRealFolderExists(folders)).toBe(false);
|
||||
});
|
||||
|
||||
it('should get active folder when activeFolderId is valid', async () => {
|
||||
const activeFolder = await Folder.save({ title: 'folder' });
|
||||
Setting.setValue('activeFolderId', activeFolder.id);
|
||||
|
||||
const validFolder = await Folder.getValidActiveFolder();
|
||||
expect(validFolder).toBe(activeFolder.id);
|
||||
});
|
||||
|
||||
it('should get default folder when activeFolderId is trashed', async () => {
|
||||
const defaultFolder = await Folder.save({ title: 'default' });
|
||||
const activeFolder = await Folder.save({ title: 'folder' });
|
||||
await Folder.delete(activeFolder.id, { toTrash: true });
|
||||
Setting.setValue('activeFolderId', activeFolder.id);
|
||||
|
||||
const validFolder = await Folder.getValidActiveFolder();
|
||||
expect(validFolder).toBe(defaultFolder.id);
|
||||
});
|
||||
|
||||
it('should get no folder when activeFolderId is undefined', async () => {
|
||||
Setting.setValue('activeFolderId', undefined);
|
||||
|
||||
const validFolder = await Folder.getValidActiveFolder();
|
||||
expect(validFolder).toBeNull();
|
||||
});
|
||||
|
||||
it('should get no folder when activeFolderId is trashed and there are no other not trashed folders', async () => {
|
||||
const activeFolder = await Folder.save({ title: 'folder' });
|
||||
const otherFolder = await Folder.save({ title: 'other' });
|
||||
await Folder.delete(activeFolder.id, { toTrash: true });
|
||||
await Folder.delete(otherFolder.id, { toTrash: true });
|
||||
Setting.setValue('activeFolderId', activeFolder.id);
|
||||
|
||||
const validFolder = await Folder.getValidActiveFolder();
|
||||
expect(validFolder).toBeNull();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -18,7 +18,6 @@ import { getTrashFolder } from '../services/trash';
|
||||
import getConflictFolderId from './utils/getConflictFolderId';
|
||||
import getTrashFolderId from '../services/trash/getTrashFolderId';
|
||||
import { getCollator } from './utils/getCollator';
|
||||
import Setting from './Setting';
|
||||
const { substrWithEllipsis } = require('../string-utils.js');
|
||||
|
||||
const logger = Logger.create('models/Folder');
|
||||
@@ -115,21 +114,21 @@ export default class Folder extends BaseItem {
|
||||
}
|
||||
}
|
||||
|
||||
public static async batchDelete(folderIds: string[], options: DeleteOptions): Promise<void> {
|
||||
public static async delete(folderId: string, options?: DeleteOptions) {
|
||||
options = {
|
||||
deleteChildren: true,
|
||||
...options,
|
||||
};
|
||||
|
||||
if (folderIds.includes(getTrashFolderId())) throw new Error('The trash folder cannot be deleted');
|
||||
if (folderId === getTrashFolderId()) throw new Error('The trash folder cannot be deleted');
|
||||
|
||||
const toTrash = !!options.toTrash;
|
||||
|
||||
const folders: FolderEntity[] = await Folder.loadItemsByIds(folderIds);
|
||||
if (!folders.length) return; // noop
|
||||
const folder = await Folder.load(folderId);
|
||||
if (!folder) return; // noop
|
||||
|
||||
const actionLogger = ActionLogger.from(options.sourceDescription);
|
||||
actionLogger.addDescription(`folder titles: ${JSON.stringify(folders.map(folder => folder.title))}`);
|
||||
actionLogger.addDescription(`folder title: ${JSON.stringify(folder.title)}`);
|
||||
options.sourceDescription = actionLogger;
|
||||
|
||||
if (options.deleteChildren) {
|
||||
@@ -140,37 +139,28 @@ export default class Folder extends BaseItem {
|
||||
toTrash,
|
||||
};
|
||||
|
||||
for (const folderId of folderIds) {
|
||||
const noteIds = await Folder.noteIds(folderId);
|
||||
await Note.batchDelete(noteIds, childrenDeleteOptions);
|
||||
const noteIds = await Folder.noteIds(folderId);
|
||||
await Note.batchDelete(noteIds, childrenDeleteOptions);
|
||||
|
||||
const subFolderIds = await Folder.subFolderIds(folderId);
|
||||
await Folder.batchDelete(subFolderIds, childrenDeleteOptions);
|
||||
const subFolderIds = await Folder.subFolderIds(folderId);
|
||||
for (let i = 0; i < subFolderIds.length; i++) {
|
||||
await Folder.delete(subFolderIds[i], childrenDeleteOptions);
|
||||
}
|
||||
}
|
||||
|
||||
if (toTrash) {
|
||||
for (const folderId of folderIds) {
|
||||
const newFolder: FolderEntity = { id: folderId, deleted_time: Date.now() };
|
||||
if ('toTrashParentId' in options) newFolder.parent_id = options.toTrashParentId;
|
||||
if (options.toTrashParentId === newFolder.id) throw new Error('Parent ID cannot be the same as ID');
|
||||
await this.save(newFolder);
|
||||
|
||||
this.dispatch({
|
||||
type: 'FOLDER_DELETE',
|
||||
id: folderId,
|
||||
});
|
||||
}
|
||||
const newFolder: FolderEntity = { id: folderId, deleted_time: Date.now() };
|
||||
if ('toTrashParentId' in options) newFolder.parent_id = options.toTrashParentId;
|
||||
if (options.toTrashParentId === newFolder.id) throw new Error('Parent ID cannot be the same as ID');
|
||||
await this.save(newFolder);
|
||||
} else {
|
||||
await super.batchDelete(folderIds, options);
|
||||
|
||||
for (const folderId of folderIds) {
|
||||
this.dispatch({
|
||||
type: 'FOLDER_DELETE',
|
||||
id: folderId,
|
||||
});
|
||||
}
|
||||
await super.delete(folderId, options);
|
||||
}
|
||||
|
||||
this.dispatch({
|
||||
type: 'FOLDER_DELETE',
|
||||
id: folderId,
|
||||
});
|
||||
}
|
||||
|
||||
public static conflictFolderTitle() {
|
||||
@@ -928,7 +918,7 @@ export default class Folder extends BaseItem {
|
||||
}
|
||||
|
||||
public static defaultFolder() {
|
||||
return this.modelSelectOne('SELECT * FROM folders WHERE deleted_time = 0 ORDER BY created_time DESC LIMIT 1');
|
||||
return this.modelSelectOne('SELECT * FROM folders ORDER BY created_time DESC LIMIT 1');
|
||||
}
|
||||
|
||||
public static async canNestUnder(folderId: string, targetFolderId: string) {
|
||||
@@ -1086,18 +1076,4 @@ export default class Folder extends BaseItem {
|
||||
return this.getRealFolders(folders).length > 0;
|
||||
}
|
||||
|
||||
public static async getValidActiveFolder() {
|
||||
const folderId = Setting.value('activeFolderId');
|
||||
if (!folderId) return null;
|
||||
|
||||
const folder = await Folder.load(folderId);
|
||||
if (!folder || !!folder.deleted_time) {
|
||||
const defaultFolder = await Folder.defaultFolder();
|
||||
if (!defaultFolder) return null;
|
||||
return defaultFolder.id;
|
||||
}
|
||||
|
||||
return folderId;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -319,37 +319,6 @@ describe('models/Setting', () => {
|
||||
Setting.setValue('spellChecker.language', 'fr-FR');
|
||||
await Setting.applyMigrations();
|
||||
expect(Setting.value('spellChecker.languages')).toStrictEqual(['fr-FR']);
|
||||
|
||||
// Also double-check that if the setting is changed and the migration applied again, the new
|
||||
// value will not be reverted back to the previous one.
|
||||
|
||||
Setting.setValue('spellChecker.languages', ['en-GB']);
|
||||
await Setting.applyMigrations();
|
||||
expect(Setting.value('spellChecker.languages')).toStrictEqual(['en-GB']);
|
||||
}));
|
||||
|
||||
it('should migrate to new setting - plugins', (async () => {
|
||||
await Setting.reset();
|
||||
|
||||
await Setting.registerSetting('plugin-org.joplinapp.plugins.AbcSheetMusic.options', {
|
||||
type: SettingItemType.String,
|
||||
public: true,
|
||||
value: '',
|
||||
isGlobal: false,
|
||||
storage: SettingStorage.Database,
|
||||
});
|
||||
|
||||
Setting.setValue('plugin-org.joplinapp.plugins.AbcSheetMusic.options', '{ scale: 2 }');
|
||||
await Setting.saveAll();
|
||||
await Setting.applyMigrations();
|
||||
expect(Setting.value('markdown.plugin.abc.options')).toBe('{ scale: 2 }');
|
||||
|
||||
// Also double-check that if the setting is changed and the migration applied again, the new
|
||||
// value will not be reverted back to the previous one.
|
||||
|
||||
Setting.setValue('markdown.plugin.abc.options', '{ scale: 3 }');
|
||||
await Setting.applyMigrations();
|
||||
expect(Setting.value('markdown.plugin.abc.options')).toBe('{ scale: 3 }');
|
||||
}));
|
||||
|
||||
it('should not override new setting, if it already set', (async () => {
|
||||
|
||||
@@ -159,12 +159,6 @@ interface UserSettingMigration {
|
||||
newName: string;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
|
||||
transformValue: Function;
|
||||
|
||||
// Currently the migration code only supports migrating a plugin setting to the regular settings
|
||||
// (not a plugin setting to a different name). So "oldName" should be the plugin setting name
|
||||
// and "newName" should be the regular setting name. Additionally, it's expected that the
|
||||
// setting is stored in the database (as they all are as of Nov 2025).
|
||||
isPluginSetting: boolean;
|
||||
}
|
||||
|
||||
const userSettingMigration: UserSettingMigration[] = [
|
||||
@@ -172,13 +166,6 @@ const userSettingMigration: UserSettingMigration[] = [
|
||||
oldName: 'spellChecker.language',
|
||||
newName: 'spellChecker.languages',
|
||||
transformValue: (value: string) => { return [value]; },
|
||||
isPluginSetting: false,
|
||||
},
|
||||
{
|
||||
oldName: 'plugin-org.joplinapp.plugins.AbcSheetMusic.options',
|
||||
newName: 'markdown.plugin.abc.options',
|
||||
transformValue: (value: string) => { return value; },
|
||||
isPluginSetting: true,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -473,35 +460,20 @@ class Setting extends BaseModel {
|
||||
this.setValue('lastSettingGlobalMigration', globalMigrations.length - 1);
|
||||
};
|
||||
|
||||
const applyUserSettingMigrations = async () => {
|
||||
for (const migration of userSettingMigration) {
|
||||
let applyMigration = false;
|
||||
let newValue: unknown = null;
|
||||
|
||||
if (migration.isPluginSetting) {
|
||||
const oldItem = await this.loadOneFromDb(migration.oldName);
|
||||
|
||||
if (oldItem) {
|
||||
if (!this.isSet(migration.newName)) {
|
||||
newValue = oldItem.value;
|
||||
applyMigration = true;
|
||||
}
|
||||
}
|
||||
} else if (!this.isSet(migration.newName) && this.isSet(migration.oldName)) {
|
||||
newValue = this.value(migration.oldName);
|
||||
applyMigration = true;
|
||||
const applyUserSettingMigrations = () => {
|
||||
// Function to translate existing user settings to new setting.
|
||||
// eslint-disable-next-line github/array-foreach -- Old code before rule was applied
|
||||
userSettingMigration.forEach(userMigration => {
|
||||
if (!this.isSet(userMigration.newName) && this.isSet(userMigration.oldName)) {
|
||||
this.setValue(userMigration.newName, userMigration.transformValue(this.value(userMigration.oldName)));
|
||||
logger.info(`Migrating ${userMigration.oldName} to ${userMigration.newName}`);
|
||||
}
|
||||
|
||||
if (applyMigration) {
|
||||
this.setValue(migration.newName, migration.transformValue(newValue));
|
||||
logger.info(`applyUserSettingMigrations: Migrated ${migration.oldName} to ${migration.newName}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
applyDefaultMigrations();
|
||||
await applyGlobalMigrations();
|
||||
await applyUserSettingMigrations();
|
||||
applyUserSettingMigrations();
|
||||
}
|
||||
|
||||
public static featureFlagKeys(appType: AppType): string[] {
|
||||
@@ -628,14 +600,6 @@ class Setting extends BaseModel {
|
||||
return this.keys(true).indexOf(key) >= 0;
|
||||
}
|
||||
|
||||
// This allows loading a setting without doing any check on anything - this can be useful to
|
||||
// retrieve a value for a setting that was previously registered, but no longer is. Also to
|
||||
// retrieve setting values for plugins before the plugin is actually loaded.
|
||||
private static async loadOneFromDb(key: string): Promise<CacheItem | null> {
|
||||
const row = await this.modelSelectOne('SELECT key, value FROM settings WHERE key = ?', [key]);
|
||||
return row ? row : null;
|
||||
}
|
||||
|
||||
// Low-level method to load a setting directly from the database. Should not be used in most cases.
|
||||
// Does not apply setting default values.
|
||||
public static async loadOne(key: string): Promise<CacheItem | null> {
|
||||
|
||||
@@ -1089,7 +1089,6 @@ const builtInMetadata = (Setting: typeof SettingType) => {
|
||||
'markdown.plugin.katex': { storage: SettingStorage.File, isGlobal: true, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable math expressions')}${wysiwygYes}` },
|
||||
'markdown.plugin.fountain': { storage: SettingStorage.File, isGlobal: true, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable Fountain syntax support')}${wysiwygYes}` },
|
||||
'markdown.plugin.mermaid': { storage: SettingStorage.File, isGlobal: true, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable Mermaid diagrams support')}${wysiwygYes}` },
|
||||
'markdown.plugin.abc': { storage: SettingStorage.File, isGlobal: true, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable ABC musical notation support')}${wysiwygYes}` },
|
||||
|
||||
'markdown.plugin.audioPlayer': { storage: SettingStorage.File, isGlobal: true, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable audio player')}${wysiwygNo}` },
|
||||
'markdown.plugin.videoPlayer': { storage: SettingStorage.File, isGlobal: true, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable video player')}${wysiwygNo}` },
|
||||
@@ -1117,8 +1116,6 @@ const builtInMetadata = (Setting: typeof SettingType) => {
|
||||
label: () => `${_('Enable file:// URLs for images and videos')}${wysiwygYes}`,
|
||||
},
|
||||
|
||||
'markdown.plugin.abc.options': { storage: SettingStorage.File, isGlobal: true, value: '', type: SettingItemType.String, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('ABC musical notation: Options')}${wysiwygNo}`, description: () => _('Options that should be used whenever rendering ABC code. It must be a JSON5 object. The full list of options is available at: %1', 'https://paulrosen.github.io/abcjs/visual/render-abc-options.html') },
|
||||
|
||||
// Tray icon (called AppIndicator) doesn't work in Ubuntu
|
||||
// http://www.webupd8.org/2017/04/fix-appindicator-not-working-for.html
|
||||
// Might be fixed in Electron 18.x but no non-beta release yet. So for now
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export default (syncTargetId: number) => {
|
||||
return [9, 10, 11].includes(syncTargetId);
|
||||
};
|
||||
@@ -7,7 +7,6 @@ import ItemChange from '../ItemChange';
|
||||
import Setting from '../Setting';
|
||||
import { checkObjectHasProperties } from '@joplin/utils/object';
|
||||
import isTrashableItem from '../../services/trash/isTrashableItem';
|
||||
import isJoplinServerVariant from './isJoplinServerVariant';
|
||||
|
||||
const logger = Logger.create('models/utils/readOnly');
|
||||
|
||||
@@ -22,7 +21,7 @@ export interface ItemSlice {
|
||||
// synchronising with Joplin Cloud or if not sharing any notebook.
|
||||
export const needsShareReadOnlyChecks = (itemType: ModelType, changeSource: number, shareState: ShareState, disableReadOnlyCheck = false) => {
|
||||
if (disableReadOnlyCheck) return false;
|
||||
if (!isJoplinServerVariant(Setting.value('sync.target'))) return false;
|
||||
if (Setting.value('sync.target') !== 10) return false;
|
||||
if (changeSource === ItemChange.SOURCE_SYNC) return false;
|
||||
if (!Setting.value('sync.userId')) return false;
|
||||
if (![ModelType.Note, ModelType.Folder, ModelType.Resource].includes(itemType)) return false;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user