1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-12-29 23:48:19 +02:00

Compare commits

..

1 Commits

Author SHA1 Message Date
Laurent Cozic
c44aad544e update 2025-11-06 17:54:15 +01:00
205 changed files with 2975 additions and 7220 deletions

View File

@@ -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

View File

@@ -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: |

View File

@@ -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

View File

@@ -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'

View File

@@ -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
View File

@@ -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

View File

@@ -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

View File

@@ -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;

View File

@@ -22,7 +22,7 @@
"version": "latest",
"excluded_platforms": ["aarch64-darwin", "x86_64-darwin"],
},
"git": "2.49.0",
"git": "2.48.1",
},
"shell": {
"init_hook": [

View File

@@ -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:

View File

@@ -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);
});
});

View 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);
});

View File

@@ -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);
}
}

View File

@@ -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();

View File

@@ -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);
});
});

View File

@@ -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();

View File

@@ -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) {

View File

@@ -52,7 +52,7 @@ describe('app.reducer', () => {
...createAppDefaultState({}),
backgroundWindows: {
testWindow: {
...createAppDefaultWindowState(),
...createAppDefaultWindowState(null),
windowId: 'testWindow',
visibleDialogs: {

View File

@@ -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;

View File

@@ -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);

View File

@@ -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'),
},

View File

@@ -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',

View File

@@ -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>
);

View File

@@ -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;

View File

@@ -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

View File

@@ -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,

View File

@@ -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,
};
};

View File

@@ -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();

View File

@@ -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[];

View File

@@ -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;

View File

@@ -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;

View File

@@ -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, {

View File

@@ -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'],
};
};

View File

@@ -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,

View File

@@ -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;

View File

@@ -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,
]);

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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}
>

View File

@@ -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>

View File

@@ -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}

View File

@@ -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']}

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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>;

View File

@@ -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',
};
};

View File

@@ -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 });

View File

@@ -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',
};
};

View File

@@ -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,
});

View File

@@ -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';

View File

@@ -1,8 +0,0 @@
.multi-note-actions {
display: inline-flex;
justify-content: center;
padding-top: var(--joplin-margin-top);
width: 100%;
overflow-y: auto;
}

View File

@@ -38,7 +38,7 @@ describe('NoteListUtils', () => {
const mockStore = {
getState: () => {
return {
...createAppDefaultWindowState(),
...createAppDefaultWindowState(null),
settings: {},
};
},

View File

@@ -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));
}
}
}

View File

@@ -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],
]);
});
});

View File

@@ -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;

View File

@@ -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",

View File

@@ -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

View File

@@ -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;

View File

@@ -73,7 +73,6 @@ const useSettingButtonInfo = (setSettingsVisible: SetSettingsVisible) => {
name: 'showToolbarSettings',
tooltip: _('Settings'),
iconName: 'material cogs',
visible: true,
enabled: true,
onClick: () => setSettingsVisible(true),
title: '',

View File

@@ -20,8 +20,6 @@ const builtInCommandNames = [
EditorCommandType.ToggleBulletedList,
EditorCommandType.ToggleCheckList,
'-',
`editor.${EditorCommandType.InsertTable}`,
'-',
EditorCommandType.IndentLess,
EditorCommandType.IndentMore,
`editor.${EditorCommandType.SwapLineDown}`,

View File

@@ -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 }

View File

@@ -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'),

View File

@@ -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),
};
};

View File

@@ -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,

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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);

View File

@@ -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",

View File

