You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2026-04-05 11:01:11 +02:00
Compare commits
79 Commits
wheel_trac
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b90458f387 | ||
|
|
1a2d045ed2 | ||
|
|
719d5ce4bb | ||
|
|
76f0f1494e | ||
|
|
e4f916bea5 | ||
|
|
cf9098e6a3 | ||
|
|
18ffdb2f50 | ||
|
|
acd2ef4edf | ||
|
|
9d91d4f85c | ||
|
|
635af9748a | ||
|
|
612e5a08f3 | ||
|
|
d3477f8626 | ||
|
|
9e836a8984 | ||
|
|
3f14ffdf73 | ||
|
|
fd9f6c11ab | ||
|
|
0cc79724c3 | ||
|
|
e6fddd054a | ||
|
|
860b22b0e7 | ||
|
|
281b0ed124 | ||
|
|
5dc5cb62db | ||
|
|
28bb43b3b5 | ||
|
|
c8bfcb16be | ||
|
|
634956bcc6 | ||
|
|
346ab98133 | ||
|
|
55008c9de9 | ||
|
|
f4ba70c49c | ||
|
|
e61379ed59 | ||
|
|
75cd9b4cb7 | ||
|
|
43120d2b3e | ||
|
|
5656731dca | ||
|
|
4cfe54161d | ||
|
|
7f2a95f66e | ||
|
|
75819f3be3 | ||
|
|
e709921310 | ||
|
|
b19d47ca4a | ||
|
|
516981b80c | ||
|
|
a90d162989 | ||
|
|
6cf9f1cc11 | ||
|
|
ee7362564c | ||
|
|
cdf5367934 | ||
|
|
7a76c31c26 | ||
|
|
004ab78a7a | ||
|
|
a7067c30c4 | ||
|
|
be081316b3 | ||
|
|
c9fb33cb20 | ||
|
|
dfdc0f3c35 | ||
|
|
0fa3a509d6 | ||
|
|
18cf0a95ad | ||
|
|
7d454123f9 | ||
|
|
e4fb72cd08 | ||
|
|
741e1b19e5 | ||
|
|
6637c05cc8 | ||
|
|
5877670e33 | ||
|
|
2320beec39 | ||
|
|
a0effc9ff8 | ||
|
|
92cd5630f7 | ||
|
|
9fbca68062 | ||
|
|
953fb20006 | ||
|
|
fb18be14a1 | ||
|
|
75c4dbc9df | ||
|
|
1f5b4269ab | ||
|
|
9c23574977 | ||
|
|
fe5ff98429 | ||
|
|
b721b3ac77 | ||
|
|
638485376c | ||
|
|
575f4235c3 | ||
|
|
8184d3ef37 | ||
|
|
1262a5a1ff | ||
|
|
05fc3e9104 | ||
|
|
064e72c43a | ||
|
|
088d8eb159 | ||
|
|
333bc5d123 | ||
|
|
93f4c97433 | ||
|
|
eeeb7d6ba1 | ||
|
|
bda1dc2aa8 | ||
|
|
4073596373 | ||
|
|
c16eb16af4 | ||
|
|
0c0d7713df | ||
|
|
5161d18d19 |
@@ -103,6 +103,7 @@ packages/app-cli/app/command-apidoc.js
|
||||
packages/app-cli/app/command-attach.js
|
||||
packages/app-cli/app/command-batch.js
|
||||
packages/app-cli/app/command-cat.js
|
||||
packages/app-cli/app/command-clear.js
|
||||
packages/app-cli/app/command-config.js
|
||||
packages/app-cli/app/command-cp.js
|
||||
packages/app-cli/app/command-done.test.js
|
||||
@@ -535,8 +536,6 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/utils/useSyncDialogState.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/useWindowCommands.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/useWindowControl.js
|
||||
packages/app-desktop/gui/dialogs.js
|
||||
packages/app-desktop/gui/hooks/useCtrlWheelZoom.test.js
|
||||
packages/app-desktop/gui/hooks/useCtrlWheelZoom.js
|
||||
packages/app-desktop/gui/hooks/useEffectDebugger.js
|
||||
packages/app-desktop/gui/hooks/useElementHeight.js
|
||||
packages/app-desktop/gui/hooks/useImperativeHandlerDebugger.js
|
||||
@@ -699,10 +698,15 @@ packages/app-mobile/components/EditorToolbar/utils/isSelected.js
|
||||
packages/app-mobile/components/EditorToolbar/utils/selectedCommandNamesFromState.js
|
||||
packages/app-mobile/components/EditorToolbar/utils/toolbarButtonsFromState.js
|
||||
packages/app-mobile/components/EditorToolbar/utils/useButtonSize.js
|
||||
packages/app-mobile/components/EditorToolbar/utils/useSaveToolbarButtons.test.js
|
||||
packages/app-mobile/components/EditorToolbar/utils/useSaveToolbarButtons.js
|
||||
packages/app-mobile/components/EditorToolbar/utils/useToolbarEditorState.test.js
|
||||
packages/app-mobile/components/EditorToolbar/utils/useToolbarEditorState.js
|
||||
packages/app-mobile/components/ExtendedWebView/index.jest.js
|
||||
packages/app-mobile/components/ExtendedWebView/index.js
|
||||
packages/app-mobile/components/ExtendedWebView/index.web.js
|
||||
packages/app-mobile/components/ExtendedWebView/types.js
|
||||
packages/app-mobile/components/ExtendedWebView/utils/polyfillScrollFunctions.js
|
||||
packages/app-mobile/components/ExtendedWebView/utils/useCss.js
|
||||
packages/app-mobile/components/FolderPicker.js
|
||||
packages/app-mobile/components/Icon.js
|
||||
@@ -873,6 +877,7 @@ packages/app-mobile/components/screens/Notes/NewNoteButton.test.js
|
||||
packages/app-mobile/components/screens/Notes/NewNoteButton.js
|
||||
packages/app-mobile/components/screens/Notes/Notes.js
|
||||
packages/app-mobile/components/screens/Notes/TextWrapCalculator.js
|
||||
packages/app-mobile/components/screens/ResourceScreen.js
|
||||
packages/app-mobile/components/screens/SearchScreen/SearchBar.js
|
||||
packages/app-mobile/components/screens/SearchScreen/SearchResults.test.js
|
||||
packages/app-mobile/components/screens/SearchScreen/SearchResults.js
|
||||
@@ -889,6 +894,8 @@ packages/app-mobile/components/screens/dropbox-login.js
|
||||
packages/app-mobile/components/screens/encryption-config.test.js
|
||||
packages/app-mobile/components/screens/encryption-config.js
|
||||
packages/app-mobile/components/screens/folder.js
|
||||
packages/app-mobile/components/screens/resourceScreenUtils.test.js
|
||||
packages/app-mobile/components/screens/resourceScreenUtils.js
|
||||
packages/app-mobile/components/screens/status.js
|
||||
packages/app-mobile/components/screens/tags.js
|
||||
packages/app-mobile/components/side-menu-content.js
|
||||
@@ -1443,6 +1450,7 @@ packages/lib/services/CommandService.test.js
|
||||
packages/lib/services/CommandService.js
|
||||
packages/lib/services/DecryptionWorker.test.js
|
||||
packages/lib/services/DecryptionWorker.js
|
||||
packages/lib/services/ExternalEditWatcher.test.js
|
||||
packages/lib/services/ExternalEditWatcher.js
|
||||
packages/lib/services/ExternalEditWatcher/utils.js
|
||||
packages/lib/services/ItemChangeUtils.js
|
||||
|
||||
4
.github/workflows/build-android.yml
vendored
4
.github/workflows/build-android.yml
vendored
@@ -4,10 +4,6 @@
|
||||
name: react-native-android-build-apk
|
||||
on: [push, pull_request]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.ref == 'refs/heads/dev' && github.run_id || format('{0}-{1}', github.workflow, github.ref) }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
AssembleRelease:
|
||||
if: github.repository == 'laurent22/joplin'
|
||||
|
||||
4
.github/workflows/build-macos-m1.yml
vendored
4
.github/workflows/build-macos-m1.yml
vendored
@@ -1,10 +1,6 @@
|
||||
name: Build macOS M1
|
||||
on: [push, pull_request]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.ref == 'refs/heads/dev' && github.run_id || format('{0}-{1}', github.workflow, github.ref) }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
Main:
|
||||
# We always process desktop release tags, because they also publish the release
|
||||
|
||||
2
.github/workflows/check-pr-title.yml
vendored
2
.github/workflows/check-pr-title.yml
vendored
@@ -4,6 +4,6 @@ jobs:
|
||||
main:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: Slashgear/action-check-pr-title@v4.3.0
|
||||
- uses: Slashgear/action-check-pr-title@v5.0.1
|
||||
with:
|
||||
regexp: "(Desktop|Mobile|All|Cli|Tools|Chore|Clipper|Server|Android|iOS|Plugins|CI|Plugin Repo|Doc): (Fixes|Resolves) #[0-9]+: .+"
|
||||
|
||||
4
.github/workflows/github-actions-main.yml
vendored
4
.github/workflows/github-actions-main.yml
vendored
@@ -1,10 +1,6 @@
|
||||
name: Joplin Continuous Integration
|
||||
on: [push, pull_request]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.ref == 'refs/heads/dev' && github.run_id || format('{0}-{1}', github.workflow, github.ref) }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
Main:
|
||||
# We always process server or desktop release tags, because they also publish the release
|
||||
|
||||
4
.github/workflows/ui-tests.yml
vendored
4
.github/workflows/ui-tests.yml
vendored
@@ -1,10 +1,6 @@
|
||||
name: Joplin UI tests
|
||||
on: [push, pull_request]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.ref == 'refs/heads/dev' && github.run_id || format('{0}-{1}', github.workflow, github.ref) }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
|
||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -76,6 +76,7 @@ packages/app-cli/app/command-apidoc.js
|
||||
packages/app-cli/app/command-attach.js
|
||||
packages/app-cli/app/command-batch.js
|
||||
packages/app-cli/app/command-cat.js
|
||||
packages/app-cli/app/command-clear.js
|
||||
packages/app-cli/app/command-config.js
|
||||
packages/app-cli/app/command-cp.js
|
||||
packages/app-cli/app/command-done.test.js
|
||||
@@ -508,8 +509,6 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/utils/useSyncDialogState.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/useWindowCommands.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/useWindowControl.js
|
||||
packages/app-desktop/gui/dialogs.js
|
||||
packages/app-desktop/gui/hooks/useCtrlWheelZoom.test.js
|
||||
packages/app-desktop/gui/hooks/useCtrlWheelZoom.js
|
||||
packages/app-desktop/gui/hooks/useEffectDebugger.js
|
||||
packages/app-desktop/gui/hooks/useElementHeight.js
|
||||
packages/app-desktop/gui/hooks/useImperativeHandlerDebugger.js
|
||||
@@ -672,10 +671,15 @@ packages/app-mobile/components/EditorToolbar/utils/isSelected.js
|
||||
packages/app-mobile/components/EditorToolbar/utils/selectedCommandNamesFromState.js
|
||||
packages/app-mobile/components/EditorToolbar/utils/toolbarButtonsFromState.js
|
||||
packages/app-mobile/components/EditorToolbar/utils/useButtonSize.js
|
||||
packages/app-mobile/components/EditorToolbar/utils/useSaveToolbarButtons.test.js
|
||||
packages/app-mobile/components/EditorToolbar/utils/useSaveToolbarButtons.js
|
||||
packages/app-mobile/components/EditorToolbar/utils/useToolbarEditorState.test.js
|
||||
packages/app-mobile/components/EditorToolbar/utils/useToolbarEditorState.js
|
||||
packages/app-mobile/components/ExtendedWebView/index.jest.js
|
||||
packages/app-mobile/components/ExtendedWebView/index.js
|
||||
packages/app-mobile/components/ExtendedWebView/index.web.js
|
||||
packages/app-mobile/components/ExtendedWebView/types.js
|
||||
packages/app-mobile/components/ExtendedWebView/utils/polyfillScrollFunctions.js
|
||||
packages/app-mobile/components/ExtendedWebView/utils/useCss.js
|
||||
packages/app-mobile/components/FolderPicker.js
|
||||
packages/app-mobile/components/Icon.js
|
||||
@@ -846,6 +850,7 @@ packages/app-mobile/components/screens/Notes/NewNoteButton.test.js
|
||||
packages/app-mobile/components/screens/Notes/NewNoteButton.js
|
||||
packages/app-mobile/components/screens/Notes/Notes.js
|
||||
packages/app-mobile/components/screens/Notes/TextWrapCalculator.js
|
||||
packages/app-mobile/components/screens/ResourceScreen.js
|
||||
packages/app-mobile/components/screens/SearchScreen/SearchBar.js
|
||||
packages/app-mobile/components/screens/SearchScreen/SearchResults.test.js
|
||||
packages/app-mobile/components/screens/SearchScreen/SearchResults.js
|
||||
@@ -862,6 +867,8 @@ packages/app-mobile/components/screens/dropbox-login.js
|
||||
packages/app-mobile/components/screens/encryption-config.test.js
|
||||
packages/app-mobile/components/screens/encryption-config.js
|
||||
packages/app-mobile/components/screens/folder.js
|
||||
packages/app-mobile/components/screens/resourceScreenUtils.test.js
|
||||
packages/app-mobile/components/screens/resourceScreenUtils.js
|
||||
packages/app-mobile/components/screens/status.js
|
||||
packages/app-mobile/components/screens/tags.js
|
||||
packages/app-mobile/components/side-menu-content.js
|
||||
@@ -1416,6 +1423,7 @@ packages/lib/services/CommandService.test.js
|
||||
packages/lib/services/CommandService.js
|
||||
packages/lib/services/DecryptionWorker.test.js
|
||||
packages/lib/services/DecryptionWorker.js
|
||||
packages/lib/services/ExternalEditWatcher.test.js
|
||||
packages/lib/services/ExternalEditWatcher.js
|
||||
packages/lib/services/ExternalEditWatcher/utils.js
|
||||
packages/lib/services/ItemChangeUtils.js
|
||||
|
||||
@@ -11,13 +11,13 @@
|
||||
},
|
||||
"nodejs": "24.11.1",
|
||||
"pkg-config": "latest",
|
||||
"python": "3.13.3",
|
||||
"python": "3.14.0",
|
||||
"bat": "latest",
|
||||
"electron": {
|
||||
"version": "latest",
|
||||
"excluded_platforms": ["aarch64-darwin", "x86_64-darwin"],
|
||||
},
|
||||
"git": "2.51.0",
|
||||
"git": "2.51.2",
|
||||
},
|
||||
"shell": {
|
||||
"init_hook": [
|
||||
|
||||
@@ -72,6 +72,7 @@
|
||||
"@crowdin/cli": "4",
|
||||
"@joplin/utils": "~2.12",
|
||||
"@seiyab/eslint-plugin-react-hooks": "4.5.1-beta.0",
|
||||
"@types/jest": "29.5.14",
|
||||
"@typescript-eslint/eslint-plugin": "6.21.0",
|
||||
"@typescript-eslint/parser": "6.21.0",
|
||||
"cspell": "5.21.2",
|
||||
@@ -82,7 +83,7 @@
|
||||
"eslint-plugin-promise": "6.6.0",
|
||||
"eslint-plugin-react": "7.37.5",
|
||||
"execa": "5.1.1",
|
||||
"fs-extra": "11.3.2",
|
||||
"fs-extra": "11.3.3",
|
||||
"glob": "11.1.0",
|
||||
"gulp": "4.0.2",
|
||||
"husky": "9.1.7",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import BaseApplication from '@joplin/lib/BaseApplication';
|
||||
import { refreshFolders } from '@joplin/lib/folders-screen-utils.js';
|
||||
import ResourceService from '@joplin/lib/services/ResourceService';
|
||||
import BaseModel, { ModelType } from '@joplin/lib/BaseModel';
|
||||
import { ModelType } from '@joplin/lib/BaseModel';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
import BaseItem from '@joplin/lib/models/BaseItem';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
@@ -15,20 +15,22 @@ import RevisionService from '@joplin/lib/services/RevisionService';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import setupCommand from './setupCommand';
|
||||
import { FolderEntity, NoteEntity } from '@joplin/lib/services/database/types';
|
||||
|
||||
type FolderOrNoteType = ModelType.Note | ModelType.Folder | 'folderOrNote';
|
||||
import initializeCommandService from './utils/initializeCommandService';
|
||||
const { cliUtils } = require('./cli-utils.js');
|
||||
const Cache = require('@joplin/lib/Cache');
|
||||
|
||||
class Application extends BaseApplication {
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Dynamic command loading system
|
||||
private commands_: Record<string, any> = {};
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Dynamic command metadata
|
||||
private commandMetadata_: any = null;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Dynamic command type
|
||||
private activeCommand_: any = null;
|
||||
private allCommandsLoaded_ = false;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Dynamic GUI type with many optional methods
|
||||
private gui_: any = null;
|
||||
private cache_ = new Cache();
|
||||
|
||||
@@ -40,18 +42,16 @@ class Application extends BaseApplication {
|
||||
return this.gui().stdoutMaxWidth();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
public async guessTypeAndLoadItem(pattern: string, options: any = null) {
|
||||
let type = BaseModel.TYPE_NOTE;
|
||||
public async guessTypeAndLoadItem(pattern: string, options: { parent?: FolderEntity } | null = null) {
|
||||
let type: FolderOrNoteType = ModelType.Note;
|
||||
if (pattern.indexOf('/') === 0) {
|
||||
type = BaseModel.TYPE_FOLDER;
|
||||
type = ModelType.Folder;
|
||||
pattern = pattern.substr(1);
|
||||
}
|
||||
return this.loadItem(type, pattern, options);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
public async loadItem(type: ModelType | 'folderOrNote', pattern: string, options: any = null) {
|
||||
public async loadItem(type: FolderOrNoteType, pattern: string, options: { parent?: FolderEntity } | null = null) {
|
||||
const output = await this.loadItems(type, pattern, options);
|
||||
|
||||
if (output.length > 1) {
|
||||
@@ -75,37 +75,36 @@ class Application extends BaseApplication {
|
||||
}
|
||||
}
|
||||
|
||||
public async loadItemOrFail(type: ModelType | 'folderOrNote', pattern: string) {
|
||||
public async loadItemOrFail(type: FolderOrNoteType, pattern: string) {
|
||||
const output = await this.loadItem(type, pattern);
|
||||
if (!output) throw new Error(_('Cannot find "%s".', pattern));
|
||||
return output;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
public async loadItems(type: ModelType | 'folderOrNote', pattern: string, options: any = null): Promise<(FolderEntity | NoteEntity)[]> {
|
||||
public async loadItems(type: FolderOrNoteType, pattern: string, options: { parent?: FolderEntity } | null = null): Promise<(FolderEntity | NoteEntity)[]> {
|
||||
if (type === 'folderOrNote') {
|
||||
const folders: FolderEntity[] = await this.loadItems(BaseModel.TYPE_FOLDER, pattern, options);
|
||||
const folders: FolderEntity[] = await this.loadItems(ModelType.Folder, pattern, options);
|
||||
if (folders.length) return folders;
|
||||
return await this.loadItems(BaseModel.TYPE_NOTE, pattern, options);
|
||||
return await this.loadItems(ModelType.Note, pattern, options);
|
||||
}
|
||||
|
||||
pattern = pattern ? pattern.toString() : '';
|
||||
|
||||
if (type === BaseModel.TYPE_FOLDER && (pattern === Folder.conflictFolderTitle() || pattern === Folder.conflictFolderId())) return [Folder.conflictFolder()];
|
||||
if (type === ModelType.Folder && (pattern === Folder.conflictFolderTitle() || pattern === Folder.conflictFolderId())) return [Folder.conflictFolder()];
|
||||
|
||||
if (!options) options = {};
|
||||
|
||||
const parent = options.parent ? options.parent : app().currentFolder();
|
||||
const ItemClass = BaseItem.itemClass(type);
|
||||
|
||||
if (type === BaseModel.TYPE_NOTE && pattern.indexOf('*') >= 0) {
|
||||
if (type === ModelType.Note && pattern.indexOf('*') >= 0) {
|
||||
// Handle it as pattern
|
||||
if (!parent) throw new Error(_('No notebook selected.'));
|
||||
return await Note.previews(parent.id, { titlePattern: pattern });
|
||||
} else {
|
||||
// Single item
|
||||
let item = null;
|
||||
if (type === BaseModel.TYPE_NOTE) {
|
||||
if (type === ModelType.Note) {
|
||||
if (!parent) throw new Error(_('No notebook has been specified.'));
|
||||
item = await (ItemClass as typeof Note).loadFolderNoteByField(parent.id, 'title', pattern);
|
||||
} else {
|
||||
@@ -172,7 +171,7 @@ class Application extends BaseApplication {
|
||||
}
|
||||
|
||||
if (uiType !== null) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Dynamic command type
|
||||
const temp: Record<string, any> = {};
|
||||
for (const n in this.commands_) {
|
||||
if (!this.commands_.hasOwnProperty(n)) continue;
|
||||
@@ -233,8 +232,7 @@ class Application extends BaseApplication {
|
||||
CommandClass = require(`${__dirname}/command-${name}.js`);
|
||||
} catch (error) {
|
||||
if (error.message && error.message.indexOf('Cannot find module') >= 0) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const e: any = new Error(_('No such command: %s', name));
|
||||
const e: Error & { type?: string } = new Error(_('No such command: %s', name));
|
||||
e.type = 'notFound';
|
||||
throw e;
|
||||
} else {
|
||||
@@ -253,8 +251,7 @@ class Application extends BaseApplication {
|
||||
isDummy: () => {
|
||||
return true;
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
prompt: (initialText = '', promptString = '', options: any = null) => {
|
||||
prompt: (initialText = '', promptString = '', options: Record<string, unknown> | null = null) => {
|
||||
return cliUtils.prompt(initialText, promptString, options);
|
||||
},
|
||||
showConsole: () => {},
|
||||
@@ -276,8 +273,7 @@ class Application extends BaseApplication {
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
public async execCommand(argv: string[]): Promise<any> {
|
||||
public async execCommand(argv: string[]): Promise<void> {
|
||||
if (!argv.length) return this.execCommand(['help']);
|
||||
// reg.logger().debug('execCommand()', argv);
|
||||
const commandName = argv[0];
|
||||
@@ -396,8 +392,7 @@ class Application extends BaseApplication {
|
||||
const keychainEnabled = this.checkIfKeychainEnabled(argv);
|
||||
argv = await super.start(argv, { keychainEnabled });
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
cliUtils.setStdout((object: any) => {
|
||||
cliUtils.setStdout((object: string) => {
|
||||
return this.stdout(object);
|
||||
});
|
||||
|
||||
@@ -448,7 +443,7 @@ class Application extends BaseApplication {
|
||||
this.gui_.setLogger(this.logger());
|
||||
await this.gui_.start();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Redux dispatch type requires AnyAction
|
||||
await refreshFolders((action: any) => this.store().dispatch(action), '');
|
||||
|
||||
const tags = await Tag.allWithNotes();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import BaseCommand from './base-command';
|
||||
import app from './app';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import BaseModel from '@joplin/lib/BaseModel';
|
||||
import { ModelType } from '@joplin/lib/BaseModel';
|
||||
import shim from '@joplin/lib/shim';
|
||||
|
||||
class Command extends BaseCommand {
|
||||
@@ -17,7 +17,7 @@ class Command extends BaseCommand {
|
||||
public override async action(args: any) {
|
||||
const title = args['note'];
|
||||
|
||||
const note = await app().loadItem(BaseModel.TYPE_NOTE, title, { parent: app().currentFolder() });
|
||||
const note = await app().loadItem(ModelType.Note, title, { parent: app().currentFolder() });
|
||||
this.encryptionCheck(note);
|
||||
if (!note) throw new Error(_('Cannot find "%s".', title));
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import BaseCommand from './base-command';
|
||||
import app from './app';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import BaseModel from '@joplin/lib/BaseModel';
|
||||
import { ModelType } from '@joplin/lib/BaseModel';
|
||||
import BaseItem from '@joplin/lib/models/BaseItem';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
|
||||
@@ -22,7 +22,7 @@ class Command extends BaseCommand {
|
||||
public override async action(args: any) {
|
||||
const title = args['note'];
|
||||
|
||||
const item = await app().loadItem(BaseModel.TYPE_NOTE, title, { parent: app().currentFolder() });
|
||||
const item = await app().loadItem(ModelType.Note, title, { parent: app().currentFolder() });
|
||||
if (!item) throw new Error(_('Cannot find "%s".', title));
|
||||
|
||||
let content = '';
|
||||
|
||||
19
packages/app-cli/app/command-clear.ts
Normal file
19
packages/app-cli/app/command-clear.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import BaseCommand from './base-command';
|
||||
import app from './app';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
|
||||
class Command extends BaseCommand {
|
||||
public override usage() {
|
||||
return 'clear';
|
||||
}
|
||||
|
||||
public override description() {
|
||||
return _('Clears the console output.');
|
||||
}
|
||||
|
||||
public override async action() {
|
||||
app().gui().widget('console').clear();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Command;
|
||||
@@ -1,7 +1,7 @@
|
||||
import BaseCommand from './base-command';
|
||||
import app from './app';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import BaseModel from '@joplin/lib/BaseModel';
|
||||
import { ModelType } from '@joplin/lib/BaseModel';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
|
||||
class Command extends BaseCommand {
|
||||
@@ -17,14 +17,14 @@ class Command extends BaseCommand {
|
||||
public override async action(args: any) {
|
||||
let folder = null;
|
||||
if (args['notebook']) {
|
||||
folder = await app().loadItem(BaseModel.TYPE_FOLDER, args['notebook']);
|
||||
folder = await app().loadItem(ModelType.Folder, args['notebook']);
|
||||
} else {
|
||||
folder = app().currentFolder();
|
||||
}
|
||||
|
||||
if (!folder) throw new Error(_('Cannot find "%s".', args['notebook']));
|
||||
|
||||
const notes = await app().loadItems(BaseModel.TYPE_NOTE, args['note']);
|
||||
const notes = await app().loadItems(ModelType.Note, args['note']);
|
||||
if (!notes.length) throw new Error(_('Cannot find "%s".', args['note']));
|
||||
|
||||
for (let i = 0; i < notes.length; i++) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import BaseCommand from './base-command';
|
||||
import app from './app';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import BaseModel from '@joplin/lib/BaseModel';
|
||||
import { ModelType } from '@joplin/lib/BaseModel';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import time from '@joplin/lib/time';
|
||||
import { NoteEntity } from '@joplin/lib/services/database/types';
|
||||
@@ -17,7 +17,7 @@ class Command extends BaseCommand {
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
public static async handleAction(commandInstance: BaseCommand, args: any, isCompleted: boolean) {
|
||||
const note: NoteEntity = await app().loadItem(BaseModel.TYPE_NOTE, args.note);
|
||||
const note: NoteEntity = await app().loadItem(ModelType.Note, args.note);
|
||||
commandInstance.encryptionCheck(note);
|
||||
if (!note) throw new Error(_('Cannot find "%s".', args.note));
|
||||
if (!note.is_todo) throw new Error(_('Note is not a to-do: "%s"', args.note));
|
||||
|
||||
@@ -6,7 +6,7 @@ import app from './app';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import BaseModel from '@joplin/lib/BaseModel';
|
||||
import { ModelType } from '@joplin/lib/BaseModel';
|
||||
|
||||
class Command extends BaseCommand {
|
||||
public override usage() {
|
||||
@@ -39,7 +39,7 @@ class Command extends BaseCommand {
|
||||
const title = args['note'];
|
||||
|
||||
if (!app().currentFolder()) throw new Error(_('No active notebook.'));
|
||||
let note = await app().loadItem(BaseModel.TYPE_NOTE, title);
|
||||
let note = await app().loadItem(ModelType.Note, title);
|
||||
|
||||
this.encryptionCheck(note);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import BaseCommand from './base-command';
|
||||
import InteropService from '@joplin/lib/services/interop/InteropService';
|
||||
import BaseModel from '@joplin/lib/BaseModel';
|
||||
import { ModelType } from '@joplin/lib/BaseModel';
|
||||
import app from './app';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { ExportOptions } from '@joplin/lib/services/interop/types';
|
||||
@@ -34,12 +34,12 @@ class Command extends BaseCommand {
|
||||
if (exportOptions.format === 'html') throw new Error('HTML export is not supported. Please use the desktop application.');
|
||||
|
||||
if (args.options.note) {
|
||||
const notes = await app().loadItems(BaseModel.TYPE_NOTE, args.options.note, { parent: app().currentFolder() });
|
||||
const notes = await app().loadItems(ModelType.Note, args.options.note, { parent: app().currentFolder() });
|
||||
if (!notes.length) throw new Error(_('Cannot find "%s".', args.options.note));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
exportOptions.sourceNoteIds = notes.map((n: any) => n.id);
|
||||
} else if (args.options.notebook) {
|
||||
const folders = await app().loadItems(BaseModel.TYPE_FOLDER, args.options.notebook);
|
||||
const folders = await app().loadItems(ModelType.Folder, args.options.notebook);
|
||||
if (!folders.length) throw new Error(_('Cannot find "%s".', args.options.notebook));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
exportOptions.sourceFolderIds = folders.map((n: any) => n.id);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import BaseCommand from './base-command';
|
||||
import app from './app';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import BaseModel from '@joplin/lib/BaseModel';
|
||||
import { ModelType } from '@joplin/lib/BaseModel';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
|
||||
class Command extends BaseCommand {
|
||||
@@ -17,7 +17,7 @@ class Command extends BaseCommand {
|
||||
public override async action(args: any) {
|
||||
const title = args['note'];
|
||||
|
||||
const item = await app().loadItem(BaseModel.TYPE_NOTE, title, { parent: app().currentFolder() });
|
||||
const item = await app().loadItem(ModelType.Note, title, { parent: app().currentFolder() });
|
||||
if (!item) throw new Error(_('Cannot find "%s".', title));
|
||||
const url = Note.geolocationUrl(item);
|
||||
this.stdout(url);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import BaseCommand from './base-command';
|
||||
import InteropService from '@joplin/lib/services/interop/InteropService';
|
||||
import BaseModel from '@joplin/lib/BaseModel';
|
||||
import { ModelType } from '@joplin/lib/BaseModel';
|
||||
const { cliUtils } = require('./cli-utils.js');
|
||||
import app from './app';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
@@ -33,7 +33,7 @@ class Command extends BaseCommand {
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
public override async action(args: any) {
|
||||
let destinationFolder = await app().loadItem(BaseModel.TYPE_FOLDER, args.notebook);
|
||||
let destinationFolder = await app().loadItem(ModelType.Folder, args.notebook);
|
||||
|
||||
if (args.notebook && !destinationFolder) throw new Error(_('Cannot find "%s".', args.notebook));
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const BaseCommand = require('./base-command').default;
|
||||
import app from './app';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import BaseModel from '@joplin/lib/BaseModel';
|
||||
import { ModelType } from '@joplin/lib/BaseModel';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
import { FolderEntity } from '@joplin/lib/services/database/types';
|
||||
|
||||
@@ -23,7 +23,7 @@ class Command extends BaseCommand {
|
||||
// validDestinationFolder check for presents and ambiguous folders
|
||||
public async validDestinationFolder(targetFolder: string) {
|
||||
|
||||
const destinationFolder = await app().loadItem(BaseModel.TYPE_FOLDER, targetFolder);
|
||||
const destinationFolder = await app().loadItem(ModelType.Folder, targetFolder);
|
||||
if (!destinationFolder) {
|
||||
throw new Error(_('Cannot find: "%s"', targetFolder));
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import BaseCommand from './base-command';
|
||||
import app from './app';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import BaseModel from '@joplin/lib/BaseModel';
|
||||
import { ModelType } from '@joplin/lib/BaseModel';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
|
||||
@@ -21,7 +21,7 @@ class Command extends BaseCommand {
|
||||
let folder = null;
|
||||
|
||||
if (destination !== 'root') {
|
||||
folder = await app().loadItem(BaseModel.TYPE_FOLDER, destination);
|
||||
folder = await app().loadItem(ModelType.Folder, destination);
|
||||
if (!folder) throw new Error(_('Cannot find "%s".', destination));
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ class Command extends BaseCommand {
|
||||
throw new Error(_('Ambiguous notebook "%s". Please use short notebook id instead - press "ti" to see the short notebook id', destination));
|
||||
}
|
||||
|
||||
const itemFolder = await app().loadItem(BaseModel.TYPE_FOLDER, pattern);
|
||||
const itemFolder = await app().loadItem(ModelType.Folder, pattern);
|
||||
if (itemFolder) {
|
||||
const sourceDuplicates = await Folder.search({ titlePattern: pattern, limit: 2 });
|
||||
if (sourceDuplicates.length > 1) {
|
||||
@@ -42,7 +42,7 @@ class Command extends BaseCommand {
|
||||
await Folder.moveToFolder(itemFolder.id, folder.id);
|
||||
}
|
||||
} else {
|
||||
const notes = await app().loadItems(BaseModel.TYPE_NOTE, pattern);
|
||||
const notes = await app().loadItems(ModelType.Note, pattern);
|
||||
if (notes.length === 0) throw new Error(_('Cannot find "%s".', pattern));
|
||||
for (let i = 0; i < notes.length; i++) {
|
||||
await Note.moveToFolder(notes[i].id, folder.id);
|
||||
|
||||
@@ -2,7 +2,7 @@ import BaseCommand from './base-command';
|
||||
import app from './app';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
import BaseModel from '@joplin/lib/BaseModel';
|
||||
import { ModelType } from '@joplin/lib/BaseModel';
|
||||
import { substrWithEllipsis } from '@joplin/lib/string-utils';
|
||||
|
||||
class Command extends BaseCommand {
|
||||
@@ -26,7 +26,7 @@ class Command extends BaseCommand {
|
||||
const pattern = args['notebook'];
|
||||
const force = args.options && args.options.force === true;
|
||||
|
||||
const folder = await app().loadItemOrFail(BaseModel.TYPE_FOLDER, pattern);
|
||||
const folder = await app().loadItemOrFail(ModelType.Folder, pattern);
|
||||
|
||||
const permanent = args.options?.permanent === true || !!folder.deleted_time;
|
||||
const ellipsizedFolderTitle = substrWithEllipsis(folder.title, 0, 32);
|
||||
|
||||
@@ -2,7 +2,7 @@ import BaseCommand from './base-command';
|
||||
import app from './app';
|
||||
import { _, _n } from '@joplin/lib/locale';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import BaseModel, { DeleteOptions } from '@joplin/lib/BaseModel';
|
||||
import { DeleteOptions, ModelType } from '@joplin/lib/BaseModel';
|
||||
import { NoteEntity } from '@joplin/lib/services/database/types';
|
||||
|
||||
class Command extends BaseCommand {
|
||||
@@ -26,7 +26,7 @@ class Command extends BaseCommand {
|
||||
const pattern = args['note-pattern'];
|
||||
const force = args.options && args.options.force === true;
|
||||
|
||||
const notes: NoteEntity[] = await app().loadItems(BaseModel.TYPE_NOTE, pattern);
|
||||
const notes: NoteEntity[] = await app().loadItems(ModelType.Note, pattern);
|
||||
if (!notes.length) throw new Error(_('Cannot find "%s".', pattern));
|
||||
|
||||
let ok = true;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import BaseCommand from './base-command';
|
||||
import app from './app';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import BaseModel from '@joplin/lib/BaseModel';
|
||||
import { ModelType } from '@joplin/lib/BaseModel';
|
||||
import Database from '@joplin/lib/database';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
|
||||
@@ -29,7 +29,7 @@ class Command extends BaseCommand {
|
||||
let propValue = args['value'];
|
||||
if (!propValue) propValue = '';
|
||||
|
||||
const notes = await app().loadItems(BaseModel.TYPE_NOTE, title);
|
||||
const notes = await app().loadItems(ModelType.Note, title);
|
||||
if (!notes.length) throw new Error(_('Cannot find "%s".', title));
|
||||
|
||||
for (let i = 0; i < notes.length; i++) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import BaseCommand from './base-command';
|
||||
import app from './app';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import BaseModel from '@joplin/lib/BaseModel';
|
||||
import { ModelType } from '@joplin/lib/BaseModel';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
|
||||
class Command extends BaseCommand {
|
||||
@@ -19,7 +19,7 @@ class Command extends BaseCommand {
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
public override async action(args: any) {
|
||||
const folder = await app().loadItem(BaseModel.TYPE_FOLDER, args['notebook']);
|
||||
const folder = await app().loadItem(ModelType.Folder, args['notebook']);
|
||||
if (!folder) throw new Error(_('Cannot find "%s".', args['notebook']));
|
||||
|
||||
// Auto-expand parent folders in GUI if present
|
||||
|
||||
@@ -34,6 +34,12 @@ class ConsoleWidget extends TextWidget {
|
||||
super.onBlur();
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.lines_ = [];
|
||||
this.updateText_ = true;
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.updateText_) {
|
||||
if (this.lines_.length > this.maxLines_) {
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
"chalk": "4.1.2",
|
||||
"compare-version": "0.1.2",
|
||||
"file-type": "16.5.4",
|
||||
"fs-extra": "11.3.2",
|
||||
"fs-extra": "11.3.3",
|
||||
"html-entities": "1.4.0",
|
||||
"keytar": "7.9.0",
|
||||
"md5": "2.3.0",
|
||||
|
||||
@@ -297,7 +297,11 @@ class AppComponent extends Component {
|
||||
if (!this.state.contentScriptLoaded) {
|
||||
let msg = 'Loading...';
|
||||
if (this.state.contentScriptError) msg = `The Joplin extension is not available on this tab due to: ${this.state.contentScriptError}`;
|
||||
return <div style={{ padding: 10, fontSize: 12, maxWidth: 200 }}>{msg}</div>;
|
||||
return (
|
||||
<div className="App Startup">
|
||||
{msg}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const warningComponent = !this.props.warning ? null : <div className="Warning">{ this.props.warning }</div>;
|
||||
|
||||
@@ -6,7 +6,7 @@ const shim: typeof ShimType = require('@joplin/lib/shim').default;
|
||||
import { isCallbackUrl } from '@joplin/lib/callbackUrlUtils';
|
||||
import { FileLocker } from '@joplin/utils/fs';
|
||||
import { IpcMessageHandler, IpcServer, Message, newHttpError, sendMessage, SendMessageOptions, startServer, stopServer } from '@joplin/utils/ipc';
|
||||
import { BrowserWindow, Tray, WebContents, screen, App, nativeTheme, powerMonitor } from 'electron';
|
||||
import { BrowserWindow, Tray, WebContents, screen, App, nativeTheme, Menu } from 'electron';
|
||||
import bridge from './bridge';
|
||||
import * as url from 'url';
|
||||
const path = require('path');
|
||||
@@ -30,8 +30,7 @@ interface RendererProcessQuitReply {
|
||||
}
|
||||
|
||||
interface PluginWindows {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
[key: string]: any;
|
||||
[key: string]: BrowserWindow;
|
||||
}
|
||||
|
||||
type SecondaryWindowId = string;
|
||||
@@ -48,7 +47,6 @@ export interface Options {
|
||||
}
|
||||
|
||||
export default class ElectronAppWrapper {
|
||||
private logger_: Logger = null;
|
||||
private electronApp_: App;
|
||||
private env_: string;
|
||||
private isDebugMode_: boolean;
|
||||
@@ -61,8 +59,7 @@ export default class ElectronAppWrapper {
|
||||
private secondaryWindows_: Map<SecondaryWindowId, SecondaryWindowData> = new Map();
|
||||
|
||||
private willQuitApp_ = false;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
private tray_: any = null;
|
||||
private tray_: Tray = null;
|
||||
private buildDir_: string = null;
|
||||
private rendererProcessQuitReply_: RendererProcessQuitReply = null;
|
||||
|
||||
@@ -75,8 +72,9 @@ export default class ElectronAppWrapper {
|
||||
private ipcServer_: IpcServer|null = null;
|
||||
private ipcStartPort_ = 2658;
|
||||
|
||||
private ipcLogger_: Logger;
|
||||
private ipcLoggerFilePath_: string;
|
||||
private mainProcessLoggerFilePath_: string;
|
||||
private ipcLogger_: LoggerWrapper;
|
||||
private appLogger_: LoggerWrapper;
|
||||
|
||||
public constructor(electronApp: App, { env, profilePath, isDebugMode, initialCallbackUrl, isEndToEndTesting }: Options) {
|
||||
this.electronApp_ = electronApp;
|
||||
@@ -88,28 +86,20 @@ export default class ElectronAppWrapper {
|
||||
|
||||
this.profileLocker_ = new FileLocker(`${this.profilePath_}/lock`);
|
||||
|
||||
// Note: in certain contexts `this.logger_` doesn't seem to be available, especially for IPC
|
||||
// calls, either because it hasn't been set or other issue. So we set one here specifically
|
||||
// for this.
|
||||
this.ipcLogger_ = new Logger();
|
||||
this.ipcLoggerFilePath_ = `${profilePath}/log-cross-app-ipc.txt`;
|
||||
this.ipcLogger_.addTarget(TargetType.File, {
|
||||
path: this.ipcLoggerFilePath_,
|
||||
const mainProcessLogger = new Logger();
|
||||
this.mainProcessLoggerFilePath_ = `${profilePath}/log-main-process.txt`;
|
||||
mainProcessLogger.addTarget(TargetType.File, {
|
||||
path: this.mainProcessLoggerFilePath_,
|
||||
});
|
||||
|
||||
this.ipcLogger_ = Logger.create('IPC', mainProcessLogger);
|
||||
this.appLogger_ = Logger.create('App', mainProcessLogger);
|
||||
}
|
||||
|
||||
public electronApp() {
|
||||
return this.electronApp_;
|
||||
}
|
||||
|
||||
public setLogger(v: Logger) {
|
||||
this.logger_ = v;
|
||||
}
|
||||
|
||||
public logger() {
|
||||
return this.logger_;
|
||||
}
|
||||
|
||||
public mainWindow() {
|
||||
return this.win_;
|
||||
}
|
||||
@@ -122,8 +112,8 @@ export default class ElectronAppWrapper {
|
||||
return !!this.ipcServer_;
|
||||
}
|
||||
|
||||
public ipcLoggerFilePath() {
|
||||
return this.ipcLoggerFilePath_;
|
||||
public mainProcessLogFilePath() {
|
||||
return this.mainProcessLoggerFilePath_;
|
||||
}
|
||||
|
||||
public windowById(joplinId: string) {
|
||||
@@ -352,7 +342,7 @@ export default class ElectronAppWrapper {
|
||||
} catch (error) {
|
||||
// This will throw an exception "Object has been destroyed" if the app is closed
|
||||
// in less that the timeout interval. It can be ignored.
|
||||
console.warn('Error opening dev tools', error);
|
||||
this.appLogger_.warn('Error opening dev tools', error);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
@@ -405,15 +395,6 @@ export default class ElectronAppWrapper {
|
||||
};
|
||||
addWindowEventHandlers(this.win_.webContents);
|
||||
|
||||
// BrowserWindow 'focus' fires when the OS gives focus to the application window
|
||||
// (i.e. coming from another app or from the taskbar), not on intra-app focus switches.
|
||||
// We use a dedicated IPC channel so the renderer can trigger an immediate sync on
|
||||
// OS-level focus gain without conflating it with the 'window-focused' channel that
|
||||
// handles Joplin-internal window routing.
|
||||
this.win_.on('focus', () => {
|
||||
this.win_?.webContents.send('main-window-focused');
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
this.win_.on('close', (event: any) => {
|
||||
// If it's on macOS, the app is completely closed only if the user chooses to close the app (willQuitApp_ will be true)
|
||||
@@ -423,12 +404,15 @@ export default class ElectronAppWrapper {
|
||||
// On Windows and Linux, the app is closed when the window is closed *except* if the tray icon is used. In which
|
||||
// case the app must be explicitly closed with Ctrl+Q or by right-clicking on the tray icon and selecting "Exit".
|
||||
|
||||
this.appLogger_.info('[appClose] Window close event - willQuitApp_:', this.willQuitApp_, 'rendererProcessQuitReply_:', this.rendererProcessQuitReply_, 'secondaryWindows:', this.secondaryWindows_.size, 'trayShown:', this.trayShown());
|
||||
|
||||
let isGoingToExit = false;
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
if (this.willQuitApp_) {
|
||||
isGoingToExit = true;
|
||||
} else {
|
||||
this.appLogger_.info('[appClose] macOS: willQuitApp_ is false, hiding window instead of closing');
|
||||
event.preventDefault();
|
||||
|
||||
const w = this.win_;
|
||||
@@ -452,21 +436,27 @@ export default class ElectronAppWrapper {
|
||||
}
|
||||
}
|
||||
|
||||
this.appLogger_.info('[appClose] isGoingToExit:', isGoingToExit);
|
||||
|
||||
if (isGoingToExit) {
|
||||
if (!this.rendererProcessQuitReply_) {
|
||||
// If we haven't notified the renderer process yet, do it now
|
||||
// so that it can tell us if we can really close the app or not.
|
||||
// Search for "appClose" event for closing logic on renderer side.
|
||||
this.appLogger_.info('[appClose] Sending appClose to renderer, waiting for reply...');
|
||||
event.preventDefault();
|
||||
if (this.win_) this.win_.webContents.send('appClose');
|
||||
} else {
|
||||
// If the renderer process has responded, check if we can close or not
|
||||
this.appLogger_.info('[appClose] Got renderer reply - canClose:', this.rendererProcessQuitReply_.canClose);
|
||||
if (this.rendererProcessQuitReply_.canClose) {
|
||||
// Really quit the app
|
||||
this.appLogger_.info('[appClose] Closing app now');
|
||||
this.rendererProcessQuitReply_ = null;
|
||||
this.win_ = null;
|
||||
} else {
|
||||
// Wait for renderer to finish task
|
||||
this.appLogger_.info('[appClose] Renderer says cannot close yet, waiting...');
|
||||
event.preventDefault();
|
||||
this.rendererProcessQuitReply_ = null;
|
||||
}
|
||||
@@ -482,8 +472,31 @@ export default class ElectronAppWrapper {
|
||||
// Match the main window's zoom:
|
||||
window.webContents.setZoomFactor(this.mainWindow().webContents.getZoomFactor());
|
||||
|
||||
window.once('close', () => {
|
||||
this.secondaryWindows_.delete(windowId);
|
||||
window.once('close', (event) => {
|
||||
// Check both: BrowserWindow and webContents can be destroyed independently
|
||||
if (this.win_ && !this.win_.isDestroyed() && !this.win_.webContents.isDestroyed()) {
|
||||
this.win_.webContents.send('secondary-window-closing', windowId);
|
||||
}
|
||||
if (this.secondaryWindows_.has(windowId)) {
|
||||
this.secondaryWindows_.delete(windowId);
|
||||
|
||||
// Avoid closing a destroyed window. Closing a destroyed window results in the following error:
|
||||
// Error: Render frame was disposed before WebFrameMain could be accessed
|
||||
const stillOpen = !window.isDestroyed();
|
||||
if (stillOpen) {
|
||||
event.preventDefault();
|
||||
|
||||
// As of March 2026, Electron crashes with "Assertion failed: (Environment::GetCurrent(isolate)) == (env)" if the native 'close'
|
||||
// event is allowed to close a secondary window. As a workaround, briefly hide the window and .close() it later.
|
||||
// See https://github.com/laurent22/joplin/issues/14628.
|
||||
window.hide();
|
||||
setTimeout(() => {
|
||||
if (!window.isDestroyed()) {
|
||||
window.close();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
const allSecondaryWindowsClosed = this.secondaryWindows_.size === 0;
|
||||
const mainWindowVisuallyClosed = this.mainWindowHidden_;
|
||||
@@ -531,8 +544,8 @@ export default class ElectronAppWrapper {
|
||||
// sends a message. In which case, the above code would try to
|
||||
// access a destroyed webview.
|
||||
// https://github.com/laurent22/joplin/issues/4570
|
||||
console.error('Could not process plugin message:', message);
|
||||
console.error(error);
|
||||
this.appLogger_.error('Could not process plugin message:', message);
|
||||
this.appLogger_.error(error);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -557,8 +570,7 @@ export default class ElectronAppWrapper {
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
public registerPluginWindow(pluginId: string, window: any) {
|
||||
public registerPluginWindow(pluginId: string, window: BrowserWindow) {
|
||||
this.pluginWindows_[pluginId] = window;
|
||||
}
|
||||
|
||||
@@ -587,6 +599,7 @@ export default class ElectronAppWrapper {
|
||||
}
|
||||
|
||||
public quit() {
|
||||
this.appLogger_.info('[appClose] quit() called');
|
||||
this.onExit();
|
||||
this.electronApp_.quit();
|
||||
}
|
||||
@@ -595,6 +608,7 @@ export default class ElectronAppWrapper {
|
||||
dispatch: (action: { type: string; [key: string]: unknown })=> void,
|
||||
syncPending: boolean,
|
||||
) {
|
||||
this.appLogger_.info('[appClose] quitWithSyncCheck() called - syncPending:', syncPending);
|
||||
if (syncPending) {
|
||||
dispatch({ type: 'QUIT_SYNC_DIALOG_OPEN' });
|
||||
} else {
|
||||
@@ -644,8 +658,7 @@ export default class ElectronAppWrapper {
|
||||
}
|
||||
|
||||
// Note: this must be called only after the "ready" event of the app has been dispatched
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
public createTray(contextMenu: any) {
|
||||
public createTray(contextMenu: Menu) {
|
||||
try {
|
||||
this.tray_ = new Tray(`${this.buildDir()}/icons/${this.trayIconFilename_()}`);
|
||||
this.tray_.setToolTip(this.electronApp_.name);
|
||||
@@ -653,7 +666,7 @@ export default class ElectronAppWrapper {
|
||||
|
||||
this.tray_.on('click', () => {
|
||||
if (!this.mainWindow()) {
|
||||
console.warn('The window object was not available during the click event from tray icon');
|
||||
this.appLogger_.warn('The window object was not available during the click event from tray icon');
|
||||
return;
|
||||
}
|
||||
if (!this.mainWindow().isVisible()) {
|
||||
@@ -663,7 +676,7 @@ export default class ElectronAppWrapper {
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Cannot create tray', error);
|
||||
this.appLogger_.error('Cannot create tray', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -810,7 +823,7 @@ export default class ElectronAppWrapper {
|
||||
}
|
||||
|
||||
this.quit();
|
||||
if (this.env() === 'dev') console.warn(`Closing the application because another instance is already running, or the previous instance was force-quit within the last ${Math.round(this.profileLocker_.options.interval / Second)} seconds.`);
|
||||
if (this.env() === 'dev') this.appLogger_.warn(`Closing the application because another instance is already running, or the previous instance was force-quit within the last ${Math.round(this.profileLocker_.options.interval / Second)} seconds.`);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -858,8 +871,7 @@ export default class ElectronAppWrapper {
|
||||
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);
|
||||
this.appLogger_.error('Failed to check for and enable accessibility support:', error.stderr);
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -869,8 +881,7 @@ export default class ElectronAppWrapper {
|
||||
// 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.appLogger_.info('Linux accessibility: Enabling full accessibility support.');
|
||||
this.electronApp().setAccessibilitySupportEnabled(true);
|
||||
}
|
||||
}
|
||||
@@ -889,10 +900,12 @@ export default class ElectronAppWrapper {
|
||||
this.createWindow();
|
||||
|
||||
this.electronApp_.on('before-quit', () => {
|
||||
this.appLogger_.info('[appClose] before-quit event fired, setting willQuitApp_ = true');
|
||||
this.willQuitApp_ = true;
|
||||
});
|
||||
|
||||
this.electronApp_.on('window-all-closed', () => {
|
||||
this.appLogger_.info('[appClose] window-all-closed event fired');
|
||||
this.quit();
|
||||
});
|
||||
|
||||
@@ -905,11 +918,6 @@ export default class ElectronAppWrapper {
|
||||
event.preventDefault();
|
||||
void this.openCallbackUrl(url);
|
||||
});
|
||||
|
||||
// When the OS wakes from sleep, notify the renderer so it can trigger an immediate sync.
|
||||
powerMonitor.on('resume', () => {
|
||||
this.win_?.webContents.send('system-resumed');
|
||||
});
|
||||
}
|
||||
|
||||
public async openCallbackUrl(url: string) {
|
||||
|
||||
@@ -11,8 +11,7 @@ const logger = Logger.create('app.reducer');
|
||||
export interface AppStateRoute {
|
||||
type: string;
|
||||
routeName: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
props: any;
|
||||
props: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export enum AppStateDialogName {
|
||||
@@ -22,8 +21,7 @@ export enum AppStateDialogName {
|
||||
|
||||
export interface AppStateDialog {
|
||||
name: AppStateDialogName;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
props: Record<string, any>;
|
||||
props: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface NoteIdToScrollPercent {
|
||||
|
||||
@@ -78,8 +78,7 @@ type StartupTask = { label: string; task: ()=> void|Promise<void> };
|
||||
|
||||
class Application extends BaseApplication {
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
private checkAllPluginStartedIID_: any = null;
|
||||
private checkAllPluginStartedIID_: ReturnType<typeof setInterval> = null;
|
||||
private initPluginServiceDone_ = false;
|
||||
private ocrService_: OcrService;
|
||||
private protocolHandler_: CustomContentProtocolHandler;
|
||||
@@ -734,22 +733,9 @@ class Application extends BaseApplication {
|
||||
}
|
||||
});
|
||||
|
||||
// Trigger an immediate sync when the main window gains OS-level focus (i.e. the user
|
||||
// switches back to Joplin from another application) or when the system wakes from sleep.
|
||||
// A 30-second cool-down prevents duplicate syncs during rapid focus-in/focus-out cycles.
|
||||
const minResumeSyncIntervalMs = 30_000;
|
||||
let lastFocusSyncTime = 0;
|
||||
|
||||
const scheduleResumeSync = () => {
|
||||
const now = Date.now();
|
||||
if (now - lastFocusSyncTime > minResumeSyncIntervalMs) {
|
||||
lastFocusSyncTime = now;
|
||||
void reg.scheduleSync(0);
|
||||
}
|
||||
};
|
||||
|
||||
ipcRenderer.on('main-window-focused', scheduleResumeSync);
|
||||
ipcRenderer.on('system-resumed', scheduleResumeSync);
|
||||
ipcRenderer.on('secondary-window-closing', (_event, windowId: string) => {
|
||||
this.dispatch({ type: 'WINDOW_CLOSE', windowId });
|
||||
});
|
||||
});
|
||||
|
||||
addTask('app/initPluginService', () => this.initPluginService());
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import ElectronAppWrapper from './ElectronAppWrapper';
|
||||
import shim, { MessageBoxType } from '@joplin/lib/shim';
|
||||
import { _, setLocale } from '@joplin/lib/locale';
|
||||
import { BrowserWindow, nativeTheme, nativeImage, shell, dialog, MessageBoxSyncOptions, safeStorage, Menu, MenuItemConstructorOptions, MenuItem } from 'electron';
|
||||
import { BrowserWindow, nativeTheme, nativeImage, shell, dialog, MessageBoxSyncOptions, safeStorage, Menu, MenuItemConstructorOptions, MenuItem, BrowserWindowConstructorOptions, FileFilter, SaveDialogOptions } from 'electron';
|
||||
import { dirname, toSystemSlashes } from '@joplin/lib/path-utils';
|
||||
import { fileUriToPath } from '@joplin/utils/url';
|
||||
import { urlDecode } from '@joplin/lib/string-utils';
|
||||
@@ -25,8 +25,7 @@ interface OpenDialogOptions {
|
||||
properties?: string[];
|
||||
defaultPath?: string;
|
||||
createDirectory?: boolean;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
filters?: any[];
|
||||
filters?: FileFilter[];
|
||||
}
|
||||
|
||||
type OnAllowedExtensionsChange = (newExtensions: string[])=> void;
|
||||
@@ -208,8 +207,7 @@ export class Bridge {
|
||||
this.onAllowedExtensionsChangeListener_ = listener;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
public async captureException(error: any) {
|
||||
public async captureException(error: unknown) {
|
||||
Sentry.captureException(error);
|
||||
// We wait to give the "beforeSend" event handler time to process the crash dump and write
|
||||
// it to file.
|
||||
@@ -335,8 +333,7 @@ export class Bridge {
|
||||
return require('electron').shell.showItemInFolder(toSystemSlashes(fullPath));
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
public newBrowserWindow(options: any) {
|
||||
public newBrowserWindow(options: BrowserWindowConstructorOptions) {
|
||||
return new BrowserWindow(options);
|
||||
}
|
||||
|
||||
@@ -353,8 +350,7 @@ export class Bridge {
|
||||
return this.activeWindow().webContents.closeDevTools();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
public async showSaveDialog(options: any) {
|
||||
public async showSaveDialog(options: SaveDialogOptions) {
|
||||
if (!options) options = {};
|
||||
if (!('defaultPath' in options) && this.lastSelectedPaths_.file) options.defaultPath = this.lastSelectedPaths_.file;
|
||||
const { filePath } = await dialog.showSaveDialog(this.activeWindow(), options);
|
||||
@@ -381,8 +377,7 @@ export class Bridge {
|
||||
}
|
||||
|
||||
// Don't use this directly - call one of the showXxxxxxxMessageBox() instead
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
private showMessageBox_(window: any, options: MessageDialogOptions): number {
|
||||
private showMessageBox_(window: BrowserWindow, options: MessageDialogOptions): number {
|
||||
if (!window) window = this.activeWindow();
|
||||
return dialog.showMessageBoxSync(window, { message: '', ...options });
|
||||
}
|
||||
@@ -428,8 +423,7 @@ export class Bridge {
|
||||
return result;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
public showInfoMessageBox(message: string, options: any = {}) {
|
||||
public showInfoMessageBox(message: string, options: MessageDialogOptions = {}) {
|
||||
const result = this.showMessageBox_(this.activeWindow(), { type: 'info',
|
||||
message: message,
|
||||
buttons: [_('OK')], ...options });
|
||||
@@ -559,7 +553,7 @@ export class Bridge {
|
||||
});
|
||||
|
||||
if (buttonIndex === 1) {
|
||||
void this.openItem(this.electronApp().ipcLoggerFilePath());
|
||||
void this.openItem(this.electronApp().mainProcessLogFilePath());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,8 +49,8 @@ function truncateText(text: string, length: number) {
|
||||
}
|
||||
|
||||
async function getSkippedVersions(): Promise<string[]> {
|
||||
const r = await KvStore.instance().value<string>('updateCheck::skippedVersions');
|
||||
return r ? JSON.parse(r) : [];
|
||||
const r = await KvStore.instance().value('updateCheck::skippedVersions');
|
||||
return r && typeof r === 'string' ? JSON.parse(r) : [];
|
||||
}
|
||||
|
||||
async function isSkippedVersion(v: string): Promise<boolean> {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const Root = styled.h1<any>`
|
||||
const Root = styled.h1<{ justifyContent?: string }>`
|
||||
display: flex;
|
||||
justify-content: ${props => props.justifyContent ? props.justifyContent : 'center'};
|
||||
font-family: ${props => props.theme.fontFamily};
|
||||
|
||||
@@ -8,7 +8,7 @@ const { themeStyle } = require('@joplin/lib/theme');
|
||||
const Shared = require('@joplin/lib/components/shared/dropbox-login-shared');
|
||||
|
||||
interface Props {
|
||||
themeId: string;
|
||||
themeId: number;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
@@ -63,8 +63,7 @@ class DropboxLoginScreenComponent extends React.Component<any, any> {
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const mapStateToProps = (state: any) => {
|
||||
const mapStateToProps = (state: { settings: { theme: number } }) => {
|
||||
return {
|
||||
themeId: state.settings.theme,
|
||||
};
|
||||
|
||||
@@ -47,10 +47,13 @@ export default function(props: Props) {
|
||||
}, [props.dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!titleInputRef.current) return;
|
||||
focus('Dialog::titleInputRef', titleInputRef.current);
|
||||
|
||||
setTimeout(() => {
|
||||
titleInputRef.current.select();
|
||||
if (titleInputRef.current) {
|
||||
titleInputRef.current.select();
|
||||
}
|
||||
}, 100);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -20,6 +20,10 @@ import ToggleAdvancedSettingsButton from '../ConfigScreen/controls/ToggleAdvance
|
||||
import MacOSMissingPasswordHelpLink from '../ConfigScreen/controls/MissingPasswordHelpLink';
|
||||
import { Dispatch } from 'redux';
|
||||
import { shouldCancelPendingEnableAfterMasterPasswordDialog, shouldOpenMasterPasswordDialogForEnable, shouldResumeEnableAfterMasterPasswordDialog } from './enableFlow';
|
||||
import Dialog from '@joplin/lib/components/Dialog';
|
||||
import DialogButtonRow from '../DialogButtonRow';
|
||||
import DialogTitle from '../DialogTitle';
|
||||
import PasswordInput from '../PasswordInput/PasswordInput';
|
||||
|
||||
interface Props {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
@@ -39,6 +43,10 @@ interface Props {
|
||||
export const EncryptionConfigScreen = (props: Props) => {
|
||||
const { inputPasswords, onInputPasswordChange } = useInputPasswords(props.passwords);
|
||||
const [pendingEnableEncryption, setPendingEnableEncryption] = useState(false);
|
||||
const [enableEncryptionPromptVisible, setEnableEncryptionPromptVisible] = useState(false);
|
||||
const [enableEncryptionPassword, setEnableEncryptionPassword] = useState('');
|
||||
const promptPromiseRef = useRef<(password: string | null)=> void>(null);
|
||||
|
||||
const wasMasterPasswordDialogOpen = useRef(props.masterPasswordDialogOpen);
|
||||
|
||||
const theme = useMemo(() => {
|
||||
@@ -235,7 +243,7 @@ export const EncryptionConfigScreen = (props: Props) => {
|
||||
const newEnabled = !isEnabled;
|
||||
const masterKey = getDefaultMasterKey();
|
||||
const hasMasterPassword = !!props.masterPassword;
|
||||
let newPassword = '';
|
||||
let newPassword: string | null = '';
|
||||
|
||||
if (isEnabled) {
|
||||
const answer = await dialogs.confirm(_('Disabling encryption means *all* your notes and attachments are going to be re-synchronised and sent unencrypted to the sync target. Do you wish to continue?'));
|
||||
@@ -253,8 +261,14 @@ export const EncryptionConfigScreen = (props: Props) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const msg = enableEncryptionConfirmationMessages(masterKey, hasMasterPassword);
|
||||
newPassword = await dialogs.prompt(msg.join('\n\n'), '', '', { type: 'password' });
|
||||
// Wait for the custom React Dialog to resolve
|
||||
setEnableEncryptionPassword('');
|
||||
setEnableEncryptionPromptVisible(true);
|
||||
newPassword = await new Promise<string | null>((resolve) => {
|
||||
promptPromiseRef.current = resolve;
|
||||
});
|
||||
|
||||
if (newPassword === null) return; // User cancelled
|
||||
}
|
||||
|
||||
if (hasMasterPassword && newEnabled) {
|
||||
@@ -271,6 +285,63 @@ export const EncryptionConfigScreen = (props: Props) => {
|
||||
}
|
||||
}, [props.dispatch, props.masterPassword, props.masterPasswordDialogOpen]);
|
||||
|
||||
const renderEnableEncryptionDialog = () => {
|
||||
if (!enableEncryptionPromptVisible) return null;
|
||||
|
||||
const masterKey = getDefaultMasterKey();
|
||||
const hasMasterPassword = !!props.masterPassword;
|
||||
|
||||
const msg = enableEncryptionConfirmationMessages(masterKey, hasMasterPassword);
|
||||
const messageComps = msg.map((m, index) => <p key={index} style={theme.textStyle}>{m}</p>);
|
||||
|
||||
const onClose = () => {
|
||||
setEnableEncryptionPromptVisible(false);
|
||||
if (promptPromiseRef.current) promptPromiseRef.current(null);
|
||||
};
|
||||
|
||||
const onDialogButtonRowClick = (event: { buttonName: string }) => {
|
||||
if (event.buttonName === 'cancel') {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (event.buttonName === 'ok') {
|
||||
setEnableEncryptionPromptVisible(false);
|
||||
if (promptPromiseRef.current) promptPromiseRef.current(enableEncryptionPassword);
|
||||
}
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Required because PasswordInput's ChangeEventHandler type is incorrect
|
||||
const onPasswordInputChange = (event: any) => {
|
||||
setEnableEncryptionPassword(event.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog onCancel={onClose} className="enable-encryption-dialog">
|
||||
<div className="dialog-root">
|
||||
<DialogTitle title={_('Enable encryption')}/>
|
||||
<div className="dialog-content">
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
{messageComps}
|
||||
</div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label style={{ ...theme.textStyle, marginBottom: 5, display: 'block' }} htmlFor="enable-encryption-password">{_('Password:')}</label>
|
||||
<PasswordInput
|
||||
inputId="enable-encryption-password"
|
||||
value={enableEncryptionPassword}
|
||||
onChange={onPasswordInputChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogButtonRow
|
||||
themeId={props.themeId}
|
||||
onClick={onDialogButtonRowClick}
|
||||
okButtonDisabled={!enableEncryptionPassword}
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const renderEncryptionSection = () => {
|
||||
const decryptedItemsInfo = <p>{decryptedStatText(stats)}</p>;
|
||||
const toggleButton = (
|
||||
@@ -451,6 +522,7 @@ export const EncryptionConfigScreen = (props: Props) => {
|
||||
{renderMasterKeySection(props.masterKeys.filter(mk => !masterKeyEnabled(mk)), false)}
|
||||
{renderNonExistingMasterKeysSection()}
|
||||
{renderAdvancedSection()}
|
||||
{renderEnableEncryptionDialog()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -31,8 +31,7 @@ interface State {
|
||||
|
||||
interface Props {
|
||||
message?: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
children: any;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
interface BannerProps {
|
||||
|
||||
@@ -6,14 +6,12 @@ import { _ } from '@joplin/lib/locale';
|
||||
|
||||
interface Props {
|
||||
tip: string;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
|
||||
onClick: Function;
|
||||
onClick: ()=> void;
|
||||
themeId: number;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
style: any;
|
||||
style?: React.CSSProperties;
|
||||
|
||||
'aria-controls'?: string;
|
||||
'aria-expanded'?: string;
|
||||
'aria-expanded'?: boolean;
|
||||
}
|
||||
|
||||
class HelpButtonComponent extends React.Component<Props> {
|
||||
@@ -31,8 +29,7 @@ class HelpButtonComponent extends React.Component<Props> {
|
||||
const theme = themeStyle(this.props.themeId);
|
||||
const style = { ...this.props.style, color: theme.color, textDecoration: 'none' };
|
||||
const helpIconStyle = { flex: 0, width: 16, height: 16, marginLeft: 10 };
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const extraProps: any = {};
|
||||
const extraProps: Record<string, string> = {};
|
||||
if (this.props.tip) {
|
||||
extraProps['data-tip'] = this.props.tip;
|
||||
extraProps['aria-description'] = this.props.tip;
|
||||
|
||||
@@ -3,11 +3,9 @@ import { themeStyle } from '@joplin/lib/theme';
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
style: any;
|
||||
style?: React.CSSProperties;
|
||||
iconName: string;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
|
||||
onClick: Function;
|
||||
onClick: ()=> void;
|
||||
}
|
||||
|
||||
class IconButton extends React.Component<Props> {
|
||||
@@ -20,7 +18,7 @@ class IconButton extends React.Component<Props> {
|
||||
};
|
||||
const icon = <i style={iconStyle} className={`fas ${this.props.iconName}`}></i>;
|
||||
|
||||
const rootStyle = {
|
||||
const rootStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
textDecoration: 'none',
|
||||
padding: 10,
|
||||
|
||||
@@ -45,6 +45,9 @@ import PluginNotification from './PluginNotification/PluginNotification';
|
||||
import { Toast } from '@joplin/lib/services/plugins/api/types';
|
||||
import PluginService from '@joplin/lib/services/plugins/PluginService';
|
||||
import QuitSyncDialog from './QuitSyncDialog';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
|
||||
const logger = Logger.create('MainScreen');
|
||||
|
||||
const ipcRenderer = require('electron').ipcRenderer;
|
||||
|
||||
@@ -277,10 +280,12 @@ class MainScreenComponent extends React.Component<Props, State> {
|
||||
// If a note is being saved, we wait till it is saved and then call
|
||||
// "appCloseReply" again.
|
||||
ipcRenderer.on('appClose', async () => {
|
||||
logger.info('[appClose] Received appClose event - hasNotesBeingSaved:', this.props.hasNotesBeingSaved);
|
||||
if (this.waitForNotesSavedIID_) shim.clearInterval(this.waitForNotesSavedIID_);
|
||||
this.waitForNotesSavedIID_ = null;
|
||||
|
||||
const sendCanClose = async (canClose: boolean) => {
|
||||
logger.info('[appClose] Sending appCloseReply - canClose:', canClose);
|
||||
if (canClose) {
|
||||
Setting.setValue('wasClosedSuccessfully', true);
|
||||
await Setting.saveAll();
|
||||
@@ -291,8 +296,10 @@ class MainScreenComponent extends React.Component<Props, State> {
|
||||
await sendCanClose(!this.props.hasNotesBeingSaved);
|
||||
|
||||
if (this.props.hasNotesBeingSaved) {
|
||||
logger.info('[appClose] Notes are being saved, waiting...');
|
||||
this.waitForNotesSavedIID_ = shim.setInterval(() => {
|
||||
if (!this.props.hasNotesBeingSaved) {
|
||||
logger.info('[appClose] Notes saved, now sending canClose: true');
|
||||
shim.clearInterval(this.waitForNotesSavedIID_);
|
||||
this.waitForNotesSavedIID_ = null;
|
||||
void sendCanClose(true);
|
||||
|
||||
@@ -823,6 +823,12 @@ function useMenu(props: Props) {
|
||||
Setting.incValue('windowContentZoomFactor', -10);
|
||||
},
|
||||
accelerator: 'CommandOrControl+-',
|
||||
}, {
|
||||
type: 'separator',
|
||||
visible: shim.isMac(),
|
||||
}, {
|
||||
role: 'togglefullscreen',
|
||||
visible: shim.isMac(),
|
||||
}],
|
||||
},
|
||||
go: {
|
||||
|
||||
@@ -5,7 +5,6 @@ import { AppState, AppStateRoute } from '../app.reducer';
|
||||
import bridge from '../services/bridge';
|
||||
import { useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { WindowIdContext } from './NewWindowOrIFrame';
|
||||
import useCtrlWheelZoom from './hooks/useCtrlWheelZoom';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Partial refactor of code from before rule was applied
|
||||
type ScreenProps = any;
|
||||
@@ -99,7 +98,6 @@ const NavigatorComponent: React.FC<Props> = props => {
|
||||
|
||||
useWindowTitleManager(screenInfo);
|
||||
useWindowRefocusManager(route);
|
||||
useCtrlWheelZoom();
|
||||
const size = useContainerSize(container);
|
||||
|
||||
if (!route) throw new Error('Route must not be null');
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { defaultWindowId } from '@joplin/lib/reducer';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import * as React from 'react';
|
||||
import { useState, useEffect, useRef, createContext } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
@@ -40,7 +39,7 @@ const useDocument = (
|
||||
|
||||
useEffect(() => {
|
||||
let openedWindow: Window|null = null;
|
||||
const unmounted = false;
|
||||
let unmounted = false;
|
||||
if (iframeElement) {
|
||||
setDoc(iframeElement?.contentWindow?.document);
|
||||
} else if (mode === WindowMode.NewWindow) {
|
||||
@@ -52,11 +51,16 @@ const useDocument = (
|
||||
void (async () => {
|
||||
while (!unmounted) {
|
||||
await new Promise<void>(resolve => {
|
||||
shim.setTimeout(() => resolve(), 2000);
|
||||
setTimeout(() => resolve(), 2000);
|
||||
});
|
||||
|
||||
// Re-check after sleep to avoid duplicate WINDOW_CLOSE if IPC already fired.
|
||||
if (unmounted) break;
|
||||
|
||||
if (openedWindow?.closed) {
|
||||
onCloseRef.current?.();
|
||||
// Null out doc first so React stops rendering into the destroyed window
|
||||
// before WINDOW_CLOSE triggers unmounting (prevents renderer crash on Windows).
|
||||
setDoc(null);
|
||||
openedWindow = null;
|
||||
break;
|
||||
}
|
||||
@@ -65,6 +69,8 @@ const useDocument = (
|
||||
}
|
||||
|
||||
return () => {
|
||||
unmounted = true;
|
||||
|
||||
// Delay: Closing immediately causes Electron to crash
|
||||
setTimeout(() => {
|
||||
if (!openedWindow?.closed) {
|
||||
|
||||
@@ -22,18 +22,21 @@ interface KeyToLabelMap {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
let markupToHtml_: any = null;
|
||||
let markupToHtml_: ReturnType<typeof markupLanguageUtils.newMarkupToHtml> = null;
|
||||
function markupToHtml() {
|
||||
if (markupToHtml_) return markupToHtml_;
|
||||
markupToHtml_ = markupLanguageUtils.newMarkupToHtml();
|
||||
return markupToHtml_;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
|
||||
function countElements(text: string, wordSetter: Function, characterSetter: Function, characterNoSpaceSetter: Function, cjkCharacterSetter: React.Dispatch<React.SetStateAction<number>>, lineSetter: Function) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
Countable.count(text, (counter: any) => {
|
||||
interface CounterResult {
|
||||
words: number;
|
||||
all: number;
|
||||
characters: number;
|
||||
}
|
||||
|
||||
function countElements(text: string, wordSetter: React.Dispatch<React.SetStateAction<number>>, characterSetter: React.Dispatch<React.SetStateAction<number>>, characterNoSpaceSetter: React.Dispatch<React.SetStateAction<number>>, cjkCharacterSetter: React.Dispatch<React.SetStateAction<number>>, lineSetter: React.Dispatch<React.SetStateAction<number>>) {
|
||||
Countable.count(text, (counter: CounterResult) => {
|
||||
wordSetter(counter.words);
|
||||
characterSetter(counter.all);
|
||||
characterNoSpaceSetter(counter.characters);
|
||||
@@ -53,8 +56,7 @@ function formatReadTime(readTimeMinutes: number) {
|
||||
|
||||
export default function NoteContentPropertiesDialog(props: NoteContentPropertiesDialogProps) {
|
||||
const theme = themeStyle(props.themeId);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const tableBodyComps: any[] = [];
|
||||
const tableBodyComps: React.JSX.Element[] = [];
|
||||
// For the source Markdown
|
||||
const [lines, setLines] = useState<number>(0);
|
||||
const [words, setWords] = useState<number>(0);
|
||||
|
||||
@@ -45,8 +45,8 @@ const Editor = (props: Props, ref: ForwardedRef<CodeMirrorControl>) => {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const pasteEventHandler = (_editor: any, event: Event) => {
|
||||
const pasteEventHandler = (_editor: unknown, ...args: unknown[]) => {
|
||||
const event = args[0] as Event;
|
||||
props.onEditorPaste(event);
|
||||
};
|
||||
|
||||
|
||||
@@ -1493,6 +1493,23 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
|
||||
}
|
||||
}
|
||||
|
||||
const clearInheritedCheckedStateOnChecklistEnter = () => {
|
||||
const currentNode = editor.selection.getStart();
|
||||
const currentListItem = editor.dom.getParent(currentNode, 'li') as HTMLLIElement;
|
||||
if (!currentListItem) return;
|
||||
|
||||
const parentChecklist = editor.dom.getParent(currentListItem, 'ul.joplin-checklist');
|
||||
if (!parentChecklist) return;
|
||||
|
||||
if (!currentListItem.classList.contains('checked')) return;
|
||||
|
||||
const textContent = (currentListItem.textContent ?? '').replace(/\u200B/g, '').trim();
|
||||
if (textContent !== '') return;
|
||||
|
||||
currentListItem.classList.remove('checked');
|
||||
onChangeHandler();
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
async function onKeyDown(event: any) {
|
||||
// It seems "paste as text" is handled automatically on Windows and Linux,
|
||||
@@ -1508,6 +1525,13 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
|
||||
event.preventDefault();
|
||||
pasteAsPlainText(null);
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && !event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey && !event.isComposing) {
|
||||
shim.setTimeout(() => {
|
||||
if (!editor || !editor.getDoc()) return;
|
||||
clearInheritedCheckedStateOnChecklistEnter();
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
function onPasteAsText() {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { CommandRuntime, CommandDeclaration } from '@joplin/lib/services/CommandService';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { focus } from '@joplin/lib/utils/focusHandler';
|
||||
import { RefObject } from 'react';
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'focusElementNoteTitle',
|
||||
@@ -8,8 +9,7 @@ export const declaration: CommandDeclaration = {
|
||||
parentLabel: () => _('Focus'),
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
export const runtime = (comp: any): CommandRuntime => {
|
||||
export const runtime = (comp: { titleInputRef: RefObject<HTMLInputElement> }): CommandRuntime => {
|
||||
return {
|
||||
execute: async () => {
|
||||
if (!comp.titleInputRef.current) return;
|
||||
|
||||
@@ -6,6 +6,7 @@ const baseContext: Record<string, any> = {
|
||||
modalDialogVisible: false,
|
||||
gotoAnythingVisible: false,
|
||||
markdownEditorPaneVisible: true,
|
||||
markdownViewerPaneVisible: false,
|
||||
oneNoteSelected: true,
|
||||
noteIsMarkdown: true,
|
||||
noteIsReadOnly: false,
|
||||
@@ -98,9 +99,38 @@ describe('editorCommandDeclarations', () => {
|
||||
{
|
||||
textBold: false,
|
||||
textPaste: false,
|
||||
|
||||
// TODO: textCopy should be enabled in read-only notes:
|
||||
// textCopy: false,
|
||||
textCopy: true,
|
||||
textSelectAll: true,
|
||||
},
|
||||
],
|
||||
[
|
||||
// Viewer-only mode (no editor pane visible, only the rendered viewer)
|
||||
{
|
||||
markdownEditorPaneVisible: false,
|
||||
richTextEditorVisible: false,
|
||||
markdownViewerPaneVisible: true,
|
||||
},
|
||||
{
|
||||
textCopy: true,
|
||||
textSelectAll: true,
|
||||
textCut: false,
|
||||
textPaste: false,
|
||||
textBold: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
// Viewer-only mode with a read-only note
|
||||
{
|
||||
markdownEditorPaneVisible: false,
|
||||
richTextEditorVisible: false,
|
||||
markdownViewerPaneVisible: true,
|
||||
noteIsReadOnly: true,
|
||||
},
|
||||
{
|
||||
textCopy: true,
|
||||
textSelectAll: true,
|
||||
textCut: false,
|
||||
textPaste: false,
|
||||
},
|
||||
],
|
||||
])('should correctly determine whether command is enabled (case %#)', (context, expectedStates) => {
|
||||
|
||||
@@ -10,18 +10,31 @@ const workWithHtmlNotes = [
|
||||
'textSelectAll',
|
||||
];
|
||||
|
||||
// Commands that should remain enabled in viewer mode and when the note is read-only.
|
||||
const worksInViewerAndReadOnlyMode = [
|
||||
'textCopy',
|
||||
'textSelectAll',
|
||||
];
|
||||
|
||||
export const enabledCondition = (commandName: string) => {
|
||||
const markdownEditorOnly = !Object.keys(joplinCommandToTinyMceCommands).includes(commandName);
|
||||
const noteMustBeMarkdown = !workWithHtmlNotes.includes(commandName);
|
||||
const allowInViewerAndReadOnlyMode = worksInViewerAndReadOnlyMode.includes(commandName);
|
||||
|
||||
const editorPaneCondition = markdownEditorOnly
|
||||
? 'markdownEditorPaneVisible'
|
||||
: allowInViewerAndReadOnlyMode
|
||||
? '(markdownEditorPaneVisible || richTextEditorVisible || markdownViewerPaneVisible)'
|
||||
: '(markdownEditorPaneVisible || richTextEditorVisible)';
|
||||
|
||||
const output = [
|
||||
// gotoAnythingVisible: Enable if the command palette (which is a modal dialog) is visible
|
||||
'(!modalDialogVisible || gotoAnythingVisible)',
|
||||
|
||||
markdownEditorOnly ? 'markdownEditorPaneVisible' : '(markdownEditorPaneVisible || richTextEditorVisible)',
|
||||
editorPaneCondition,
|
||||
'oneNoteSelected',
|
||||
noteMustBeMarkdown ? 'noteIsMarkdown' : '',
|
||||
'!noteIsReadOnly',
|
||||
allowInViewerAndReadOnlyMode ? '' : '!noteIsReadOnly',
|
||||
];
|
||||
|
||||
return output.filter(c => !!c).join(' && ');
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { processImagesInPastedHtml, processPastedHtml } from './resourceHandling';
|
||||
import { processImagesInPastedHtml, processPastedHtml, getResourcesFromPasteEvent } from './resourceHandling';
|
||||
import markupLanguageUtils from '@joplin/lib/markupLanguageUtils';
|
||||
import HtmlToMd from '@joplin/lib/HtmlToMd';
|
||||
import { HtmlToMarkdownHandler, MarkupToHtmlHandler } from './types';
|
||||
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
|
||||
|
||||
jest.mock('electron', () => ({
|
||||
clipboard: {
|
||||
has: jest.fn(),
|
||||
readBuffer: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
interface ClipboardMock {
|
||||
has: jest.Mock;
|
||||
readBuffer: jest.Mock;
|
||||
}
|
||||
|
||||
const mockClipboard = (require('electron') as { clipboard: ClipboardMock }).clipboard;
|
||||
|
||||
const createTestMarkupConverters = () => {
|
||||
const markupToHtml: MarkupToHtmlHandler = async (markupLanguage, markup, options) => {
|
||||
const conv = markupLanguageUtils.newMarkupToHtml({}, {
|
||||
@@ -23,6 +37,11 @@ const createTestMarkupConverters = () => {
|
||||
};
|
||||
|
||||
describe('resourceHandling', () => {
|
||||
afterEach(() => {
|
||||
mockClipboard.has.mockReset();
|
||||
mockClipboard.readBuffer.mockReset();
|
||||
});
|
||||
|
||||
it('should sanitize pasted HTML', async () => {
|
||||
Setting.setConstant('resourceDir', '/home/.config/joplin/resources');
|
||||
|
||||
@@ -129,4 +148,39 @@ describe('resourceHandling', () => {
|
||||
expect(result).not.toContain(expectAbsent);
|
||||
expect(result).not.toContain('data:');
|
||||
});
|
||||
|
||||
// Tests for getResourcesFromPasteEvent - clipboard image paste (issue #14613)
|
||||
// The test environment (non-Electron, no sharp) skips image validation and
|
||||
// just copies the file, so any non-empty buffer works as test data.
|
||||
const testImageBuffer = Buffer.from(minimalPng, 'base64');
|
||||
|
||||
test.each([
|
||||
{ format: 'image/jpeg', description: 'JPEG (bug #14613)' },
|
||||
{ format: 'image/jpg', description: 'JPG alias' },
|
||||
{ format: 'image/png', description: 'PNG (regression check)' },
|
||||
])('should paste $description image from clipboard via getResourcesFromPasteEvent', async ({ format }) => {
|
||||
await setupDatabaseAndSynchronizer(1);
|
||||
await switchClient(1);
|
||||
mockClipboard.has.mockImplementation((f: string) => f === format);
|
||||
mockClipboard.readBuffer.mockImplementation((f: string) => {
|
||||
return f === format ? testImageBuffer : Buffer.alloc(0);
|
||||
});
|
||||
const mockEvent = { preventDefault: jest.fn() };
|
||||
const result = await getResourcesFromPasteEvent(mockEvent);
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0]).toContain('](:/');
|
||||
expect(mockEvent.preventDefault).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ description: 'clipboard has no image', hasResult: false },
|
||||
{ description: 'buffer is empty despite has() returning true', hasResult: true },
|
||||
])('should return empty when $description', async ({ hasResult }) => {
|
||||
mockClipboard.has.mockReturnValue(hasResult);
|
||||
mockClipboard.readBuffer.mockReturnValue(Buffer.alloc(0));
|
||||
const mockEvent = { preventDefault: jest.fn() };
|
||||
const result = await getResourcesFromPasteEvent(mockEvent);
|
||||
expect(result).toEqual([]);
|
||||
expect(mockEvent.preventDefault).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -93,28 +93,38 @@ export function resourcesStatus(resourceInfos: any) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
export async function getResourcesFromPasteEvent(event: any) {
|
||||
const output = [];
|
||||
const formats = clipboard.availableFormats();
|
||||
for (let i = 0; i < formats.length; i++) {
|
||||
const format = formats[i].toLowerCase();
|
||||
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;
|
||||
// clipboard.has() and readBuffer() are used instead of availableFormats() and
|
||||
// readImage(), which don't work for JPEG on Linux.
|
||||
// https://github.com/laurent22/joplin/issues/14613
|
||||
const supportedFormats = ['image/png', 'image/jpeg', 'image/jpg'];
|
||||
|
||||
for (const format of supportedFormats) {
|
||||
if (!clipboard.has(format)) continue;
|
||||
|
||||
const data = clipboard.readBuffer(format);
|
||||
if (!data || data.length === 0) continue;
|
||||
|
||||
if (event) event.preventDefault();
|
||||
|
||||
const fileExt = mimeUtils.toFileExtension(format);
|
||||
const filePath = `${Setting.value('tempDir')}/${md5(Date.now() + Math.random())}.${fileExt}`;
|
||||
|
||||
let md = null;
|
||||
try {
|
||||
await shim.fsDriver().writeFile(filePath, data, 'buffer');
|
||||
md = await commandAttachFileToBody('', [filePath]);
|
||||
} finally {
|
||||
try {
|
||||
await shim.fsDriver().remove(filePath);
|
||||
} catch (cleanupError) {
|
||||
logger.warn('getResourcesFromPasteEvent: Failed to remove temporary file.', cleanupError);
|
||||
}
|
||||
if (event) event.preventDefault();
|
||||
}
|
||||
|
||||
const image = clipboard.readImage();
|
||||
|
||||
const fileExt = mimeUtils.toFileExtension(format);
|
||||
const filePath = `${Setting.value('tempDir')}/${md5(Date.now())}.${fileExt}`;
|
||||
|
||||
await shim.writeImageToFile(image, format, filePath);
|
||||
const md = await commandAttachFileToBody('', [filePath]);
|
||||
await shim.fsDriver().remove(filePath);
|
||||
|
||||
if (md) output.push(md);
|
||||
if (md) {
|
||||
output.push(md);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return output;
|
||||
|
||||
@@ -54,7 +54,16 @@ export default (props: Props) => {
|
||||
classes.push(props.isReverse ? 'fa-chevron-down' : 'fa-chevron-up');
|
||||
chevron = <i className={classes.join(' ')}></i>;
|
||||
}
|
||||
return <span className="titlewrapper">{getColumnTitle(column.name, true)}{chevron}</span>;
|
||||
const title = getColumnTitle(column.name);
|
||||
let titleElement: React.ReactNode = title;
|
||||
|
||||
if (column.name === 'note.checkboxes') {
|
||||
titleElement = <i className="fas fa-adjust" aria-label={title} title={title}></i>;
|
||||
} else if (column.name === 'note.is_todo') {
|
||||
titleElement = <i className="fas fa-check" aria-label={title} title={title}></i>;
|
||||
}
|
||||
|
||||
return <span className="titlewrapper">{titleElement}{chevron}</span>;
|
||||
};
|
||||
|
||||
const renderResizer = () => {
|
||||
@@ -77,6 +86,7 @@ export default (props: Props) => {
|
||||
draggable={true}
|
||||
className={classes.join(' ')}
|
||||
style={style}
|
||||
title={getColumnTitle(column.name)}
|
||||
onClick={onClick}
|
||||
onDragStart={props.onDragStart}
|
||||
onDragOver={props.onDragOver}
|
||||
|
||||
@@ -16,14 +16,6 @@ const titles: Record<ColumnName, ()=> string> = {
|
||||
'note.user_updated_time': () => _('Updated'),
|
||||
};
|
||||
|
||||
const titlesForHeader: Partial<Record<ColumnName, ()=> string>> = {
|
||||
'note.checkboxes': () => '◐',
|
||||
'note.is_todo': () => '✓',
|
||||
};
|
||||
|
||||
export default (name: ColumnName, forHeader = false) => {
|
||||
let fn: ()=> string = null;
|
||||
if (forHeader) fn = titlesForHeader[name];
|
||||
if (!fn) fn = titles[name];
|
||||
return fn ? fn() : name;
|
||||
export default (name: ColumnName) => {
|
||||
return titles[name]();
|
||||
};
|
||||
|
||||
@@ -18,8 +18,7 @@ interface RenderedNote {
|
||||
html: string;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const hashContent = (content: any) => {
|
||||
const hashContent = (content: unknown) => {
|
||||
return createHash('sha1').update(JSON.stringify(content)).digest('hex');
|
||||
};
|
||||
|
||||
|
||||
@@ -317,7 +317,7 @@ class NotePropertiesDialog extends React.Component<Props, State> {
|
||||
const styles = this.styles(this.props.themeId);
|
||||
const theme = themeStyle(this.props.themeId);
|
||||
const labelText = this.formatLabel(key);
|
||||
const labelComp = <label htmlFor={uniqueId(key)} role='rowheader' style={{ ...theme.textStyle, ...theme.controlBoxLabel }}>{labelText}</label>;
|
||||
const labelComp = <label htmlFor={uniqueId(key)} style={{ ...theme.textStyle, ...theme.controlBoxLabel }}>{labelText}</label>;
|
||||
let controlComp = null;
|
||||
let editComp = null;
|
||||
let editCompHandler = null;
|
||||
@@ -422,11 +422,11 @@ class NotePropertiesDialog extends React.Component<Props, State> {
|
||||
textOverflow: 'ellipsis',
|
||||
display: 'inline-block',
|
||||
};
|
||||
controlComp = (
|
||||
controlComp = displayedValue ? (
|
||||
<a href="#" onClick={() => bridge().openExternal(url)} style={urlStyle}>
|
||||
{displayedValue}
|
||||
</a>
|
||||
);
|
||||
) : null;
|
||||
} else if (key === 'revisionsLink') {
|
||||
controlComp = (
|
||||
<a href="#" onClick={this.revisionsLink_click} style={theme.urlStyle}>
|
||||
@@ -468,10 +468,10 @@ class NotePropertiesDialog extends React.Component<Props, State> {
|
||||
}
|
||||
|
||||
return (
|
||||
<div role='row' key={key} style={theme.controlBox} className="note-property-box">
|
||||
{labelComp}
|
||||
<span role='cell'>{controlComp} {editComp}</span>
|
||||
</div>
|
||||
<tr key={key} style={theme.controlBox} className="note-property-box">
|
||||
<th>{labelComp}</th>
|
||||
<td>{controlComp} {editComp}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -497,10 +497,12 @@ class NotePropertiesDialog extends React.Component<Props, State> {
|
||||
|
||||
return (
|
||||
<Dialog onCancel={this.props.onClose}>
|
||||
<div style={theme.dialogTitle} id='note-properties-dialog-title'>{_('Note properties')}</div>
|
||||
<div role='table' aria-labelledby='note-properties-dialog-title'>
|
||||
{noteComps}
|
||||
</div>
|
||||
<h1 style={theme.dialogTitle} id='note-properties-dialog-title'>{_('Note properties')}</h1>
|
||||
<table aria-labelledby='note-properties-dialog-title'>
|
||||
<tbody>
|
||||
{noteComps}
|
||||
</tbody>
|
||||
</table>
|
||||
<DialogButtonRow
|
||||
themeId={this.props.themeId}
|
||||
okButtonShow={!this.isReadOnly()}
|
||||
|
||||
@@ -5,28 +5,22 @@ import { focus } from '@joplin/lib/utils/focusHandler';
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
|
||||
onNext: Function;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
|
||||
onPrevious: Function;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
|
||||
onClose: Function;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
|
||||
onChange: Function;
|
||||
onNext: ()=> void;
|
||||
onPrevious: ()=> void;
|
||||
onClose: ()=> void;
|
||||
onChange: (query: string)=> void;
|
||||
query: string;
|
||||
searching: boolean;
|
||||
resultCount: number;
|
||||
selectedIndex: number;
|
||||
visiblePanes: string[];
|
||||
editorType: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
style: any;
|
||||
style: React.CSSProperties;
|
||||
}
|
||||
|
||||
class NoteSearchBar extends React.Component<Props> {
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
private backgroundColor: any;
|
||||
private backgroundColor: string;
|
||||
private searchInputRef: React.RefObject<HTMLInputElement>;
|
||||
|
||||
public constructor(props: Props) {
|
||||
@@ -56,8 +50,7 @@ class NoteSearchBar extends React.Component<Props> {
|
||||
return style;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
public buttonIconComponent(iconName: string, clickHandler: any, isEnabled: boolean) {
|
||||
public buttonIconComponent(iconName: string, clickHandler: ()=> void, isEnabled: boolean) {
|
||||
const theme = themeStyle(this.props.themeId);
|
||||
|
||||
const searchButton = {
|
||||
@@ -85,14 +78,12 @@ class NoteSearchBar extends React.Component<Props> {
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
private searchInput_change(event: any) {
|
||||
private searchInput_change(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const query = event.currentTarget.value;
|
||||
this.triggerOnChange(query);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
private searchInput_keyDown(event: any) {
|
||||
private searchInput_keyDown(event: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (event.keyCode === 13) {
|
||||
// ENTER
|
||||
event.preventDefault();
|
||||
@@ -114,7 +105,7 @@ class NoteSearchBar extends React.Component<Props> {
|
||||
if (event.keyCode === 70) {
|
||||
// F key
|
||||
if (event.ctrlKey) {
|
||||
event.target.select();
|
||||
event.currentTarget.select();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,8 +15,7 @@ interface Props {
|
||||
onDomReady: Function;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
|
||||
onIpcMessage: Function;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
viewerStyle: any;
|
||||
viewerStyle: React.CSSProperties;
|
||||
contentMaxWidth?: number;
|
||||
themeId: number;
|
||||
}
|
||||
|
||||
@@ -12,8 +12,7 @@ import { AppState } from '../../app.reducer';
|
||||
|
||||
interface NoteToolbarProps {
|
||||
themeId: number;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
style: any;
|
||||
style: React.CSSProperties;
|
||||
toolbarButtonInfos: ToolbarItem[];
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ const { themeStyle } = require('@joplin/lib/theme');
|
||||
const { OneDriveApiNodeUtils } = require('@joplin/lib/onedrive-api-node-utils.js');
|
||||
|
||||
interface Props {
|
||||
themeId: string;
|
||||
themeId: number;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
@@ -24,10 +24,8 @@ class OneDriveLoginScreenComponent extends React.Component<any, any> {
|
||||
}
|
||||
|
||||
public async componentDidMount() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const log = (s: any) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
this.setState((state: any) => {
|
||||
const log = (s: string) => {
|
||||
this.setState((state: { authLog: { key: string; text: string }[] }) => {
|
||||
const authLog = state.authLog.slice();
|
||||
authLog.push({ key: (Date.now() + Math.random()).toString(), text: s });
|
||||
return { authLog: authLog };
|
||||
@@ -38,8 +36,7 @@ class OneDriveLoginScreenComponent extends React.Component<any, any> {
|
||||
const syncTarget = reg.syncTarget(syncTargetId);
|
||||
const oneDriveApiUtils = new OneDriveApiNodeUtils(syncTarget.api());
|
||||
const auth = await oneDriveApiUtils.oauthDance({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
log: (s: any) => log(s),
|
||||
log: (s: string) => log(s),
|
||||
});
|
||||
|
||||
Setting.setValue(`sync.${syncTargetId}.auth`, auth ? JSON.stringify(auth) : null);
|
||||
@@ -85,8 +82,7 @@ class OneDriveLoginScreenComponent extends React.Component<any, any> {
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const mapStateToProps = (state: any) => {
|
||||
const mapStateToProps = (state: { settings: { theme: number } }) => {
|
||||
return {
|
||||
themeId: state.settings.theme,
|
||||
};
|
||||
|
||||
@@ -277,8 +277,52 @@ export default class PromptDialog extends React.Component<Props, any> {
|
||||
style={styles.dateTimeInput}
|
||||
/>;
|
||||
} else if (this.props.inputType === 'tags') {
|
||||
const uniqueAutocomplete = [];
|
||||
const seenLabels = new Set();
|
||||
const autocompleteOptions = this.props.autocomplete || [];
|
||||
for (const option of autocompleteOptions) {
|
||||
const key = (option.label || '').trim().normalize('NFC').toLowerCase();
|
||||
if (!seenLabels.has(key)) {
|
||||
uniqueAutocomplete.push(option);
|
||||
seenLabels.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
inputComp = <CreatableSelect className="tag-selector" onMenuOpen={this.select_menuOpen} onMenuClose={this.select_menuClose} styles={styles.select} theme={styles.selectTheme} ref={this.answerInput_} value={this.state.answer} placeholder="" components={makeAnimated() as any} isMulti={true} isClearable={false} backspaceRemovesValue={true} options={this.props.autocomplete} onChange={onSelectChange} onKeyDown={(event: any) => onKeyDown(event)} />;
|
||||
inputComp = <CreatableSelect
|
||||
className="tag-selector"
|
||||
onMenuOpen={this.select_menuOpen}
|
||||
onMenuClose={this.select_menuClose}
|
||||
styles={styles.select}
|
||||
theme={styles.selectTheme}
|
||||
ref={this.answerInput_}
|
||||
value={this.state.answer}
|
||||
placeholder=""
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
components={makeAnimated() as any}
|
||||
isMulti={true}
|
||||
isClearable={false}
|
||||
backspaceRemovesValue={true}
|
||||
options={uniqueAutocomplete}
|
||||
onChange={onSelectChange}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
onKeyDown={(event: any) => onKeyDown(event)}
|
||||
filterOption={(option, rawInput) => {
|
||||
const input = (rawInput || '').trim().normalize('NFC').toLowerCase();
|
||||
const label = (option.label || '').trim().normalize('NFC').toLowerCase();
|
||||
return label.includes(input);
|
||||
}}
|
||||
isValidNewOption={(inputValue, _selectValue, selectOptions) => {
|
||||
const input = (inputValue || '').trim().normalize('NFC').toLowerCase();
|
||||
if (!input) return false;
|
||||
|
||||
// If it matches an existing option (case-insensitive + normalized), it's not a valid "new" option
|
||||
const exists = selectOptions.some(option => {
|
||||
return (option.label || '').trim().normalize('NFC').toLowerCase() === input;
|
||||
});
|
||||
return !exists;
|
||||
}}
|
||||
/>;
|
||||
} else if (this.props.inputType === 'dropdown') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
inputComp = <Select className="item-selector" onMenuOpen={this.select_menuOpen} onMenuClose={this.select_menuClose} styles={styles.select} theme={styles.selectTheme} ref={this.answerInput_} components={makeAnimated() as any} value={this.props.answer} defaultValue={this.props.defaultValue} isClearable={false} options={this.props.autocomplete} onChange={onSelectChange} onKeyDown={(event: any) => onKeyDown(event)} />;
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as React from 'react';
|
||||
import { AppState } from '../../app.reducer';
|
||||
import { FolderEntity, TagsWithNoteCountEntity } from '@joplin/lib/services/database/types';
|
||||
import areAllFoldersCollapsed from '@joplin/lib/models/utils/areAllFoldersCollapsed';
|
||||
import getCanBeCollapsedFolderIds from '@joplin/lib/models/utils/getCanBeCollapsedFolderIds';
|
||||
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
|
||||
import { connect } from 'react-redux';
|
||||
import { Dispatch } from 'redux';
|
||||
@@ -48,6 +49,11 @@ const FolderAndTagList: React.FC<Props> = props => {
|
||||
return areAllFoldersCollapsed(props.folders, props.collapsedFolderIds);
|
||||
}, [props.collapsedFolderIds, props.folders]);
|
||||
|
||||
const hasSubFolders = useMemo(() => {
|
||||
return getCanBeCollapsedFolderIds(props.folders).length > 0;
|
||||
}, [props.folders]);
|
||||
|
||||
|
||||
const listContainerRef = useRef<HTMLDivElement|null>(null);
|
||||
const onRenderItem = useOnRenderItem({
|
||||
...props,
|
||||
@@ -76,7 +82,7 @@ const FolderAndTagList: React.FC<Props> = props => {
|
||||
const listHeight = useElementHeight(itemListContainer);
|
||||
const listStyle = useMemo(() => ({ height: listHeight }), [listHeight]);
|
||||
|
||||
const onRenderContentWrapper = useOnRenderListWrapper({ allFoldersCollapsed, selectedIndex, onKeyDown: onKeyEventHandler });
|
||||
const onRenderContentWrapper = useOnRenderListWrapper({ allFoldersCollapsed, selectedIndex, onKeyDown: onKeyEventHandler, hasSubFolders });
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -7,6 +7,7 @@ interface Props {
|
||||
selectedIndex: number;
|
||||
onKeyDown: React.KeyboardEventHandler;
|
||||
allFoldersCollapsed: boolean;
|
||||
hasSubFolders: boolean;
|
||||
}
|
||||
|
||||
const onAddFolderButtonClick = () => {
|
||||
@@ -19,6 +20,7 @@ const onToggleAllFolders = (allFoldersCollapsed: boolean) => {
|
||||
|
||||
interface CollapseExpandAllButtonProps {
|
||||
allFoldersCollapsed: boolean;
|
||||
hasSubFolders: boolean;
|
||||
}
|
||||
|
||||
const CollapseExpandAllButton = (props: CollapseExpandAllButtonProps) => {
|
||||
@@ -27,7 +29,12 @@ const CollapseExpandAllButton = (props: CollapseExpandAllButtonProps) => {
|
||||
const icon = props.allFoldersCollapsed ? 'far fa-caret-square-right' : 'far fa-caret-square-down';
|
||||
const label = props.allFoldersCollapsed ? _('Expand all notebooks') : _('Collapse all notebooks');
|
||||
|
||||
return <button onClick={() => onToggleAllFolders(props.allFoldersCollapsed)} className='sidebar-header-button -collapseall' title={label}>
|
||||
return <button
|
||||
onClick={() => onToggleAllFolders(props.allFoldersCollapsed)}
|
||||
className={`sidebar-header-button -collapseall ${props.hasSubFolders ? '' : '-disabled'}`}
|
||||
title={label}
|
||||
disabled={!props.hasSubFolders}
|
||||
>
|
||||
<i
|
||||
aria-label={label}
|
||||
role='img'
|
||||
@@ -55,7 +62,7 @@ const useOnRenderListWrapper = (props: Props) => {
|
||||
const listHasValidSelection = props.selectedIndex >= 0;
|
||||
const allowContainerFocus = !listHasValidSelection;
|
||||
return <>
|
||||
<CollapseExpandAllButton allFoldersCollapsed={props.allFoldersCollapsed}/>
|
||||
<CollapseExpandAllButton allFoldersCollapsed={props.allFoldersCollapsed} hasSubFolders={props.hasSubFolders}/>
|
||||
<NewFolderButton/>
|
||||
<div
|
||||
role='tree'
|
||||
@@ -66,7 +73,7 @@ const useOnRenderListWrapper = (props: Props) => {
|
||||
{...listItems}
|
||||
</div>
|
||||
</>;
|
||||
}, [props.selectedIndex, props.onKeyDown, props.allFoldersCollapsed]);
|
||||
}, [props.selectedIndex, props.onKeyDown, props.allFoldersCollapsed, props.hasSubFolders]);
|
||||
};
|
||||
|
||||
export default useOnRenderListWrapper;
|
||||
|
||||
@@ -13,12 +13,12 @@
|
||||
font-size: var(--joplin-toolbar-icon-size);
|
||||
color: var(--joplin-color2);
|
||||
|
||||
&:hover {
|
||||
&:hover:not(:disabled) {
|
||||
color: var(--joplin-color-hover2);
|
||||
background: none;
|
||||
}
|
||||
|
||||
&:active {
|
||||
&:active:not(:disabled) {
|
||||
color: var(--joplin-color-active2);
|
||||
background: none;
|
||||
}
|
||||
@@ -26,4 +26,8 @@
|
||||
&.-collapseall {
|
||||
right: 25px;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,12 +7,15 @@ import { getCollator, getCollatorLocale } from '@joplin/lib/models/utils/getColl
|
||||
const { connect } = require('react-redux');
|
||||
const { themeStyle } = require('@joplin/lib/theme');
|
||||
|
||||
interface TagData {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
style: any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
items: any[];
|
||||
style: React.CSSProperties;
|
||||
items: TagData[];
|
||||
}
|
||||
|
||||
function TagList(props: Props) {
|
||||
@@ -34,8 +37,7 @@ function TagList(props: Props) {
|
||||
const tags = useMemo(() => {
|
||||
const output = props.items.slice();
|
||||
const collator = getCollator(collatorLocale);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
output.sort((a: any, b: any) => {
|
||||
output.sort((a: TagData, b: TagData) => {
|
||||
return collator.compare(a.title, b.title);
|
||||
});
|
||||
|
||||
|
||||
@@ -33,8 +33,7 @@ class Dialogs {
|
||||
await this.smalltalk.alert(title, message);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
public async confirm(message: string, title = '', options: any = {}) {
|
||||
public async confirm(message: string, title = '', options: unknown = {}) {
|
||||
try {
|
||||
await this.smalltalk.confirm(title, message, options);
|
||||
return true;
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import useCtrlWheelZoom from './useCtrlWheelZoom';
|
||||
|
||||
jest.mock('@joplin/lib/models/Setting', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
incValue: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const dispatchWheel = (options: WheelEventInit) => {
|
||||
document.dispatchEvent(new WheelEvent('wheel', { bubbles: true, ...options }));
|
||||
};
|
||||
|
||||
describe('useCtrlWheelZoom', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('should zoom on Ctrl/Meta+Wheel', () => {
|
||||
renderHook(() => useCtrlWheelZoom());
|
||||
|
||||
dispatchWheel({ deltaY: -100, ctrlKey: true });
|
||||
expect(Setting.incValue).toHaveBeenCalledWith('windowContentZoomFactor', 10);
|
||||
|
||||
jest.clearAllMocks();
|
||||
|
||||
dispatchWheel({ deltaY: 100, ctrlKey: true });
|
||||
expect(Setting.incValue).toHaveBeenCalledWith('windowContentZoomFactor', -10);
|
||||
|
||||
jest.clearAllMocks();
|
||||
|
||||
dispatchWheel({ deltaY: -100, metaKey: true });
|
||||
expect(Setting.incValue).toHaveBeenCalledWith('windowContentZoomFactor', 10);
|
||||
});
|
||||
|
||||
test('should not zoom on wheel without modifier', () => {
|
||||
renderHook(() => useCtrlWheelZoom());
|
||||
|
||||
dispatchWheel({ deltaY: -100 });
|
||||
|
||||
expect(Setting.incValue).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
|
||||
const useCtrlWheelZoom = () => {
|
||||
useEffect(() => {
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault();
|
||||
Setting.incValue('windowContentZoomFactor', e.deltaY < 0 ? 10 : -10);
|
||||
}
|
||||
};
|
||||
document.addEventListener('wheel', handleWheel, { passive: false });
|
||||
return () => document.removeEventListener('wheel', handleWheel);
|
||||
}, []);
|
||||
};
|
||||
|
||||
export default useCtrlWheelZoom;
|
||||
@@ -827,7 +827,37 @@
|
||||
}));
|
||||
|
||||
// By default, Chromium inlines body styles (e.g. theme background color) into the clipboard HTML.
|
||||
// Intercept the copy event and write only the selected content to bypass this behaviour.
|
||||
// Intercept the copy event and write only the selected content with inlined light-theme styles to bypass this behaviour.
|
||||
const clipboardCssProps = [
|
||||
'font-family', 'font-size', 'font-weight', 'font-style',
|
||||
'text-decoration-line', 'line-height', 'color',
|
||||
'background-color',
|
||||
'margin-top', 'margin-bottom', 'margin-left', 'margin-right',
|
||||
'padding-top', 'padding-bottom', 'padding-left', 'padding-right',
|
||||
'border-top', 'border-bottom', 'border-left', 'border-right',
|
||||
'border-collapse', 'border-radius',
|
||||
'list-style-type',
|
||||
'white-space', 'text-align',
|
||||
'opacity',
|
||||
];
|
||||
|
||||
const lightThemeOverrideCss = [
|
||||
'body, #joplin-container-content, #rendered-md { background-color: transparent !important; color: #32373F !important; }',
|
||||
'html *, html *::before, html *::after { background-color: transparent !important; }',
|
||||
'a { color: #155BDA !important; }',
|
||||
'code, .inline-code, .mce-content-body code { color: rgb(0,0,0) !important; background-color: rgb(243,243,243) !important; border-color: rgb(220,220,220) !important; }',
|
||||
'pre.hljs { background-color: rgb(243,243,243) !important; }',
|
||||
'pre.hljs code { background-color: transparent !important; }',
|
||||
'kbd { color: rgb(0,0,0) !important; background-color: rgb(243,243,243) !important; }',
|
||||
'table, table thead, table tbody, table tr, table td, table th { background-color: transparent !important; color: #32373F !important; }',
|
||||
'table th { background-color: rgb(247,247,247) !important; }',
|
||||
'table:has(thead) tr:nth-child(even) { background-color: rgb(247,247,247) !important; }',
|
||||
'table td, table th { border-color: rgb(220,220,220) !important; }',
|
||||
'blockquote { border-left-color: rgb(220,220,220) !important; opacity: 0.7 !important; }',
|
||||
'h1 { border-bottom-color: #dddddd !important; }',
|
||||
'mark { background-color: #F3B717 !important; color: black !important; }',
|
||||
].join('\n');
|
||||
|
||||
document.addEventListener('copy', webviewLib.logEnabledEventHandler(e => {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.isCollapsed) return;
|
||||
@@ -852,6 +882,35 @@
|
||||
node = node.parentElement;
|
||||
}
|
||||
|
||||
const overrideStyle = document.createElement('style');
|
||||
overrideStyle.textContent = lightThemeOverrideCss;
|
||||
document.head.appendChild(overrideStyle);
|
||||
|
||||
const renderedMd = document.getElementById('rendered-md') || contentElement;
|
||||
wrapper.style.cssText = 'position:absolute;left:-9999px;visibility:hidden;pointer-events:none';
|
||||
renderedMd.appendChild(wrapper);
|
||||
|
||||
try {
|
||||
const elements = wrapper.querySelectorAll('*');
|
||||
for (const el of elements) {
|
||||
let cs;
|
||||
try { cs = window.getComputedStyle(el); } catch (_) { continue; }
|
||||
|
||||
const parts = [];
|
||||
for (const prop of clipboardCssProps) {
|
||||
const val = cs.getPropertyValue(prop);
|
||||
if (val) parts.push(`${prop}: ${val}`);
|
||||
}
|
||||
if (parts.length > 0) {
|
||||
el.setAttribute('style', parts.join('; '));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
renderedMd.removeChild(wrapper);
|
||||
overrideStyle.remove();
|
||||
wrapper.style.cssText = '';
|
||||
}
|
||||
|
||||
e.clipboardData.setData('text/html', wrapper.innerHTML);
|
||||
e.clipboardData.setData('text/plain', selection.toString());
|
||||
e.preventDefault();
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
@use './base-button.scss';
|
||||
@use './dialog-modal-layer.scss';
|
||||
@use './user-webview-dialog.scss';
|
||||
@use './note-property-box.scss';
|
||||
@use './prompt-dialog.scss';
|
||||
@use './flat-button.scss';
|
||||
@use './link-button.scss';
|
||||
|
||||
10
packages/app-desktop/gui/styles/note-property-box.scss
Normal file
10
packages/app-desktop/gui/styles/note-property-box.scss
Normal file
@@ -0,0 +1,10 @@
|
||||
.note-property-box {
|
||||
> th, > td {
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.rdt {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
@@ -432,7 +432,9 @@ test.describe('markdownEditor', () => {
|
||||
});
|
||||
|
||||
expect(clipboardHtml).toContain('hello');
|
||||
expect(clipboardHtml).not.toMatch(/background-color\s*:/i);
|
||||
expect(clipboardHtml).toContain('<strong>');
|
||||
// Dark theme background (#1D2024) must not leak into clipboard
|
||||
expect(clipboardHtml).not.toMatch(/1D2024/i);
|
||||
expect(clipboardHtml).toContain('<strong');
|
||||
expect(clipboardHtml).toMatch(/font-weight/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,29 @@ import MainScreen from './models/MainScreen';
|
||||
import NoteEditorScreen from './models/NoteEditorScreen';
|
||||
|
||||
test.describe('multiWindow', () => {
|
||||
// Disabled: This test often hangs when closing secondary windows (see https://github.com/laurent22/joplin/issues/14628):
|
||||
// Disabled: Playwright's page.close() triggers a different code path than
|
||||
// a user closing the window, causing the test to be unreliable.
|
||||
// The fix works correctly in manual testing (see https://github.com/laurent22/joplin/issues/14628).
|
||||
test.fixme('should not crash when closing a secondary window', async ({ mainWindow, electronApp }) => {
|
||||
const mainPage = await new MainScreen(mainWindow).setup();
|
||||
await mainPage.createNewNote('Test');
|
||||
|
||||
const window = await mainPage.openNewWindow(electronApp);
|
||||
|
||||
// Should load successfully
|
||||
const screen = new NoteEditorScreen(window);
|
||||
await screen.waitFor();
|
||||
|
||||
// Close the secondary window
|
||||
await window.close();
|
||||
|
||||
// Wait for the Portal cleanup to complete before checking main window stability
|
||||
await mainWindow.waitForTimeout(2000);
|
||||
|
||||
// Main window should remain stable — no white screen or renderer crash
|
||||
await expect(await mainPage.noteEditor.contentLocator()).toBeVisible();
|
||||
});
|
||||
|
||||
test.fixme('should support quickly creating, then closing secondary windows', async ({ mainWindow, electronApp }) => {
|
||||
const mainPage = await new MainScreen(mainWindow).setup();
|
||||
await mainPage.createNewNote('Test');
|
||||
@@ -28,4 +50,3 @@ test.describe('multiWindow', () => {
|
||||
await expect(await mainPage.noteEditor.contentLocator()).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { test, expect } from './util/test';
|
||||
import MainScreen from './models/MainScreen';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import { Page } from '@playwright/test';
|
||||
import SettingsScreen from './models/SettingsScreen';
|
||||
|
||||
const createScanner = (page: Page) => {
|
||||
return new AxeBuilder({ page })
|
||||
@@ -38,25 +39,24 @@ const expectNoViolations = async (page: Page) => {
|
||||
};
|
||||
|
||||
test.describe('wcag', () => {
|
||||
// Disabled due to random failure in CI:
|
||||
// for (const tabName of ['General', 'Plugins']) {
|
||||
// test(`should not detect significant issues in the settings screen ${tabName} tab`, async ({ electronApp, mainWindow }) => {
|
||||
// const mainScreen = await new MainScreen(mainWindow).setup();
|
||||
// await mainScreen.waitFor();
|
||||
//
|
||||
// await mainScreen.openSettings(electronApp);
|
||||
//
|
||||
// // Should be on the settings screen
|
||||
// const settingsScreen = new SettingsScreen(mainWindow);
|
||||
// await settingsScreen.waitFor();
|
||||
//
|
||||
// const tabLocator = settingsScreen.getTabLocator(tabName);
|
||||
// await tabLocator.click();
|
||||
// await expect(tabLocator).toBeFocused();
|
||||
//
|
||||
// await expectNoViolations(mainWindow);
|
||||
// });
|
||||
// }
|
||||
for (const tabName of ['General', 'Plugins']) {
|
||||
test(`should not detect significant issues in the settings screen ${tabName} tab`, async ({ electronApp, mainWindow }) => {
|
||||
const mainScreen = await new MainScreen(mainWindow).setup();
|
||||
await mainScreen.waitFor();
|
||||
|
||||
await mainScreen.openSettings(electronApp);
|
||||
|
||||
// Should be on the settings screen
|
||||
const settingsScreen = new SettingsScreen(mainWindow);
|
||||
await settingsScreen.waitFor();
|
||||
|
||||
const tabLocator = settingsScreen.getTabLocator(tabName);
|
||||
await tabLocator.click();
|
||||
await expect(tabLocator).toBeFocused();
|
||||
|
||||
await expectNoViolations(mainWindow);
|
||||
});
|
||||
}
|
||||
|
||||
test('should not detect significant issues in the main screen with an open note', async ({ mainWindow }) => {
|
||||
const mainScreen = await new MainScreen(mainWindow).setup();
|
||||
@@ -82,6 +82,17 @@ test.describe('wcag', () => {
|
||||
await expectNoViolations(mainWindow);
|
||||
});
|
||||
|
||||
test('should not detect significant issues in the note properties screen', async ({ mainWindow, electronApp }) => {
|
||||
const mainScreen = await new MainScreen(mainWindow).setup();
|
||||
await mainScreen.createNewNote('Test');
|
||||
await mainScreen.goToAnything.runCommand(electronApp, 'showNoteProperties');
|
||||
|
||||
const header = mainScreen.dialog.locator('h1');
|
||||
await expect(header).toBeVisible();
|
||||
|
||||
await expectNoViolations(mainWindow);
|
||||
});
|
||||
|
||||
test('should not detect significant issues in the change app layout screen', async ({ mainWindow, electronApp }) => {
|
||||
const mainScreen = await new MainScreen(mainWindow).setup();
|
||||
await mainScreen.changeLayoutScreen.open(electronApp);
|
||||
|
||||
@@ -106,10 +106,6 @@ a {
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
.note-property-box .rdt {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.help-tooltip {
|
||||
font-family: sans-serif;
|
||||
max-width: 200px;
|
||||
@@ -290,18 +286,18 @@ Component-specific classes
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.master-password-dialog .dialog-root {
|
||||
.master-password-dialog .dialog-root, .enable-encryption-dialog .dialog-root {
|
||||
min-width: 500px;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.master-password-dialog .dialog-content {
|
||||
.master-password-dialog .dialog-content, .enable-encryption-dialog .dialog-content {
|
||||
background-color: var(--joplin-background-color3);
|
||||
padding: 1em;
|
||||
padding-bottom: 1px;
|
||||
}
|
||||
|
||||
.master-password-dialog .current-password-wrapper {
|
||||
.master-password-dialog .current-password-wrapper, .enable-encryption-dialog .current-password-wrapper {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
@@ -151,7 +151,7 @@
|
||||
"@joplin/renderer": "~3.6",
|
||||
"@joplin/tools": "~3.6",
|
||||
"@joplin/utils": "~3.6",
|
||||
"@playwright/test": "1.56.1",
|
||||
"@playwright/test": "1.57.0",
|
||||
"@sentry/electron": "4.24.0",
|
||||
"@testing-library/dom": "10.4.1",
|
||||
"@testing-library/react": "16.3.2",
|
||||
@@ -168,11 +168,11 @@
|
||||
"color": "3.2.1",
|
||||
"compare-versions": "6.1.1",
|
||||
"debounce": "1.2.1",
|
||||
"electron": "39.2.3",
|
||||
"electron": "40.8.3",
|
||||
"electron-builder": "24.13.3",
|
||||
"electron-updater": "6.6.8",
|
||||
"electron-window-state": "5.0.3",
|
||||
"esbuild": "^0.26.0",
|
||||
"esbuild": "^0.27.0",
|
||||
"formatcoords": "1.1.3",
|
||||
"glob": "11.1.0",
|
||||
"gulp": "4.0.2",
|
||||
@@ -186,7 +186,7 @@
|
||||
"md5": "2.3.0",
|
||||
"moment": "2.30.1",
|
||||
"mustache": "4.2.0",
|
||||
"nan": "2.23.1",
|
||||
"nan": "2.24.0",
|
||||
"node-notifier": "10.0.1",
|
||||
"node-rsa": "1.1.1",
|
||||
"pdfjs-dist": "3.11.174",
|
||||
@@ -215,7 +215,7 @@
|
||||
"dependencies": {
|
||||
"@electron/remote": "2.1.3",
|
||||
"@joplin/onenote-converter": "~3.6",
|
||||
"fs-extra": "11.3.2",
|
||||
"fs-extra": "11.3.3",
|
||||
"keytar": "7.9.0",
|
||||
"node-fetch": "2.6.7",
|
||||
"sqlite3": "5.1.6"
|
||||
|
||||
@@ -62,8 +62,7 @@ export default class BackOffHandler {
|
||||
return this.backOffIntervals_[effectiveIndex];
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
public async wait(path: string, args: any) {
|
||||
public async wait(path: string, args: unknown) {
|
||||
const interval = this.backOffInterval();
|
||||
if (!interval) return;
|
||||
|
||||
|
||||
@@ -155,13 +155,16 @@ export default class PluginRunner extends BasePluginRunner {
|
||||
if (message.pluginId !== plugin.id) return;
|
||||
|
||||
if (message.mainWindowCallbackId) {
|
||||
const promise = callbackPromises[message.mainWindowCallbackId];
|
||||
const callbackId = message.mainWindowCallbackId;
|
||||
const promise = callbackPromises[callbackId];
|
||||
|
||||
if (!promise) {
|
||||
console.error('Got a callback without matching promise: ', message);
|
||||
return;
|
||||
}
|
||||
|
||||
delete callbackPromises[callbackId];
|
||||
|
||||
if (message.error) {
|
||||
promise.reject(message.error);
|
||||
} else {
|
||||
|
||||
@@ -9,8 +9,7 @@ interface HookDependencies {
|
||||
themeId: number;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
function themeToCssVariables(theme: any) {
|
||||
function themeToCssVariables(theme: Record<string, unknown>) {
|
||||
const lines = [];
|
||||
lines.push(':root {');
|
||||
|
||||
|
||||
@@ -113,12 +113,15 @@
|
||||
}
|
||||
|
||||
if (message.pluginCallbackId) {
|
||||
const promise = callbackPromises[message.pluginCallbackId];
|
||||
const callbackId = message.pluginCallbackId;
|
||||
const promise = callbackPromises[callbackId];
|
||||
if (!promise) {
|
||||
console.error('Got a callback without matching promise: ', message);
|
||||
return;
|
||||
}
|
||||
|
||||
delete callbackPromises[callbackId];
|
||||
|
||||
if (message.error) {
|
||||
promise.reject(message.error);
|
||||
} else {
|
||||
|
||||
@@ -4,13 +4,13 @@ import SpellCheckerServiceDriverBase from '@joplin/lib/services/spellChecker/Spe
|
||||
import bridge from '../bridge';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import { languageCodeOnly, localesFromLanguageCode } from '@joplin/lib/locale';
|
||||
import { Session } from 'electron';
|
||||
|
||||
const logger = Logger.create('SpellCheckerServiceDriverNative');
|
||||
|
||||
export default class SpellCheckerServiceDriverNative extends SpellCheckerServiceDriverBase {
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
private session(): any {
|
||||
private session(): Session {
|
||||
return bridge().mainWindow().webContents.session;
|
||||
}
|
||||
|
||||
|
||||
@@ -24,8 +24,8 @@ async function main() {
|
||||
// We need to force the ABI because Electron Builder or node-abi picks the
|
||||
// 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';
|
||||
// https://github.com/electron/node-abi/blob/main/abi_registry.json
|
||||
const forceAbiArgs = '--force-abi 143';
|
||||
|
||||
if (isWindows()) {
|
||||
// Cannot run this in parallel, or the 64-bit version might end up
|
||||
|
||||
2
packages/app-mobile/.gitignore
vendored
2
packages/app-mobile/.gitignore
vendored
@@ -74,6 +74,8 @@ components/**/*.bundle.js.md5
|
||||
components/**/*.bundle.min.js
|
||||
web/public/pluginAssets/*
|
||||
|
||||
/pluginAssets/
|
||||
|
||||
utils/fs-driver-android.js
|
||||
android/app/build-*
|
||||
|
||||
|
||||
@@ -67,14 +67,14 @@ const useSearchResults = ({
|
||||
const collatorLocale = getCollatorLocale();
|
||||
const results = useMemo(() => {
|
||||
const collator = getCollator(collatorLocale);
|
||||
const lowerSearch = search?.toLowerCase();
|
||||
const lowerSearch = (search || '').trim().normalize('NFC').toLowerCase();
|
||||
return options
|
||||
.filter(option => option.title.toLowerCase().includes(lowerSearch))
|
||||
.filter(option => (option.title || '').trim().normalize('NFC').toLowerCase().includes(lowerSearch))
|
||||
.sort((a, b) => {
|
||||
if (a.title === b.title) return 0;
|
||||
// Full matches should go first
|
||||
if (a.title.toLowerCase() === lowerSearch) return -1;
|
||||
if (b.title.toLowerCase() === lowerSearch) return 1;
|
||||
if ((a.title || '').trim().normalize('NFC').toLowerCase() === lowerSearch) return -1;
|
||||
if ((b.title || '').trim().normalize('NFC').toLowerCase() === lowerSearch) return 1;
|
||||
return collator.compare(a.title, b.title);
|
||||
});
|
||||
}, [search, options, collatorLocale]);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import { fireEvent, render, screen, waitFor } from '../../utils/testing/testingLibrary';
|
||||
import { act, fireEvent, render, screen, waitFor } from '../../utils/testing/testingLibrary';
|
||||
|
||||
import { Store } from 'redux';
|
||||
import { AppState } from '../../utils/types';
|
||||
@@ -27,8 +27,9 @@ const queryToolbarButton = (label: string) => {
|
||||
};
|
||||
|
||||
const openSettings = async () => {
|
||||
const settingButton = screen.getByRole('button', { name: 'Settings' });
|
||||
fireEvent.press(settingButton);
|
||||
await act(async () => {
|
||||
fireEvent.press(screen.getByRole('button', { name: 'Settings' }));
|
||||
});
|
||||
|
||||
// Settings should be open:
|
||||
const settingsHeader = await screen.findByRole('heading', { name: 'Manage toolbar options' });
|
||||
@@ -50,13 +51,19 @@ const toggleSettingsItem = async (props: ToggleSettingItemProps) => {
|
||||
} else {
|
||||
expect(itemCheckbox).not.toBeChecked();
|
||||
}
|
||||
fireEvent.press(itemCheckbox);
|
||||
await act(async () => {
|
||||
fireEvent.press(itemCheckbox);
|
||||
});
|
||||
|
||||
// Re-query after the press: the item may be re-mounted with a new key when
|
||||
// it moves between the enabled and disabled sections.
|
||||
await waitFor(() => {
|
||||
const updatedCheckbox = screen.queryByRole('checkbox', { name: props.name });
|
||||
expect(updatedCheckbox).not.toBeNull();
|
||||
if (finalChecked) {
|
||||
expect(itemCheckbox).toBeChecked();
|
||||
expect(updatedCheckbox).toBeChecked();
|
||||
} else {
|
||||
expect(itemCheckbox).not.toBeChecked();
|
||||
expect(updatedCheckbox).not.toBeChecked();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import * as React from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import createRootStyle from '../../utils/createRootStyle';
|
||||
import { View, StyleSheet, ScrollView } from 'react-native';
|
||||
import { AccessibilityInfo, View, StyleSheet, ScrollView } from 'react-native';
|
||||
import { Divider, Text, TouchableRipple } from 'react-native-paper';
|
||||
import IconButton from '../IconButton';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { themeStyle } from '../global-style';
|
||||
import { connect } from 'react-redux';
|
||||
@@ -17,6 +18,9 @@ import selectedCommandNamesFromState from './utils/selectedCommandNamesFromState
|
||||
import stateToWhenClauseContext from '../../services/commands/stateToWhenClauseContext';
|
||||
import { DeleteButton } from '../buttons';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import useToolbarEditorState, { ReorderableItem } from './utils/useToolbarEditorState';
|
||||
import useSaveToolbarButtons from './utils/useSaveToolbarButtons';
|
||||
import focusView from '../../utils/focusView';
|
||||
|
||||
const toolbarButtonUtils = new ToolbarButtonUtils(CommandService.instance());
|
||||
|
||||
@@ -41,8 +45,13 @@ const useStyle = (themeId: number) => {
|
||||
color: theme.color,
|
||||
fontSize: theme.fontSizeLarge,
|
||||
},
|
||||
disabledIcon: {
|
||||
color: theme.colorFaded,
|
||||
fontSize: theme.fontSizeLarge,
|
||||
},
|
||||
labelText: {
|
||||
fontSize: theme.fontSize,
|
||||
flex: 1,
|
||||
},
|
||||
listContainer: {
|
||||
marginTop: theme.marginTop,
|
||||
@@ -59,62 +68,229 @@ const useStyle = (themeId: number) => {
|
||||
padding: 4,
|
||||
paddingTop: theme.itemMarginTop,
|
||||
paddingBottom: theme.itemMarginBottom,
|
||||
minHeight: 44,
|
||||
},
|
||||
// Like listItem but without vertical padding -- the TouchableRipple inside
|
||||
// carries the padding, so its minHeight drives the row height directly.
|
||||
enabledListItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
gap: theme.margin,
|
||||
paddingLeft: 4,
|
||||
paddingRight: 4,
|
||||
},
|
||||
arrowButtonsContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
arrowIcon: {
|
||||
color: theme.color,
|
||||
fontSize: 24,
|
||||
},
|
||||
arrowIconDisabled: {
|
||||
color: theme.colorFaded,
|
||||
fontSize: 24,
|
||||
opacity: 0.38,
|
||||
},
|
||||
sectionHeader: {
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 4,
|
||||
color: theme.colorFaded,
|
||||
},
|
||||
enabledItemTouchable: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
gap: theme.margin,
|
||||
paddingTop: theme.itemMarginTop,
|
||||
paddingBottom: theme.itemMarginBottom,
|
||||
minHeight: 44,
|
||||
},
|
||||
disabledLabelText: {
|
||||
fontSize: theme.fontSize,
|
||||
flex: 1,
|
||||
color: theme.colorFaded,
|
||||
},
|
||||
});
|
||||
}, [themeId]);
|
||||
};
|
||||
type Styles = ReturnType<typeof useStyle>;
|
||||
|
||||
const setCommandIncluded = (
|
||||
commandName: string,
|
||||
lastSelectedCommands: string[],
|
||||
allCommandNames: string[],
|
||||
include: boolean,
|
||||
interface EnabledItemRowProps {
|
||||
item: ReorderableItem;
|
||||
index: number;
|
||||
isFirst: boolean;
|
||||
isLast: boolean;
|
||||
styles: Styles;
|
||||
themeId: number;
|
||||
shouldFocus?: boolean;
|
||||
onFocused?: ()=> void;
|
||||
onToggle: (commandName: string)=> void;
|
||||
onMoveUp: (index: number)=> void;
|
||||
onMoveDown: (index: number)=> void;
|
||||
}
|
||||
|
||||
// After a move re-render, focus the arrow that was pressed.
|
||||
// If we hit a boundary (now first or last), swap to the opposite arrow.
|
||||
// index/isFirst/isLast reflect the new position after the parent re-renders.
|
||||
//
|
||||
// We delay the focusView call: when an item moves DOWN, TalkBack re-evaluates
|
||||
// focus after the accessibility tree update (content changed ahead of the focused
|
||||
// element), jumping to X. The delay lets TalkBack settle so our call wins.
|
||||
// Refs are captured before the timeout to avoid stale closures.
|
||||
// useEffect (not useLayoutEffect) is correct here since the 100ms delay
|
||||
// already negates any synchronous-paint timing advantage.
|
||||
const useArrowFocusAfterMove = (
|
||||
upArrowRef: React.RefObject<View>,
|
||||
downArrowRef: React.RefObject<View>,
|
||||
pendingArrowFocusRef: React.MutableRefObject<'up'|'down'|null>,
|
||||
index: number,
|
||||
isFirst: boolean,
|
||||
isLast: boolean,
|
||||
) => {
|
||||
let newSelectedCommands;
|
||||
if (include) {
|
||||
newSelectedCommands = [];
|
||||
for (const name of allCommandNames) {
|
||||
const isDivider = name === '-';
|
||||
if (isDivider || name === commandName || lastSelectedCommands.includes(name)) {
|
||||
newSelectedCommands.push(name);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
newSelectedCommands = lastSelectedCommands.filter(name => name !== commandName);
|
||||
}
|
||||
Setting.setValue('editor.toolbarButtons', newSelectedCommands);
|
||||
useEffect(() => {
|
||||
const direction = pendingArrowFocusRef.current;
|
||||
pendingArrowFocusRef.current = null;
|
||||
|
||||
const upRef = upArrowRef.current;
|
||||
const downRef = downArrowRef.current;
|
||||
const atFirst = isFirst;
|
||||
const atLast = isLast;
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (!direction) return;
|
||||
const target = direction === 'up'
|
||||
? (atFirst ? downRef : upRef)
|
||||
: (atLast ? upRef : downRef);
|
||||
if (target) focusView('toolbar-editor-arrow', target);
|
||||
}, 100);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [index, isFirst, isLast, upArrowRef, downArrowRef, pendingArrowFocusRef]);
|
||||
};
|
||||
|
||||
interface ItemToggleProps {
|
||||
item: ToolbarButtonInfo;
|
||||
selectedCommandNames: string[];
|
||||
allCommandNames: string[];
|
||||
styles: Styles;
|
||||
}
|
||||
const ToolbarItemToggle: React.FC<ItemToggleProps> = ({
|
||||
item, selectedCommandNames, styles, allCommandNames,
|
||||
}) => {
|
||||
const title = item.title || item.tooltip;
|
||||
const checked = selectedCommandNames.includes(item.name);
|
||||
// When a row becomes the pending-focus target (e.g. after being added), focus its checkbox.
|
||||
// We defer via queueMicrotask: UIManager.focus (used by focusView on web) silently fails if
|
||||
// called during React's commit phase before the DOM has settled. A microtask fires after the
|
||||
// current call stack clears but before the next frame, making it faster and more deterministic
|
||||
// than a setTimeout while still giving the DOM time to update.
|
||||
const useCheckboxFocusOnAdd = (
|
||||
shouldFocus: boolean|undefined,
|
||||
onFocused: (()=> void)|undefined,
|
||||
checkboxRef: React.RefObject<View>,
|
||||
) => {
|
||||
useEffect(() => {
|
||||
const ref = checkboxRef.current;
|
||||
const focused = onFocused;
|
||||
let cancelled = false;
|
||||
queueMicrotask(() => {
|
||||
if (cancelled || !shouldFocus || !ref) return;
|
||||
focusView('toolbar-editor', ref);
|
||||
focused?.();
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [shouldFocus, onFocused, checkboxRef]);
|
||||
};
|
||||
|
||||
const onToggle = useCallback(() => {
|
||||
setCommandIncluded(item.name, selectedCommandNames, allCommandNames, !checked);
|
||||
}, [item, selectedCommandNames, allCommandNames, checked]);
|
||||
const EnabledItemRow: React.FC<EnabledItemRowProps> = ({
|
||||
item, index, isFirst, isLast, styles, themeId, shouldFocus, onFocused, onToggle, onMoveUp, onMoveDown,
|
||||
}) => {
|
||||
const title = item.buttonInfo.title || item.buttonInfo.tooltip;
|
||||
|
||||
// Local refs for checkbox and arrow focus management
|
||||
const checkboxRef = useRef<View>(null);
|
||||
const upArrowRef = useRef<View>(null);
|
||||
const downArrowRef = useRef<View>(null);
|
||||
const pendingArrowFocusRef = useRef<'up'|'down'|null>(null);
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
onToggle(item.commandName);
|
||||
AccessibilityInfo.announceForAccessibility(_('%s removed from toolbar', title));
|
||||
}, [onToggle, item.commandName, title]);
|
||||
|
||||
const handleMoveUp = useCallback(() => {
|
||||
pendingArrowFocusRef.current = 'up';
|
||||
onMoveUp(index);
|
||||
AccessibilityInfo.announceForAccessibility(_('%s moved up', title));
|
||||
}, [onMoveUp, index, title]);
|
||||
|
||||
const handleMoveDown = useCallback(() => {
|
||||
pendingArrowFocusRef.current = 'down';
|
||||
onMoveDown(index);
|
||||
AccessibilityInfo.announceForAccessibility(_('%s moved down', title));
|
||||
}, [onMoveDown, index, title]);
|
||||
|
||||
useArrowFocusAfterMove(upArrowRef, downArrowRef, pendingArrowFocusRef, index, isFirst, isLast);
|
||||
useCheckboxFocusOnAdd(shouldFocus, onFocused, checkboxRef);
|
||||
|
||||
return (
|
||||
<View style={styles.enabledListItem}>
|
||||
<TouchableRipple
|
||||
ref={checkboxRef}
|
||||
accessibilityRole='checkbox'
|
||||
accessibilityState={{ checked: true }}
|
||||
aria-checked={true}
|
||||
onPress={handleToggle}
|
||||
style={styles.enabledItemTouchable}
|
||||
>
|
||||
<>
|
||||
<Icon name='ionicon checkbox-outline' style={styles.icon} accessibilityLabel={null}/>
|
||||
<Icon name={item.buttonInfo.iconName} style={styles.icon} accessibilityLabel={null}/>
|
||||
<Text style={styles.labelText}>{title}</Text>
|
||||
</>
|
||||
</TouchableRipple>
|
||||
<View style={styles.arrowButtonsContainer}>
|
||||
<IconButton
|
||||
pressableRef={upArrowRef}
|
||||
iconName='material arrow-up'
|
||||
iconStyle={isFirst ? styles.arrowIconDisabled : styles.arrowIcon}
|
||||
onPress={handleMoveUp}
|
||||
disabled={isFirst}
|
||||
description={_('Move %s up', title)}
|
||||
themeId={themeId}
|
||||
/>
|
||||
<IconButton
|
||||
pressableRef={downArrowRef}
|
||||
iconName='material arrow-down'
|
||||
iconStyle={isLast ? styles.arrowIconDisabled : styles.arrowIcon}
|
||||
onPress={handleMoveDown}
|
||||
disabled={isLast}
|
||||
description={_('Move %s down', title)}
|
||||
themeId={themeId}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
interface DisabledItemRowProps {
|
||||
item: ReorderableItem;
|
||||
styles: Styles;
|
||||
onToggle: (commandName: string)=> void;
|
||||
}
|
||||
|
||||
const DisabledItemRow: React.FC<DisabledItemRowProps> = ({
|
||||
item, styles, onToggle,
|
||||
}) => {
|
||||
const title = item.buttonInfo.title || item.buttonInfo.tooltip;
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
onToggle(item.commandName);
|
||||
AccessibilityInfo.announceForAccessibility(_('%s added to toolbar', title));
|
||||
}, [onToggle, item.commandName, title]);
|
||||
|
||||
return (
|
||||
<TouchableRipple
|
||||
accessibilityRole='checkbox'
|
||||
accessibilityState={{ checked }}
|
||||
aria-checked={checked}
|
||||
onPress={onToggle}
|
||||
accessibilityState={{ checked: false }}
|
||||
aria-checked={false}
|
||||
onPress={handleToggle}
|
||||
>
|
||||
<View style={styles.listItem}>
|
||||
<Icon name={checked ? 'ionicon checkbox-outline' : 'ionicon square-outline'} style={styles.icon} accessibilityLabel={null}/>
|
||||
<Icon name={item.iconName} style={styles.icon} accessibilityLabel={null}/>
|
||||
<Text style={styles.labelText}>
|
||||
{title}
|
||||
</Text>
|
||||
<Icon name='ionicon square-outline' style={styles.disabledIcon} accessibilityLabel={null}/>
|
||||
<Icon name={item.buttonInfo.iconName} style={styles.disabledIcon} accessibilityLabel={null}/>
|
||||
<Text style={styles.disabledLabelText}>{title}</Text>
|
||||
</View>
|
||||
</TouchableRipple>
|
||||
);
|
||||
@@ -123,19 +299,62 @@ const ToolbarItemToggle: React.FC<ItemToggleProps> = ({
|
||||
const ToolbarEditorScreen: React.FC<EditorDialogProps> = props => {
|
||||
const styles = useStyle(props.themeId);
|
||||
|
||||
const renderItem = (item: ToolbarItem, index: number) => {
|
||||
if (item.type === 'separator') {
|
||||
return <Divider key={`separator-${index}`} />;
|
||||
// Filter button infos to only include actual buttons (not separators)
|
||||
const allButtonInfos = useMemo(() => {
|
||||
return props.defaultToolbarButtonInfos.filter(
|
||||
(item): item is ToolbarButtonInfo => item.type === 'button',
|
||||
);
|
||||
}, [props.defaultToolbarButtonInfos]);
|
||||
|
||||
const [pendingFocusCommand, setPendingFocusCommand] = useState<string|null>(null);
|
||||
const isReinitializingRef = useRef(false);
|
||||
|
||||
const {
|
||||
enabledItems,
|
||||
disabledItems,
|
||||
handleMoveUp,
|
||||
handleMoveDown,
|
||||
handleToggle: doToggle,
|
||||
reinitialize: baseReinitialize,
|
||||
} = useToolbarEditorState({
|
||||
initialSelectedCommandNames: props.selectedCommandNames,
|
||||
allCommandNames: props.allCommandNames,
|
||||
allButtonInfos,
|
||||
});
|
||||
|
||||
useSaveToolbarButtons(enabledItems, isReinitializingRef);
|
||||
|
||||
const reinitialize = useCallback((selectedNames: string[]) => {
|
||||
isReinitializingRef.current = true;
|
||||
baseReinitialize(selectedNames);
|
||||
}, [baseReinitialize]);
|
||||
|
||||
const handleToggle = useCallback((commandName: string) => {
|
||||
const enabledIndex = enabledItems.findIndex(item => item.commandName === commandName);
|
||||
const isBeingEnabled = enabledIndex === -1;
|
||||
|
||||
if (isBeingEnabled) {
|
||||
setPendingFocusCommand(commandName);
|
||||
} else if (enabledItems.length > 1) {
|
||||
const nextFocus = enabledIndex < enabledItems.length - 1
|
||||
? enabledItems[enabledIndex + 1].commandName
|
||||
: enabledItems[enabledIndex - 1].commandName;
|
||||
setPendingFocusCommand(nextFocus);
|
||||
}
|
||||
|
||||
return <ToolbarItemToggle
|
||||
key={`command-${item.name}`}
|
||||
item={item}
|
||||
styles={styles}
|
||||
allCommandNames={props.allCommandNames}
|
||||
selectedCommandNames={props.selectedCommandNames}
|
||||
/>;
|
||||
};
|
||||
doToggle(commandName);
|
||||
}, [doToggle, enabledItems]);
|
||||
|
||||
const handleFocused = useCallback(() => setPendingFocusCommand(null), []);
|
||||
|
||||
// Re-sync local state whenever the dialog becomes visible (e.g. after Restore defaults)
|
||||
const prevVisible = useRef(props.visible);
|
||||
useEffect(() => {
|
||||
if (props.visible && !prevVisible.current) {
|
||||
reinitialize(props.selectedCommandNames);
|
||||
}
|
||||
prevVisible.current = props.visible;
|
||||
}, [props.visible, props.selectedCommandNames, reinitialize]);
|
||||
|
||||
const onRestoreDefaultLayout = useCallback(async () => {
|
||||
// Dismiss before showing the confirm dialog to prevent modal conflicts.
|
||||
@@ -168,7 +387,41 @@ const ToolbarEditorScreen: React.FC<EditorDialogProps> = props => {
|
||||
<Text variant='bodyMedium'>{_('Check elements to display in the toolbar')}</Text>
|
||||
</View>
|
||||
<ScrollView style={styles.listContainer}>
|
||||
{props.defaultToolbarButtonInfos.map((item, index) => renderItem(item, index))}
|
||||
{enabledItems.map((item, index) => (
|
||||
<EnabledItemRow
|
||||
key={`enabled-${item.commandName}`}
|
||||
item={item}
|
||||
index={index}
|
||||
isFirst={index === 0}
|
||||
isLast={index === enabledItems.length - 1}
|
||||
styles={styles}
|
||||
themeId={props.themeId}
|
||||
shouldFocus={item.commandName === pendingFocusCommand}
|
||||
onFocused={handleFocused}
|
||||
onToggle={handleToggle}
|
||||
onMoveUp={handleMoveUp}
|
||||
onMoveDown={handleMoveDown}
|
||||
/>
|
||||
))}
|
||||
|
||||
{disabledItems.length > 0 && (
|
||||
<>
|
||||
<Divider />
|
||||
<Text variant='labelMedium' style={styles.sectionHeader}>
|
||||
{_('Available')}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
{disabledItems.map((item) => (
|
||||
<DisabledItemRow
|
||||
key={`disabled-${item.commandName}`}
|
||||
item={item}
|
||||
styles={styles}
|
||||
onToggle={handleToggle}
|
||||
/>
|
||||
))}
|
||||
|
||||
{props.hasCustomizedLayout ? restoreButton : null}
|
||||
</ScrollView>
|
||||
</DismissibleDialog>
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { renderHook, act, waitFor } from '../../../utils/testing/testingLibrary';
|
||||
import { setupDatabase, switchClient } from '@joplin/lib/testing/test-utils';
|
||||
import useSaveToolbarButtons from './useSaveToolbarButtons';
|
||||
import { ReorderableItem } from './useToolbarEditorState';
|
||||
import { ToolbarButtonInfo } from '@joplin/lib/services/commands/ToolbarButtonUtils';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
|
||||
const mockItem = (name: string): ReorderableItem => ({
|
||||
commandName: name,
|
||||
buttonInfo: {
|
||||
type: 'button',
|
||||
name,
|
||||
title: name,
|
||||
tooltip: name,
|
||||
iconName: `icon-${name}`,
|
||||
enabled: true,
|
||||
visible: true,
|
||||
onClick: jest.fn(),
|
||||
} as ToolbarButtonInfo,
|
||||
});
|
||||
|
||||
describe('useSaveToolbarButtons', () => {
|
||||
beforeEach(async () => {
|
||||
await setupDatabase(0);
|
||||
await switchClient(0);
|
||||
Setting.setValue('editor.toolbarButtons', []);
|
||||
});
|
||||
|
||||
it('should not save on initial mount', async () => {
|
||||
const isReinitializing = { current: false };
|
||||
renderHook(() => useSaveToolbarButtons([mockItem('textBold')], isReinitializing));
|
||||
// Wait a tick to ensure the effect has run
|
||||
await act(async () => {});
|
||||
expect(Setting.value('editor.toolbarButtons')).toEqual([]);
|
||||
});
|
||||
|
||||
it('should save when enabledItems changes after initial mount', async () => {
|
||||
const isReinitializing = { current: false };
|
||||
const { rerender } = renderHook(
|
||||
({ items }: { items: ReorderableItem[] }) => useSaveToolbarButtons(items, isReinitializing),
|
||||
{ initialProps: { items: [mockItem('textBold')] } },
|
||||
);
|
||||
|
||||
rerender({ items: [mockItem('textBold'), mockItem('textItalic')] });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(Setting.value('editor.toolbarButtons')).toEqual(['textBold', 'textItalic']);
|
||||
});
|
||||
});
|
||||
|
||||
it('should not save when isReinitializing is set, and should reset the flag', async () => {
|
||||
const isReinitializing = { current: false };
|
||||
const { rerender } = renderHook(
|
||||
({ items }: { items: ReorderableItem[] }) => useSaveToolbarButtons(items, isReinitializing),
|
||||
{ initialProps: { items: [mockItem('textBold')] } },
|
||||
);
|
||||
|
||||
// First do a real save so the initial-mount skip is consumed
|
||||
rerender({ items: [mockItem('textBold'), mockItem('textItalic')] });
|
||||
await waitFor(() => {
|
||||
expect(Setting.value('editor.toolbarButtons')).toEqual(['textBold', 'textItalic']);
|
||||
});
|
||||
|
||||
// Now simulate reinitialize
|
||||
isReinitializing.current = true;
|
||||
rerender({ items: [mockItem('textCode')] });
|
||||
|
||||
// Give the effect time to run
|
||||
await act(async () => {});
|
||||
// Setting should be unchanged
|
||||
expect(Setting.value('editor.toolbarButtons')).toEqual(['textBold', 'textItalic']);
|
||||
// Flag should have been reset
|
||||
expect(isReinitializing.current).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useRef, useEffect, MutableRefObject } from 'react';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { ReorderableItem } from './useToolbarEditorState';
|
||||
|
||||
// Persists the enabled toolbar button order to settings after user edits.
|
||||
// Skips the initial mount and any change triggered by reinitialize (indicated
|
||||
// by the caller setting isReinitializing.current = true before the state update).
|
||||
const useSaveToolbarButtons = (
|
||||
enabledItems: ReorderableItem[],
|
||||
isReinitializing: MutableRefObject<boolean>,
|
||||
) => {
|
||||
const isInitialMount = useRef(true);
|
||||
useEffect(() => {
|
||||
if (isInitialMount.current) {
|
||||
isInitialMount.current = false;
|
||||
return;
|
||||
}
|
||||
if (isReinitializing.current) {
|
||||
isReinitializing.current = false;
|
||||
return;
|
||||
}
|
||||
Setting.setValue('editor.toolbarButtons', enabledItems.map(item => item.commandName));
|
||||
}, [enabledItems, isReinitializing]);
|
||||
};
|
||||
|
||||
export default useSaveToolbarButtons;
|
||||
@@ -0,0 +1,113 @@
|
||||
import { renderHook, act } from '../../../utils/testing/testingLibrary';
|
||||
import { setupDatabase, switchClient } from '@joplin/lib/testing/test-utils';
|
||||
import useToolbarEditorState, { ReorderableItem } from './useToolbarEditorState';
|
||||
import { ToolbarButtonInfo } from '@joplin/lib/services/commands/ToolbarButtonUtils';
|
||||
|
||||
const createMockButtonInfo = (name: string, title: string): ToolbarButtonInfo => ({
|
||||
type: 'button',
|
||||
name,
|
||||
title,
|
||||
tooltip: title,
|
||||
iconName: `icon-${name}`,
|
||||
enabled: true,
|
||||
visible: true,
|
||||
onClick: jest.fn(),
|
||||
});
|
||||
|
||||
describe('useToolbarEditorState', () => {
|
||||
beforeEach(async () => {
|
||||
await setupDatabase(0);
|
||||
await switchClient(0);
|
||||
});
|
||||
|
||||
const defaultAllCommandNames = [
|
||||
'attachFile',
|
||||
'-',
|
||||
'textBold',
|
||||
'textItalic',
|
||||
'-',
|
||||
'textCode',
|
||||
'textMath',
|
||||
'-',
|
||||
'hideKeyboard',
|
||||
];
|
||||
|
||||
const defaultAllButtonInfos: ToolbarButtonInfo[] = [
|
||||
createMockButtonInfo('attachFile', 'Attach File'),
|
||||
createMockButtonInfo('textBold', 'Bold'),
|
||||
createMockButtonInfo('textItalic', 'Italic'),
|
||||
createMockButtonInfo('textCode', 'Code'),
|
||||
createMockButtonInfo('textMath', 'Math'),
|
||||
createMockButtonInfo('hideKeyboard', 'Hide Keyboard'),
|
||||
];
|
||||
|
||||
const toNames = (items: ReorderableItem[]) => items.map(i => i.commandName);
|
||||
|
||||
const renderToolbarHook = (initialSelectedCommandNames: string[]) => renderHook(() =>
|
||||
useToolbarEditorState({
|
||||
initialSelectedCommandNames,
|
||||
allCommandNames: defaultAllCommandNames,
|
||||
allButtonInfos: defaultAllButtonInfos,
|
||||
}),
|
||||
);
|
||||
|
||||
it('should partition items into enabled and disabled, excluding separators', () => {
|
||||
const { result } = renderToolbarHook(['-', 'textBold', '-', 'textItalic']);
|
||||
|
||||
expect(toNames(result.current.enabledItems)).toEqual(['textBold', 'textItalic']);
|
||||
expect(toNames(result.current.disabledItems)).toEqual([
|
||||
'attachFile', 'textCode', 'textMath', 'hideKeyboard',
|
||||
]);
|
||||
expect(toNames(result.current.disabledItems)).not.toContain('-');
|
||||
});
|
||||
|
||||
it('handleMoveUp and handleMoveDown should reorder items, with no-op at boundaries', async () => {
|
||||
const { result } = renderToolbarHook(['textBold', 'textItalic', 'textCode']);
|
||||
|
||||
// Move first item down
|
||||
await act(async () => { result.current.handleMoveDown(0); });
|
||||
expect(toNames(result.current.enabledItems)).toEqual(['textItalic', 'textBold', 'textCode']);
|
||||
|
||||
// Move it back up
|
||||
await act(async () => { result.current.handleMoveUp(1); });
|
||||
expect(toNames(result.current.enabledItems)).toEqual(['textBold', 'textItalic', 'textCode']);
|
||||
|
||||
// No-op at boundaries
|
||||
const orderBefore = toNames(result.current.enabledItems);
|
||||
await act(async () => { result.current.handleMoveUp(0); });
|
||||
await act(async () => { result.current.handleMoveDown(2); });
|
||||
expect(toNames(result.current.enabledItems)).toEqual(orderBefore);
|
||||
});
|
||||
|
||||
it('handleToggle should move items between enabled and disabled, preserving default order', async () => {
|
||||
const { result } = renderToolbarHook(['textCode', 'textBold', 'textItalic']);
|
||||
|
||||
// Toggle an enabled item off
|
||||
await act(async () => { result.current.handleToggle('textBold'); });
|
||||
expect(toNames(result.current.enabledItems)).toEqual(['textCode', 'textItalic']);
|
||||
expect(toNames(result.current.disabledItems)).toContain('textBold');
|
||||
// Disabled list should respect default order
|
||||
const disabled = toNames(result.current.disabledItems);
|
||||
expect(disabled.indexOf('attachFile')).toBeLessThan(disabled.indexOf('textBold'));
|
||||
expect(disabled.indexOf('textBold')).toBeLessThan(disabled.indexOf('textMath'));
|
||||
|
||||
// Toggle it back on: should append to end of enabled list
|
||||
await act(async () => { result.current.handleToggle('textBold'); });
|
||||
expect(toNames(result.current.enabledItems)).toEqual(['textCode', 'textItalic', 'textBold']);
|
||||
});
|
||||
|
||||
it('reinitialize should reset state to new selection', async () => {
|
||||
const { result } = renderToolbarHook(['textBold', 'textItalic']);
|
||||
|
||||
// Make a change first
|
||||
await act(async () => { result.current.handleMoveDown(0); });
|
||||
expect(toNames(result.current.enabledItems)).toEqual(['textItalic', 'textBold']);
|
||||
|
||||
// Reinitialize with a different selection
|
||||
await act(async () => { result.current.reinitialize(['textCode', 'textMath']); });
|
||||
|
||||
expect(toNames(result.current.enabledItems)).toEqual(['textCode', 'textMath']);
|
||||
expect(toNames(result.current.disabledItems)).toContain('textBold');
|
||||
expect(toNames(result.current.disabledItems)).toContain('textItalic');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { ToolbarButtonInfo } from '@joplin/lib/services/commands/ToolbarButtonUtils';
|
||||
|
||||
export interface ReorderableItem {
|
||||
commandName: string;
|
||||
buttonInfo: ToolbarButtonInfo;
|
||||
}
|
||||
|
||||
interface UseToolbarEditorStateProps {
|
||||
initialSelectedCommandNames: string[];
|
||||
allCommandNames: string[];
|
||||
allButtonInfos: ToolbarButtonInfo[];
|
||||
}
|
||||
|
||||
interface UseToolbarEditorStateResult {
|
||||
enabledItems: ReorderableItem[];
|
||||
disabledItems: ReorderableItem[];
|
||||
handleMoveUp: (index: number)=> void;
|
||||
handleMoveDown: (index: number)=> void;
|
||||
handleToggle: (commandName: string)=> void;
|
||||
reinitialize: (selectedNames: string[])=> void;
|
||||
}
|
||||
|
||||
type ItemsState = {
|
||||
enabledItems: ReorderableItem[];
|
||||
disabledItems: ReorderableItem[];
|
||||
};
|
||||
|
||||
const useToolbarEditorState = (props: UseToolbarEditorStateProps): UseToolbarEditorStateResult => {
|
||||
const { initialSelectedCommandNames, allCommandNames, allButtonInfos } = props;
|
||||
|
||||
// Build a lookup map from command name to button info
|
||||
const buttonInfoMap = useMemo(() => {
|
||||
const map = new Map<string, ToolbarButtonInfo>();
|
||||
for (const info of allButtonInfos) {
|
||||
if (info.type === 'button') {
|
||||
map.set(info.name, info);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [allButtonInfos]);
|
||||
|
||||
// Filter out separators from allCommandNames for building the disabled list
|
||||
const allCommandNamesWithoutSeparators = useMemo(() => {
|
||||
return allCommandNames.filter(name => name !== '-');
|
||||
}, [allCommandNames]);
|
||||
|
||||
// Build initial enabled items from selectedCommandNames (filtering separators)
|
||||
const buildEnabledItems = useCallback((selectedNames: string[]): ReorderableItem[] => {
|
||||
const items: ReorderableItem[] = [];
|
||||
for (const name of selectedNames) {
|
||||
if (name === '-') continue;
|
||||
const buttonInfo = buttonInfoMap.get(name);
|
||||
if (buttonInfo) {
|
||||
items.push({ commandName: name, buttonInfo });
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}, [buttonInfoMap]);
|
||||
|
||||
// Build disabled items: commands in allCommandNames but not in enabled, preserving default order
|
||||
const buildDisabledItems = useCallback((enabledNames: Set<string>): ReorderableItem[] => {
|
||||
const items: ReorderableItem[] = [];
|
||||
for (const name of allCommandNamesWithoutSeparators) {
|
||||
if (!enabledNames.has(name)) {
|
||||
const buttonInfo = buttonInfoMap.get(name);
|
||||
if (buttonInfo) {
|
||||
items.push({ commandName: name, buttonInfo });
|
||||
}
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}, [allCommandNamesWithoutSeparators, buttonInfoMap]);
|
||||
|
||||
// Both lists are combined into one state object so that handleToggle can update them
|
||||
// atomically in a single functional updater. This eliminates the stale-closure race
|
||||
// that would occur if they were separate useState values (rapid double-taps could see
|
||||
// an outdated snapshot of enabledItems and toggle in the wrong direction).
|
||||
const [{ enabledItems, disabledItems }, setItems] = useState<ItemsState>(() => ({
|
||||
enabledItems: buildEnabledItems(initialSelectedCommandNames),
|
||||
disabledItems: buildDisabledItems(new Set(initialSelectedCommandNames.filter(n => n !== '-'))),
|
||||
}));
|
||||
|
||||
const reinitialize = useCallback((selectedNames: string[]) => {
|
||||
setItems({
|
||||
enabledItems: buildEnabledItems(selectedNames),
|
||||
disabledItems: buildDisabledItems(new Set(selectedNames.filter(n => n !== '-'))),
|
||||
});
|
||||
}, [buildEnabledItems, buildDisabledItems]);
|
||||
|
||||
const handleMoveUp = useCallback((index: number) => {
|
||||
setItems(prev => {
|
||||
if (index <= 0) return prev;
|
||||
const newEnabled = [...prev.enabledItems];
|
||||
[newEnabled[index - 1], newEnabled[index]] = [newEnabled[index], newEnabled[index - 1]];
|
||||
return { ...prev, enabledItems: newEnabled };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleMoveDown = useCallback((index: number) => {
|
||||
setItems(prev => {
|
||||
if (index >= prev.enabledItems.length - 1) return prev;
|
||||
const newEnabled = [...prev.enabledItems];
|
||||
[newEnabled[index], newEnabled[index + 1]] = [newEnabled[index + 1], newEnabled[index]];
|
||||
return { ...prev, enabledItems: newEnabled };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleToggle = useCallback((commandName: string) => {
|
||||
setItems(prev => {
|
||||
const isCurrentlyEnabled = prev.enabledItems.some(item => item.commandName === commandName);
|
||||
|
||||
if (isCurrentlyEnabled) {
|
||||
const newEnabled = prev.enabledItems.filter(item => item.commandName !== commandName);
|
||||
const buttonInfo = buttonInfoMap.get(commandName);
|
||||
if (!buttonInfo) return prev;
|
||||
|
||||
// Insert in default-relative order
|
||||
const newDisabled: ReorderableItem[] = [];
|
||||
let inserted = false;
|
||||
for (const name of allCommandNamesWithoutSeparators) {
|
||||
if (name === commandName) {
|
||||
newDisabled.push({ commandName, buttonInfo });
|
||||
inserted = true;
|
||||
} else {
|
||||
const existing = prev.disabledItems.find(item => item.commandName === name);
|
||||
if (existing) newDisabled.push(existing);
|
||||
}
|
||||
}
|
||||
if (!inserted) newDisabled.push({ commandName, buttonInfo });
|
||||
|
||||
return { enabledItems: newEnabled, disabledItems: newDisabled };
|
||||
} else {
|
||||
const buttonInfo = buttonInfoMap.get(commandName);
|
||||
if (!buttonInfo) return prev;
|
||||
return {
|
||||
enabledItems: [...prev.enabledItems, { commandName, buttonInfo }],
|
||||
disabledItems: prev.disabledItems.filter(item => item.commandName !== commandName),
|
||||
};
|
||||
}
|
||||
});
|
||||
}, [buttonInfoMap, allCommandNamesWithoutSeparators]);
|
||||
|
||||
return {
|
||||
enabledItems,
|
||||
disabledItems,
|
||||
handleMoveUp,
|
||||
handleMoveDown,
|
||||
handleToggle,
|
||||
reinitialize,
|
||||
};
|
||||
};
|
||||
|
||||
export default useToolbarEditorState;
|
||||
@@ -9,6 +9,7 @@ import Logger from '@joplin/utils/Logger';
|
||||
import { Props, WebViewControl } from './types';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import useCss from './utils/useCss';
|
||||
import polyfillScrollFunctions from './utils/polyfillScrollFunctions';
|
||||
|
||||
const logger = Logger.create('ExtendedWebView');
|
||||
|
||||
@@ -55,9 +56,9 @@ const ExtendedWebView = (props: Props, ref: Ref<WebViewControl>) => {
|
||||
|
||||
useEffect(() => {
|
||||
// JSDOM polyfills
|
||||
dom.window.eval(`
|
||||
window.scrollBy = (_amount) => { };
|
||||
dom.window.eval(polyfillScrollFunctions);
|
||||
|
||||
dom.window.eval(`
|
||||
// JSDOM iframes are missing certain functionality required by Joplin,
|
||||
// including:
|
||||
// - MessageEvent.source: Should point to the window that created a message.
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
const polyfillScrollFunctions = `
|
||||
if (!window.scrollBy) {
|
||||
window.scrollBy = (_amount) => { };
|
||||
}
|
||||
if (!Element.prototype.scrollIntoView) {
|
||||
Element.prototype.scrollIntoView = function() { };
|
||||
}
|
||||
`;
|
||||
|
||||
export default polyfillScrollFunctions;
|
||||
@@ -104,8 +104,7 @@ const useRerenderHandler = (props: Props) => {
|
||||
props.fontSize, props.showNoteLinkIcon,
|
||||
];
|
||||
const previousDeps = usePrevious(effectDependencies, []);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const changedDeps = effectDependencies.reduce((accum: any, dependency: any, index: any) => {
|
||||
const changedDeps = effectDependencies.reduce((accum: Record<number, boolean>, dependency: unknown, index: number) => {
|
||||
if (dependency !== previousDeps[index]) {
|
||||
return { ...accum, [index]: true };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { themeStyle } from '@joplin/lib/theme';
|
||||
import themeToCss from '@joplin/lib/services/style/themeToCss';
|
||||
import ExtendedWebView from '../ExtendedWebView';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { editorFont } from '../global-style';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useMemo, useCallback, useRef } from 'react';
|
||||
@@ -17,10 +19,12 @@ import shim from '@joplin/lib/shim';
|
||||
|
||||
const logger = Logger.create('RichTextEditor');
|
||||
|
||||
function useCss(themeId: number, editorCss: string): string {
|
||||
function useCss(themeId: number, editorCss: string, fontFamilyId: number): string {
|
||||
return useMemo(() => {
|
||||
const theme = themeStyle(themeId);
|
||||
const themeVariableCss = themeToCss(theme);
|
||||
const font = editorFont(fontFamilyId);
|
||||
const fontFamily = font ? `${JSON.stringify(font)}, sans-serif` : 'sans-serif';
|
||||
return `
|
||||
${themeVariableCss}
|
||||
${editorCss}
|
||||
@@ -42,7 +46,7 @@ function useCss(themeId: number, editorCss: string): string {
|
||||
padding-bottom: 1px;
|
||||
padding-top: 10px;
|
||||
|
||||
font-family: ${JSON.stringify(theme.fontFamily)}, sans-serif;
|
||||
font-family: ${fontFamily} !important;
|
||||
}
|
||||
|
||||
.RichTextEditor {
|
||||
@@ -52,7 +56,7 @@ function useCss(themeId: number, editorCss: string): string {
|
||||
position: relative;
|
||||
}
|
||||
`;
|
||||
}, [themeId, editorCss]);
|
||||
}, [themeId, editorCss, fontFamilyId]);
|
||||
}
|
||||
|
||||
function useHtml(initialCss: string): string {
|
||||
@@ -127,7 +131,7 @@ const RichTextEditor: React.FC<EditorProps> = props => {
|
||||
true;
|
||||
`;
|
||||
|
||||
const css = useCss(props.themeId, editorWebViewSetup.pageSetup.css);
|
||||
const css = useCss(props.themeId, editorWebViewSetup.pageSetup.css, Setting.value('style.editor.fontFamily') as number);
|
||||
const html = useHtml(css);
|
||||
|
||||
const onMessage = useCallback((event: OnMessageEvent) => {
|
||||
|
||||
@@ -33,8 +33,7 @@ export interface SearchPanelProps {
|
||||
}
|
||||
|
||||
interface ActionButtonProps {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
styles: any;
|
||||
styles: ReturnType<typeof useStyles>;
|
||||
themeId: number;
|
||||
iconName: string;
|
||||
title: string;
|
||||
@@ -55,8 +54,7 @@ const ActionButton = (props: ActionButtonProps) => {
|
||||
};
|
||||
|
||||
interface ToggleButtonProps {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
styles: any;
|
||||
styles: ReturnType<typeof useStyles>;
|
||||
themeId: number;
|
||||
iconName: string;
|
||||
title: string;
|
||||
|
||||
@@ -87,8 +87,7 @@ interface ScreenHeaderState {
|
||||
}
|
||||
|
||||
class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeaderState> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
private cachedStyles: any;
|
||||
private cachedStyles: Record<number, ReturnType<typeof StyleSheet.create>>;
|
||||
public constructor(props: ScreenHeaderProps) {
|
||||
super(props);
|
||||
this.cachedStyles = {};
|
||||
|
||||
@@ -21,12 +21,13 @@ const useStyles = (themeId: number, hasContent: boolean) => {
|
||||
return StyleSheet.create({
|
||||
root: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
alignItems: 'center',
|
||||
},
|
||||
inputStyle: {
|
||||
fontSize: theme.fontSize,
|
||||
flexGrow: 1,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
borderWidth: 0,
|
||||
borderBlockColor: 'transparent',
|
||||
paddingLeft: 0,
|
||||
|
||||
@@ -209,18 +209,27 @@ const TagEditor: React.FC<Props> = props => {
|
||||
const styles = useStyles(props.themeId, props.headerStyle);
|
||||
|
||||
const comboBoxItems = useMemo(() => {
|
||||
const seenTitles = new Set();
|
||||
|
||||
return props.allTags
|
||||
// Exclude tags already associated with the note
|
||||
.filter(tag => !props.tags.some(o => o.toLowerCase() === tag.title?.toLowerCase()))
|
||||
.filter(tag => {
|
||||
const tagTitle = (tag.title || '').trim().normalize('NFC').toLowerCase();
|
||||
return !props.tags.some(o => (o || '').trim().normalize('NFC').toLowerCase() === tagTitle);
|
||||
})
|
||||
.map((tag): Option => {
|
||||
const title = tag.title ?? 'Untitled';
|
||||
const title = (tag.title || '').trim().normalize('NFC');
|
||||
const key = title.toLowerCase();
|
||||
if (!title || seenTitles.has(key)) return null;
|
||||
seenTitles.add(key);
|
||||
return {
|
||||
title,
|
||||
icon: null,
|
||||
accessibilityHint: _('Adds tag'),
|
||||
willRemoveOnPress: true,
|
||||
};
|
||||
});
|
||||
})
|
||||
.filter((item): item is Option => !!item);
|
||||
}, [props.tags, props.allTags]);
|
||||
|
||||
const [autofocusTag, setAutofocusTag] = useState('');
|
||||
@@ -230,14 +239,16 @@ const TagEditor: React.FC<Props> = props => {
|
||||
}, []);
|
||||
|
||||
const onAddTag = useCallback((title: string) => {
|
||||
AccessibilityInfo.announceForAccessibility(_('Added tag: %s', title));
|
||||
props.onTagsChange([...props.tags, title.trim()]);
|
||||
const trimmedTitle = (title || '').trim();
|
||||
if (!trimmedTitle) return;
|
||||
AccessibilityInfo.announceForAccessibility(_('Added tag: %s', trimmedTitle));
|
||||
props.onTagsChange([...props.tags, trimmedTitle]);
|
||||
}, [props.tags, props.onTagsChange]);
|
||||
|
||||
const onRemoveTag = useCallback(async (title: string) => {
|
||||
if (!title) return;
|
||||
const lowercaseTitle = title.toLowerCase();
|
||||
const previousTagIndex = props.tags.findIndex(item => item.toLowerCase() === lowercaseTitle);
|
||||
const normalizedTitle = title.trim().normalize('NFC').toLowerCase();
|
||||
const previousTagIndex = props.tags.findIndex(item => (item || '').trim().normalize('NFC').toLowerCase() === normalizedTitle);
|
||||
const targetTag = props.tags[previousTagIndex + 1] ?? props.tags[previousTagIndex - 1];
|
||||
setAutofocusTag(targetTag);
|
||||
|
||||
@@ -245,7 +256,7 @@ const TagEditor: React.FC<Props> = props => {
|
||||
// prevent focus from occasionally jumping away from the tag box.
|
||||
await msleep(100);
|
||||
AccessibilityInfo.announceForAccessibility(_('Removed tag: %s', title));
|
||||
props.onTagsChange(props.tags.filter(tag => tag.toLowerCase() !== lowercaseTitle));
|
||||
props.onTagsChange(props.tags.filter(tag => (tag || '').trim().normalize('NFC').toLowerCase() !== normalizedTitle));
|
||||
}, [props.tags, props.onTagsChange]);
|
||||
|
||||
const onComboBoxSelect = useCallback((item: { title: string }) => {
|
||||
@@ -255,13 +266,15 @@ const TagEditor: React.FC<Props> = props => {
|
||||
|
||||
const allTagsSetNormalized = useMemo(() => {
|
||||
return new Set([
|
||||
...props.allTags.map(tag => tag.title?.trim()?.toLowerCase()),
|
||||
...props.tags.map(tag => tag.trim().toLowerCase()),
|
||||
...props.allTags.map(tag => (tag.title || '').trim().normalize('NFC').toLowerCase()),
|
||||
...props.tags.map(tag => (tag || '').trim().normalize('NFC').toLowerCase()),
|
||||
]);
|
||||
}, [props.allTags, props.tags]);
|
||||
|
||||
const onCanAddTag = useCallback((tag: string) => {
|
||||
return !allTagsSetNormalized.has(tag.trim().toLowerCase());
|
||||
const normalized = (tag || '').trim().normalize('NFC');
|
||||
if (!normalized) return false;
|
||||
return !allTagsSetNormalized.has(normalized.toLowerCase());
|
||||
}, [allTagsSetNormalized]);
|
||||
|
||||
const showAssociatedTags = props.mode === TagEditorMode.Large || props.tags.length > 0;
|
||||
|
||||
@@ -32,8 +32,7 @@ interface ActionButtonProps {
|
||||
|
||||
// Returns a render function compatible with React Native Paper.
|
||||
const getIconRenderFunction = (iconName: string) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
return (props: any) => <Icon name={iconName} {...props} />;
|
||||
return (props: Omit<React.ComponentProps<typeof Icon>, 'name'>) => <Icon name={iconName} {...props} />;
|
||||
};
|
||||
|
||||
const useIcon = (iconName: string) => {
|
||||
|
||||
@@ -5,8 +5,7 @@ const getFormData = () => {
|
||||
|
||||
const serializeForm = (form: HTMLFormElement) => {
|
||||
const formData = new FormData(form);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const serializedData: Record<string, any> = {};
|
||||
const serializedData: Record<string, FormDataEntryValue | null> = {};
|
||||
for (const key of formData.keys()) {
|
||||
serializedData[key] = formData.get(key);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user