@@ -1 +0,0 @@
module.exports = `KCgpID0+IHsKCWxldCBpbml0RG9uZV8gPSBmYWxzZTsKCgljb25zdCBnZXRMaWJyYXJ5ID0gKCkgPT4gewoJCXJldHVybiB3aW5kb3c/LkFCQ0pTOwoJfTsKCgljb25zdCBnZXRPcHRpb25zID0gKGVsZW1lbnQpID0+IHsKCQljb25zdCBvcHRpb25zID0gZWxlbWVudC5nZXRBdHRyaWJ1dGUoJ2RhdGEtYWJjLW9wdGlvbnMnKTsKCgkJaWYgKG9wdGlvbnMpIHsKCQkJdHJ5IHsKCQkJCXJldHVybiBKU09OLnBhcnNlKG9wdGlvbnMpOwoJCQl9IGNhdGNoIChlcnJvcikgewoJCQkJY29uc29sZS5lcnJvcignQ291bGQgbm90IHBhcnNlIEFCQyBvcHRpb25zOicsIG9wdGlvbnMsIGVycm9yKTsKCQkJfQoJCX0KCgkJcmV0dXJuIHt9OwoJfTsKCgljb25zdCBpbml0aWFsaXplID0gKCkgPT4gewoJCWlmIChpbml0RG9uZV8pIHJldHVybiB0cnVlOwoKCQljb25zdCBsaWIgPSBnZXRMaWJyYXJ5KCk7CgkJaWYgKCFsaWIpIHJldHVybiBmYWxzZTsKCgkJaW5pdERvbmVfID0gdHJ1ZTsKCgkJY29uc3QgZWxlbWVudHMgPSBkb2N1bWVudC5nZXRFbGVtZW50c0J5Q2xhc3NOYW1lKCdqb3BsaW4tYWJjLW5vdGF0aW9uJyk7CgoJCWZvciAoY29uc3QgZWxlbWVudCBvZiBlbGVtZW50cykgewoJCQljb25zdCBzb3VyY2VFbGVtZW50ID0gZWxlbWVudC5xdWVyeVNlbGVjdG9yKCcuam9wbGluLXNvdXJjZScpOwoJCQljb25zdCByZW5kZXJlZEVsZW1lbnQgPSBlbGVtZW50LnF1ZXJ5U2VsZWN0b3IoJy5qb3BsaW4tcmVuZGVyZWQnKTsKCQkJY29uc3Qgb3B0aW9ucyA9IGdldE9wdGlvbnMoc291cmNlRWxlbWVudCk7CgkJCWxpYi5yZW5kZXJBYmMocmVuZGVyZWRFbGVtZW50LCBzb3VyY2VFbGVtZW50LnRleHRDb250ZW50LCB7IC4uLm9wdGlvbnMgfSk7CgkJfQoKCQlyZXR1cm4gdHJ1ZTsKCX07CgoJZG9jdW1lbnQuYWRkRXZlbnRMaXN0ZW5lcignam9wbGluLW5vdGVEaWRVcGRhdGUnLCAoKSA9PiB7CgkJaW5pdERvbmVfID0gZmFsc2U7CgkJaW5pdGlhbGl6ZSgpOwoJfSk7CgoJY29uc3QgaW5pdElJRF8gPSBzZXRJbnRlcnZhbCgoKSA9PiB7CgkJaWYgKGluaXRpYWxpemUoKSkgY2xlYXJJbnRlcnZhbChpbml0SUlEXyk7Cgl9LCAxMDApOwoKCWRvY3VtZW50LmFkZEV2ZW50TGlzdGVuZXIoJ0RPTUNvbnRlbnRMb2FkZWQnLCAoKSA9PiB7CgkJaWYgKGluaXRpYWxpemUoKSkgY2xlYXJJbnRlcnZhbChpbml0SUlEXyk7Cgl9KTsKfSkoKTsKCg==`;

File diff suppressed because one or more lines are too long

View File

@@ -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' },

View File

@@ -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

View File

@@ -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';
}

View File

@@ -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({

View File

@@ -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('![test](:/a123456789abcdef0123456789abcdef)\n![test 2](:/b123456789abcdef0123456789abcde2)', 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(
'![markdown](:/a123456789abcdef0123456789abcdef)\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');
});
});

View File

@@ -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: ![alt](src)
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) => {

View File

@@ -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',
}],
});
});
});

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -30,7 +30,6 @@ export enum EditorCommandType {
ToggleHeading5 = 'textHeading5',
InsertHorizontalRule = 'textHorizontalRule',
InsertTable = 'textTable',
// Find commands
ToggleSearch = 'textSearch',

View File

@@ -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",

View File

@@ -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;

View File

@@ -80,7 +80,6 @@ export default class JoplinServerApi {
const optionSession = this.options_.session();
if (optionSession) {
this.session_ = optionSession;
return optionSession;
}

View File

@@ -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 = {};

View File

@@ -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();
}

View File

@@ -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> => {

View File

@@ -1,4 +1,5 @@
const shim = require('./shim').default;
const Promise = require('promise');
class DatabaseDriverNode {
open(options) {

View File

@@ -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 [];

View File

@@ -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();
});
});

View File

@@ -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;
}
}

View File

@@ -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 () => {

View File

@@ -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> {

View File

@@ -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

View File

@@ -1,3 +0,0 @@
export default (syncTargetId: number) => {
return [9, 10, 11].includes(syncTargetId);
};

View File

@@ -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