You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2026-06-18 20:16:34 +02:00
Compare commits
101 Commits
cli-v3.6.2
...
whiteboard
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b7667c773 | |||
| c980c7131f | |||
| 4200e72208 | |||
| 435ffe6d8a | |||
| 7d9d7aa082 | |||
| b51ab1bacb | |||
| 003ab6aaea | |||
| 5d5c9cb245 | |||
| 9d345256c0 | |||
| 076c6a9bd4 | |||
| 48e67e23b0 | |||
| 5efb74daf0 | |||
| 45bdb581d8 | |||
| b9861fc13c | |||
| d4da382345 | |||
| be8f2e35ee | |||
| 8ef62586ce | |||
| ede0270ce3 | |||
| 6f55b47fba | |||
| db6e2afb07 | |||
| 3173dbd9fd | |||
| 43a64aa143 | |||
| 94c0c83a3d | |||
| 9234de7bac | |||
| 498eb8a5cc | |||
| 8b33f39d28 | |||
| 9220f6b3bd | |||
| 789c76d888 | |||
| 90115dc8ba | |||
| b11d2b7e6e | |||
| 344428d7cf | |||
| 9f185b4e08 | |||
| 476800ebf9 | |||
| 4b1f0f8314 | |||
| d68e7e839c | |||
| 14f28767a2 | |||
| f3d2065a79 | |||
| 749390153a | |||
| 0b3372b00a | |||
| c7ea2c71bc | |||
| 54add1e9a3 | |||
| 887ba6fefe | |||
| 44f542d459 | |||
| 698b59b8fc | |||
| 81efd996e9 | |||
| 411959fd3f | |||
| 272c0f862c | |||
| 59210161cb | |||
| d741e1ae57 | |||
| d0555e344d | |||
| 99e979f383 | |||
| 28163caf86 | |||
| b8d9f0c1d2 | |||
| 9eebfe49f9 | |||
| 63926d7d87 | |||
| 25a1b141cb | |||
| ff88777e67 | |||
| d0a6803f47 | |||
| 96e5b53c2a | |||
| a22fdaa5c9 | |||
| 25048623ce | |||
| 21367da256 | |||
| 00f25718e6 | |||
| 08fb54f9f3 | |||
| 8d2c0a52d2 | |||
| af9a4f076a | |||
| 8cc2e56a4a | |||
| 6aed6bf97b | |||
| 80e951dfef | |||
| bbcd8c83fa | |||
| a93998ea9d | |||
| a81088c33f | |||
| 2326202db7 | |||
| 34b0a9b2f3 | |||
| 7ac3710f5c | |||
| b9f287904f | |||
| d1d492622c | |||
| 7637829d2d | |||
| 47b8b0a16f | |||
| 3969b450b4 | |||
| d0f87f0c69 | |||
| 147738ec52 | |||
| ec85b1a7e6 | |||
| 57bd5ff14a | |||
| 55b2b8c49b | |||
| 4759ab151e | |||
| 074dd6ed35 | |||
| f16f6ea5ad | |||
| 99e3a2eaec | |||
| 293f6661c1 | |||
| 8aa8e648ca | |||
| 570d2cc1f3 | |||
| 8e8a6dd656 | |||
| c1b5e0dade | |||
| 22eb606a53 | |||
| b9fddca475 | |||
| 5fdd648954 | |||
| d744db5063 | |||
| 98b1502a4a | |||
| ae5ab0bfc1 | |||
| bb983ff1d4 |
@@ -200,6 +200,7 @@ packages/app-desktop/gui/ClipperConfigScreen.js
|
||||
packages/app-desktop/gui/ConfigScreen/ButtonBar.js
|
||||
packages/app-desktop/gui/ConfigScreen/ConfigScreen.js
|
||||
packages/app-desktop/gui/ConfigScreen/Sidebar.js
|
||||
packages/app-desktop/gui/ConfigScreen/configSearch.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/FontSearch.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/GlobalHotkeyInput.test.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/GlobalHotkeyInput.js
|
||||
@@ -212,6 +213,8 @@ packages/app-desktop/gui/ConfigScreen/controls/ToggleAdvancedSettingsButton.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/plugins/SearchPlugins.js
|
||||
packages/app-desktop/gui/ConfigScreen/searchHighlight.test.js
|
||||
packages/app-desktop/gui/ConfigScreen/searchHighlight.js
|
||||
packages/app-desktop/gui/DialogButtonRow.js
|
||||
packages/app-desktop/gui/DialogButtonRow/useKeyboardHandler.js
|
||||
packages/app-desktop/gui/DialogTitle.js
|
||||
@@ -299,6 +302,20 @@ packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useScroll.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useTabIndenter.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useTextPatternsLookup.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useWebViewApi.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/ActionPanel.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/WhiteboardContext.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/WhiteboardEditor.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/WhiteboardSurface.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/canvasFlow.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/injectStyle.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/loadReactFlowCss.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/FileNode.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/LinkNode.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/TextNode.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/sharedStyles.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/theme.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/useCheckboxToggle.test.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/useCheckboxToggle.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteEditor.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteTitle/NoteTitleBar.js
|
||||
packages/app-desktop/gui/NoteEditor/StatusBar.js
|
||||
@@ -476,6 +493,7 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/AppDialogs.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/ModalMessageOverlay.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/PluginDialogs.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/WindowCommandsAndDialogs.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/addNoteToWhiteboard.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/addProfile.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/commandPalette.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/deleteFolder.js
|
||||
@@ -491,9 +509,11 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/index.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/linkToNote.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/moveToFolder.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newFolder.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newNote.test.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newNote.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newSubFolder.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newTodo.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newWhiteboard.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openFolder.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openFolderDialog.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openItem.js
|
||||
@@ -527,6 +547,7 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleNotesSortOrderR
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/togglePerFolderSortOrder.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleSideBar.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleVisiblePanes.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleWhiteboardEditor.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/utils/canUseNativeUndo.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/types.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/appDialogs.js
|
||||
@@ -1060,6 +1081,8 @@ packages/editor/CodeMirror/editorCommands/markdownCommands.js
|
||||
packages/editor/CodeMirror/editorCommands/sortSelectedLines.test.js
|
||||
packages/editor/CodeMirror/editorCommands/sortSelectedLines.js
|
||||
packages/editor/CodeMirror/editorCommands/supportsCommand.js
|
||||
packages/editor/CodeMirror/editorCommands/tableCommands.test.js
|
||||
packages/editor/CodeMirror/editorCommands/tableCommands.js
|
||||
packages/editor/CodeMirror/extensions/biDirectionalTextExtension.js
|
||||
packages/editor/CodeMirror/extensions/ctrlClickActionExtension.js
|
||||
packages/editor/CodeMirror/extensions/ctrlClickCheckboxExtension.js
|
||||
@@ -1088,6 +1111,7 @@ packages/editor/CodeMirror/extensions/overwriteModeExtension.js
|
||||
packages/editor/CodeMirror/extensions/rendering/addFormattingClasses.js
|
||||
packages/editor/CodeMirror/extensions/rendering/renderBlockImages.test.js
|
||||
packages/editor/CodeMirror/extensions/rendering/renderBlockImages.js
|
||||
packages/editor/CodeMirror/extensions/rendering/renderTables.js
|
||||
packages/editor/CodeMirror/extensions/rendering/renderingExtension.js
|
||||
packages/editor/CodeMirror/extensions/rendering/replaceBackslashEscapes.js
|
||||
packages/editor/CodeMirror/extensions/rendering/replaceBulletLists.js
|
||||
@@ -1148,6 +1172,8 @@ packages/editor/CodeMirror/utils/markdown/getCheckboxAtPosition.js
|
||||
packages/editor/CodeMirror/utils/markdown/renumberSelectedLists.test.js
|
||||
packages/editor/CodeMirror/utils/markdown/renumberSelectedLists.js
|
||||
packages/editor/CodeMirror/utils/markdown/stripBlockquote.js
|
||||
packages/editor/CodeMirror/utils/markdown/tableUtils.test.js
|
||||
packages/editor/CodeMirror/utils/markdown/tableUtils.js
|
||||
packages/editor/CodeMirror/utils/markdown/toggleCheckboxAt.js
|
||||
packages/editor/CodeMirror/utils/setupVim.js
|
||||
packages/editor/CodeMirror/vendor/announceSearchMatch.js
|
||||
@@ -1248,6 +1274,8 @@ packages/generator-joplin/tools/updateCategories.js
|
||||
packages/htmlpack/index.test.js
|
||||
packages/htmlpack/index.js
|
||||
packages/htmlpack/packToString.js
|
||||
packages/htmlpack/packToWriter.test.js
|
||||
packages/htmlpack/packToWriter.js
|
||||
packages/htmlpack/utils/parseHtmlAsync.js
|
||||
packages/lib/ArrayUtils.js
|
||||
packages/lib/AsyncActionQueue.test.js
|
||||
@@ -1323,6 +1351,8 @@ packages/lib/components/shared/ShareNoteDialog/useEncryptionWarningMessage.js
|
||||
packages/lib/components/shared/ShareNoteDialog/useOnShareLinkClick.js
|
||||
packages/lib/components/shared/ShareNoteDialog/useShareStatusMessage.js
|
||||
packages/lib/components/shared/SsoScreenShared.js
|
||||
packages/lib/components/shared/config/config-search-text.js
|
||||
packages/lib/components/shared/config/config-search.test.js
|
||||
packages/lib/components/shared/config/config-shared.js
|
||||
packages/lib/components/shared/config/plugins/types.js
|
||||
packages/lib/components/shared/config/plugins/useOnDeleteHandler.js
|
||||
@@ -1747,6 +1777,12 @@ packages/lib/services/trash/permanentlyDeleteOldItems.test.js
|
||||
packages/lib/services/trash/permanentlyDeleteOldItems.js
|
||||
packages/lib/services/trash/restoreItems.test.js
|
||||
packages/lib/services/trash/restoreItems.js
|
||||
packages/lib/services/whiteboard/generateId.js
|
||||
packages/lib/services/whiteboard/jsoncanvas.js
|
||||
packages/lib/services/whiteboard/parse.js
|
||||
packages/lib/services/whiteboard/resolveRef.js
|
||||
packages/lib/services/whiteboard/serialize.js
|
||||
packages/lib/services/whiteboard/whiteboard.test.js
|
||||
packages/lib/shim-init-node.test.js
|
||||
packages/lib/shim-init-node.js
|
||||
packages/lib/shim.js
|
||||
|
||||
@@ -6,24 +6,37 @@ If this is a Google Summer of Code pull request, please read the [GSoC pull requ
|
||||
|
||||
---
|
||||
|
||||
**Pull request title**: Please prefix the title with the platform you are targetting.
|
||||
**Pull request title**: Please prefix the title with the area you are targeting, then add the issue you are addressing.
|
||||
|
||||
Here are some examples of good titles:
|
||||
The format is:
|
||||
|
||||
<Prefix>: <Fixes|Resolves> #<issue>: <description>
|
||||
|
||||
Use "Resolves #123" for new features or improvements, and "Fixes #123" for bug fixes.
|
||||
|
||||
Examples of good titles:
|
||||
|
||||
- Desktop: Resolves #123: Added new setting to change font
|
||||
- Mobile, Desktop: Fixes #456: Fixed config screen error
|
||||
- All: Resolves #777: Made synchronisation faster
|
||||
|
||||
And here's an explanation of the title format:
|
||||
Valid prefixes:
|
||||
|
||||
- "Desktop" for the Windows/macOS/Linux app (Electron app)
|
||||
- "Mobile" for the mobile app (or "Android" / "iOS" if the pull request only applies to one of the mobile platforms)
|
||||
- "CLI" for the CLI app
|
||||
- `All` — change applies to all client apps (Desktop, Mobile and CLI)
|
||||
- `Desktop` — the Windows/macOS/Linux app (Electron app)
|
||||
- `Mobile` — the mobile app (both Android and iOS)
|
||||
- `Android` — only the Android app
|
||||
- `iOS` — only the iOS app
|
||||
- `Cli` — the command line app
|
||||
- `Server` — the Joplin Server
|
||||
- `Clipper` — the web clipper browser extension
|
||||
- `Plugins` — the plugin API or built-in plugins
|
||||
- `Plugin Repo` — the plugin repository
|
||||
- `Tools` — internal scripts and build tools
|
||||
- `CI` — continuous integration and GitHub workflows
|
||||
- `Doc` — documentation, README, website content
|
||||
- `Chore` — maintenance work that does not fit any of the above (dependency bumps, refactoring, cleanup)
|
||||
|
||||
If it's two platforms, separate them with commas - "Desktop, Mobile" or if it's for all platforms, prefix with "All".
|
||||
If the change targets two areas, separate them with commas — for example "Desktop, Mobile". If it applies to all client apps, use "All".
|
||||
|
||||
If it's not related to any platform (such as a translation, change to the documentation, etc.), simply don't add a platform.
|
||||
|
||||
Then please append the issue that you've addressed or fixed. Use "Resolves #123" for new features or improvements and "Fixes #123" for bug fixes.
|
||||
|
||||
-->
|
||||
-->
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
// Validates the PR title and acts on the result.
|
||||
//
|
||||
// - Renovate is filtered out at the workflow level (job `if:`).
|
||||
// - Translation-only PRs (every changed file is a .po) are skipped.
|
||||
// - Users in `softCheckUsers` get a relaxed check (issue number optional)
|
||||
// and only ever receive a comment, never a close.
|
||||
// - Everyone else must match the strict format. Invalid titles get a
|
||||
// comment and the PR is closed. We also apply a marker label so that
|
||||
// we can later tell our closures apart from any other closure.
|
||||
// - If the title becomes valid and the marker label is present, the PR
|
||||
// is reopened and the label is removed. Closures by humans (or by
|
||||
// another workflow) lack the label and are never overturned.
|
||||
//
|
||||
// Invoked from .github/workflows/check-pr-title.yml via actions/github-script.
|
||||
// Required inputs come from `env`: PR_AUTHOR, PR_NUMBER. The title is
|
||||
// fetched from the API rather than passed via env to avoid YAML expansion
|
||||
// silently stripping leading whitespace from `${{ ... }}`.
|
||||
|
||||
module.exports = async ({ github, context, core }) => {
|
||||
const softCheckUsers = ['laurent22', 'personalizedrefrigerator', 'mrjo118', 'tessus', 'CalebJohn', 'Rygaa'];
|
||||
const autoClosedLabel = 'auto-closed: invalid-title';
|
||||
|
||||
const prefix = '(Desktop|Mobile|All|Cli|Tools|Chore|Clipper|Server|Android|iOS|Plugins|CI|Plugin Repo|Doc)';
|
||||
const prefixList = `${prefix}(,\\s*${prefix})*`;
|
||||
const strictRegex = new RegExp(`^${prefixList}: (Fixes|Resolves) #[0-9]+: .+`);
|
||||
const softRegex = new RegExp(`^${prefixList}: ((Fixes|Resolves) #[0-9]+: )?.+`);
|
||||
|
||||
const author = process.env.PR_AUTHOR;
|
||||
const prNumber = Number(process.env.PR_NUMBER);
|
||||
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber,
|
||||
});
|
||||
const title = pr.title;
|
||||
core.info(`Title (length=${title.length}): ${JSON.stringify(title)}`);
|
||||
|
||||
const isSoft = softCheckUsers.includes(author);
|
||||
|
||||
// listFiles returns up to 30 files per page; a pure translation PR is
|
||||
// small, so checking the first page is enough.
|
||||
const { data: files } = await github.rest.pulls.listFiles({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber,
|
||||
});
|
||||
const isTranslationOnly = files.length > 0 && files.every(f => f.filename.endsWith('.po'));
|
||||
if (isTranslationOnly) {
|
||||
core.info('Translation-only PR — skipping title check.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Doc-only PRs do not require an issue number.
|
||||
const isDocOnly = /^Doc(,\s*Doc)*:/.test(title);
|
||||
const regex = isSoft || isDocOnly ? softRegex : strictRegex;
|
||||
if (regex.test(title)) {
|
||||
core.info('Title is valid.');
|
||||
|
||||
// If we previously closed this PR for an invalid title and the
|
||||
// title is now valid, reopen it. We only reopen if our marker
|
||||
// label is present, so closures by humans (or other workflows)
|
||||
// are never overturned. A maintainer can also remove the label
|
||||
// by hand to lock a PR closed regardless of future fixes.
|
||||
const wasAutoClosed = pr.state === 'closed' && pr.labels.some(l => l.name === autoClosedLabel);
|
||||
if (wasAutoClosed) {
|
||||
try {
|
||||
await github.rest.pulls.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber,
|
||||
state: 'open',
|
||||
});
|
||||
} catch (error) {
|
||||
// GitHub refuses to reopen a PR when another open PR
|
||||
// already exists from the same head→base branch pair.
|
||||
// In that case the contributor has already opened a
|
||||
// replacement, so leave this PR closed.
|
||||
if (error.status === 422) {
|
||||
core.info('Cannot reopen — another PR is already open from the same branch.');
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
name: autoClosedLabel,
|
||||
});
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
body: `@${author} thanks for fixing the title — this PR has been reopened.`,
|
||||
});
|
||||
core.info('PR reopened after title was fixed.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const helpMessage = [
|
||||
`@${author} the pull request title does not match the required format.`,
|
||||
'',
|
||||
'Please prefix the title with the area you are targeting, then add the issue you are addressing. For example:',
|
||||
'',
|
||||
'- `Desktop: Resolves #123: Added new setting to change font`',
|
||||
'- `Mobile, Desktop: Fixes #456: Fixed config screen error`',
|
||||
'- `All: Resolves #777: Made synchronisation faster`',
|
||||
'',
|
||||
'See the [pull request template](https://github.com/laurent22/joplin/blob/dev/.github/PULL_REQUEST_TEMPLATE) for the list of valid prefixes and the full specification.',
|
||||
'',
|
||||
isSoft
|
||||
? '_This PR has been left open — please update the title when you have a moment._'
|
||||
: '_This PR has been closed automatically. Once you update the title to match the format above, the PR will be reopened automatically._',
|
||||
].join('\n');
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
body: helpMessage,
|
||||
});
|
||||
|
||||
if (!isSoft) {
|
||||
// Label first so the marker is set before the close event lands.
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
labels: [autoClosedLabel],
|
||||
});
|
||||
await github.rest.pulls.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber,
|
||||
state: 'closed',
|
||||
});
|
||||
}
|
||||
|
||||
core.setFailed('Pull request title does not match the required format.');
|
||||
};
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
sudo apt-get update || true
|
||||
sudo apt-get install -y libsecret-1-dev
|
||||
|
||||
- uses: actions/setup-java@v4
|
||||
- uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '20'
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
brew install pango
|
||||
|
||||
# See github-action-main.yml for explanation
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.14'
|
||||
|
||||
|
||||
@@ -1,9 +1,31 @@
|
||||
name: Check pull request title
|
||||
on: [pull_request]
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, edited, reopened, synchronize]
|
||||
|
||||
jobs:
|
||||
main:
|
||||
# Skip the check entirely for these automation accounts.
|
||||
if: github.event.pull_request.user.login != 'renovate[bot]'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
steps:
|
||||
- uses: Slashgear/action-check-pr-title@v5.0.1
|
||||
# Sparse checkout so we only pull the script, not the full repo.
|
||||
# `pull_request_target` checks out the base branch by default,
|
||||
# which is what we want — we never execute PR-supplied code.
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
regexp: "(Desktop|Mobile|All|Cli|Tools|Chore|Clipper|Server|Android|iOS|Plugins|CI|Plugin Repo|Doc): (Fixes|Resolves) #[0-9]+: .+"
|
||||
sparse-checkout: .github/scripts
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
- name: Check title
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
with:
|
||||
script: |
|
||||
const check = require('./.github/scripts/check_pr_title.js');
|
||||
await check({ github, context, core });
|
||||
|
||||
@@ -70,6 +70,6 @@ runs:
|
||||
# Python to an earlier version.
|
||||
# Fixes error `ModuleNotFoundError: No module named 'distutils'`
|
||||
# Ref: https://github.com/nodejs/node-gyp/issues/2869
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.14'
|
||||
|
||||
+40
@@ -54,6 +54,10 @@ docs/**/*.mustache
|
||||
.idea
|
||||
/readme/i18n
|
||||
.watchman-cookie-*
|
||||
*_BACKUP_*.js
|
||||
*_BASE_*.js
|
||||
*_LOCAL_*.js
|
||||
*_REMOTE_*.js
|
||||
|
||||
# Yarn stuff
|
||||
# https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored
|
||||
@@ -173,6 +177,7 @@ packages/app-desktop/gui/ClipperConfigScreen.js
|
||||
packages/app-desktop/gui/ConfigScreen/ButtonBar.js
|
||||
packages/app-desktop/gui/ConfigScreen/ConfigScreen.js
|
||||
packages/app-desktop/gui/ConfigScreen/Sidebar.js
|
||||
packages/app-desktop/gui/ConfigScreen/configSearch.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/FontSearch.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/GlobalHotkeyInput.test.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/GlobalHotkeyInput.js
|
||||
@@ -185,6 +190,8 @@ packages/app-desktop/gui/ConfigScreen/controls/ToggleAdvancedSettingsButton.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.js
|
||||
packages/app-desktop/gui/ConfigScreen/controls/plugins/SearchPlugins.js
|
||||
packages/app-desktop/gui/ConfigScreen/searchHighlight.test.js
|
||||
packages/app-desktop/gui/ConfigScreen/searchHighlight.js
|
||||
packages/app-desktop/gui/DialogButtonRow.js
|
||||
packages/app-desktop/gui/DialogButtonRow/useKeyboardHandler.js
|
||||
packages/app-desktop/gui/DialogTitle.js
|
||||
@@ -272,6 +279,20 @@ packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useScroll.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useTabIndenter.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useTextPatternsLookup.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useWebViewApi.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/ActionPanel.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/WhiteboardContext.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/WhiteboardEditor.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/WhiteboardSurface.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/canvasFlow.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/injectStyle.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/loadReactFlowCss.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/FileNode.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/LinkNode.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/TextNode.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/nodes/sharedStyles.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/theme.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/useCheckboxToggle.test.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/useCheckboxToggle.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteEditor.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteTitle/NoteTitleBar.js
|
||||
packages/app-desktop/gui/NoteEditor/StatusBar.js
|
||||
@@ -449,6 +470,7 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/AppDialogs.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/ModalMessageOverlay.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/PluginDialogs.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/WindowCommandsAndDialogs.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/addNoteToWhiteboard.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/addProfile.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/commandPalette.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/deleteFolder.js
|
||||
@@ -464,9 +486,11 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/index.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/linkToNote.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/moveToFolder.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newFolder.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newNote.test.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newNote.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newSubFolder.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newTodo.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/newWhiteboard.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openFolder.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openFolderDialog.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/openItem.js
|
||||
@@ -500,6 +524,7 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleNotesSortOrderR
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/togglePerFolderSortOrder.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleSideBar.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleVisiblePanes.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleWhiteboardEditor.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/utils/canUseNativeUndo.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/types.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/appDialogs.js
|
||||
@@ -1033,6 +1058,8 @@ packages/editor/CodeMirror/editorCommands/markdownCommands.js
|
||||
packages/editor/CodeMirror/editorCommands/sortSelectedLines.test.js
|
||||
packages/editor/CodeMirror/editorCommands/sortSelectedLines.js
|
||||
packages/editor/CodeMirror/editorCommands/supportsCommand.js
|
||||
packages/editor/CodeMirror/editorCommands/tableCommands.test.js
|
||||
packages/editor/CodeMirror/editorCommands/tableCommands.js
|
||||
packages/editor/CodeMirror/extensions/biDirectionalTextExtension.js
|
||||
packages/editor/CodeMirror/extensions/ctrlClickActionExtension.js
|
||||
packages/editor/CodeMirror/extensions/ctrlClickCheckboxExtension.js
|
||||
@@ -1061,6 +1088,7 @@ packages/editor/CodeMirror/extensions/overwriteModeExtension.js
|
||||
packages/editor/CodeMirror/extensions/rendering/addFormattingClasses.js
|
||||
packages/editor/CodeMirror/extensions/rendering/renderBlockImages.test.js
|
||||
packages/editor/CodeMirror/extensions/rendering/renderBlockImages.js
|
||||
packages/editor/CodeMirror/extensions/rendering/renderTables.js
|
||||
packages/editor/CodeMirror/extensions/rendering/renderingExtension.js
|
||||
packages/editor/CodeMirror/extensions/rendering/replaceBackslashEscapes.js
|
||||
packages/editor/CodeMirror/extensions/rendering/replaceBulletLists.js
|
||||
@@ -1121,6 +1149,8 @@ packages/editor/CodeMirror/utils/markdown/getCheckboxAtPosition.js
|
||||
packages/editor/CodeMirror/utils/markdown/renumberSelectedLists.test.js
|
||||
packages/editor/CodeMirror/utils/markdown/renumberSelectedLists.js
|
||||
packages/editor/CodeMirror/utils/markdown/stripBlockquote.js
|
||||
packages/editor/CodeMirror/utils/markdown/tableUtils.test.js
|
||||
packages/editor/CodeMirror/utils/markdown/tableUtils.js
|
||||
packages/editor/CodeMirror/utils/markdown/toggleCheckboxAt.js
|
||||
packages/editor/CodeMirror/utils/setupVim.js
|
||||
packages/editor/CodeMirror/vendor/announceSearchMatch.js
|
||||
@@ -1221,6 +1251,8 @@ packages/generator-joplin/tools/updateCategories.js
|
||||
packages/htmlpack/index.test.js
|
||||
packages/htmlpack/index.js
|
||||
packages/htmlpack/packToString.js
|
||||
packages/htmlpack/packToWriter.test.js
|
||||
packages/htmlpack/packToWriter.js
|
||||
packages/htmlpack/utils/parseHtmlAsync.js
|
||||
packages/lib/ArrayUtils.js
|
||||
packages/lib/AsyncActionQueue.test.js
|
||||
@@ -1296,6 +1328,8 @@ packages/lib/components/shared/ShareNoteDialog/useEncryptionWarningMessage.js
|
||||
packages/lib/components/shared/ShareNoteDialog/useOnShareLinkClick.js
|
||||
packages/lib/components/shared/ShareNoteDialog/useShareStatusMessage.js
|
||||
packages/lib/components/shared/SsoScreenShared.js
|
||||
packages/lib/components/shared/config/config-search-text.js
|
||||
packages/lib/components/shared/config/config-search.test.js
|
||||
packages/lib/components/shared/config/config-shared.js
|
||||
packages/lib/components/shared/config/plugins/types.js
|
||||
packages/lib/components/shared/config/plugins/useOnDeleteHandler.js
|
||||
@@ -1720,6 +1754,12 @@ packages/lib/services/trash/permanentlyDeleteOldItems.test.js
|
||||
packages/lib/services/trash/permanentlyDeleteOldItems.js
|
||||
packages/lib/services/trash/restoreItems.test.js
|
||||
packages/lib/services/trash/restoreItems.js
|
||||
packages/lib/services/whiteboard/generateId.js
|
||||
packages/lib/services/whiteboard/jsoncanvas.js
|
||||
packages/lib/services/whiteboard/parse.js
|
||||
packages/lib/services/whiteboard/resolveRef.js
|
||||
packages/lib/services/whiteboard/serialize.js
|
||||
packages/lib/services/whiteboard/whiteboard.test.js
|
||||
packages/lib/shim-init-node.test.js
|
||||
packages/lib/shim-init-node.js
|
||||
packages/lib/shim.js
|
||||
|
||||
+326
-326
File diff suppressed because one or more lines are too long
+1
-1
@@ -5,7 +5,7 @@ nodeLinker: node-modules
|
||||
compressionLevel: mixed
|
||||
enableGlobalCache: false
|
||||
|
||||
yarnPath: .yarn/releases/yarn-4.9.2.cjs
|
||||
yarnPath: .yarn/releases/yarn-4.12.0.cjs
|
||||
|
||||
logFilters:
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1012 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 438 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 123 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 326 KiB |
+2
-2
@@ -9,9 +9,9 @@
|
||||
"vips.dev": {
|
||||
"platforms": ["aarch64-darwin"],
|
||||
},
|
||||
"nodejs": "24.11.1",
|
||||
"nodejs": "24.12.0",
|
||||
"pkg-config": "latest",
|
||||
"python": "3.14.0",
|
||||
"python": "3.14.2",
|
||||
"bat": "latest",
|
||||
"electron": {
|
||||
"version": "latest",
|
||||
|
||||
+6
-4
@@ -10,7 +10,7 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18",
|
||||
"yarn": "4.9.2"
|
||||
"yarn": "4.12.0"
|
||||
},
|
||||
"scripts": {
|
||||
"buildApiDoc": "yarn workspace joplin start apidoc ../../readme/api/references/rest_api.md",
|
||||
@@ -90,8 +90,8 @@
|
||||
"lerna": "3.22.1",
|
||||
"lint-staged": "16.2.7",
|
||||
"madge": "8.0.0",
|
||||
"npm-package-json-lint": "9.0.0",
|
||||
"typescript": "5.8.3"
|
||||
"npm-package-json-lint": "9.1.0",
|
||||
"typescript": "5.9.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/fs-extra": "11.0.4",
|
||||
@@ -100,8 +100,10 @@
|
||||
"node-gyp": "11.5.0",
|
||||
"nodemon": "3.1.11"
|
||||
},
|
||||
"packageManager": "yarn@4.9.2",
|
||||
"packageManager": "yarn@4.12.0",
|
||||
"resolutions": {
|
||||
"@codemirror/view": "6.39.9",
|
||||
"@codemirror/state": "6.5.4",
|
||||
"react-native-camera@4.2.1": "patch:react-native-camera@npm%3A4.2.1#./.yarn/patches/react-native-camera-npm-4.2.1-24b2600a7e.patch",
|
||||
"eslint": "patch:eslint@8.57.1#./.yarn/patches/eslint-npm-8.39.0-d92bace04d.patch",
|
||||
"app-builder-lib@24.4.0": "patch:app-builder-lib@npm%3A24.4.0#./.yarn/patches/app-builder-lib-npm-24.4.0-05322ff057.patch",
|
||||
|
||||
@@ -248,7 +248,7 @@ class Command extends BaseCommand {
|
||||
|
||||
logger.info('Unsharing folder', folder.id);
|
||||
await ShareService.instance().unshareFolder(folder.id);
|
||||
await reg.scheduleSync();
|
||||
await reg.waitForSyncFinishedThenSync();
|
||||
};
|
||||
|
||||
if (args.command === 'add' || args.command === 'remove' || args.command === 'delete') {
|
||||
|
||||
@@ -78,6 +78,6 @@
|
||||
"gulp": "4.0.2",
|
||||
"jest": "29.7.0",
|
||||
"temp": "0.9.4",
|
||||
"typescript": "5.8.3"
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,3 +29,4 @@ downloads/
|
||||
# Bundler output
|
||||
*.js.meta.json
|
||||
*.bundle.js
|
||||
*.bundle.css
|
||||
|
||||
@@ -58,6 +58,7 @@ export default class InteropServiceHelper {
|
||||
const exportOptions = {
|
||||
customCss: options.customCss ? options.customCss : '',
|
||||
plugins: options.plugins,
|
||||
shouldEmbedOnlyImages: true,
|
||||
};
|
||||
|
||||
htmlFile = await this.exportNoteToHtmlFile(noteId, exportOptions);
|
||||
|
||||
@@ -40,6 +40,14 @@ export interface AppWindowState extends WindowState {
|
||||
devToolsVisible: boolean;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
watchedResources: any;
|
||||
// Note IDs for which the user has chosen to view the underlying Markdown
|
||||
// instead of the Whiteboard editor. Per-window, in-memory only.
|
||||
whiteboardForceMarkdown: Record<string, boolean>;
|
||||
// Whether the currently-active note in this window contains a whiteboard
|
||||
// fence. Set by the NoteEditor when it loads / saves the body, used by
|
||||
// the toolbar to show the editor toggle button. (We can't compute this
|
||||
// from the redux note list because `body` isn't in the preview fields.)
|
||||
activeNoteIsWhiteboard: boolean;
|
||||
}
|
||||
|
||||
interface BackgroundWindowStates {
|
||||
@@ -72,6 +80,8 @@ export const createAppDefaultWindowState = (): AppWindowState => {
|
||||
editorCodeView: true,
|
||||
devToolsVisible: false,
|
||||
watchedResources: {},
|
||||
whiteboardForceMarkdown: {},
|
||||
activeNoteIsWhiteboard: false,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -205,6 +215,26 @@ export default function(state: AppState, action: any) {
|
||||
};
|
||||
break;
|
||||
|
||||
case 'WHITEBOARD_FORCE_MARKDOWN_TOGGLE': {
|
||||
const id: unknown = action.noteId;
|
||||
// Guard against dispatchers forgetting to pass a noteId — writing
|
||||
// an `undefined` key into the map would persist a junk entry.
|
||||
if (typeof id !== 'string' || !id) break;
|
||||
const current = !!state.whiteboardForceMarkdown?.[id];
|
||||
newState = {
|
||||
...state,
|
||||
whiteboardForceMarkdown: { ...(state.whiteboardForceMarkdown || {}), [id]: !current },
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
case 'WHITEBOARD_ACTIVE_NOTE_SET':
|
||||
newState = {
|
||||
...state,
|
||||
activeNoteIsWhiteboard: !!action.value,
|
||||
};
|
||||
break;
|
||||
|
||||
case 'MAIN_LAYOUT_SET':
|
||||
|
||||
newState = {
|
||||
|
||||
@@ -16,10 +16,14 @@ import restart from '../../services/restart';
|
||||
import JoplinCloudConfigScreen from '../JoplinCloudConfigScreen';
|
||||
import ToggleAdvancedSettingsButton from './controls/ToggleAdvancedSettingsButton';
|
||||
import shouldShowMissingPasswordWarning from '@joplin/lib/components/shared/config/shouldShowMissingPasswordWarning';
|
||||
import { normalizeQuery } from '@joplin/lib/components/shared/config/config-search-text.js';
|
||||
import { searchResultGroups, matchedSearchSections } from './configSearch';
|
||||
import MacOSMissingPasswordHelpLink from './controls/MissingPasswordHelpLink';
|
||||
const { KeymapConfigScreen } = require('../KeymapConfig/KeymapConfigScreen');
|
||||
import SettingComponent, { UpdateSettingValueEvent } from './controls/SettingComponent';
|
||||
import shim, { MessageBoxType } from '@joplin/lib/shim';
|
||||
import { OnChangeEvent } from '../lib/SearchInput/SearchInput';
|
||||
import highlightSearchText from './searchHighlight';
|
||||
|
||||
|
||||
interface Font {
|
||||
@@ -52,6 +56,8 @@ class ConfigScreenComponent extends React.Component<any, any> {
|
||||
changedSettingKeys: [],
|
||||
needRestart: false,
|
||||
fonts: [],
|
||||
searchQuery: '',
|
||||
searchSectionFilter: null,
|
||||
};
|
||||
|
||||
this.rowStyle_ = {
|
||||
@@ -64,6 +70,22 @@ class ConfigScreenComponent extends React.Component<any, any> {
|
||||
this.onSaveClick = this.onSaveClick.bind(this);
|
||||
this.onApplyClick = this.onApplyClick.bind(this);
|
||||
this.handleSettingButton = this.handleSettingButton.bind(this);
|
||||
this.onSearchQueryChange = this.onSearchQueryChange.bind(this);
|
||||
this.onSearchButtonClick = this.onSearchButtonClick.bind(this);
|
||||
}
|
||||
|
||||
private onSearchQueryChange(event: OnChangeEvent) {
|
||||
this.setState({
|
||||
searchQuery: event.value,
|
||||
searchSectionFilter: null,
|
||||
});
|
||||
}
|
||||
|
||||
private onSearchButtonClick() {
|
||||
this.setState({
|
||||
searchQuery: '',
|
||||
searchSectionFilter: null,
|
||||
});
|
||||
}
|
||||
|
||||
private async checkSyncConfig_() {
|
||||
@@ -165,7 +187,17 @@ class ConfigScreenComponent extends React.Component<any, any> {
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
private sidebar_selectionChange(event: any) {
|
||||
void this.switchSection(event.section.name);
|
||||
const sectionName = event.section.name;
|
||||
const searchMode = !!normalizeQuery(this.state.searchQuery);
|
||||
|
||||
if (searchMode) {
|
||||
this.setState({
|
||||
searchSectionFilter: sectionName,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
void this.switchSection(sectionName);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
@@ -184,6 +216,7 @@ class ConfigScreenComponent extends React.Component<any, any> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
public sectionToComponent(key: string, section: any, settings: any, selected: boolean) {
|
||||
const theme = themeStyle(this.props.themeId);
|
||||
const searchMode = !!normalizeQuery(this.state.searchQuery);
|
||||
|
||||
const createSettingComponents = (advanced: boolean) => {
|
||||
const output = [];
|
||||
@@ -308,16 +341,19 @@ class ConfigScreenComponent extends React.Component<any, any> {
|
||||
let advancedSettingsButton = null;
|
||||
const advancedSettingsSectionStyle = { display: 'none' };
|
||||
const advancedSettingsGroupId = `advanced_settings_${key}`;
|
||||
const advancedSettingsVisible = this.state.showAdvancedSettings || searchMode;
|
||||
|
||||
if (advancedSettingComps.length) {
|
||||
advancedSettingsButton = (
|
||||
<ToggleAdvancedSettingsButton
|
||||
onClick={() => shared.advancedSettingsButton_click(this)}
|
||||
advancedSettingsVisible={this.state.showAdvancedSettings}
|
||||
aria-controls={advancedSettingsGroupId}
|
||||
/>
|
||||
);
|
||||
advancedSettingsSectionStyle.display = this.state.showAdvancedSettings ? 'block' : 'none';
|
||||
if (!searchMode) {
|
||||
advancedSettingsButton = (
|
||||
<ToggleAdvancedSettingsButton
|
||||
onClick={() => shared.advancedSettingsButton_click(this)}
|
||||
advancedSettingsVisible={advancedSettingsVisible}
|
||||
aria-controls={advancedSettingsGroupId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
advancedSettingsSectionStyle.display = advancedSettingsVisible ? 'block' : 'none';
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -342,6 +378,10 @@ class ConfigScreenComponent extends React.Component<any, any> {
|
||||
shared.updateSettingValue(this, key, value);
|
||||
};
|
||||
|
||||
private renderSearchHighlightedText = (text: string): React.ReactNode => {
|
||||
return highlightSearchText(text, this.state.searchQuery);
|
||||
};
|
||||
|
||||
public settingToComponent<T extends string>(key: T, value: SettingValueType<T>) {
|
||||
return (
|
||||
<SettingComponent
|
||||
@@ -352,6 +392,7 @@ class ConfigScreenComponent extends React.Component<any, any> {
|
||||
fonts={this.state.fonts}
|
||||
onUpdateSettingValue={this.onUpdateSettingValue}
|
||||
onSettingButtonClick={this.handleSettingButton}
|
||||
renderSearchText={this.renderSearchHighlightedText}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -399,6 +440,9 @@ class ConfigScreenComponent extends React.Component<any, any> {
|
||||
|
||||
public render() {
|
||||
const theme = themeStyle(this.props.themeId);
|
||||
const searchQuery = normalizeQuery(this.state.searchQuery);
|
||||
const searchMode = !!searchQuery;
|
||||
const sectionFilter = this.state.searchSectionFilter;
|
||||
|
||||
const style = {
|
||||
...this.props.style,
|
||||
@@ -410,14 +454,6 @@ class ConfigScreenComponent extends React.Component<any, any> {
|
||||
|
||||
const settings = this.state.settings;
|
||||
|
||||
const containerStyle: React.CSSProperties = {
|
||||
overflow: 'auto',
|
||||
padding: theme.configScreenPadding,
|
||||
paddingTop: 0,
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
};
|
||||
|
||||
const hasChanges = this.hasChanges();
|
||||
|
||||
const settingComps = shared.settingsToComponents2(this, AppType.Desktop, settings, this.state.selectedSectionName);
|
||||
@@ -427,9 +463,13 @@ class ConfigScreenComponent extends React.Component<any, any> {
|
||||
// When screenComp is null, it means we are viewing the regular settings.
|
||||
const screenComp = this.state.screenName ? <div className="config-screen-content-wrapper" style={{ overflow: 'scroll', flex: 1 }}>{this.screenFromName(this.state.screenName)}</div> : null;
|
||||
|
||||
if (screenComp) containerStyle.display = 'none';
|
||||
const shouldHideSettingsContainer = !!screenComp && !searchMode;
|
||||
|
||||
const sections = shared.settingsSections({ device: AppType.Desktop, settings });
|
||||
const searchResultGroupItems = searchResultGroups(this.state.searchQuery, sections, AppType.Desktop);
|
||||
const matchedSections = matchedSearchSections(sections, searchResultGroupItems);
|
||||
const hasValidSectionFilter = !!sectionFilter && matchedSections.some(group => group.section.name === sectionFilter);
|
||||
const filteredMatchedSections = hasValidSectionFilter ? matchedSections.filter(group => group.section.name === sectionFilter) : matchedSections;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const needRestartComp: any = this.state.needRestart ? (
|
||||
@@ -443,50 +483,119 @@ class ConfigScreenComponent extends React.Component<any, any> {
|
||||
delete style.width;
|
||||
|
||||
const tabComponents: React.ReactNode[] = [];
|
||||
for (const section of sections) {
|
||||
const sectionId = `setting-section-${section.name}`;
|
||||
let content = null;
|
||||
const visible = section.name === this.state.selectedSectionName;
|
||||
if (visible) {
|
||||
content = (
|
||||
<>
|
||||
{screenComp}
|
||||
<div style={containerStyle}>{settingComps}</div>
|
||||
</>
|
||||
if (searchMode) {
|
||||
const searchContent = filteredMatchedSections.map(({ section }) => {
|
||||
const sectionComp = section.isScreen ? (
|
||||
<div className='search-message'>
|
||||
{_('This section opens in its own screen and is matched by section title.')}
|
||||
</div>
|
||||
) : this.sectionToComponent(section.name, section, settings, true);
|
||||
if (!sectionComp) return null;
|
||||
|
||||
return (
|
||||
<div key={`search-result-${section.name}`}>
|
||||
<h2 className='search-section-title'>
|
||||
<i
|
||||
className={Setting.sectionNameToIcon(section.name, AppType.Desktop)}
|
||||
role='img'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
{this.renderSearchHighlightedText(Setting.sectionNameToLabel(section.name))}
|
||||
</h2>
|
||||
{sectionComp}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const noResultsMessage = filteredMatchedSections.length === 0 ? (
|
||||
<div className='search-no-results'>
|
||||
{_('No matching results')}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
tabComponents.push(
|
||||
<div
|
||||
key={sectionId}
|
||||
id={sectionId}
|
||||
className={`setting-tab-panel ${!visible ? '-hidden' : ''}`}
|
||||
hidden={!visible}
|
||||
aria-labelledby={`setting-tab-${section.name}`}
|
||||
tabIndex={0}
|
||||
role='tabpanel'
|
||||
key='setting-section-search-results'
|
||||
id='setting-section-search-results'
|
||||
className='setting-tab-panel'
|
||||
role='region'
|
||||
aria-label={_('Search results')}
|
||||
>
|
||||
{content}
|
||||
<div className='search-results'>
|
||||
<div aria-live='polite' aria-atomic='true' style={{ position: 'absolute', width: 1, height: 1, overflow: 'hidden', clip: 'rect(0,0,0,0)', whiteSpace: 'nowrap' }}>
|
||||
{filteredMatchedSections.length === 0 ? _('No matching results') : _('%d sections found', filteredMatchedSections.length)}
|
||||
</div>
|
||||
<div className='search-filter-control'>
|
||||
{hasValidSectionFilter ?
|
||||
_('Filtered by section [%s]', Setting.sectionNameToLabel(sectionFilter)) :
|
||||
_('Showing all matching settings')}
|
||||
{hasValidSectionFilter ? (
|
||||
<button
|
||||
type='button'
|
||||
className='link-button'
|
||||
onClick={() => {
|
||||
this.setState({ searchSectionFilter: null });
|
||||
}}
|
||||
>
|
||||
{_('Show all results')}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{searchContent}
|
||||
{noResultsMessage}
|
||||
</div>
|
||||
</div>,
|
||||
);
|
||||
} else {
|
||||
for (const section of sections) {
|
||||
const sectionId = `setting-section-${section.name}`;
|
||||
let content = null;
|
||||
const visible = section.name === this.state.selectedSectionName;
|
||||
if (visible) {
|
||||
content = (
|
||||
<>
|
||||
{screenComp}
|
||||
<div className={`config-screen-settings-container ${shouldHideSettingsContainer ? 'hidden' : ''}`}>{settingComps}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
tabComponents.push(
|
||||
<div
|
||||
key={sectionId}
|
||||
id={sectionId}
|
||||
className={`setting-tab-panel ${!visible ? '-hidden' : ''}`}
|
||||
hidden={!visible}
|
||||
aria-labelledby={`setting-tab-${section.name}`}
|
||||
tabIndex={0}
|
||||
role='tabpanel'
|
||||
>
|
||||
{content}
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="config-screen" role="main" style={{ display: 'flex', flexDirection: 'row', height: this.props.style.height }}>
|
||||
<Sidebar
|
||||
selection={this.state.selectedSectionName}
|
||||
selection={searchMode ? (sectionFilter ?? matchedSections[0]?.section.name ?? this.state.selectedSectionName) : this.state.selectedSectionName}
|
||||
onSelectionChange={this.sidebar_selectionChange}
|
||||
sections={sections}
|
||||
searchQuery={this.state.searchQuery}
|
||||
onSearchQueryChange={this.onSearchQueryChange}
|
||||
onSearchButtonClick={this.onSearchButtonClick}
|
||||
searchResultGroups={searchResultGroupItems}
|
||||
/>
|
||||
<div style={rightStyle}>
|
||||
{needRestartComp}
|
||||
{tabComponents}
|
||||
<ButtonBar
|
||||
hasChanges={hasChanges}
|
||||
backButtonTitle={hasChanges && !screenComp ? _('Cancel') : _('Back')}
|
||||
backButtonTitle={hasChanges && (!screenComp || searchMode) ? _('Cancel') : _('Back')}
|
||||
onCancelClick={this.onCancelClick}
|
||||
onSaveClick={screenComp ? null : this.onSaveClick}
|
||||
onApplyClick={screenComp ? null : this.onApplyClick}
|
||||
onSaveClick={screenComp && !searchMode ? undefined : this.onSaveClick}
|
||||
onApplyClick={screenComp && !searchMode ? undefined : this.onApplyClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,12 +2,12 @@ import { AppType, MetadataBySection, SettingMetadataSection, SettingSectionSourc
|
||||
import * as React from 'react';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import { focus } from '@joplin/lib/utils/focusHandler';
|
||||
const styled = require('styled-components').default;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied;
|
||||
type StyleProps = any;
|
||||
import SearchInput, { OnChangeEvent } from '../lib/SearchInput/SearchInput';
|
||||
import { normalizeQuery } from '@joplin/lib/components/shared/config/config-search-text';
|
||||
import { type SearchResultGroup } from './configSearch';
|
||||
import highlightSearchText from './searchHighlight';
|
||||
|
||||
interface SectionChangeEvent {
|
||||
section: SettingMetadataSection;
|
||||
@@ -17,67 +17,19 @@ interface Props {
|
||||
selection: string;
|
||||
onSelectionChange: (event: SectionChangeEvent)=> void;
|
||||
sections: MetadataBySection;
|
||||
searchQuery: string;
|
||||
onSearchQueryChange: (event: OnChangeEvent)=> void;
|
||||
onSearchButtonClick: ()=> void;
|
||||
searchResultGroups: SearchResultGroup[];
|
||||
}
|
||||
|
||||
export const StyledRoot = styled.div`
|
||||
display: flex;
|
||||
background-color: ${(props: StyleProps) => props.theme.backgroundColor2};
|
||||
flex-direction: column;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
`;
|
||||
|
||||
export const StyledListItem = styled.a`
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
padding: ${(props: StyleProps) => props.theme.mainPadding}px;
|
||||
background: ${(props: StyleProps) => props.selected ? props.theme.selectedColor2 : 'none'};
|
||||
transition: 0.1s;
|
||||
text-decoration: none;
|
||||
cursor: default;
|
||||
opacity: ${(props: StyleProps) => props.selected ? 1 : 0.8};
|
||||
padding-left: ${(props: StyleProps) => props.isSubSection ? '35' : props.theme.mainPadding}px;
|
||||
|
||||
&:hover {
|
||||
background-color: ${(props: StyleProps) => props.theme.backgroundColorHover2};
|
||||
}
|
||||
`;
|
||||
|
||||
export const StyledDivider = styled.div`
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
color: ${(props: StyleProps) => props.theme.color2};
|
||||
padding: ${(props: StyleProps) => props.theme.mainPadding}px;
|
||||
padding-top: ${(props: StyleProps) => props.theme.mainPadding * .8}px;
|
||||
padding-bottom: ${(props: StyleProps) => props.theme.mainPadding * .8}px;
|
||||
border-top: 1px solid ${(props: StyleProps) => props.theme.dividerColor};
|
||||
border-bottom: 1px solid ${(props: StyleProps) => props.theme.dividerColor};
|
||||
background-color: ${(props: StyleProps) => props.theme.selectedColor2};
|
||||
font-size: ${(props: StyleProps) => Math.round(props.theme.fontSize)}px;
|
||||
opacity: 0.58;
|
||||
`;
|
||||
|
||||
export const StyledListItemLabel = styled.span`
|
||||
font-size: ${(props: StyleProps) => Math.round(props.theme.fontSize * 1.2)}px;
|
||||
font-weight: 500;
|
||||
color: ${(props: StyleProps) => props.theme.color2};
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
export const StyledListItemIcon = styled.i`
|
||||
font-size: ${(props: StyleProps) => Math.round(props.theme.fontSize * 1.4)}px;
|
||||
color: ${(props: StyleProps) => props.theme.color2};
|
||||
margin-right: ${(props: StyleProps) => props.theme.mainPadding / 1.5}px;
|
||||
`;
|
||||
|
||||
export default function Sidebar(props: Props) {
|
||||
const buttonRefs = useRef<HTMLElement[]>([]);
|
||||
const isSearching = !!normalizeQuery(props.searchQuery);
|
||||
|
||||
const matchedSectionNames = useMemo(() => {
|
||||
return new Set(props.searchResultGroups.map(group => group.sectionName));
|
||||
}, [props.searchResultGroups]);
|
||||
|
||||
// Making a tabbed region accessible involves supporting keyboard interaction.
|
||||
// See https://www.w3.org/WAI/ARIA/apg/patterns/tabs/ for details
|
||||
@@ -85,19 +37,43 @@ export default function Sidebar(props: Props) {
|
||||
const selectedIndex = props.sections.findIndex(section => section.name === props.selection);
|
||||
let newIndex = selectedIndex;
|
||||
|
||||
// Determine navigation direction
|
||||
let isMovingUp = false;
|
||||
if (event.code === 'ArrowUp') {
|
||||
newIndex --;
|
||||
isMovingUp = true;
|
||||
} else if (event.code === 'ArrowDown') {
|
||||
newIndex ++;
|
||||
isMovingUp = false;
|
||||
} else if (event.code === 'Home') {
|
||||
newIndex = 0;
|
||||
isMovingUp = false;
|
||||
} else if (event.code === 'End') {
|
||||
newIndex = props.sections.length - 1;
|
||||
isMovingUp = true;
|
||||
}
|
||||
|
||||
if (newIndex < 0) newIndex += props.sections.length;
|
||||
newIndex %= props.sections.length;
|
||||
|
||||
// Skip disabled (no-match) sections during search
|
||||
if (isSearching) {
|
||||
const initialIndex = newIndex;
|
||||
while (!matchedSectionNames.has(props.sections[newIndex].name)) {
|
||||
if (isMovingUp) {
|
||||
newIndex--;
|
||||
if (newIndex < 0) newIndex += props.sections.length;
|
||||
} else {
|
||||
newIndex++;
|
||||
newIndex %= props.sections.length;
|
||||
}
|
||||
// Prevent infinite loop if no matched sections
|
||||
if (newIndex === initialIndex) break;
|
||||
}
|
||||
|
||||
if (!matchedSectionNames.has(props.sections[newIndex].name)) return;
|
||||
}
|
||||
|
||||
if (newIndex !== selectedIndex) {
|
||||
event.preventDefault();
|
||||
props.onSelectionChange({ section: props.sections[newIndex] });
|
||||
@@ -107,46 +83,60 @@ export default function Sidebar(props: Props) {
|
||||
focus('Sidebar', targetButton);
|
||||
}
|
||||
}
|
||||
}, [props.sections, props.selection, props.onSelectionChange]);
|
||||
}, [props.sections, props.selection, props.onSelectionChange, matchedSectionNames, isSearching]);
|
||||
|
||||
const buttons: React.ReactNode[] = [];
|
||||
|
||||
function renderButton(section: SettingMetadataSection, index: number) {
|
||||
const selected = props.selection === section.name;
|
||||
const hasMatch = matchedSectionNames.has(section.name);
|
||||
const isDisabled = isSearching && !hasMatch;
|
||||
const isActiveTab = selected && !isDisabled;
|
||||
|
||||
const classNames = ['item'];
|
||||
if (Setting.isSubSection(section.name)) classNames.push('sub');
|
||||
if (isActiveTab) classNames.push('selected');
|
||||
if (isDisabled) classNames.push('disabled');
|
||||
|
||||
return (
|
||||
<StyledListItem
|
||||
<button
|
||||
key={section.name}
|
||||
href='#'
|
||||
type='button'
|
||||
role='tab'
|
||||
ref={(item: HTMLElement) => { buttonRefs.current[index] = item; }}
|
||||
|
||||
ref={(item: HTMLElement | null) => {
|
||||
if (item) {
|
||||
buttonRefs.current[index] = item;
|
||||
}
|
||||
}}
|
||||
className={classNames.join(' ')}
|
||||
id={`setting-tab-${section.name}`}
|
||||
aria-controls={`setting-section-${section.name}`}
|
||||
aria-selected={selected}
|
||||
tabIndex={selected ? 0 : -1}
|
||||
|
||||
isSubSection={Setting.isSubSection(section.name)}
|
||||
selected={selected}
|
||||
onClick={() => { props.onSelectionChange({ section: section }); }}
|
||||
onKeyDown={onKeyDown}
|
||||
aria-controls={isSearching ? (isDisabled ? undefined : 'setting-section-search-results') : `setting-section-${section.name}`}
|
||||
aria-selected={isActiveTab}
|
||||
aria-disabled={isDisabled}
|
||||
tabIndex={isActiveTab ? 0 : -1}
|
||||
onClick={() => {
|
||||
if (isDisabled) return;
|
||||
props.onSelectionChange({ section: section });
|
||||
}}
|
||||
onKeyDown={!isDisabled ? onKeyDown : undefined}
|
||||
>
|
||||
<StyledListItemIcon
|
||||
className={Setting.sectionNameToIcon(section.name, AppType.Desktop)}
|
||||
<i
|
||||
className={`icon ${Setting.sectionNameToIcon(section.name, AppType.Desktop)}`}
|
||||
role='img'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
<StyledListItemLabel>
|
||||
{Setting.sectionNameToLabel(section.name)}
|
||||
</StyledListItemLabel>
|
||||
</StyledListItem>
|
||||
<span className='label'>
|
||||
{highlightSearchText(Setting.sectionNameToLabel(section.name), props.searchQuery)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function renderDivider(key: string) {
|
||||
return (
|
||||
<StyledDivider key={key}>
|
||||
<div key={key} className='separator' role='presentation' aria-hidden='true'>
|
||||
{_('Plugins')}
|
||||
</StyledDivider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -164,8 +154,23 @@ export default function Sidebar(props: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledRoot className='settings-sidebar _scrollbar2' role='tablist'>
|
||||
{buttons}
|
||||
</StyledRoot>
|
||||
<div className='settings-sidebar _scrollbar2'>
|
||||
<div className='searchbox'>
|
||||
<SearchInput
|
||||
inputRef={null}
|
||||
inputClassName='settings'
|
||||
value={props.searchQuery}
|
||||
onChange={props.onSearchQueryChange}
|
||||
onSearchButtonClick={props.onSearchButtonClick}
|
||||
searchStarted={isSearching}
|
||||
placeholder={_('Search settings...')}
|
||||
aria-controls={isSearching ? 'setting-section-search-results' : undefined}
|
||||
iconButtonTabIndex={-1}
|
||||
/>
|
||||
</div>
|
||||
<div role='tablist' className='tablist'>
|
||||
{buttons}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
.settings-sidebar {
|
||||
display: flex;
|
||||
background-color: var(--joplin-background-color2);
|
||||
flex-direction: column;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
|
||||
> .searchbox {
|
||||
padding: var(--joplin-main-padding);
|
||||
padding-bottom: calc(var(--joplin-main-padding) / 2);
|
||||
}
|
||||
|
||||
> .tablist > .separator {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
color: var(--joplin-color2);
|
||||
padding: var(--joplin-main-padding);
|
||||
padding-top: calc(var(--joplin-main-padding) * 0.8);
|
||||
padding-bottom: calc(var(--joplin-main-padding) * 0.8);
|
||||
border-top: 1px solid var(--joplin-divider-color);
|
||||
border-bottom: 1px solid var(--joplin-divider-color);
|
||||
background-color: var(--joplin-selected-color2);
|
||||
font-size: var(--joplin-font-size);
|
||||
}
|
||||
|
||||
> .tablist > .item {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
border: none;
|
||||
padding: var(--joplin-main-padding);
|
||||
background: none;
|
||||
transition: 0.1s;
|
||||
text-align: left;
|
||||
cursor: default;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
|
||||
&.sub {
|
||||
padding-left: 35px;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background: var(--joplin-selected-color2);
|
||||
opacity: 1;
|
||||
|
||||
> .icon {
|
||||
color: var(--joplin-color2);
|
||||
}
|
||||
|
||||
> .label {
|
||||
color: var(--joplin-color2);
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.selected):not(.disabled) {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&:hover:not(.disabled) {
|
||||
background-color: var(--joplin-background-color-hover2);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
> .icon {
|
||||
font-size: calc(var(--joplin-font-size) * 1.4);
|
||||
color: var(--joplin-color2);
|
||||
margin-right: calc(var(--joplin-main-padding) / 1.5);
|
||||
}
|
||||
|
||||
> .label {
|
||||
font-size: calc(var(--joplin-font-size) * 1.2);
|
||||
font-weight: 500;
|
||||
color: var(--joplin-color2);
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
|
||||
> mark {
|
||||
background-color: var(--joplin-search-marker-background-color);
|
||||
color: var(--joplin-search-marker-color);
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import Setting, { AppType, SettingItem, SettingMetadataSection } from '@joplin/lib/models/Setting';
|
||||
import { includesNormalizedQuery, normalizeQuery } from '@joplin/lib/components/shared/config/config-search-text';
|
||||
|
||||
const isMetadataMatched = (
|
||||
normalizedQuery: string,
|
||||
section: SettingMetadataSection,
|
||||
metadata: SettingItem,
|
||||
appType: AppType,
|
||||
): boolean => {
|
||||
const metadataLabel = metadata.label ? metadata.label() : '';
|
||||
const metadataDescription = metadata.description ? metadata.description(appType) : '';
|
||||
const sectionLabel = Setting.sectionNameToLabel(section.name);
|
||||
|
||||
const normalizedCandidates = [
|
||||
sectionLabel,
|
||||
metadataLabel,
|
||||
metadataDescription,
|
||||
];
|
||||
|
||||
return normalizedCandidates.some(value => includesNormalizedQuery(normalizedQuery, value || ''));
|
||||
};
|
||||
|
||||
export interface SearchResultGroup {
|
||||
sectionName: string;
|
||||
matchingKeys: string[];
|
||||
}
|
||||
|
||||
export interface MatchedSearchSection {
|
||||
section: SettingMetadataSection;
|
||||
matchingKeys: string[];
|
||||
}
|
||||
|
||||
export const searchResultGroups = (
|
||||
query: string,
|
||||
sections: SettingMetadataSection[],
|
||||
appType: AppType,
|
||||
): SearchResultGroup[] => {
|
||||
const normalizedQuery = normalizeQuery(query);
|
||||
if (!normalizedQuery) return [];
|
||||
|
||||
const output: SearchResultGroup[] = [];
|
||||
|
||||
for (const section of sections) {
|
||||
const sectionTitleMatched = includesNormalizedQuery(normalizedQuery, Setting.sectionNameToLabel(section.name));
|
||||
|
||||
if (sectionTitleMatched && section.isScreen) {
|
||||
output.push({
|
||||
sectionName: section.name,
|
||||
matchingKeys: [],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const matchingKeys: string[] = [];
|
||||
|
||||
for (const metadata of section.metadatas) {
|
||||
if (!metadata.key) continue;
|
||||
|
||||
if (sectionTitleMatched || isMetadataMatched(normalizedQuery, section, metadata, appType)) {
|
||||
matchingKeys.push(metadata.key);
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchingKeys.length) continue;
|
||||
|
||||
output.push({
|
||||
sectionName: section.name,
|
||||
matchingKeys,
|
||||
});
|
||||
}
|
||||
|
||||
return output;
|
||||
};
|
||||
|
||||
export const matchedSearchSections = (
|
||||
sections: SettingMetadataSection[],
|
||||
groups: SearchResultGroup[],
|
||||
): MatchedSearchSection[] => {
|
||||
if (!groups.length) return [];
|
||||
|
||||
const sectionByName: Record<string, SettingMetadataSection> = {};
|
||||
|
||||
for (const section of sections) {
|
||||
sectionByName[section.name] = section;
|
||||
}
|
||||
|
||||
const output: MatchedSearchSection[] = [];
|
||||
|
||||
for (const group of groups) {
|
||||
const section = sectionByName[group.sectionName];
|
||||
if (!section) continue;
|
||||
|
||||
const matchingKeySet = new Set(group.matchingKeys);
|
||||
const metadatas = section.metadatas.filter(metadata => metadata.key && matchingKeySet.has(metadata.key));
|
||||
if (!metadatas.length && !section.isScreen) continue;
|
||||
|
||||
output.push({
|
||||
section: {
|
||||
...section,
|
||||
metadatas,
|
||||
},
|
||||
matchingKeys: group.matchingKeys,
|
||||
});
|
||||
}
|
||||
|
||||
return output;
|
||||
};
|
||||
@@ -30,6 +30,7 @@ interface Props {
|
||||
fonts: string[];
|
||||
onUpdateSettingValue: (event: UpdateSettingValueEvent)=> void;
|
||||
onSettingButtonClick: (key: string)=> void;
|
||||
renderSearchText?: (text: string)=> React.ReactNode;
|
||||
}
|
||||
|
||||
const SettingComponent: React.FC<Props> = props => {
|
||||
@@ -41,6 +42,11 @@ const SettingComponent: React.FC<Props> = props => {
|
||||
props.onUpdateSettingValue({ key, value });
|
||||
}, [props.onUpdateSettingValue]);
|
||||
|
||||
const renderText = useCallback((text: string): React.ReactNode => {
|
||||
if (!props.renderSearchText) return text;
|
||||
return props.renderSearchText(text);
|
||||
}, [props.renderSearchText]);
|
||||
|
||||
const rowStyle = {
|
||||
marginBottom: theme.mainPadding * 1.5,
|
||||
};
|
||||
@@ -72,15 +78,15 @@ const SettingComponent: React.FC<Props> = props => {
|
||||
const descriptionText = Setting.keyDescription(key, AppType.Desktop);
|
||||
const inputId = useId();
|
||||
const descriptionId = useId();
|
||||
const descriptionComp = <SettingDescription id={descriptionId} text={descriptionText}/>;
|
||||
const descriptionComp = <SettingDescription id={descriptionId} text={descriptionText} renderText={renderText}/>;
|
||||
|
||||
if (key in settingKeyToControl) {
|
||||
const CustomSettingComponent = settingKeyToControl[key];
|
||||
const label = md.label ? <SettingLabel text={md.label()} htmlFor={null} /> : null;
|
||||
const label = md.label ? <SettingLabel text={md.label()} htmlFor={null} renderText={renderText} /> : null;
|
||||
return (
|
||||
<div style={rowStyle}>
|
||||
{label}
|
||||
<SettingDescription id={descriptionId} text={md.description ? md.description(AppType.Desktop) : null}/>
|
||||
<SettingDescription id={descriptionId} text={md.description ? md.description(AppType.Desktop) : null} renderText={renderText}/>
|
||||
<CustomSettingComponent
|
||||
value={props.value}
|
||||
themeId={props.themeId}
|
||||
@@ -112,7 +118,7 @@ const SettingComponent: React.FC<Props> = props => {
|
||||
|
||||
return (
|
||||
<div style={rowStyle}>
|
||||
<SettingLabel htmlFor={inputId} text={md.label()}/>
|
||||
<SettingLabel htmlFor={inputId} text={md.label()} renderText={renderText}/>
|
||||
<select
|
||||
value={value}
|
||||
className='setting-select-control'
|
||||
@@ -152,7 +158,7 @@ const SettingComponent: React.FC<Props> = props => {
|
||||
className='setting-label -for-checkbox'
|
||||
htmlFor={inputId}
|
||||
>
|
||||
{md.label()}
|
||||
{renderText(md.label())}
|
||||
</label>
|
||||
</div>
|
||||
{descriptionComp}
|
||||
@@ -254,7 +260,7 @@ const SettingComponent: React.FC<Props> = props => {
|
||||
const pathDescriptionId = `setting_path_label_${key}`;
|
||||
return (
|
||||
<div style={rowStyle}>
|
||||
<SettingLabel text={md.label()} htmlFor={inputId}/>
|
||||
<SettingLabel text={md.label()} htmlFor={inputId} renderText={renderText}/>
|
||||
<div style={{ display: 'flex' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ ...rowStyle, marginBottom: 5 }}>
|
||||
@@ -295,7 +301,7 @@ const SettingComponent: React.FC<Props> = props => {
|
||||
};
|
||||
return (
|
||||
<div style={rowStyle}>
|
||||
<SettingLabel text={md.label()} htmlFor={inputId}/>
|
||||
<SettingLabel text={md.label()} htmlFor={inputId} renderText={renderText}/>
|
||||
{
|
||||
md.subType === SettingItemSubType.FontFamily || md.subType === SettingItemSubType.MonospaceFontFamily ?
|
||||
<FontSearch
|
||||
@@ -335,7 +341,7 @@ const SettingComponent: React.FC<Props> = props => {
|
||||
|
||||
return (
|
||||
<div style={rowStyle}>
|
||||
<SettingLabel htmlFor={inputId} text={label.join(' ')}/>
|
||||
<SettingLabel htmlFor={inputId} text={label.join(' ')} renderText={renderText}/>
|
||||
<input
|
||||
type="number"
|
||||
style={textInputBaseStyle}
|
||||
@@ -353,7 +359,7 @@ const SettingComponent: React.FC<Props> = props => {
|
||||
);
|
||||
} else if (md.type === Setting.TYPE_BUTTON) {
|
||||
const labelComp = md.hideLabel ? null : (
|
||||
<SettingLabel text={md.label()} htmlFor={null} />
|
||||
<SettingLabel text={md.label()} htmlFor={null} renderText={renderText} />
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import * as React from 'react';
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
text: string|null;
|
||||
id?: string;
|
||||
renderText?: (text: string)=> React.ReactNode;
|
||||
}
|
||||
|
||||
const SettingDescription: React.FC<Props> = props => {
|
||||
return <div className={`setting-description ${!props.text ? '-empty' : ''}`} id={props.id}>{props.text}</div>;
|
||||
const renderedText = props.text && props.renderText ? props.renderText(props.text) : props.text;
|
||||
return <div className={`setting-description ${!props.text ? '-empty' : ''}`} id={props.id}>{renderedText}</div>;
|
||||
};
|
||||
|
||||
export default SettingDescription;
|
||||
|
||||
@@ -7,7 +7,7 @@ interface Props {
|
||||
const SettingHeader: React.FC<Props> = props => {
|
||||
return (
|
||||
<div className='setting-header'>
|
||||
<label>{props.text}</label>
|
||||
<span>{props.text}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,12 +3,13 @@ import * as React from 'react';
|
||||
interface Props {
|
||||
htmlFor: string|null;
|
||||
text: string;
|
||||
renderText?: (text: string)=> React.ReactNode;
|
||||
}
|
||||
|
||||
const SettingLabel: React.FC<Props> = props => {
|
||||
return (
|
||||
<div className='setting-label'>
|
||||
<label htmlFor={props.htmlFor}>{props.text}</label>
|
||||
<label htmlFor={props.htmlFor}>{props.renderText ? props.renderText(props.text) : props.text}</label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -173,7 +173,7 @@ export default function(props: Props) {
|
||||
themeId={props.themeId}
|
||||
value={item.enabled}
|
||||
onToggle={() => props.onToggle({ item })}
|
||||
aria-label={_('Enabled')}
|
||||
aria-label={item.enabled ? _('Disable %s', item.manifest.name) : _('Enable %s', item.manifest.name)}
|
||||
/>;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import * as React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import highlightSearchText from './searchHighlight';
|
||||
|
||||
describe('searchHighlight', () => {
|
||||
const countMarks = (result: React.ReactNode[]): number => {
|
||||
return result.filter((element) => React.isValidElement(element) && (element as React.ReactElement).type === 'mark').length;
|
||||
};
|
||||
|
||||
it('should highlight all matching occurrences (case-insensitive)', () => {
|
||||
const text = 'Synchronization settings for sync behavior';
|
||||
const query = 'sync';
|
||||
|
||||
const result = highlightSearchText(text, query) as React.ReactNode[];
|
||||
const markCount = countMarks(result);
|
||||
|
||||
expect(markCount).toBe(2);
|
||||
});
|
||||
|
||||
it('should return original text when query is empty', () => {
|
||||
const text = 'Some test text';
|
||||
const resultEmpty = highlightSearchText(text, '');
|
||||
|
||||
expect(resultEmpty).toBe(text);
|
||||
});
|
||||
|
||||
it('should return original text when query is whitespace only', () => {
|
||||
const text = 'Some test text';
|
||||
const resultWhitespace = highlightSearchText(text, ' ');
|
||||
|
||||
expect(resultWhitespace).toBe(text);
|
||||
});
|
||||
|
||||
it('should return original text when input text is empty', () => {
|
||||
const result = highlightSearchText('', 'query');
|
||||
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should handle special regex characters in query', () => {
|
||||
const text = 'Test (nested) [brackets] and {braces}';
|
||||
const query = '(nested)';
|
||||
|
||||
const result = highlightSearchText(text, query) as React.ReactNode[];
|
||||
const markCount = countMarks(result);
|
||||
|
||||
expect(markCount).toBe(1);
|
||||
});
|
||||
|
||||
it('should render mark elements for case-insensitive matches', () => {
|
||||
const result = highlightSearchText('Synchronization', 'sync');
|
||||
const rendered = render(<>{result}</>);
|
||||
|
||||
const marks = rendered.container.querySelectorAll('mark');
|
||||
expect(marks.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should preserve full text content when highlighting', () => {
|
||||
const text = 'Search and Find';
|
||||
const result = highlightSearchText(text, 'find');
|
||||
const rendered = render(<>{result}</>);
|
||||
|
||||
expect(rendered.container.textContent).toBe(text);
|
||||
});
|
||||
|
||||
it('should render highlighted mark for matches', () => {
|
||||
const result = highlightSearchText('Find this', 'find');
|
||||
const rendered = render(<>{result}</>);
|
||||
|
||||
const marks = rendered.container.querySelectorAll('mark');
|
||||
expect(marks.length).toBeGreaterThan(0);
|
||||
expect(marks[0].textContent?.toLowerCase()).toContain('find');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import * as React from 'react';
|
||||
import { escapeRegExp } from '@joplin/lib/string-utils';
|
||||
|
||||
// Returns a React node where every case-insensitive match of `query` in `text`
|
||||
// is wrapped in a `mark` element.
|
||||
const highlightSearchText = (
|
||||
text: string,
|
||||
query: string,
|
||||
): React.ReactNode => {
|
||||
if (!text) return text;
|
||||
|
||||
const trimmedQuery = query.trim();
|
||||
if (!trimmedQuery) return text;
|
||||
|
||||
const matcher = new RegExp(`(${escapeRegExp(trimmedQuery)})`, 'ig');
|
||||
const parts = text.split(matcher);
|
||||
if (parts.length === 1) return text;
|
||||
|
||||
return parts.map((part, index) => {
|
||||
if (index % 2 === 1) {
|
||||
return <mark key={`highlight-${index}`}>{part}</mark>;
|
||||
}
|
||||
|
||||
return <React.Fragment key={`text-${index}`}>{part}</React.Fragment>;
|
||||
});
|
||||
};
|
||||
|
||||
export default highlightSearchText;
|
||||
@@ -1,4 +1,5 @@
|
||||
@use "./styles/index.scss";
|
||||
@use "./Sidebar/style.scss" as sidebar-styles;
|
||||
|
||||
.config-screen-content-wrapper {
|
||||
padding: 24px;
|
||||
@@ -45,4 +46,67 @@
|
||||
background-color: var(--joplin-background-color3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
// Container styles
|
||||
.config-screen-settings-container {
|
||||
overflow: auto;
|
||||
padding: var(--joplin-config-screen-padding);
|
||||
padding-top: 0;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
|
||||
&.hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Search mode styles
|
||||
.search-results {
|
||||
display: block;
|
||||
overflow: auto;
|
||||
padding: var(--joplin-config-screen-padding);
|
||||
padding-top: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.search-filter-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 20px;
|
||||
margin-bottom: 8px;
|
||||
color: var(--joplin-color);
|
||||
}
|
||||
|
||||
.search-section-title {
|
||||
font-weight: 500;
|
||||
margin-top: 30px;
|
||||
margin-bottom: 10px;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--joplin-color2);
|
||||
background-color: var(--joplin-background-color2);
|
||||
font-size: calc(var(--joplin-font-size) * 1.2);
|
||||
|
||||
> i {
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.search-message {
|
||||
margin-bottom: 20px;
|
||||
color: var(--joplin-color);
|
||||
}
|
||||
|
||||
.search-no-results {
|
||||
margin-top: 20px;
|
||||
color: var(--joplin-color-faded);
|
||||
}
|
||||
|
||||
.config-screen mark {
|
||||
background-color: var(--joplin-search-marker-background-color);
|
||||
color: var(--joplin-search-marker-color);
|
||||
padding: 0;
|
||||
}
|
||||
@@ -40,13 +40,33 @@ interface Props {
|
||||
masterPasswordDialogOpen: boolean;
|
||||
}
|
||||
|
||||
interface EncryptionDialogOptions{
|
||||
className: string;
|
||||
title: string;
|
||||
content: React.ReactNode;
|
||||
onClose: ()=> void;
|
||||
onDialogButtonRowClick: (event: { buttonName: string })=> void;
|
||||
okButtonDisabled?: boolean;
|
||||
}
|
||||
|
||||
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 [enableEncryptionError, setEnableEncryptionError] = useState('');
|
||||
const [disableEncryptionPromptVisible, setDisableEncryptionPromptVisible] = useState(false);
|
||||
const disablePromptPromiseRef = useRef<(value: boolean)=> void>(null);
|
||||
const promptPromiseRef = useRef<(password: string | null)=> void>(null);
|
||||
|
||||
// Cleanup on unmount to resolve pending promises if the user navigates away
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (disablePromptPromiseRef.current) disablePromptPromiseRef.current(false);
|
||||
if (promptPromiseRef.current) promptPromiseRef.current(null);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const wasMasterPasswordDialogOpen = useRef(props.masterPasswordDialogOpen);
|
||||
|
||||
const theme = useMemo(() => {
|
||||
@@ -246,7 +266,10 @@ export const EncryptionConfigScreen = (props: Props) => {
|
||||
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?'));
|
||||
setDisableEncryptionPromptVisible(true);
|
||||
const answer = await new Promise<boolean>((resolve) => {
|
||||
disablePromptPromiseRef.current = resolve;
|
||||
});
|
||||
if (!answer) return;
|
||||
} else {
|
||||
if (shouldOpenMasterPasswordDialogForEnable({
|
||||
@@ -263,6 +286,7 @@ export const EncryptionConfigScreen = (props: Props) => {
|
||||
|
||||
// Wait for the custom React Dialog to resolve
|
||||
setEnableEncryptionPassword('');
|
||||
setEnableEncryptionError('');
|
||||
setEnableEncryptionPromptVisible(true);
|
||||
newPassword = await new Promise<string | null>((resolve) => {
|
||||
promptPromiseRef.current = resolve;
|
||||
@@ -271,13 +295,6 @@ export const EncryptionConfigScreen = (props: Props) => {
|
||||
if (newPassword === null) return; // User cancelled
|
||||
}
|
||||
|
||||
if (hasMasterPassword && newEnabled) {
|
||||
if (!(await masterPasswordIsValid(newPassword))) {
|
||||
await dialogs.alert('Invalid password. Please try again. If you have forgotten your password you will need to reset it.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await toggleAndSetupEncryption(EncryptionService.instance(), newEnabled, masterKey, newPassword);
|
||||
} catch (error) {
|
||||
@@ -285,6 +302,24 @@ export const EncryptionConfigScreen = (props: Props) => {
|
||||
}
|
||||
}, [props.dispatch, props.masterPassword, props.masterPasswordDialogOpen]);
|
||||
|
||||
const renderEncryptionDialog = (options: EncryptionDialogOptions) => {
|
||||
return (
|
||||
<Dialog onCancel={options.onClose} className={options.className}>
|
||||
<div className='dialog-root'>
|
||||
<DialogTitle title={options.title}/>
|
||||
<div className='dialog-content'>
|
||||
{options.content}
|
||||
</div>
|
||||
<DialogButtonRow
|
||||
themeId={props.themeId}
|
||||
onClick={options.onDialogButtonRowClick}
|
||||
okButtonDisabled={options.okButtonDisabled ?? false}
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const renderEnableEncryptionDialog = () => {
|
||||
if (!enableEncryptionPromptVisible) return null;
|
||||
|
||||
@@ -299,12 +334,19 @@ export const EncryptionConfigScreen = (props: Props) => {
|
||||
if (promptPromiseRef.current) promptPromiseRef.current(null);
|
||||
};
|
||||
|
||||
const onDialogButtonRowClick = (event: { buttonName: string }) => {
|
||||
const onDialogButtonRowClick = async (event: { buttonName: string }) => {
|
||||
if (event.buttonName === 'cancel') {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (event.buttonName === 'ok') {
|
||||
if (hasMasterPassword) {
|
||||
if (!(await masterPasswordIsValid(enableEncryptionPassword))) {
|
||||
setEnableEncryptionError(_('Invalid password. Please try again. If you have forgotten your password you will need to reset it.'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
setEnableEncryptionError('');
|
||||
setEnableEncryptionPromptVisible(false);
|
||||
if (promptPromiseRef.current) promptPromiseRef.current(enableEncryptionPassword);
|
||||
}
|
||||
@@ -312,34 +354,72 @@ export const EncryptionConfigScreen = (props: Props) => {
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Required because PasswordInput's ChangeEventHandler type is incorrect
|
||||
const onPasswordInputChange = (event: any) => {
|
||||
setEnableEncryptionError('');
|
||||
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>
|
||||
return renderEncryptionDialog({
|
||||
className: 'enable-encryption-dialog',
|
||||
title: _('Enable encryption'),
|
||||
content: (
|
||||
<>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
{messageComps}
|
||||
</div>
|
||||
<DialogButtonRow
|
||||
themeId={props.themeId}
|
||||
onClick={onDialogButtonRowClick}
|
||||
okButtonDisabled={!enableEncryptionPassword}
|
||||
/>
|
||||
<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>
|
||||
{enableEncryptionError && (
|
||||
<div style={{ ...theme.textStyle, color: theme.colorError, marginTop: 10, marginBottom: 10 }}>
|
||||
{enableEncryptionError}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
onClose,
|
||||
onDialogButtonRowClick,
|
||||
okButtonDisabled: !enableEncryptionPassword,
|
||||
});
|
||||
};
|
||||
|
||||
const renderDisableEncryptionDialog = () => {
|
||||
if (!disableEncryptionPromptVisible) return null;
|
||||
|
||||
const onClose = () => {
|
||||
setDisableEncryptionPromptVisible(false);
|
||||
if (disablePromptPromiseRef.current) disablePromptPromiseRef.current(false);
|
||||
};
|
||||
|
||||
const onDialogButtonRowClick = (event: { buttonName: string }) => {
|
||||
if (event.buttonName === 'cancel') {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.buttonName === 'ok') {
|
||||
setDisableEncryptionPromptVisible(false);
|
||||
if (disablePromptPromiseRef.current) disablePromptPromiseRef.current(true);
|
||||
}
|
||||
};
|
||||
|
||||
return renderEncryptionDialog({
|
||||
className: 'disable-encryption-dialog',
|
||||
title: _('Disable encryption'),
|
||||
content: (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<p style={theme.textStyle}>
|
||||
{_('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?')}
|
||||
</p>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
),
|
||||
onClose,
|
||||
onDialogButtonRowClick,
|
||||
});
|
||||
};
|
||||
|
||||
const renderEncryptionSection = () => {
|
||||
@@ -523,6 +603,7 @@ export const EncryptionConfigScreen = (props: Props) => {
|
||||
{renderNonExistingMasterKeysSection()}
|
||||
{renderAdvancedSection()}
|
||||
{renderEnableEncryptionDialog()}
|
||||
{renderDisableEncryptionDialog()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ import KvStore from '@joplin/lib/services/KvStore';
|
||||
import ShareService from '@joplin/lib/services/share/ShareService';
|
||||
import LabelledPasswordInput from '../PasswordInput/LabelledPasswordInput';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import time from '@joplin/lib/time';
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
@@ -24,6 +25,11 @@ enum Mode {
|
||||
Reset = 2,
|
||||
}
|
||||
|
||||
const syncAfterDefaultInterval = async () => {
|
||||
await time.msleep(reg.defaultScheduleInterval());
|
||||
await reg.waitForSyncFinishedThenSync();
|
||||
};
|
||||
|
||||
export default function(props: Props) {
|
||||
const [status, setStatus] = useState(MasterPasswordStatus.NotSet);
|
||||
const [hasMasterPasswordEncryptedData, setHasMasterPasswordEncryptedData] = useState(true);
|
||||
@@ -78,7 +84,8 @@ export default function(props: Props) {
|
||||
} else {
|
||||
throw new Error(`Unknown mode: ${mode}`);
|
||||
}
|
||||
void reg.waitForSyncFinishedThenSync(null);
|
||||
// We need to defer the sync, as enabling encryption may take a few seconds to complete
|
||||
void syncAfterDefaultInterval();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
void shim.showErrorDialog(error.message);
|
||||
|
||||
@@ -530,12 +530,16 @@ function useMenu(props: Props) {
|
||||
];
|
||||
|
||||
// the following menu items will be available for all OS under Tools
|
||||
const toolsItemsAll = [{
|
||||
label: _('Note attachments...'),
|
||||
click: () => {
|
||||
navigateTo('Resources');
|
||||
const toolsItemsAll = [
|
||||
menuItemDic.newWhiteboard,
|
||||
separator(),
|
||||
{
|
||||
label: _('Note attachments...'),
|
||||
click: () => {
|
||||
navigateTo('Resources');
|
||||
},
|
||||
},
|
||||
}];
|
||||
];
|
||||
|
||||
if (!shim.isMac()) {
|
||||
toolsItems = toolsItems.concat(toolsItemsWindowsLinux);
|
||||
|
||||
@@ -66,6 +66,7 @@ const mapStateToProps = (state: AppState, connectProps: ConnectProps) => {
|
||||
'textCheckbox',
|
||||
'textHeading',
|
||||
'textHorizontalRule',
|
||||
'editor.textTable',
|
||||
'insertDateTime',
|
||||
'toggleEditors',
|
||||
].concat(pluginUtils.commandNamesFromViews(state.pluginService.plugins, 'editorToolbar'));
|
||||
|
||||
@@ -252,6 +252,23 @@ function CodeMirror(props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
|
||||
reg.logger().warn('"editor.scrollToText" is unsupported in legacy editor - please use the new editor');
|
||||
return false;
|
||||
},
|
||||
// Table editing commands are only supported in the v6 editor
|
||||
'editor.tableAddRow': () => {
|
||||
reg.logger().warn('Table editing commands are not supported in the legacy editor');
|
||||
return false;
|
||||
},
|
||||
'editor.tableAddColumn': () => {
|
||||
reg.logger().warn('Table editing commands are not supported in the legacy editor');
|
||||
return false;
|
||||
},
|
||||
'editor.tableDeleteRow': () => {
|
||||
reg.logger().warn('Table editing commands are not supported in the legacy editor');
|
||||
return false;
|
||||
},
|
||||
'editor.tableDeleteColumn': () => {
|
||||
reg.logger().warn('Table editing commands are not supported in the legacy editor');
|
||||
return false;
|
||||
},
|
||||
};
|
||||
|
||||
if (commands[cmd.name]) {
|
||||
|
||||
@@ -21,6 +21,7 @@ const useEditorSettings = (props: EditorSettingsProps) => {
|
||||
markdownInsert: state.settings['markdown.plugin.insert'],
|
||||
katex: state.settings['markdown.plugin.katex'],
|
||||
inlineRendering: state.settings['editor.inlineRendering'],
|
||||
tableEditing: state.settings['editor.tableEditing'],
|
||||
imageRendering: state.settings['editor.imageRendering'],
|
||||
highlightActiveLine: state.settings['editor.highlightActiveLine'],
|
||||
monospaceFont: state.settings['style.editor.monospaceFontFamily'],
|
||||
@@ -48,6 +49,7 @@ const useEditorSettings = (props: EditorSettingsProps) => {
|
||||
markdownInsertEnabled: settings.markdownInsert,
|
||||
katexEnabled: settings.katex,
|
||||
inlineRenderingEnabled: settings.inlineRendering,
|
||||
tableEditingEnabled: settings.tableEditing,
|
||||
imageRenderingEnabled: settings.imageRendering,
|
||||
highlightActiveLine: settings.highlightActiveLine,
|
||||
themeData: {
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import * as React from 'react';
|
||||
import { CSSProperties, ReactNode, useMemo } from 'react';
|
||||
import { useWhiteboardContext } from './WhiteboardContext';
|
||||
import { whiteboardColors, WhiteboardThemeColors } from './theme';
|
||||
|
||||
interface Props {
|
||||
// Where to anchor the panel relative to its positioned ancestor.
|
||||
// 'bottom-center' is the default — used for selection-context panels.
|
||||
position?: 'bottom-center' | 'top-center' | 'top-right';
|
||||
// Optional caption shown at the start of the bar (e.g. "1 connection").
|
||||
caption?: ReactNode;
|
||||
// Buttons / inputs / dividers shown in the bar.
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
const positionStyles: Record<NonNullable<Props['position']>, CSSProperties> = {
|
||||
'bottom-center': { bottom: 16, left: '50%', transform: 'translateX(-50%)' },
|
||||
'top-center': { top: 16, left: '50%', transform: 'translateX(-50%)' },
|
||||
'top-right': { top: 8, right: 8 },
|
||||
};
|
||||
|
||||
const baseStyle = (colors: WhiteboardThemeColors): CSSProperties => ({
|
||||
position: 'absolute',
|
||||
zIndex: 10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0,
|
||||
padding: 4,
|
||||
background: colors.cardBackground,
|
||||
border: `1px solid ${colors.cardBorder}`,
|
||||
borderRadius: 8,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.10)',
|
||||
fontSize: 12,
|
||||
height: 36,
|
||||
color: colors.textColor,
|
||||
});
|
||||
|
||||
const captionStyle = (colors: WhiteboardThemeColors): CSSProperties => ({
|
||||
color: colors.mutedColor,
|
||||
padding: '0 10px',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
|
||||
const dividerStyle = (colors: WhiteboardThemeColors): CSSProperties => ({
|
||||
width: 1,
|
||||
height: 20,
|
||||
background: colors.dividerColor,
|
||||
margin: '0 4px',
|
||||
});
|
||||
|
||||
export const ActionPanel = ({ position = 'bottom-center', caption, children }: Props) => {
|
||||
const ctx = useWhiteboardContext();
|
||||
const colors = useMemo(() => whiteboardColors(ctx.themeId), [ctx.themeId]);
|
||||
const style: CSSProperties = { ...baseStyle(colors), ...positionStyles[position] };
|
||||
return (
|
||||
<div style={style}>
|
||||
{caption ? (
|
||||
<>
|
||||
<div style={captionStyle(colors)}>{caption}</div>
|
||||
<div style={dividerStyle(colors)} />
|
||||
</>
|
||||
) : null}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const buttonBase = (colors: WhiteboardThemeColors): CSSProperties => ({
|
||||
height: 28,
|
||||
padding: '0 10px',
|
||||
fontSize: 12,
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: colors.textColor,
|
||||
cursor: 'pointer',
|
||||
borderRadius: 6,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
|
||||
interface ActionButtonProps {
|
||||
onClick: ()=> void;
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
title?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const ActionButton = ({ onClick, active, disabled, title, children }: ActionButtonProps) => {
|
||||
const ctx = useWhiteboardContext();
|
||||
const colors = useMemo(() => whiteboardColors(ctx.themeId), [ctx.themeId]);
|
||||
// "Active" tint is a translucent brand blue overlay so it reads on both
|
||||
// light and dark backgrounds without needing a second hex value.
|
||||
const activeBg = 'rgba(74, 144, 226, 0.18)';
|
||||
const activeFg = '#2766b8';
|
||||
const hoverBg = 'rgba(127,127,127,0.12)';
|
||||
const style: CSSProperties = {
|
||||
...buttonBase(colors),
|
||||
background: active ? activeBg : 'transparent',
|
||||
color: active ? activeFg : colors.textColor,
|
||||
opacity: disabled ? 0.45 : 1,
|
||||
cursor: disabled ? 'default' : 'pointer',
|
||||
};
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
// Many ActionButtons render only a glyph (—, →, ↔ …) whose Unicode
|
||||
// name isn't a useful accessible name. Mirror `title` into
|
||||
// aria-label so screen readers and voice-control users get the
|
||||
// human-readable label the tooltip already shows.
|
||||
aria-label={title}
|
||||
aria-pressed={active}
|
||||
style={style}
|
||||
onMouseEnter={e => { if (!disabled && !active) (e.currentTarget.style.background = hoverBg); }}
|
||||
onMouseLeave={e => { if (!active) (e.currentTarget.style.background = 'transparent'); }}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export const ActionDivider = () => {
|
||||
const ctx = useWhiteboardContext();
|
||||
const colors = useMemo(() => whiteboardColors(ctx.themeId), [ctx.themeId]);
|
||||
return <div style={dividerStyle(colors)} />;
|
||||
};
|
||||
|
||||
const inputStyle = (colors: WhiteboardThemeColors): CSSProperties => ({
|
||||
height: 24,
|
||||
padding: '0 8px',
|
||||
fontSize: 12,
|
||||
border: `1px solid ${colors.cardBorder}`,
|
||||
borderRadius: 4,
|
||||
margin: '0 4px',
|
||||
background: colors.cardBackground,
|
||||
color: colors.textColor,
|
||||
// Keep the browser's default focus ring for keyboard accessibility — do
|
||||
// not set `outline: 'none'`.
|
||||
});
|
||||
|
||||
interface ActionInputProps {
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
width?: number;
|
||||
onChange: (value: string)=> void;
|
||||
}
|
||||
|
||||
export const ActionInput = ({ value, placeholder, width = 140, onChange }: ActionInputProps) => {
|
||||
const ctx = useWhiteboardContext();
|
||||
const colors = useMemo(() => whiteboardColors(ctx.themeId), [ctx.themeId]);
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
// Without a visible <label> the placeholder is the only label a
|
||||
// screen reader / voice-control user has to identify the field.
|
||||
aria-label={placeholder}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
style={{ ...inputStyle(colors), width }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
import { MarkupLanguage } from '@joplin/renderer';
|
||||
import { ResourceInfos } from '../../utils/types';
|
||||
import { MarkupToHtmlOptions } from '../../../hooks/useMarkupToHtml';
|
||||
import { RenderResult } from '@joplin/renderer/types';
|
||||
|
||||
export interface WhiteboardContextValue {
|
||||
markupToHtml: (markupLanguage: MarkupLanguage, md: string, options?: MarkupToHtmlOptions)=> Promise<RenderResult>;
|
||||
resourceInfos: ResourceInfos;
|
||||
resourceDirectory: string;
|
||||
themeId: number;
|
||||
onOpenRef: (ref: string)=> void;
|
||||
onUpdateNode: (canvasNodeId: string, patch: Record<string, unknown>)=> void;
|
||||
onPromoteTextNode: (canvasNodeId: string)=> void;
|
||||
}
|
||||
|
||||
export const WhiteboardContext = createContext<WhiteboardContextValue | null>(null);
|
||||
|
||||
export const useWhiteboardContext = () => {
|
||||
const ctx = useContext(WhiteboardContext);
|
||||
if (!ctx) throw new Error('WhiteboardContext used outside provider');
|
||||
return ctx;
|
||||
};
|
||||
@@ -0,0 +1,197 @@
|
||||
import * as React from 'react';
|
||||
import { ForwardedRef, forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
|
||||
import { NoteBodyEditorProps, NoteBodyEditorRef } from '../../utils/types';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import { Canvas, CanvasNode, FileCanvasNode, TextCanvasNode } from '@joplin/lib/services/whiteboard/jsoncanvas';
|
||||
import { parseWhiteboard } from '@joplin/lib/services/whiteboard/parse';
|
||||
import { serializeWhiteboard } from '@joplin/lib/services/whiteboard/serialize';
|
||||
import { themeStyle } from '@joplin/lib/theme';
|
||||
import { WhiteboardContext } from './WhiteboardContext';
|
||||
import WhiteboardSurface from './WhiteboardSurface';
|
||||
|
||||
const SAVE_DEBOUNCE_MS = 400;
|
||||
|
||||
const WhiteboardEditor = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditorRef>) => {
|
||||
const bodyRef = useRef(props.content);
|
||||
bodyRef.current = props.content;
|
||||
|
||||
const initialParse = useMemo(() => parseWhiteboard(props.content), [props.content]);
|
||||
const initialCanvas = initialParse.canvas;
|
||||
const parseError = initialParse.parseError;
|
||||
const [canvas, setCanvas] = useState<Canvas>(initialCanvas);
|
||||
// Mirror the canvas state in a ref so async handlers can read the latest
|
||||
// version without going through a setCanvas updater (which must stay pure).
|
||||
const canvasRef = useRef<Canvas>(initialCanvas);
|
||||
canvasRef.current = canvas;
|
||||
|
||||
// Debounced save. We split "schedule" from "unmount" cleanup because the
|
||||
// effect's normal cleanup runs whenever `canvas` changes — that's a
|
||||
// re-schedule, not a reason to drop the pending save. Only an actual
|
||||
// unmount (or an explicit caller via `content()`) should flush.
|
||||
const lastSerializedRef = useRef<string>(JSON.stringify(canvas));
|
||||
const pendingSerializedRef = useRef<string | null>(null);
|
||||
const pendingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const onChangeRef = useRef(props.onChange);
|
||||
onChangeRef.current = props.onChange;
|
||||
|
||||
// Reload when the body switches to a different note, or when the body has
|
||||
// changed underneath us (external write — e.g. the "add note to whiteboard"
|
||||
// command — which produces a body we didn't emit).
|
||||
const lastEmittedBodyRef = useRef<string>(props.content);
|
||||
useEffect(() => {
|
||||
if (props.content === lastEmittedBodyRef.current) return;
|
||||
lastEmittedBodyRef.current = props.content;
|
||||
const parsed = parseWhiteboard(props.content);
|
||||
setCanvas(parsed.canvas);
|
||||
// Mark the freshly-loaded canvas as already-synced so the debounced
|
||||
// save effect doesn't echo it straight back as a write.
|
||||
lastSerializedRef.current = JSON.stringify(parsed.canvas);
|
||||
}, [props.content, props.contentKey]);
|
||||
|
||||
const flushPendingSave = useCallback((): string => {
|
||||
if (pendingTimeoutRef.current !== null) {
|
||||
clearTimeout(pendingTimeoutRef.current);
|
||||
pendingTimeoutRef.current = null;
|
||||
}
|
||||
const serialized = pendingSerializedRef.current;
|
||||
if (serialized === null) return bodyRef.current;
|
||||
pendingSerializedRef.current = null;
|
||||
lastSerializedRef.current = serialized;
|
||||
const newBody = serializeWhiteboard(bodyRef.current, JSON.parse(serialized) as Canvas);
|
||||
bodyRef.current = newBody;
|
||||
lastEmittedBodyRef.current = newBody;
|
||||
onChangeRef.current({ changeId: null, content: newBody });
|
||||
return newBody;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Never write back when the source body had an unparseable fence —
|
||||
// otherwise opening a corrupt note would silently overwrite the
|
||||
// user's recoverable JSON with an empty canvas.
|
||||
if (parseError) return undefined;
|
||||
const serialized = JSON.stringify(canvas);
|
||||
if (serialized === lastSerializedRef.current) return undefined;
|
||||
pendingSerializedRef.current = serialized;
|
||||
// Replace any prior pending timeout — we'll re-schedule from the new
|
||||
// canvas. Crucially we do NOT clear the pending serialised payload
|
||||
// here, so the unmount-flush effect can still see it.
|
||||
if (pendingTimeoutRef.current !== null) clearTimeout(pendingTimeoutRef.current);
|
||||
pendingTimeoutRef.current = setTimeout(() => {
|
||||
pendingTimeoutRef.current = null;
|
||||
flushPendingSave();
|
||||
}, SAVE_DEBOUNCE_MS);
|
||||
return undefined;
|
||||
}, [canvas, flushPendingSave, parseError]);
|
||||
|
||||
// Flush on unmount. The empty-deps effect's cleanup only fires once.
|
||||
useEffect(() => {
|
||||
return () => { flushPendingSave(); };
|
||||
}, [flushPendingSave]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
// Callers that read `content()` (e.g. the form-note save flow) get
|
||||
// the latest body even if a debounced save is still pending.
|
||||
content: () => flushPendingSave(),
|
||||
resetScroll: () => { /* not applicable */ },
|
||||
scrollTo: () => { /* not applicable */ },
|
||||
supportsCommand: () => false,
|
||||
execCommand: async () => { /* not applicable */ },
|
||||
}), [flushPendingSave]);
|
||||
|
||||
const onUpdateNode = useCallback((nodeId: string, patch: Record<string, unknown>) => {
|
||||
setCanvas(prev => ({
|
||||
...prev,
|
||||
nodes: prev.nodes.map(n => n.id === nodeId ? { ...n, ...patch } as CanvasNode : n),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const onOpenRef = useCallback((value: string) => {
|
||||
if (!value) return;
|
||||
// `openItem` already handles every supported link form: `:/id`,
|
||||
// `joplin://`, `file://`, any other URL scheme (http/https/mailto/
|
||||
// ftp/...), and shows a user-facing error for unsupported strings.
|
||||
void CommandService.instance().execute('openItem', value);
|
||||
}, []);
|
||||
|
||||
// Promote a text card to a real Joplin note: create a note in the same
|
||||
// folder as the whiteboard, with the card's text as body and its first
|
||||
// non-empty line as title; replace the text node with a file-ref node
|
||||
// pointing at the new note.
|
||||
const onPromoteTextNode = useCallback(async (canvasNodeId: string) => {
|
||||
const noteId = props.noteId;
|
||||
if (!noteId) return;
|
||||
|
||||
// Read the latest canvas state directly — never inside a setCanvas
|
||||
// updater, since updaters must stay pure (React 18 strict mode runs
|
||||
// them twice in dev, which would create the note twice).
|
||||
const node = canvasRef.current.nodes.find(n => n.id === canvasNodeId) as TextCanvasNode | undefined;
|
||||
if (!node || node.type !== 'text') return;
|
||||
|
||||
const parentNote = await Note.load(noteId);
|
||||
if (!parentNote) return;
|
||||
|
||||
const title = (node.text.split('\n').find(l => l.trim().length) || '').replace(/^#+\s*/, '').trim() || 'Untitled';
|
||||
const created = await Note.save({
|
||||
parent_id: parentNote.parent_id,
|
||||
title,
|
||||
body: node.text,
|
||||
});
|
||||
|
||||
// Re-locate the node from the latest state in case it moved/resized
|
||||
// between the promote click and the save returning. If it was deleted
|
||||
// in the meantime, drop the operation silently.
|
||||
const latest = canvasRef.current.nodes.find(n => n.id === canvasNodeId) as TextCanvasNode | undefined;
|
||||
if (!latest || latest.type !== 'text') return;
|
||||
|
||||
const replacement: FileCanvasNode = {
|
||||
id: latest.id,
|
||||
type: 'file',
|
||||
x: latest.x,
|
||||
y: latest.y,
|
||||
width: latest.width,
|
||||
height: latest.height,
|
||||
file: `:/${created.id}`,
|
||||
};
|
||||
setCanvas(curr => ({
|
||||
...curr,
|
||||
nodes: curr.nodes.map(n => n.id === latest.id ? replacement : n),
|
||||
}));
|
||||
}, [props.noteId]);
|
||||
|
||||
const contextValue = useMemo(() => ({
|
||||
markupToHtml: props.markupToHtml,
|
||||
resourceInfos: props.resourceInfos,
|
||||
resourceDirectory: props.resourceDirectory,
|
||||
themeId: props.themeId,
|
||||
onOpenRef,
|
||||
onUpdateNode,
|
||||
onPromoteTextNode,
|
||||
}), [props.markupToHtml, props.resourceInfos, props.resourceDirectory, props.themeId, onOpenRef, onUpdateNode, onPromoteTextNode]);
|
||||
|
||||
if (parseError) {
|
||||
const theme = themeStyle(props.themeId);
|
||||
return (
|
||||
<div style={{ ...props.style, display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, padding: 24, overflow: 'auto' }}>
|
||||
<div style={{ backgroundColor: theme.warningBackgroundColor, color: theme.color, padding: 16, borderRadius: 4, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{ fontWeight: 600 }}>This whiteboard could not be loaded</div>
|
||||
<div style={{ whiteSpace: 'pre-wrap', fontFamily: 'monospace', fontSize: 12 }}>{parseError}</div>
|
||||
<div>Click the eye icon in the toolbar to switch to the Markdown editor and fix the JSON manually.</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ ...props.style, display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
|
||||
<WhiteboardContext.Provider value={contextValue}>
|
||||
<WhiteboardSurface
|
||||
canvas={canvas}
|
||||
onChange={setCanvas}
|
||||
/>
|
||||
</WhiteboardContext.Provider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default forwardRef(WhiteboardEditor);
|
||||
@@ -0,0 +1,340 @@
|
||||
import * as React from 'react';
|
||||
import { CSSProperties, DragEvent as ReactDragEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Background,
|
||||
Connection,
|
||||
ConnectionMode,
|
||||
Controls,
|
||||
Edge,
|
||||
MarkerType,
|
||||
MiniMap,
|
||||
Node,
|
||||
NodeTypes,
|
||||
OnConnect,
|
||||
OnEdgesChange,
|
||||
OnNodesChange,
|
||||
ReactFlow,
|
||||
ReactFlowProvider,
|
||||
applyEdgeChanges,
|
||||
applyNodeChanges,
|
||||
useReactFlow,
|
||||
} from '@xyflow/react';
|
||||
import ensureReactFlowCss, { applyReactFlowTheme } from './loadReactFlowCss';
|
||||
import generateId from '@joplin/lib/services/whiteboard/generateId';
|
||||
import { _, _n } from '@joplin/lib/locale';
|
||||
import { Canvas, CanvasEdge, CanvasNode } from '@joplin/lib/services/whiteboard/jsoncanvas';
|
||||
|
||||
ensureReactFlowCss();
|
||||
import { canvasNodeToFlowNode, canvasToFlow, flowToCanvas, WhiteboardFlowEdge, WhiteboardFlowNode } from './canvasFlow';
|
||||
import TextNode from './nodes/TextNode';
|
||||
import FileNode from './nodes/FileNode';
|
||||
import LinkNode from './nodes/LinkNode';
|
||||
import { ActionButton, ActionDivider, ActionInput, ActionPanel } from './ActionPanel';
|
||||
import { useWhiteboardContext } from './WhiteboardContext';
|
||||
import { whiteboardColors } from './theme';
|
||||
|
||||
// `markerUnits: 'userSpaceOnUse'` keeps the arrowhead at an absolute size,
|
||||
// independent of the edge's stroke width. Without it, selected edges (which
|
||||
// have a thicker stroke) would render a proportionally bigger arrow.
|
||||
const makeArrowMarker = () => ({ type: MarkerType.ArrowClosed, width: 27, height: 27, markerUnits: 'userSpaceOnUse' });
|
||||
|
||||
type ArrowMode = 'none' | 'forward' | 'backward' | 'both' | 'mixed';
|
||||
const arrowModeFor = (e: WhiteboardFlowEdge): Exclude<ArrowMode, 'mixed'> => {
|
||||
const start = !!e.markerStart;
|
||||
const end = !!e.markerEnd;
|
||||
if (start && end) return 'both';
|
||||
if (end) return 'forward';
|
||||
if (start) return 'backward';
|
||||
return 'none';
|
||||
};
|
||||
|
||||
interface Props {
|
||||
canvas: Canvas;
|
||||
onChange: (canvas: Canvas)=> void;
|
||||
}
|
||||
|
||||
const nodeTypes: NodeTypes = {
|
||||
wbText: TextNode as unknown as NodeTypes[string],
|
||||
wbFile: FileNode as unknown as NodeTypes[string],
|
||||
wbLink: LinkNode as unknown as NodeTypes[string],
|
||||
};
|
||||
|
||||
const InnerSurface = ({ canvas, onChange }: Props) => {
|
||||
const ctx = useWhiteboardContext();
|
||||
const colors = useMemo(() => whiteboardColors(ctx.themeId), [ctx.themeId]);
|
||||
|
||||
// Re-apply React Flow's CSS custom properties whenever the theme changes
|
||||
// so edges, minimap, controls and dot grid follow the active Joplin theme.
|
||||
useEffect(() => {
|
||||
applyReactFlowTheme(colors);
|
||||
}, [colors]);
|
||||
|
||||
const containerStyle: CSSProperties = useMemo(() => ({
|
||||
position: 'relative',
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background: colors.surfaceBackground,
|
||||
outline: 'none',
|
||||
}), [colors]);
|
||||
|
||||
const initial = useMemo(() => canvasToFlow(canvas), [canvas]);
|
||||
const [flowNodes, setFlowNodes] = useState<WhiteboardFlowNode[]>(initial.nodes);
|
||||
const [flowEdges, setFlowEdges] = useState<WhiteboardFlowEdge[]>(initial.edges);
|
||||
// JSONCanvas group nodes are not rendered, but we preserve them through
|
||||
// round-trip so importing a canvas from another tool and
|
||||
// re-saving doesn't silently drop them.
|
||||
const preservedGroupsRef = useRef<CanvasNode[]>(initial.preservedGroups);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const rf = useReactFlow();
|
||||
|
||||
// When the incoming canvas changes (note loaded externally), reload state
|
||||
// — but skip if it's the same canvas we just emitted (avoid feedback loops).
|
||||
// Seed with the *round-tripped* serialization so optional edge fields
|
||||
// (fromEnd/toEnd) added by flowToCanvas don't make the very first push-back
|
||||
// effect see the canvas as "different" and emit a spurious onChange.
|
||||
const lastEmittedRef = useRef<string>(JSON.stringify(flowToCanvas(initial.nodes, initial.edges, initial.preservedGroups)));
|
||||
useEffect(() => {
|
||||
const incoming = JSON.stringify(canvas);
|
||||
if (incoming === lastEmittedRef.current) return;
|
||||
const next = canvasToFlow(canvas);
|
||||
setFlowNodes(next.nodes);
|
||||
setFlowEdges(next.edges);
|
||||
preservedGroupsRef.current = next.preservedGroups;
|
||||
// Stamp with the round-tripped form too, for the same reason as the
|
||||
// initial seed above.
|
||||
lastEmittedRef.current = JSON.stringify(flowToCanvas(next.nodes, next.edges, next.preservedGroups));
|
||||
}, [canvas]);
|
||||
|
||||
// Push changes back to the parent whenever the local flow state changes.
|
||||
useEffect(() => {
|
||||
const out = flowToCanvas(flowNodes, flowEdges, preservedGroupsRef.current);
|
||||
const serialized = JSON.stringify(out);
|
||||
if (serialized === lastEmittedRef.current) return;
|
||||
lastEmittedRef.current = serialized;
|
||||
onChange(out);
|
||||
}, [flowNodes, flowEdges, onChange]);
|
||||
|
||||
const onNodesChange: OnNodesChange = useCallback((changes) => {
|
||||
setFlowNodes(prev => applyNodeChanges(changes, prev) as WhiteboardFlowNode[]);
|
||||
}, []);
|
||||
|
||||
const onEdgesChange: OnEdgesChange = useCallback((changes) => {
|
||||
setFlowEdges(prev => applyEdgeChanges(changes, prev) as WhiteboardFlowEdge[]);
|
||||
}, []);
|
||||
|
||||
const onConnect: OnConnect = useCallback((connection: Connection) => {
|
||||
const edge: WhiteboardFlowEdge = {
|
||||
id: generateId(),
|
||||
source: connection.source,
|
||||
target: connection.target,
|
||||
sourceHandle: connection.sourceHandle ?? undefined,
|
||||
targetHandle: connection.targetHandle ?? undefined,
|
||||
markerEnd: makeArrowMarker(),
|
||||
data: {
|
||||
canvasEdge: {
|
||||
id: '',
|
||||
fromNode: connection.source,
|
||||
toNode: connection.target,
|
||||
} as CanvasEdge,
|
||||
},
|
||||
};
|
||||
setFlowEdges(prev => [...prev, edge]);
|
||||
}, []);
|
||||
|
||||
const defaultEdgeOptions = useMemo(() => ({
|
||||
markerEnd: makeArrowMarker(),
|
||||
}), []);
|
||||
|
||||
// Append a freshly-created canvas node to the surface's local flow state.
|
||||
// The render-cycle effect at flowToCanvas → onChange propagates the new
|
||||
// node up to the parent, so we don't need an explicit onAddNode prop.
|
||||
const addCanvasNode = useCallback((n: Exclude<CanvasNode, { type: 'group' }>) => {
|
||||
setFlowNodes(prev => [...prev, canvasNodeToFlowNode(n)]);
|
||||
}, []);
|
||||
|
||||
const onAddText = useCallback(() => {
|
||||
const view = rf.getViewport();
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
const cx = rect ? (rect.width / 2 - view.x) / view.zoom : 0;
|
||||
const cy = rect ? (rect.height / 2 - view.y) / view.zoom : 0;
|
||||
addCanvasNode({
|
||||
id: generateId(),
|
||||
type: 'text',
|
||||
x: cx - 100,
|
||||
y: cy - 50,
|
||||
width: 200,
|
||||
height: 100,
|
||||
text: _('New text card'),
|
||||
});
|
||||
}, [rf, addCanvasNode]);
|
||||
|
||||
// Selection summaries for the action panels.
|
||||
const selectedEdges = useMemo(() => flowEdges.filter(e => e.selected), [flowEdges]);
|
||||
const selectedNodes = useMemo(() => flowNodes.filter(n => n.selected), [flowNodes]);
|
||||
|
||||
// Edges fed to React Flow. For selected edges we override marker colour
|
||||
// to match the selection blue — markers are SVG <marker> defs that don't
|
||||
// inherit stroke colour from the edge path automatically.
|
||||
const SELECTED_EDGE_COLOR = '#4a90e2';
|
||||
const renderedEdges = useMemo<WhiteboardFlowEdge[]>(() => {
|
||||
return flowEdges.map(e => {
|
||||
if (!e.selected) return e;
|
||||
const tint = (m: WhiteboardFlowEdge['markerEnd']) =>
|
||||
(m && typeof m === 'object') ? { ...m, color: SELECTED_EDGE_COLOR } : m;
|
||||
return { ...e, markerEnd: tint(e.markerEnd), markerStart: tint(e.markerStart) };
|
||||
});
|
||||
}, [flowEdges]);
|
||||
|
||||
const updateSelectedEdges = useCallback((patch: (e: WhiteboardFlowEdge)=> WhiteboardFlowEdge) => {
|
||||
setFlowEdges(prev => prev.map(e => e.selected ? patch(e) : e));
|
||||
}, []);
|
||||
|
||||
const currentArrowMode: ArrowMode = useMemo(() => {
|
||||
if (!selectedEdges.length) return 'none';
|
||||
const first = arrowModeFor(selectedEdges[0]);
|
||||
for (const e of selectedEdges) if (arrowModeFor(e) !== first) return 'mixed';
|
||||
return first;
|
||||
}, [selectedEdges]);
|
||||
|
||||
const setArrowMode = useCallback((mode: Exclude<ArrowMode, 'mixed'>) => {
|
||||
updateSelectedEdges(e => ({
|
||||
...e,
|
||||
markerStart: (mode === 'backward' || mode === 'both') ? makeArrowMarker() : undefined,
|
||||
markerEnd: (mode === 'forward' || mode === 'both') ? makeArrowMarker() : undefined,
|
||||
}));
|
||||
}, [updateSelectedEdges]);
|
||||
|
||||
const flipDirection = useCallback(() => {
|
||||
updateSelectedEdges(e => ({
|
||||
...e,
|
||||
source: e.target,
|
||||
target: e.source,
|
||||
sourceHandle: e.targetHandle,
|
||||
targetHandle: e.sourceHandle,
|
||||
markerStart: e.markerEnd,
|
||||
markerEnd: e.markerStart,
|
||||
}));
|
||||
}, [updateSelectedEdges]);
|
||||
|
||||
const setEdgeLabel = useCallback((label: string) => {
|
||||
updateSelectedEdges(e => ({ ...e, label }));
|
||||
}, [updateSelectedEdges]);
|
||||
|
||||
const onDragOver = useCallback((e: ReactDragEvent<HTMLDivElement>) => {
|
||||
const types = Array.from(e.dataTransfer.types);
|
||||
if (types.includes('text/x-jop-note-ids') || types.includes('text/x-jop-resource-ids')) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'link';
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onDrop = useCallback((e: ReactDragEvent<HTMLDivElement>) => {
|
||||
const noteIdsRaw = e.dataTransfer.getData('text/x-jop-note-ids');
|
||||
const resourceIdsRaw = e.dataTransfer.getData('text/x-jop-resource-ids');
|
||||
if (!noteIdsRaw && !resourceIdsRaw) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const drop = rf.screenToFlowPosition({ x: e.clientX, y: e.clientY });
|
||||
const tryParse = (raw: string): string[] => {
|
||||
if (!raw) return [];
|
||||
try { const v = JSON.parse(raw); return Array.isArray(v) ? v : []; } catch { return []; }
|
||||
};
|
||||
const ids = [
|
||||
...tryParse(noteIdsRaw),
|
||||
...tryParse(resourceIdsRaw),
|
||||
];
|
||||
let offset = 0;
|
||||
for (const id of ids) {
|
||||
addCanvasNode({
|
||||
id: generateId(),
|
||||
type: 'file',
|
||||
x: drop.x - 120 + offset,
|
||||
y: drop.y - 60 + offset,
|
||||
width: 240,
|
||||
height: 160,
|
||||
file: `:/${id}`,
|
||||
});
|
||||
offset += 24;
|
||||
}
|
||||
}, [rf, addCanvasNode]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={containerStyle}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
<ReactFlow
|
||||
nodes={flowNodes as unknown as Node[]}
|
||||
edges={renderedEdges as unknown as Edge[]}
|
||||
nodeTypes={nodeTypes}
|
||||
defaultEdgeOptions={defaultEdgeOptions}
|
||||
connectionMode={ConnectionMode.Loose}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
deleteKeyCode={['Backspace', 'Delete']}
|
||||
multiSelectionKeyCode={['Shift', 'Meta', 'Control']}
|
||||
selectionKeyCode={['Shift']}
|
||||
panOnScroll
|
||||
panOnDrag
|
||||
zoomOnPinch
|
||||
zoomOnScroll={false}
|
||||
fitView={flowNodes.length > 0}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<Background gap={16} size={1} />
|
||||
<Controls showInteractive={false} />
|
||||
<MiniMap pannable zoomable style={{ width: 160, height: 100 }} />
|
||||
|
||||
<ActionPanel position="top-right">
|
||||
<ActionButton onClick={onAddText} title={_('Add a text card')}>{_('+ Text')}</ActionButton>
|
||||
</ActionPanel>
|
||||
|
||||
{selectedEdges.length > 0 ? (
|
||||
<ActionPanel
|
||||
position="bottom-center"
|
||||
caption={_n('%d connection', '%d connections', selectedEdges.length, selectedEdges.length)}
|
||||
>
|
||||
<ActionButton onClick={() => setArrowMode('none')} active={currentArrowMode === 'none'} title={_('No arrow')}>—</ActionButton>
|
||||
<ActionButton onClick={() => setArrowMode('forward')} active={currentArrowMode === 'forward'} title={_('Arrow at target')}>→</ActionButton>
|
||||
<ActionButton onClick={() => setArrowMode('backward')} active={currentArrowMode === 'backward'} title={_('Arrow at source')}>←</ActionButton>
|
||||
<ActionButton onClick={() => setArrowMode('both')} active={currentArrowMode === 'both'} title={_('Bidirectional')}>↔</ActionButton>
|
||||
<ActionDivider />
|
||||
<ActionButton onClick={flipDirection} title={_('Swap source and target')}>{_('Flip')}</ActionButton>
|
||||
{selectedEdges.length === 1 ? (
|
||||
<>
|
||||
<ActionDivider />
|
||||
<ActionInput
|
||||
value={typeof selectedEdges[0].label === 'string' ? selectedEdges[0].label : ''}
|
||||
placeholder={_('Label')}
|
||||
onChange={setEdgeLabel}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</ActionPanel>
|
||||
) : null}
|
||||
|
||||
{selectedNodes.length > 0 && selectedEdges.length === 0 ? (
|
||||
<ActionPanel
|
||||
position="bottom-center"
|
||||
caption={_n('%d card', '%d cards', selectedNodes.length, selectedNodes.length)}
|
||||
>
|
||||
{/* Per-card actions can be added here later (colour, alignment, etc.). */}
|
||||
</ActionPanel>
|
||||
) : null}
|
||||
</ReactFlow>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const WhiteboardSurface = (props: Props) => (
|
||||
<ReactFlowProvider>
|
||||
<InnerSurface {...props} />
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
|
||||
export default WhiteboardSurface;
|
||||
@@ -0,0 +1,137 @@
|
||||
// Translation between the JSONCanvas spec shape and React Flow's
|
||||
// node/edge shape. Pure functions, no React imports.
|
||||
|
||||
import { Edge as FlowEdge, MarkerType, Node as FlowNode } from '@xyflow/react';
|
||||
import { Canvas, CanvasEdge, CanvasNode, CanvasNodeSide } from '@joplin/lib/services/whiteboard/jsoncanvas';
|
||||
|
||||
export type WhiteboardNodeData = {
|
||||
canvasNode: CanvasNode;
|
||||
};
|
||||
|
||||
export type WhiteboardFlowNode = FlowNode<WhiteboardNodeData>;
|
||||
export type WhiteboardFlowEdge = FlowEdge<{ canvasEdge: CanvasEdge }>;
|
||||
|
||||
// Maps JSONCanvas node types to our React Flow node types. Group nodes are
|
||||
// not rendered, so they're filtered out before this is called.
|
||||
const flowTypeForCanvasType = (type: 'text' | 'file' | 'link'): string => {
|
||||
switch (type) {
|
||||
case 'text': return 'wbText';
|
||||
case 'file': return 'wbFile';
|
||||
case 'link': return 'wbLink';
|
||||
}
|
||||
};
|
||||
|
||||
// Convert a single (non-group) JSONCanvas node into the React Flow shape.
|
||||
// Used both by the bulk converter at load time and by the surface when the
|
||||
// user creates a new node interactively.
|
||||
export const canvasNodeToFlowNode = (n: Exclude<CanvasNode, { type: 'group' }>): WhiteboardFlowNode => ({
|
||||
id: n.id,
|
||||
type: flowTypeForCanvasType(n.type),
|
||||
position: { x: n.x, y: n.y },
|
||||
data: { canvasNode: n },
|
||||
style: { width: n.width, height: n.height },
|
||||
width: n.width,
|
||||
height: n.height,
|
||||
selectable: true,
|
||||
draggable: true,
|
||||
});
|
||||
|
||||
const sideToHandle = (side: CanvasNodeSide | undefined): string | undefined => {
|
||||
if (!side) return undefined;
|
||||
return side; // React Flow handle ids match side names ('top', 'right', 'bottom', 'left').
|
||||
};
|
||||
|
||||
// `markerUnits: 'userSpaceOnUse'` keeps the arrowhead at an absolute size,
|
||||
// independent of the edge's stroke width. Without it, selected edges (which
|
||||
// have a thicker stroke) would render a proportionally bigger arrow.
|
||||
const arrowMarker = () => ({ type: MarkerType.ArrowClosed, width: 27, height: 27, markerUnits: 'userSpaceOnUse' });
|
||||
|
||||
export interface CanvasToFlowResult {
|
||||
nodes: WhiteboardFlowNode[];
|
||||
edges: WhiteboardFlowEdge[];
|
||||
// JSONCanvas group nodes are not rendered by this editor, but we keep them
|
||||
// here so they can be merged back on save and round-trip cleanly.
|
||||
preservedGroups: CanvasNode[];
|
||||
}
|
||||
|
||||
export const canvasToFlow = (canvas: Canvas): CanvasToFlowResult => {
|
||||
const preservedGroups: CanvasNode[] = [];
|
||||
const nodes: WhiteboardFlowNode[] = [];
|
||||
|
||||
for (const n of canvas.nodes) {
|
||||
if (n.type === 'group') {
|
||||
preservedGroups.push(n);
|
||||
continue;
|
||||
}
|
||||
nodes.push(canvasNodeToFlowNode(n));
|
||||
}
|
||||
|
||||
const edges: WhiteboardFlowEdge[] = canvas.edges.map(e => ({
|
||||
id: e.id,
|
||||
source: e.fromNode,
|
||||
target: e.toNode,
|
||||
sourceHandle: sideToHandle(e.fromSide),
|
||||
targetHandle: sideToHandle(e.toSide),
|
||||
label: e.label,
|
||||
data: { canvasEdge: e },
|
||||
type: 'default',
|
||||
markerStart: e.fromEnd === 'arrow' ? arrowMarker() : undefined,
|
||||
markerEnd: e.toEnd === 'none' ? undefined : arrowMarker(),
|
||||
}));
|
||||
|
||||
return { nodes, edges, preservedGroups };
|
||||
};
|
||||
|
||||
const handleToSide = (handle?: string | null): CanvasNodeSide | undefined => {
|
||||
if (handle === 'top' || handle === 'right' || handle === 'bottom' || handle === 'left') return handle;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// Apply React Flow positions back to canvas nodes. Preserves any field we
|
||||
// don't track (color, subpath, etc.) by spreading the original canvasNode
|
||||
// from `data`. Group nodes that were filtered out at load time are merged
|
||||
// back unchanged via `preservedGroups`.
|
||||
export const flowToCanvas = (
|
||||
flowNodes: WhiteboardFlowNode[],
|
||||
flowEdges: WhiteboardFlowEdge[],
|
||||
preservedGroups: CanvasNode[] = [],
|
||||
): Canvas => {
|
||||
const nodes: CanvasNode[] = flowNodes.map(fn => {
|
||||
const orig = fn.data?.canvasNode;
|
||||
const width = (typeof fn.width === 'number' ? fn.width : (typeof fn.style?.width === 'number' ? fn.style.width : orig?.width)) ?? 200;
|
||||
const height = (typeof fn.height === 'number' ? fn.height : (typeof fn.style?.height === 'number' ? fn.style.height : orig?.height)) ?? 100;
|
||||
const base = {
|
||||
id: fn.id,
|
||||
x: fn.position.x,
|
||||
y: fn.position.y,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
if (!orig) {
|
||||
return { ...base, type: 'text', text: '' };
|
||||
}
|
||||
return { ...orig, ...base };
|
||||
});
|
||||
|
||||
// Re-attach preserved group nodes so a round-trip doesn't drop them.
|
||||
for (const g of preservedGroups) nodes.push(g);
|
||||
|
||||
const edges: CanvasEdge[] = flowEdges.map(fe => {
|
||||
const orig = fe.data?.canvasEdge;
|
||||
const fromEnd: CanvasEdge['fromEnd'] = fe.markerStart ? 'arrow' : 'none';
|
||||
const toEnd: CanvasEdge['toEnd'] = fe.markerEnd ? 'arrow' : 'none';
|
||||
return {
|
||||
id: fe.id,
|
||||
fromNode: fe.source,
|
||||
toNode: fe.target,
|
||||
fromSide: handleToSide(fe.sourceHandle),
|
||||
toSide: handleToSide(fe.targetHandle),
|
||||
label: typeof fe.label === 'string' ? fe.label : (orig?.label),
|
||||
...(orig?.color ? { color: orig.color } : {}),
|
||||
fromEnd,
|
||||
toEnd,
|
||||
};
|
||||
});
|
||||
|
||||
return { nodes, edges };
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
// Joplin's desktop build doesn't run CSS imports through a loader, so we
|
||||
// inject style sheets at runtime as <style> tags.
|
||||
//
|
||||
// - `injectStyle(id, css)` is a one-shot, idempotent injection.
|
||||
// - `replaceStyle(id, css)` updates an existing <style> in place — for
|
||||
// theme-dependent CSS that needs to change when the theme changes.
|
||||
|
||||
const injectStyle = (id: string, css: string) => {
|
||||
if (typeof document === 'undefined') return;
|
||||
if (document.getElementById(id)) return;
|
||||
const el = document.createElement('style');
|
||||
el.id = id;
|
||||
el.textContent = css;
|
||||
document.head.appendChild(el);
|
||||
};
|
||||
|
||||
export const replaceStyle = (id: string, css: string) => {
|
||||
if (typeof document === 'undefined') return;
|
||||
let el = document.getElementById(id) as HTMLStyleElement | null;
|
||||
if (!el) {
|
||||
el = document.createElement('style');
|
||||
el.id = id;
|
||||
document.head.appendChild(el);
|
||||
}
|
||||
el.textContent = css;
|
||||
};
|
||||
|
||||
export default injectStyle;
|
||||
@@ -0,0 +1,92 @@
|
||||
// React Flow ships CSS as a separate file. Joplin's desktop build doesn't run
|
||||
// CSS imports through a loader, so we read the stylesheet at runtime and
|
||||
// inject it into the document head once. We also expose a theme-aware
|
||||
// override that overrides React Flow's CSS custom properties (--xy-*) so
|
||||
// edges, minimap and dot grid follow the active Joplin theme.
|
||||
|
||||
import injectStyle, { replaceStyle } from './injectStyle';
|
||||
import { SELECTION_COLOR, WhiteboardThemeColors } from './theme';
|
||||
|
||||
const STYLE_ELEMENT_ID = 'whiteboard-react-flow-css';
|
||||
const THEME_STYLE_ELEMENT_ID = 'whiteboard-react-flow-theme';
|
||||
|
||||
let injected = false;
|
||||
|
||||
const ensureReactFlowCss = () => {
|
||||
if (injected) return;
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
try {
|
||||
// require() at runtime so this resolves through Node, which is fine in
|
||||
// Electron's renderer process.
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const fs = require('fs');
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const path = require('path');
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const pkgPath = require.resolve('@xyflow/react/package.json');
|
||||
const cssPath = path.join(path.dirname(pkgPath), 'dist', 'style.css');
|
||||
const baseCss = fs.readFileSync(cssPath, 'utf8');
|
||||
|
||||
// React Flow base styles, then our overrides. Selected edges should
|
||||
// stand out as clearly as selected cards, and connection handles are
|
||||
// hidden until hover/selection so the canvas isn't littered with dots.
|
||||
const overrides = `
|
||||
.react-flow__edge.selected .react-flow__edge-path,
|
||||
.react-flow__edge:focus .react-flow__edge-path,
|
||||
.react-flow__edge:focus-visible .react-flow__edge-path {
|
||||
stroke: ${SELECTION_COLOR} !important;
|
||||
stroke-width: 2 !important;
|
||||
}
|
||||
.react-flow__edge.selected .react-flow__edge-textbg {
|
||||
fill: ${SELECTION_COLOR};
|
||||
}
|
||||
.react-flow__edge.selected .react-flow__edge-text {
|
||||
fill: #ffffff;
|
||||
}
|
||||
.react-flow__node .react-flow__handle {
|
||||
opacity: 0;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
.react-flow__node:hover .react-flow__handle,
|
||||
.react-flow__node.selected .react-flow__handle,
|
||||
.react-flow__handle.connectingfrom,
|
||||
.react-flow__handle.connectingto {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
injectStyle(STYLE_ELEMENT_ID, `${baseCss}${overrides}`);
|
||||
injected = true;
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to load React Flow CSS', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Apply the active Joplin theme to React Flow by setting its `--xy-*` CSS
|
||||
// custom properties at the `.react-flow` root. Re-injected on every theme
|
||||
// change so dark mode actually looks dark. Scoped via a class so we don't
|
||||
// accidentally affect other React Flow instances if Joplin ever embeds one.
|
||||
export const applyReactFlowTheme = (colors: WhiteboardThemeColors) => {
|
||||
const css = `
|
||||
.react-flow {
|
||||
--xy-background-color-default: ${colors.surfaceBackground};
|
||||
--xy-background-pattern-dots-color-default: ${colors.dividerColor};
|
||||
--xy-edge-stroke-default: ${colors.dividerColor};
|
||||
--xy-edge-stroke-selected-default: ${SELECTION_COLOR};
|
||||
--xy-connectionline-stroke-default: ${colors.handleColor};
|
||||
--xy-attribution-background-color-default: ${colors.cardBackground};
|
||||
--xy-minimap-background-color-default: ${colors.cardBackground};
|
||||
--xy-minimap-mask-background-color-default: ${colors.surfaceBackground};
|
||||
--xy-minimap-node-background-color-default: ${colors.handleColor};
|
||||
--xy-node-color-default: ${colors.textColor};
|
||||
--xy-node-background-color-default: ${colors.cardBackground};
|
||||
--xy-controls-button-background-color-default: ${colors.cardBackground};
|
||||
--xy-controls-button-color-default: ${colors.textColor};
|
||||
--xy-controls-button-border-color-default: ${colors.dividerColor};
|
||||
}
|
||||
`;
|
||||
replaceStyle(THEME_STYLE_ELEMENT_ID, css);
|
||||
};
|
||||
|
||||
export default ensureReactFlowCss;
|
||||
@@ -0,0 +1,278 @@
|
||||
import * as React from 'react';
|
||||
import { CSSProperties, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Handle, NodeProps, NodeResizer } from '@xyflow/react';
|
||||
import { pathToFileURL } from 'url';
|
||||
import { MarkupLanguage } from '@joplin/renderer';
|
||||
import BaseItem from '@joplin/lib/models/BaseItem';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import ItemChange from '@joplin/lib/models/ItemChange';
|
||||
import { ModelType } from '@joplin/lib/BaseModel';
|
||||
import attachedResources from '@joplin/lib/utils/attachedResources';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import { resourceFullPath } from '@joplin/lib/models/utils/resourceUtils';
|
||||
import { ResourceEntity } from '@joplin/lib/services/database/types';
|
||||
import { FileCanvasNode } from '@joplin/lib/services/whiteboard/jsoncanvas';
|
||||
import { isInternalRef, RefKind, resolveFileRef } from '@joplin/lib/services/whiteboard/resolveRef';
|
||||
import { useWhiteboardContext } from '../WhiteboardContext';
|
||||
import { WhiteboardNodeData } from '../canvasFlow';
|
||||
import useCheckboxToggle from '../useCheckboxToggle';
|
||||
import { whiteboardColors } from '../theme';
|
||||
import { bodyStyle, cardStyle, handlePositions, headerStyle } from './sharedStyles';
|
||||
|
||||
const logger = Logger.create('WhiteboardFileNode');
|
||||
|
||||
// Header showing the linked note's title — replaces the generic "NOTE" badge
|
||||
// when we know the title. Truncated with ellipsis on overflow.
|
||||
const noteHeaderStyle = (textColor: string, dividerColor: string): CSSProperties => ({
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: textColor,
|
||||
padding: '5px 8px',
|
||||
borderBottom: `1px solid ${dividerColor}`,
|
||||
flexShrink: 0,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
});
|
||||
|
||||
// Build a file:// URL pointing at the resource's blob on disk. Delegates
|
||||
// the path-on-disk computation to resourceFullPath so we get the same
|
||||
// extension logic as the rest of Joplin (file_extension first, then a
|
||||
// mime → extension fallback for resources missing an explicit extension).
|
||||
// pathToFileURL handles Windows separators and special-character encoding.
|
||||
const resourceUrlFor = (resource: ResourceEntity | null, resourceDirectory: string): string | null => {
|
||||
if (!resource || !resourceDirectory) return null;
|
||||
return pathToFileURL(resourceFullPath(resource, resourceDirectory)).href;
|
||||
};
|
||||
|
||||
interface ResolvedItem {
|
||||
kind: 'note' | 'resource' | 'unknown';
|
||||
title: string;
|
||||
body?: string;
|
||||
// Note metadata used to gate writes from this card (e.g. checkbox
|
||||
// toggling) and to enable conflict detection on save.
|
||||
userUpdatedTime?: number;
|
||||
deletedTime?: number;
|
||||
// The full resource entity for `kind: 'resource'` items, so we can pass
|
||||
// it straight to resourceFullPath / resourceFilename (which know how to
|
||||
// fall back from missing file_extension to a mime-derived one).
|
||||
resource?: ResourceEntity;
|
||||
}
|
||||
|
||||
const useResolvedRef = (file: string): { resolved: ResolvedItem | null; refetch: ()=> void } => {
|
||||
const [resolved, setResolved] = useState<ResolvedItem | null>(null);
|
||||
const [refetchCount, setRefetchCount] = useState(0);
|
||||
const lastLoadedFileRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const ref = resolveFileRef(file);
|
||||
if (ref.kind === RefKind.External) {
|
||||
setResolved(null);
|
||||
lastLoadedFileRef.current = null;
|
||||
return undefined;
|
||||
}
|
||||
// Clear any previously-resolved item before loading when the ref has
|
||||
// changed, so switching from one internal ref to another doesn't show
|
||||
// stale content during the async load. Skip the clear on a refetch
|
||||
// of the same ref (e.g. after a checkbox toggle saves the note) —
|
||||
// otherwise the preview would flicker on every refetch.
|
||||
if (lastLoadedFileRef.current !== file) {
|
||||
setResolved(null);
|
||||
lastLoadedFileRef.current = file;
|
||||
}
|
||||
void (async () => {
|
||||
try {
|
||||
const item = await BaseItem.loadItemById(ref.id);
|
||||
if (cancelled) return;
|
||||
if (!item) {
|
||||
setResolved({ kind: 'unknown', title: file });
|
||||
return;
|
||||
}
|
||||
if (item.type_ === ModelType.Note) {
|
||||
setResolved({
|
||||
kind: 'note',
|
||||
title: item.title || 'Untitled',
|
||||
body: item.body || '',
|
||||
userUpdatedTime: item.user_updated_time,
|
||||
deletedTime: item.deleted_time,
|
||||
});
|
||||
} else if (item.type_ === ModelType.Resource) {
|
||||
setResolved({
|
||||
kind: 'resource',
|
||||
title: item.title || file,
|
||||
resource: item as ResourceEntity,
|
||||
});
|
||||
} else {
|
||||
setResolved({ kind: 'unknown', title: file });
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setResolved({ kind: 'unknown', title: file });
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [file, refetchCount]);
|
||||
|
||||
return { resolved, refetch: () => setRefetchCount(c => c + 1) };
|
||||
};
|
||||
|
||||
const FileNode = ({ data, selected }: NodeProps<{ id: string; type: 'wbFile'; data: WhiteboardNodeData; position: { x: number; y: number } }>) => {
|
||||
const ctx = useWhiteboardContext();
|
||||
const node = data.canvasNode as FileCanvasNode;
|
||||
const colors = useMemo(() => whiteboardColors(ctx.themeId), [ctx.themeId]);
|
||||
|
||||
const onDoubleClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
ctx.onOpenRef(node.file);
|
||||
}, [ctx, node.file]);
|
||||
|
||||
const { resolved, refetch } = useResolvedRef(node.file);
|
||||
// Internal refs go through the resolved resource (which carries mime +
|
||||
// file_extension from the database). External refs may already be URLs
|
||||
// (http/https/file), in which case we use them as-is for rendering;
|
||||
// bare paths from other tools can't be resolved here so we leave url null and fall
|
||||
// back to the text branch.
|
||||
const isInternal = isInternalRef(node.file);
|
||||
const url = isInternal
|
||||
? resourceUrlFor(resolved?.resource ?? null, ctx.resourceDirectory)
|
||||
: (/^(https?:|file:)\/\//i.test(node.file) ? node.file : null);
|
||||
const mime = resolved?.resource?.mime;
|
||||
const isPdf = isInternal
|
||||
? mime === 'application/pdf'
|
||||
: /\.pdf(\?|$|#)/i.test(node.file);
|
||||
const isImage = isInternal
|
||||
? !!mime?.startsWith('image/')
|
||||
: /\.(png|jpe?g|gif|webp|svg|bmp)(\?|$|#)/i.test(node.file);
|
||||
|
||||
// Render note bodies as compiled HTML, like the TextNode does. Resources
|
||||
// linked from the note body need to be resolved separately — the editor's
|
||||
// own resourceInfos only covers resources of the *whiteboard* note.
|
||||
const [noteHtml, setNoteHtml] = useState<string>('');
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (resolved?.kind !== 'note' || !resolved.body) {
|
||||
setNoteHtml('');
|
||||
return undefined;
|
||||
}
|
||||
void (async () => {
|
||||
try {
|
||||
const linkedResources = await attachedResources(resolved.body);
|
||||
if (cancelled) return;
|
||||
const result = await ctx.markupToHtml(MarkupLanguage.Markdown, resolved.body, {
|
||||
resourceInfos: linkedResources,
|
||||
});
|
||||
if (!cancelled) setNoteHtml(result?.html ?? '');
|
||||
} catch {
|
||||
if (!cancelled) setNoteHtml('');
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [resolved, ctx]);
|
||||
|
||||
// Save the linked note's body when the user toggles a checkbox in its
|
||||
// preview. We rely on the same reload-on-external-change path that lets
|
||||
// other commands (e.g. addNoteToWhiteboard) update notes outside the
|
||||
// editor's own state — once the body is saved, refetching `resolved`
|
||||
// happens via `useResolvedRef` which is keyed on `node.file`.
|
||||
const linkedNoteId = resolved?.kind === 'note' ? resolveFileRef(node.file).id : null;
|
||||
const linkedNoteUserUpdatedTime = resolved?.kind === 'note' ? resolved.userUpdatedTime : undefined;
|
||||
const linkedNoteDeletedTime = resolved?.kind === 'note' ? resolved.deletedTime : undefined;
|
||||
// Per-card in-flight flag. Rapid checkbox toggles can otherwise overwrite
|
||||
// each other because all clicks read from the same stale `resolved.body`
|
||||
// until refetch completes. While a save is pending we drop further
|
||||
// toggles; the user retries once the preview catches up.
|
||||
const savingRef = useRef(false);
|
||||
const onLinkedNoteBodyChange = useCallback(async (newBody: string) => {
|
||||
if (!linkedNoteId) return;
|
||||
// Don't write to deleted (in-trash) notes — Note.save would either
|
||||
// fail or, worse, silently resurrect the note via the timestamp bump.
|
||||
if (linkedNoteDeletedTime) {
|
||||
logger.info(`Ignoring checkbox toggle on deleted note: ${linkedNoteId}`);
|
||||
return;
|
||||
}
|
||||
if (savingRef.current) {
|
||||
logger.info(`Dropped concurrent toggle on ${linkedNoteId} — a save is in flight`);
|
||||
return;
|
||||
}
|
||||
savingRef.current = true;
|
||||
try {
|
||||
// Pass user_updated_time so the save layer can detect concurrent
|
||||
// edits (e.g. the same note open in another window). changeSource
|
||||
// is set explicitly so sync/telemetry can attribute the write.
|
||||
await Note.save(
|
||||
{
|
||||
id: linkedNoteId,
|
||||
body: newBody,
|
||||
...(linkedNoteUserUpdatedTime ? { user_updated_time: linkedNoteUserUpdatedTime } : {}),
|
||||
},
|
||||
{ changeSource: ItemChange.SOURCE_UNSPECIFIED },
|
||||
);
|
||||
refetch();
|
||||
} catch (error) {
|
||||
// Read-only / shared-without-write-permission notes throw here.
|
||||
// Log and leave the preview as-is — the next refetch will revert
|
||||
// the visible checkbox state to match the on-disk body.
|
||||
logger.warn(`Could not save linked note ${linkedNoteId}:`, error);
|
||||
refetch();
|
||||
} finally {
|
||||
savingRef.current = false;
|
||||
}
|
||||
}, [linkedNoteId, linkedNoteUserUpdatedTime, linkedNoteDeletedTime, refetch]);
|
||||
const checkboxRef = useCheckboxToggle({
|
||||
body: resolved?.kind === 'note' ? (resolved.body ?? '') : '',
|
||||
onChange: onLinkedNoteBodyChange,
|
||||
});
|
||||
|
||||
const renderContent = () => {
|
||||
// Image / PDF resource — render directly.
|
||||
if (url && isImage) {
|
||||
return <img src={url} style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain', alignSelf: 'center', flex: 1 }} alt={resolved?.title ?? ''} />;
|
||||
}
|
||||
if (url && isPdf) {
|
||||
return <embed src={url} type="application/pdf" style={{ width: '100%', height: '100%' }} />;
|
||||
}
|
||||
|
||||
// Internal note ref — show the note's title in the header and the body
|
||||
// preview below.
|
||||
if (resolved?.kind === 'note') {
|
||||
return (
|
||||
<>
|
||||
<div style={noteHeaderStyle(colors.textColor, colors.dividerColor)} title={resolved.title}>{resolved.title}</div>
|
||||
<div ref={checkboxRef} className="wb-card-md" style={bodyStyle(colors)} dangerouslySetInnerHTML={{ __html: noteHtml }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Internal resource (non-image / non-pdf) — show its title.
|
||||
if (resolved?.kind === 'resource') {
|
||||
return (
|
||||
<>
|
||||
<div style={headerStyle(colors)}>Resource</div>
|
||||
<div style={bodyStyle(colors)}>{resolved.title}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading or external file path.
|
||||
return (
|
||||
<>
|
||||
<div style={headerStyle(colors)}>{node.file.startsWith(':/') ? 'Note / Resource' : 'File'}</div>
|
||||
<div style={bodyStyle(colors)}>{resolved === null && node.file.startsWith(':/') ? 'Loading…' : node.file}</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<NodeResizer minWidth={80} minHeight={40} isVisible={!!selected} />
|
||||
{handlePositions.map(({ id: hid, position }) => (
|
||||
<Handle key={hid} type="source" position={position} id={hid} style={{ background: colors.handleColor }} />
|
||||
))}
|
||||
<div style={cardStyle(colors, !!selected)} onDoubleClick={onDoubleClick}>
|
||||
{renderContent()}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileNode;
|
||||
@@ -0,0 +1,34 @@
|
||||
import * as React from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { Handle, NodeProps, NodeResizer } from '@xyflow/react';
|
||||
import { LinkCanvasNode } from '@joplin/lib/services/whiteboard/jsoncanvas';
|
||||
import { useWhiteboardContext } from '../WhiteboardContext';
|
||||
import { WhiteboardNodeData } from '../canvasFlow';
|
||||
import { whiteboardColors } from '../theme';
|
||||
import { bodyStyle, cardStyle, handlePositions, headerStyle } from './sharedStyles';
|
||||
|
||||
const LinkNode = ({ data, selected }: NodeProps<{ id: string; type: 'wbLink'; data: WhiteboardNodeData; position: { x: number; y: number } }>) => {
|
||||
const ctx = useWhiteboardContext();
|
||||
const node = data.canvasNode as LinkCanvasNode;
|
||||
const colors = useMemo(() => whiteboardColors(ctx.themeId), [ctx.themeId]);
|
||||
|
||||
const onDoubleClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
ctx.onOpenRef(node.url);
|
||||
}, [ctx, node.url]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<NodeResizer minWidth={80} minHeight={40} isVisible={!!selected} />
|
||||
{handlePositions.map(({ id: hid, position }) => (
|
||||
<Handle key={hid} type="source" position={position} id={hid} style={{ background: colors.handleColor }} />
|
||||
))}
|
||||
<div style={cardStyle(colors, !!selected)} onDoubleClick={onDoubleClick}>
|
||||
<div style={headerStyle(colors)}>Link</div>
|
||||
<div style={{ ...bodyStyle(colors), wordBreak: 'break-all' }}>{node.url}</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LinkNode;
|
||||
@@ -0,0 +1,228 @@
|
||||
import * as React from 'react';
|
||||
import { CSSProperties, KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Handle, NodeProps, NodeResizer } from '@xyflow/react';
|
||||
import { MarkupLanguage } from '@joplin/renderer';
|
||||
import { focus } from '@joplin/lib/utils/focusHandler';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { TextCanvasNode } from '@joplin/lib/services/whiteboard/jsoncanvas';
|
||||
import { useWhiteboardContext, WhiteboardContextValue } from '../WhiteboardContext';
|
||||
import { WhiteboardNodeData } from '../canvasFlow';
|
||||
import useCheckboxToggle from '../useCheckboxToggle';
|
||||
import { replaceStyle } from '../injectStyle';
|
||||
import { SELECTION_COLOR, WhiteboardThemeColors, whiteboardColors } from '../theme';
|
||||
import { cardStyle as baseCardStyle, handlePositions } from './sharedStyles';
|
||||
|
||||
// Text cards put content directly inside the card div (no inner body wrapper)
|
||||
// so we extend the shared card style with text-content tokens.
|
||||
const textCardStyle = (colors: WhiteboardThemeColors, selected: boolean): CSSProperties => ({
|
||||
...baseCardStyle(colors, selected, 'auto'),
|
||||
padding: 8,
|
||||
fontSize: 13,
|
||||
lineHeight: 1.4,
|
||||
});
|
||||
|
||||
const editTextareaStyle = (textColor: string): CSSProperties => ({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
resize: 'none',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.4,
|
||||
color: textColor,
|
||||
background: 'transparent',
|
||||
});
|
||||
|
||||
const renderedHtmlStyle: CSSProperties = {
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
wordBreak: 'break-word',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.4,
|
||||
};
|
||||
|
||||
// Build the theme-dependent stylesheet for in-card markdown rendering. The
|
||||
// renderer's HTML uses note-viewer CSS variables that don't exist outside the
|
||||
// iframe, so we provide our own scoped overrides — and, crucially, keep them
|
||||
// in sync with the active theme so dark mode looks dark.
|
||||
const buildCardMdCss = (colors: WhiteboardThemeColors): string => `
|
||||
.wb-card-md h1 { font-size: 16px; margin: 4px 0; }
|
||||
.wb-card-md h2 { font-size: 15px; margin: 4px 0; }
|
||||
.wb-card-md h3 { font-size: 14px; margin: 3px 0; }
|
||||
.wb-card-md h4, .wb-card-md h5, .wb-card-md h6 { font-size: 13px; margin: 3px 0; }
|
||||
.wb-card-md p { margin: 4px 0; }
|
||||
.wb-card-md ul, .wb-card-md ol { margin: 4px 0; padding-left: 20px; }
|
||||
.wb-card-md li { margin: 2px 0; }
|
||||
.wb-card-md li.md-checkbox, .wb-card-md li.joplin-checkbox { list-style: none; margin-left: -20px; }
|
||||
.wb-card-md li.md-checkbox input[type=checkbox] { margin-right: 6px; vertical-align: middle; }
|
||||
.wb-card-md .checkbox-wrapper { display: inline; }
|
||||
.wb-card-md pre { margin: 4px 0; padding: 6px; font-size: 12px; background: ${colors.codeBackground}; border: 1px solid ${colors.codeBorder}; border-radius: 4px; overflow: auto; color: ${colors.codeColor}; }
|
||||
.wb-card-md code { font-size: 12px; background: ${colors.codeBackground}; color: ${colors.codeColor}; padding: 1px 4px; border-radius: 3px; }
|
||||
.wb-card-md pre code { background: transparent; padding: 0; border: none; }
|
||||
.wb-card-md blockquote { margin: 4px 0; padding-left: 8px; border-left: 3px solid ${colors.blockquoteBorder}; color: ${colors.blockquoteColor}; }
|
||||
.wb-card-md img { max-width: 100%; height: auto; }
|
||||
.wb-card-md table { font-size: 12px; border-collapse: collapse; }
|
||||
.wb-card-md th, .wb-card-md td { padding: 2px 6px; border: 1px solid ${colors.tableBorder}; }
|
||||
.wb-card-md hr { margin: 6px 0; border: none; border-top: 1px solid ${colors.dividerColor}; }
|
||||
.wb-card-md a { color: ${colors.linkColor || SELECTION_COLOR}; }
|
||||
`;
|
||||
|
||||
const useRenderedMarkdown = (md: string, ctx: WhiteboardContextValue) => {
|
||||
const [html, setHtml] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!md) {
|
||||
setHtml('');
|
||||
return undefined;
|
||||
}
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await ctx.markupToHtml(MarkupLanguage.Markdown, md, {
|
||||
resourceInfos: ctx.resourceInfos,
|
||||
});
|
||||
if (!cancelled) setHtml(result?.html ?? '');
|
||||
} catch {
|
||||
if (!cancelled) setHtml('');
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [md, ctx]);
|
||||
|
||||
return html;
|
||||
};
|
||||
|
||||
const TextNode = ({ data, selected, id }: NodeProps<{ id: string; type: 'wbText'; data: WhiteboardNodeData; position: { x: number; y: number } }>) => {
|
||||
const ctx = useWhiteboardContext();
|
||||
const node = data.canvasNode as TextCanvasNode;
|
||||
|
||||
const colors = useMemo(() => whiteboardColors(ctx.themeId), [ctx.themeId]);
|
||||
|
||||
// Re-inject the in-card markdown stylesheet whenever the theme changes
|
||||
// so dark mode swaps in dark code blocks, blockquote tints, etc.
|
||||
useEffect(() => {
|
||||
replaceStyle('wb-card-md-style', buildCardMdCss(colors));
|
||||
}, [colors]);
|
||||
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(node.text);
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editing) setDraft(node.text);
|
||||
}, [editing, node.text]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editing && textareaRef.current) {
|
||||
focus('WhiteboardTextNode::beginEdit', textareaRef.current);
|
||||
textareaRef.current.select();
|
||||
}
|
||||
}, [editing]);
|
||||
|
||||
const html = useRenderedMarkdown(node.text, ctx);
|
||||
|
||||
const beginEdit = useCallback(() => {
|
||||
setDraft(node.text);
|
||||
setEditing(true);
|
||||
}, [node.text]);
|
||||
|
||||
const commit = useCallback(() => {
|
||||
if (!editing) return;
|
||||
if (draft !== node.text) ctx.onUpdateNode(id, { text: draft });
|
||||
setEditing(false);
|
||||
}, [editing, draft, node.text, ctx, id]);
|
||||
|
||||
const cancel = useCallback(() => setEditing(false), []);
|
||||
|
||||
const onKeyDown = useCallback((e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
cancel();
|
||||
} else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
commit();
|
||||
}
|
||||
}, [commit, cancel]);
|
||||
|
||||
const onDoubleClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
beginEdit();
|
||||
}, [beginEdit]);
|
||||
|
||||
// Keyboard equivalent of double-click: when the card is focused (React
|
||||
// Flow makes nodes focusable for tab navigation), Enter or F2 opens the
|
||||
// editor. Skip when already editing — the textarea has its own handler.
|
||||
const onCardKeyDown = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (editing) return;
|
||||
if (e.key === 'Enter' || e.key === 'F2') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
beginEdit();
|
||||
}
|
||||
}, [editing, beginEdit]);
|
||||
|
||||
const onPromote = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
ctx.onPromoteTextNode(id);
|
||||
}, [ctx, id]);
|
||||
|
||||
const onCheckboxToggleBody = useCallback((newBody: string) => {
|
||||
ctx.onUpdateNode(id, { text: newBody });
|
||||
}, [ctx, id]);
|
||||
const checkboxRef = useCheckboxToggle({
|
||||
body: node.text,
|
||||
onChange: onCheckboxToggleBody,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<NodeResizer minWidth={80} minHeight={40} isVisible={selected && !editing} />
|
||||
{handlePositions.map(({ id: hid, position }) => (
|
||||
<Handle key={hid} type="source" position={position} id={hid} style={{ background: colors.handleColor }} />
|
||||
))}
|
||||
<div style={textCardStyle(colors, !!selected)} onDoubleClick={onDoubleClick} onKeyDown={onCardKeyDown}>
|
||||
{editing ? (
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
style={editTextareaStyle(colors.textColor)}
|
||||
value={draft}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={onKeyDown}
|
||||
className="nodrag"
|
||||
/>
|
||||
) : (
|
||||
node.text
|
||||
? <div ref={checkboxRef} className="wb-card-md" style={renderedHtmlStyle} dangerouslySetInnerHTML={{ __html: html }} />
|
||||
: <div style={{ color: colors.mutedColor }}>{_('(empty — double-click to edit)')}</div>
|
||||
)}
|
||||
</div>
|
||||
{selected && !editing && node.text ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPromote}
|
||||
className="nodrag"
|
||||
title={_('Convert this card into a Joplin note')}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -10,
|
||||
right: -10,
|
||||
padding: '2px 8px',
|
||||
fontSize: 11,
|
||||
border: `1px solid ${SELECTION_COLOR}`,
|
||||
borderRadius: 10,
|
||||
background: colors.cardBackground,
|
||||
color: SELECTION_COLOR,
|
||||
cursor: 'pointer',
|
||||
zIndex: 5,
|
||||
}}
|
||||
>
|
||||
{_('Promote to note')}
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TextNode;
|
||||
@@ -0,0 +1,53 @@
|
||||
import { CSSProperties } from 'react';
|
||||
import { Position } from '@xyflow/react';
|
||||
import { WhiteboardThemeColors } from '../theme';
|
||||
|
||||
// Common card styling shared by Text/File/Link nodes. The `overflow` field
|
||||
// varies between cards (auto for scrollable text, hidden for media/link
|
||||
// previews) so it's set per-call.
|
||||
export const cardStyle = (
|
||||
colors: WhiteboardThemeColors,
|
||||
selected: boolean,
|
||||
overflow: CSSProperties['overflow'] = 'hidden',
|
||||
): CSSProperties => ({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
border: selected ? `2px solid ${colors.cardBorderSelected}` : `1px solid ${colors.cardBorder}`,
|
||||
borderRadius: 6,
|
||||
background: colors.cardBackground,
|
||||
overflow,
|
||||
boxShadow: selected ? colors.cardShadowSelected : colors.cardShadow,
|
||||
boxSizing: 'border-box',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
color: colors.textColor,
|
||||
});
|
||||
|
||||
export const headerStyle = (colors: WhiteboardThemeColors): CSSProperties => ({
|
||||
fontSize: 11,
|
||||
color: colors.headerColor,
|
||||
padding: '4px 8px',
|
||||
borderBottom: `1px solid ${colors.dividerColor}`,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
flexShrink: 0,
|
||||
});
|
||||
|
||||
export const bodyStyle = (colors: WhiteboardThemeColors): CSSProperties => ({
|
||||
flex: 1,
|
||||
padding: 8,
|
||||
overflow: 'auto',
|
||||
wordBreak: 'break-word',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.4,
|
||||
color: colors.textColor,
|
||||
});
|
||||
|
||||
// The four sides shared by all node types — used both for rendering source
|
||||
// handles around the perimeter and for routing edges to the right anchor.
|
||||
export const handlePositions: { id: string; position: Position }[] = [
|
||||
{ id: 'top', position: Position.Top },
|
||||
{ id: 'right', position: Position.Right },
|
||||
{ id: 'bottom', position: Position.Bottom },
|
||||
{ id: 'left', position: Position.Left },
|
||||
];
|
||||
@@ -0,0 +1,63 @@
|
||||
import { themeStyle } from '@joplin/lib/theme';
|
||||
|
||||
// The blue accent for "selected" state is shared across selected cards and
|
||||
// edges. Kept as our own constant rather than pulled from the theme so the
|
||||
// selection cue stays consistent across light and dark modes.
|
||||
export const SELECTION_COLOR = '#4a90e2';
|
||||
export const SELECTION_SHADOW = 'rgba(74,144,226,0.25)';
|
||||
|
||||
export interface WhiteboardThemeColors {
|
||||
// Card / panel surfaces.
|
||||
cardBackground: string;
|
||||
cardBorder: string;
|
||||
cardBorderSelected: string;
|
||||
cardShadow: string;
|
||||
cardShadowSelected: string;
|
||||
|
||||
// Card text.
|
||||
textColor: string;
|
||||
mutedColor: string;
|
||||
headerColor: string;
|
||||
|
||||
// Markdown content inside cards.
|
||||
codeBackground: string;
|
||||
codeColor: string;
|
||||
codeBorder: string;
|
||||
blockquoteBorder: string;
|
||||
blockquoteColor: string;
|
||||
tableBorder: string;
|
||||
dividerColor: string;
|
||||
linkColor: string;
|
||||
|
||||
// Surface itself (the canvas background) and React Flow handles.
|
||||
surfaceBackground: string;
|
||||
handleColor: string;
|
||||
}
|
||||
|
||||
// Translate the active Joplin theme into the colour set our whiteboard uses.
|
||||
export const whiteboardColors = (themeId: number): WhiteboardThemeColors => {
|
||||
const theme = themeStyle(themeId);
|
||||
return {
|
||||
cardBackground: theme.backgroundColor,
|
||||
cardBorder: theme.dividerColor,
|
||||
cardBorderSelected: SELECTION_COLOR,
|
||||
cardShadow: '0 1px 3px rgba(0,0,0,0.08)',
|
||||
cardShadowSelected: `0 4px 12px ${SELECTION_SHADOW}`,
|
||||
|
||||
textColor: theme.color,
|
||||
mutedColor: theme.colorFaded || theme.color3 || theme.color,
|
||||
headerColor: theme.colorFaded || theme.color3 || theme.color,
|
||||
|
||||
codeBackground: theme.codeBackgroundColor,
|
||||
codeColor: theme.codeColor,
|
||||
codeBorder: theme.codeBorderColor,
|
||||
blockquoteBorder: theme.dividerColor,
|
||||
blockquoteColor: theme.colorFaded || theme.color,
|
||||
tableBorder: theme.dividerColor,
|
||||
dividerColor: theme.dividerColor,
|
||||
linkColor: theme.urlColor,
|
||||
|
||||
surfaceBackground: theme.backgroundColor3 || theme.backgroundColor,
|
||||
handleColor: theme.colorFaded || '#888',
|
||||
};
|
||||
};
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
import { flipNthCheckbox } from './useCheckboxToggle';
|
||||
|
||||
// Joplin's renderer only converts `- [ ]` / `- [x]` items inside bullet lists
|
||||
// into clickable checkboxes — `*`, `+`, and numbered list markers are ignored.
|
||||
// `flipNthCheckbox` must count exactly the same subset so the Nth rendered
|
||||
// checkbox maps to the Nth source-text checkbox.
|
||||
|
||||
describe('flipNthCheckbox', () => {
|
||||
test('flips an unchecked box to checked', () => {
|
||||
expect(flipNthCheckbox('- [ ] todo', 0)).toBe('- [x] todo');
|
||||
});
|
||||
|
||||
test('flips a checked box back to unchecked', () => {
|
||||
expect(flipNthCheckbox('- [x] done', 0)).toBe('- [ ] done');
|
||||
expect(flipNthCheckbox('- [X] done', 0)).toBe('- [ ] done');
|
||||
});
|
||||
|
||||
test('targets the Nth checkbox by zero-based index', () => {
|
||||
const body = '- [ ] one\n- [ ] two\n- [ ] three';
|
||||
expect(flipNthCheckbox(body, 0)).toBe('- [x] one\n- [ ] two\n- [ ] three');
|
||||
expect(flipNthCheckbox(body, 1)).toBe('- [ ] one\n- [x] two\n- [ ] three');
|
||||
expect(flipNthCheckbox(body, 2)).toBe('- [ ] one\n- [ ] two\n- [x] three');
|
||||
});
|
||||
|
||||
test('returns null when index is out of range', () => {
|
||||
expect(flipNthCheckbox('- [ ] only one', 1)).toBeNull();
|
||||
expect(flipNthCheckbox('no checkboxes here', 0)).toBeNull();
|
||||
expect(flipNthCheckbox('', 0)).toBeNull();
|
||||
});
|
||||
|
||||
test('ignores list markers other than `-` to match the renderer', () => {
|
||||
// `*`, `+`, and numbered markers are not turned into checkboxes by
|
||||
// Joplin's renderer, so they must not affect the index either.
|
||||
const body = '* [ ] starred\n+ [ ] plused\n1. [ ] numbered\n- [ ] real';
|
||||
// The only "real" checkbox is index 0 — it's the dash-prefixed one.
|
||||
expect(flipNthCheckbox(body, 0)).toBe('* [ ] starred\n+ [ ] plused\n1. [ ] numbered\n- [x] real');
|
||||
expect(flipNthCheckbox(body, 1)).toBeNull();
|
||||
});
|
||||
|
||||
test('ignores `[ ]` not preceded by a list marker', () => {
|
||||
expect(flipNthCheckbox('Standalone [ ] not a checkbox', 0)).toBeNull();
|
||||
});
|
||||
|
||||
test('handles Windows line endings', () => {
|
||||
const body = '- [ ] one\r\n- [ ] two';
|
||||
expect(flipNthCheckbox(body, 1)).toBe('- [ ] one\r\n- [x] two');
|
||||
});
|
||||
|
||||
test('preserves indentation and the surrounding text', () => {
|
||||
const body = 'Intro paragraph.\n\n - [ ] indented\n - [x] also indented\n\nOutro.';
|
||||
expect(flipNthCheckbox(body, 1)).toBe('Intro paragraph.\n\n - [ ] indented\n - [ ] also indented\n\nOutro.');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
// Joplin's renderer emits `<input type="checkbox">` elements for `- [ ]` /
|
||||
// `- [x]` markdown syntax, with an inline onclick that calls
|
||||
// `ipcProxySendToHost(...)` — that handler doesn't exist outside the note
|
||||
// viewer iframe, so clicks are no-ops in our card context.
|
||||
//
|
||||
// This hook returns a callback ref. Attach it to the container that holds
|
||||
// the rendered HTML (`<div ref={checkboxRef} dangerouslySetInnerHTML={...} />`).
|
||||
// On every change to that container's children, the hook rewires checkboxes
|
||||
// inside it: it strips the broken inline onclick, makes them enabled, and
|
||||
// installs a click listener that flips the corresponding `[ ]` / `[x]` in
|
||||
// the source markdown by index.
|
||||
|
||||
interface Options {
|
||||
body: string;
|
||||
onChange: (newBody: string)=> void;
|
||||
}
|
||||
|
||||
// Matches a markdown task-list checkbox. The capture group is the inner
|
||||
// character (' ' or 'x' / 'X'). Joplin's renderer only treats `- [ ]` /
|
||||
// `- [x]` (dash-prefixed list items) as checkboxes — `*` and `+` markers and
|
||||
// numbered lists are ignored — so we match exactly that subset, including
|
||||
// Windows line endings.
|
||||
const checkboxRegex = /(?<=(?:^|\r?\n)[ \t]*-[ \t]+)\[([ xX])\]/g;
|
||||
|
||||
// Exported for tests. Returns the body with the Nth `- [ ]` / `- [x]`
|
||||
// flipped, or null if there's no Nth checkbox.
|
||||
export const flipNthCheckbox = (body: string, index: number): string | null => {
|
||||
let count = 0;
|
||||
let result = '';
|
||||
let lastIndex = 0;
|
||||
let mutated = false;
|
||||
const regex = new RegExp(checkboxRegex.source, 'g');
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = regex.exec(body)) !== null) {
|
||||
if (count === index) {
|
||||
const newChar = match[1] === ' ' ? 'x' : ' ';
|
||||
result += `${body.slice(lastIndex, match.index)}[${newChar}]`;
|
||||
lastIndex = match.index + match[0].length;
|
||||
mutated = true;
|
||||
break;
|
||||
}
|
||||
count++;
|
||||
}
|
||||
if (!mutated) return null;
|
||||
return result + body.slice(lastIndex);
|
||||
};
|
||||
|
||||
const useCheckboxToggle = ({ body, onChange }: Options) => {
|
||||
const bodyRef = useRef(body);
|
||||
bodyRef.current = body;
|
||||
const onChangeRef = useRef(onChange);
|
||||
onChangeRef.current = onChange;
|
||||
|
||||
const observerRef = useRef<MutationObserver | null>(null);
|
||||
// Track which checkbox elements have already been wired up. WeakSet so
|
||||
// removed elements can be GC'd without leaking entries.
|
||||
const wiredRef = useRef<WeakSet<HTMLInputElement>>(new WeakSet());
|
||||
|
||||
const wireUp = useCallback((root: HTMLElement) => {
|
||||
const checkboxes = root.querySelectorAll<HTMLInputElement>('input[type=checkbox]');
|
||||
for (const cb of Array.from(checkboxes)) {
|
||||
if (wiredRef.current.has(cb)) continue;
|
||||
wiredRef.current.add(cb);
|
||||
cb.disabled = false;
|
||||
cb.removeAttribute('disabled');
|
||||
// Strip the renderer's inline onclick — it tries to call
|
||||
// ipcProxySendToHost, which we don't expose in this context.
|
||||
cb.removeAttribute('onclick');
|
||||
(cb as HTMLInputElement & { onclick: unknown }).onclick = null;
|
||||
cb.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
// Recompute the checkbox's current position at click time —
|
||||
// the wire-time index goes stale after DOM insertions or
|
||||
// removals (e.g. an external edit added/removed a list item
|
||||
// before this one).
|
||||
const current = Array.from(root.querySelectorAll<HTMLInputElement>('input[type=checkbox]'));
|
||||
const currentIndex = current.indexOf(cb);
|
||||
if (currentIndex < 0) return;
|
||||
const next = flipNthCheckbox(bodyRef.current, currentIndex);
|
||||
if (next !== null) onChangeRef.current(next);
|
||||
});
|
||||
cb.addEventListener('mousedown', (e) => e.stopPropagation());
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Callback ref: fires whenever the underlying element changes (mount,
|
||||
// unmount, replacement). Using a useCallback identity-stable ref means
|
||||
// React only invokes it when the element actually changes.
|
||||
const refCallback = useCallback((el: HTMLDivElement | null) => {
|
||||
// Tear down previous observer.
|
||||
if (observerRef.current) {
|
||||
observerRef.current.disconnect();
|
||||
observerRef.current = null;
|
||||
}
|
||||
if (!el) return;
|
||||
wireUp(el);
|
||||
const observer = new MutationObserver(() => wireUp(el));
|
||||
observer.observe(el, { childList: true, subtree: true });
|
||||
observerRef.current = observer;
|
||||
}, [wireUp]);
|
||||
|
||||
// Clean up on unmount.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (observerRef.current) {
|
||||
observerRef.current.disconnect();
|
||||
observerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return refCallback;
|
||||
};
|
||||
|
||||
export default useCheckboxToggle;
|
||||
@@ -42,6 +42,8 @@ import ItemChange from '@joplin/lib/models/ItemChange';
|
||||
import PlainEditor from './NoteBody/PlainEditor/PlainEditor';
|
||||
import CodeMirror6 from './NoteBody/CodeMirror/v6/CodeMirror';
|
||||
import CodeMirror5 from './NoteBody/CodeMirror/v5/CodeMirror';
|
||||
import WhiteboardEditor from './NoteBody/WhiteboardEditor/WhiteboardEditor';
|
||||
import { hasWhiteboardFence } from '@joplin/lib/services/whiteboard/parse';
|
||||
import { openItemById } from './utils/contextMenu';
|
||||
import { MarkupLanguage } from '@joplin/renderer';
|
||||
import useScrollWhenReadyOptions from './utils/useScrollWhenReadyOptions';
|
||||
@@ -479,7 +481,23 @@ function NoteEditorContent(props: NoteEditorProps) {
|
||||
|
||||
let editor = null;
|
||||
|
||||
if (builtInEditorVisible) {
|
||||
const noteHasWhiteboardFence = markupLanguage === MarkupLanguage.Markdown
|
||||
&& hasWhiteboardFence(formNote.body);
|
||||
|
||||
const useWhiteboardEditor = builtInEditorVisible
|
||||
&& noteHasWhiteboardFence
|
||||
&& !props.whiteboardForceMarkdown?.[formNote.id];
|
||||
|
||||
// Mirror "active note is a whiteboard" to redux so the NoteToolbar can
|
||||
// show the editor toggle. We can't compute this from the redux note list
|
||||
// because note bodies aren't in the preview fields.
|
||||
useEffect(() => {
|
||||
props.dispatch({ type: 'WHITEBOARD_ACTIVE_NOTE_SET', value: noteHasWhiteboardFence });
|
||||
}, [noteHasWhiteboardFence, props.dispatch]);
|
||||
|
||||
if (useWhiteboardEditor) {
|
||||
editor = <WhiteboardEditor {...editorProps}/>;
|
||||
} else if (builtInEditorVisible) {
|
||||
if (props.bodyEditor === 'TinyMCE') {
|
||||
editor = <TinyMCE {...editorProps}/>;
|
||||
} else if (props.bodyEditor === 'PlainText') {
|
||||
@@ -769,6 +787,7 @@ const mapStateToProps = (state: AppState, ownProps: ConnectProps) => {
|
||||
enableHtmlToMarkdownBanner: state.settings['editor.enableHtmlToMarkdownBanner'],
|
||||
enableInEditorRendering: state.settings['editor.inlineRendering'],
|
||||
showNoteLinkIcon: state.settings['notes.showNoteLinkIcon'],
|
||||
whiteboardForceMarkdown: windowState.whiteboardForceMarkdown ?? {},
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -183,6 +183,31 @@ const declarations: CommandDeclaration[] = [
|
||||
{
|
||||
name: 'viewer.focus',
|
||||
},
|
||||
{
|
||||
name: 'editor.textTable',
|
||||
label: () => _('Insert table'),
|
||||
iconName: 'fas fa-table',
|
||||
},
|
||||
{
|
||||
name: 'editor.tableAddRow',
|
||||
label: () => _('Table: Add row'),
|
||||
iconName: 'fas fa-plus',
|
||||
},
|
||||
{
|
||||
name: 'editor.tableAddColumn',
|
||||
label: () => _('Table: Add column'),
|
||||
iconName: 'fas fa-columns',
|
||||
},
|
||||
{
|
||||
name: 'editor.tableDeleteRow',
|
||||
label: () => _('Table: Delete row'),
|
||||
iconName: 'fas fa-minus',
|
||||
},
|
||||
{
|
||||
name: 'editor.tableDeleteColumn',
|
||||
label: () => _('Table: Delete column'),
|
||||
iconName: 'fas fa-times',
|
||||
},
|
||||
];
|
||||
|
||||
export default declarations;
|
||||
|
||||
@@ -76,6 +76,7 @@ export interface NoteEditorProps {
|
||||
startupPluginsLoaded: boolean;
|
||||
enableHtmlToMarkdownBanner: boolean;
|
||||
showNoteLinkIcon: boolean;
|
||||
whiteboardForceMarkdown: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export interface NoteBodyEditorRef {
|
||||
|
||||
@@ -8,7 +8,8 @@ import { connect } from 'react-redux';
|
||||
import { buildStyle } from '@joplin/lib/theme';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import getActivePluginEditorView from '@joplin/lib/services/plugins/utils/getActivePluginEditorView';
|
||||
import { AppState } from '../../app.reducer';
|
||||
import { stateUtils } from '@joplin/lib/reducer';
|
||||
import { AppState, AppWindowState } from '../../app.reducer';
|
||||
|
||||
interface NoteToolbarProps {
|
||||
themeId: number;
|
||||
@@ -51,6 +52,7 @@ const mapStateToProps = (state: AppState, ownProps: ConnectProps) => {
|
||||
const whenClauseContext = stateToWhenClauseContext(state, { windowId: ownProps.windowId });
|
||||
|
||||
const { editorPlugin } = getActivePluginEditorView(state.pluginService.plugins, ownProps.windowId);
|
||||
const windowState = stateUtils.windowStateById(state, ownProps.windowId) as AppWindowState;
|
||||
|
||||
const commands = [
|
||||
'showSpellCheckerMenu',
|
||||
@@ -59,7 +61,10 @@ const mapStateToProps = (state: AppState, ownProps: ConnectProps) => {
|
||||
'showNoteProperties',
|
||||
];
|
||||
|
||||
if (editorPlugin) commands.push('toggleEditorPlugin');
|
||||
// `toggleEditorPlugin` shows for plugin editors; we extend it to also
|
||||
// toggle the core whiteboard editor on whiteboard notes (see the command's
|
||||
// runtime). The button is the same eye icon either way.
|
||||
if (editorPlugin || windowState.activeNoteIsWhiteboard) commands.push('toggleEditorPlugin');
|
||||
|
||||
return {
|
||||
toolbarButtonInfos: toolbarButtonUtils.commandsToToolbarButtons(commands
|
||||
|
||||
@@ -7,7 +7,7 @@ import { themeStyle } from '@joplin/lib/theme';
|
||||
import bridge from '../services/bridge';
|
||||
import dialogs from './dialogs';
|
||||
import { Profile, ProfileConfig } from '@joplin/lib/services/profileConfig/types';
|
||||
import { deleteProfileById, saveProfileConfig } from '@joplin/lib/services/profileConfig';
|
||||
import { deleteProfileById, isSubProfile, saveProfileConfig } from '@joplin/lib/services/profileConfig';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
@@ -150,18 +150,64 @@ const ProfileEditorComponent: React.FC<Props> = props => {
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
const subProfile = isSubProfile(profile);
|
||||
const rootDir = Setting.value('rootProfileDir');
|
||||
const profileDir = `${rootDir}/profile-${profile.id}`;
|
||||
|
||||
try {
|
||||
await shim.fsDriver().remove(profileDir);
|
||||
logger.info('Deleted profile directory: ', profileDir);
|
||||
} catch (error) {
|
||||
logger.error('Error deleting profile directory: ', error);
|
||||
bridge().showErrorMessageBox(error.message);
|
||||
// Deleting the default profile must be handled differently. We can't delete the whole directory because it contains other profiles and global settings
|
||||
if (subProfile) {
|
||||
const profileDir = `${rootDir}/profile-${profile.id}`;
|
||||
|
||||
try {
|
||||
await shim.fsDriver().remove(profileDir);
|
||||
logger.info('Deleted profile directory: ', profileDir);
|
||||
} catch (error) {
|
||||
logger.error('Error deleting profile directory: ', error);
|
||||
bridge().showErrorMessageBox(error.message);
|
||||
}
|
||||
|
||||
await saveNewProfileConfig(() => deleteProfileById(profileConfig, profile.id));
|
||||
} else {
|
||||
const dirsToDelete = ['cache', 'resources', 'tmp'];
|
||||
const filesToDelete = ['database.sqlite', 'log.txt', 'keymap-desktop.json'];
|
||||
|
||||
// Reset settings for the default profile, but retain global settings
|
||||
try {
|
||||
await Setting.resetDefaultProfileSettings();
|
||||
} catch (error) {
|
||||
// If the first stage fails, nothing has happened, so throw an error. But if there is a failure in later steps, ignore errors but log them
|
||||
logger.error('Error deleting the default profile: ', error);
|
||||
bridge().showErrorMessageBox(error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete directories
|
||||
for (const dir of dirsToDelete) {
|
||||
const fullPath = `${rootDir}/${dir}`;
|
||||
try {
|
||||
if (await shim.fsDriver().exists(fullPath)) {
|
||||
await shim.fsDriver().remove(fullPath);
|
||||
logger.info('Deleted directory: ', fullPath);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error deleting directory: ', fullPath, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete files
|
||||
for (const file of filesToDelete) {
|
||||
const fullPath = `${rootDir}/${file}`;
|
||||
try {
|
||||
if (await shim.fsDriver().exists(fullPath)) {
|
||||
await shim.fsDriver().unlink(fullPath);
|
||||
logger.info('Deleted file: ', fullPath);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error deleting file: ', fullPath, error);
|
||||
}
|
||||
}
|
||||
|
||||
bridge().showMessageBox(_('The default profile has been reset.'));
|
||||
}
|
||||
|
||||
await saveNewProfileConfig(() => deleteProfileById(profileConfig, profile.id));
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import CommandService, { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { ModelType } from '@joplin/lib/BaseModel';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import ItemChange from '@joplin/lib/models/ItemChange';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import { Mode } from '../../../plugins/GotoAnything';
|
||||
import { GotoAnythingOptions, UiType } from './gotoAnything';
|
||||
import { parseWhiteboard } from '@joplin/lib/services/whiteboard/parse';
|
||||
import { serializeWhiteboard } from '@joplin/lib/services/whiteboard/serialize';
|
||||
import { CanvasNode } from '@joplin/lib/services/whiteboard/jsoncanvas';
|
||||
import generateId from '@joplin/lib/services/whiteboard/generateId';
|
||||
|
||||
const logger = Logger.create('addNoteToWhiteboard');
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'addNoteToWhiteboard',
|
||||
label: () => _('Add note to whiteboard...'),
|
||||
};
|
||||
|
||||
// Adds a note (chosen via Goto Anything) as a card on the currently open
|
||||
// whiteboard. The whiteboard is the currently selected note — the command is
|
||||
// only enabled when that note contains a jsoncanvas fence.
|
||||
export const runtime = (): CommandRuntime => {
|
||||
return {
|
||||
execute: async (context: CommandContext) => {
|
||||
const targetId = context.state.selectedNoteIds?.[0];
|
||||
if (!targetId) return;
|
||||
|
||||
// Quick gate before opening the picker — if the active note isn't
|
||||
// a whiteboard, bail out without disturbing the user.
|
||||
const initial = await Note.load(targetId);
|
||||
if (!initial) return;
|
||||
if (!parseWhiteboard(initial.body || '').hasCanvas) {
|
||||
logger.warn('Active note is not a whiteboard:', targetId);
|
||||
return;
|
||||
}
|
||||
|
||||
const options: GotoAnythingOptions = { mode: Mode.TitleOnly };
|
||||
const result = await CommandService.instance().execute('gotoAnything', UiType.ControlledApi, options);
|
||||
if (!result) return;
|
||||
if (result.type !== ModelType.Note) {
|
||||
logger.warn('Selected item is not a note:', result);
|
||||
return;
|
||||
}
|
||||
|
||||
// Reload the note after the picker resolves: between opening the
|
||||
// picker and now, the whiteboard editor (or a sync) may have
|
||||
// written a newer body, and we want to append onto the freshest
|
||||
// persisted state — not the snapshot we read before the prompt.
|
||||
const fresh = await Note.load(targetId);
|
||||
if (!fresh) return;
|
||||
const parsed = parseWhiteboard(fresh.body || '');
|
||||
if (!parsed.hasCanvas) {
|
||||
logger.warn('Active note is no longer a whiteboard:', targetId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Place the new card near the centre of the existing layout, with a
|
||||
// small offset for each subsequent add so cards don't stack exactly.
|
||||
const xs = parsed.canvas.nodes.map(n => n.x + n.width / 2);
|
||||
const ys = parsed.canvas.nodes.map(n => n.y + n.height / 2);
|
||||
const cx = xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : 0;
|
||||
const cy = ys.length ? ys.reduce((a, b) => a + b, 0) / ys.length : 0;
|
||||
const offset = (parsed.canvas.nodes.length % 8) * 24;
|
||||
|
||||
const newNode: CanvasNode = {
|
||||
id: generateId(),
|
||||
type: 'file',
|
||||
x: cx - 120 + offset,
|
||||
y: cy - 80 + offset,
|
||||
width: 240,
|
||||
height: 160,
|
||||
file: `:/${result.item.id}`,
|
||||
};
|
||||
|
||||
const nextCanvas = {
|
||||
...parsed.canvas,
|
||||
nodes: [...parsed.canvas.nodes, newNode],
|
||||
};
|
||||
const newBody = serializeWhiteboard(fresh.body || '', nextCanvas);
|
||||
// Pass user_updated_time so the save layer can detect concurrent
|
||||
// edits (the whiteboard editor's own debounced save, or a sync
|
||||
// write between Note.load above and Note.save here). Mirrors the
|
||||
// linked-note write path in FileNode.tsx.
|
||||
await Note.save(
|
||||
{
|
||||
id: targetId,
|
||||
body: newBody,
|
||||
...(fresh.user_updated_time ? { user_updated_time: fresh.user_updated_time } : {}),
|
||||
},
|
||||
{ changeSource: ItemChange.SOURCE_UNSPECIFIED },
|
||||
);
|
||||
},
|
||||
enabledCondition: 'oneNoteSelected && activeNoteIsWhiteboard && !noteIsReadOnly',
|
||||
};
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
// AUTO-GENERATED using `gulp buildScriptIndexes`
|
||||
import * as addNoteToWhiteboard from './addNoteToWhiteboard';
|
||||
import * as addProfile from './addProfile';
|
||||
import * as commandPalette from './commandPalette';
|
||||
import * as deleteFolder from './deleteFolder';
|
||||
@@ -16,6 +17,7 @@ import * as newFolder from './newFolder';
|
||||
import * as newNote from './newNote';
|
||||
import * as newSubFolder from './newSubFolder';
|
||||
import * as newTodo from './newTodo';
|
||||
import * as newWhiteboard from './newWhiteboard';
|
||||
import * as openFolder from './openFolder';
|
||||
import * as openFolderDialog from './openFolderDialog';
|
||||
import * as openItem from './openItem';
|
||||
@@ -48,8 +50,10 @@ import * as toggleNotesSortOrderReverse from './toggleNotesSortOrderReverse';
|
||||
import * as togglePerFolderSortOrder from './togglePerFolderSortOrder';
|
||||
import * as toggleSideBar from './toggleSideBar';
|
||||
import * as toggleVisiblePanes from './toggleVisiblePanes';
|
||||
import * as toggleWhiteboardEditor from './toggleWhiteboardEditor';
|
||||
|
||||
const index: any[] = [
|
||||
addNoteToWhiteboard,
|
||||
addProfile,
|
||||
commandPalette,
|
||||
deleteFolder,
|
||||
@@ -67,6 +71,7 @@ const index: any[] = [
|
||||
newNote,
|
||||
newSubFolder,
|
||||
newTodo,
|
||||
newWhiteboard,
|
||||
openFolder,
|
||||
openFolderDialog,
|
||||
openItem,
|
||||
@@ -99,6 +104,7 @@ const index: any[] = [
|
||||
togglePerFolderSortOrder,
|
||||
toggleSideBar,
|
||||
toggleVisiblePanes,
|
||||
toggleWhiteboardEditor,
|
||||
];
|
||||
|
||||
export default index;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { runtime } from './newNote';
|
||||
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
|
||||
describe('newNote', () => {
|
||||
beforeEach(async () => {
|
||||
await setupDatabaseAndSynchronizer(1);
|
||||
await switchClient(1);
|
||||
});
|
||||
test.each([
|
||||
[null, null],
|
||||
['order', true],
|
||||
['order', false],
|
||||
])('should create a new note', async (sortOrderField: string, sortOrderReverse: boolean) => {
|
||||
// The command needs an active folder ID.
|
||||
const activeFolder = await Folder.save({ title: 'folder' });
|
||||
const initialNote = await Note.save({ title: 'test', parent_id: activeFolder.id });
|
||||
Setting.setValue('activeFolderId', activeFolder.id);
|
||||
Setting.setValue('notes.sortOrder.field', sortOrderField);
|
||||
Setting.setValue('notes.sortOrder.reverse', sortOrderReverse);
|
||||
|
||||
await runtime().execute(null, 'test note', true);
|
||||
|
||||
// Correct note should have been created
|
||||
const newNote = (await Note.loadByField('body', 'test note'));
|
||||
expect(newNote.body).toEqual('test note');
|
||||
expect(newNote.parent_id).toEqual(activeFolder.id);
|
||||
if (sortOrderField === 'order' && !!sortOrderReverse) {
|
||||
expect(newNote.order).toBeGreaterThanOrEqual(initialNote.order + Note.defaultIntevalBetweenNotes);
|
||||
} else if (sortOrderField === 'order' && !sortOrderReverse) {
|
||||
expect(newNote.order).toBeLessThanOrEqual(initialNote.order - Note.defaultIntevalBetweenNotes);
|
||||
} else {
|
||||
expect(newNote.order).toEqual(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -2,9 +2,57 @@ import { utils, CommandRuntime, CommandDeclaration, CommandContext } from '@jopl
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { NoteEntity } from '@joplin/lib/services/database/types';
|
||||
|
||||
export const newNoteEnabledConditions = 'oneFolderSelected && selectedFolderIsValid && !inConflictFolder && !folderIsReadOnly && !folderIsTrash';
|
||||
|
||||
export interface CreateNoteOptions {
|
||||
body?: string;
|
||||
title?: string;
|
||||
isTodo?: boolean;
|
||||
updateGeolocation?: boolean;
|
||||
}
|
||||
|
||||
// Shared helper used by both `newNote` and any sibling commands that create
|
||||
// a fresh, provisional note in the currently-active folder (e.g. the
|
||||
// whiteboard "New whiteboard" command). Returns null if there's no valid
|
||||
// active folder. Caller is responsible for telling the user the command
|
||||
// was a no-op in that case (it's already gated by `newNoteEnabledConditions`
|
||||
// in practice).
|
||||
export const createNoteInActiveFolder = async (options: CreateNoteOptions = {}): Promise<NoteEntity | null> => {
|
||||
const folder = await Folder.getValidActiveFolder();
|
||||
if (!folder) return null;
|
||||
|
||||
const defaultValues = Note.previewFieldsWithDefaultValues({ includeTimestamps: false });
|
||||
|
||||
let order;
|
||||
if (Setting.value('notes.sortOrder.field') === 'order') {
|
||||
order = await Note.getNextOrderValue(folder.id);
|
||||
}
|
||||
|
||||
const note = await Note.save({
|
||||
...defaultValues,
|
||||
parent_id: folder.id,
|
||||
is_todo: options.isTodo ? 1 : 0,
|
||||
body: options.body ?? '',
|
||||
...(options.title ? { title: options.title } : {}),
|
||||
...(order !== undefined ? { order } : {}),
|
||||
}, { provisional: true });
|
||||
|
||||
if (options.updateGeolocation !== false) {
|
||||
void Note.updateGeolocation(note.id);
|
||||
}
|
||||
|
||||
utils.store.dispatch({ type: 'NOTE_SELECT', id: note.id });
|
||||
|
||||
// Immediately sort the note list so that the new note is positioned
|
||||
// correctly before scrolling to it.
|
||||
utils.store.dispatch({ type: 'NOTE_SORT' });
|
||||
|
||||
return note;
|
||||
};
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'newNote',
|
||||
label: () => _('New note'),
|
||||
@@ -14,29 +62,7 @@ export const declaration: CommandDeclaration = {
|
||||
export const runtime = (): CommandRuntime => {
|
||||
return {
|
||||
execute: async (_context: CommandContext, body = '', isTodo = false) => {
|
||||
const folder = await Folder.getValidActiveFolder();
|
||||
if (!folder) return;
|
||||
|
||||
const defaultValues = Note.previewFieldsWithDefaultValues({ includeTimestamps: false });
|
||||
|
||||
let newNote = { ...defaultValues, parent_id: folder.id,
|
||||
is_todo: isTodo ? 1 : 0,
|
||||
body: body };
|
||||
|
||||
newNote = await Note.save(newNote, { provisional: true });
|
||||
|
||||
void Note.updateGeolocation(newNote.id);
|
||||
|
||||
utils.store.dispatch({
|
||||
type: 'NOTE_SELECT',
|
||||
id: newNote.id,
|
||||
});
|
||||
|
||||
// Immediately sort the note list so that the new note is positioned correctly before
|
||||
// scrolling to it.
|
||||
utils.store.dispatch({
|
||||
type: 'NOTE_SORT',
|
||||
});
|
||||
await createNoteInActiveFolder({ body, isTodo });
|
||||
},
|
||||
enabledCondition: newNoteEnabledConditions,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { newWhiteboardBody } from '@joplin/lib/services/whiteboard/serialize';
|
||||
import { createNoteInActiveFolder, newNoteEnabledConditions } from './newNote';
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'newWhiteboard',
|
||||
label: () => _('Create whiteboard'),
|
||||
iconName: 'fa-th',
|
||||
};
|
||||
|
||||
export const runtime = (): CommandRuntime => {
|
||||
return {
|
||||
execute: async (_context: CommandContext, title?: string) => {
|
||||
await createNoteInActiveFolder({
|
||||
title: title || _('Untitled whiteboard'),
|
||||
body: newWhiteboardBody(),
|
||||
// A whiteboard isn't a place-stamped capture; skip the
|
||||
// reverse-geocode lookup that fires for ordinary new notes.
|
||||
updateGeolocation: false,
|
||||
});
|
||||
},
|
||||
enabledCondition: newNoteEnabledConditions,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'toggleWhiteboardEditor',
|
||||
label: () => _('Toggle whiteboard / Markdown view'),
|
||||
iconName: 'fas fa-eye',
|
||||
};
|
||||
|
||||
export const runtime = (): CommandRuntime => {
|
||||
return {
|
||||
execute: async (context: CommandContext, noteId?: string) => {
|
||||
const id = noteId || context.state.selectedNoteIds?.[0];
|
||||
if (!id) return;
|
||||
context.dispatch({ type: 'WHITEBOARD_FORCE_MARKDOWN_TOGGLE', noteId: id });
|
||||
},
|
||||
enabledCondition: 'oneNoteSelected',
|
||||
};
|
||||
};
|
||||
@@ -4,40 +4,6 @@ import CommandService from '@joplin/lib/services/CommandService';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
|
||||
import StyledInput from '../../style/StyledInput';
|
||||
const styled = require('styled-components').default;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
type StyleProps = any;
|
||||
|
||||
export const Root = styled.div`
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const SearchButton = styled.button`
|
||||
position: absolute;
|
||||
right: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
height: 100%;
|
||||
opacity: ${(props: StyleProps) => props.disabled ? 0.5 : 1};
|
||||
`;
|
||||
|
||||
export const SearchButtonIcon = styled.span`
|
||||
font-size: ${(props: StyleProps) => props.theme.toolbarIconSize}px;
|
||||
color: ${(props: StyleProps) => props.theme.color4};
|
||||
`;
|
||||
|
||||
export const SearchInput = styled(StyledInput)`
|
||||
padding-right: 20px;
|
||||
flex: 1;
|
||||
width: 10px;
|
||||
|
||||
&::-webkit-search-cancel-button {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
@@ -51,11 +17,13 @@ interface Props {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
|
||||
onKeyDown?: Function;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
|
||||
onSearchButtonClick: Function;
|
||||
onSearchButtonClick: ()=> void;
|
||||
searchStarted: boolean;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
inputClassName?: string;
|
||||
'aria-controls'?: string;
|
||||
iconButtonTabIndex?: number;
|
||||
}
|
||||
|
||||
export interface OnChangeEvent {
|
||||
@@ -71,10 +39,14 @@ export default function(props: Props) {
|
||||
props.onChange({ value: event.currentTarget.value });
|
||||
}, [props.onChange]);
|
||||
|
||||
const fieldClassName = ['field'];
|
||||
if (props.inputClassName) fieldClassName.push(props.inputClassName);
|
||||
|
||||
return (
|
||||
<Root>
|
||||
<SearchInput
|
||||
<div className='search-input'>
|
||||
<StyledInput
|
||||
ref={props.inputRef}
|
||||
className={fieldClassName.join(' ')}
|
||||
value={props.value}
|
||||
type="search"
|
||||
placeholder={props.placeholder || _('Search...')}
|
||||
@@ -84,14 +56,19 @@ export default function(props: Props) {
|
||||
onKeyDown={props.onKeyDown}
|
||||
spellCheck={false}
|
||||
disabled={props.disabled}
|
||||
aria-label={props.placeholder || _('Search...')}
|
||||
aria-controls={props['aria-controls']}
|
||||
/>
|
||||
<SearchButton
|
||||
<button
|
||||
type='button'
|
||||
className='button'
|
||||
aria-label={iconLabel}
|
||||
disabled={props.disabled}
|
||||
tabIndex={props.iconButtonTabIndex}
|
||||
onClick={props.onSearchButtonClick}
|
||||
>
|
||||
<SearchButtonIcon className={iconName}/>
|
||||
</SearchButton>
|
||||
</Root>
|
||||
<span className={`icon ${iconName}`}/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
.search-input {
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
|
||||
> .field {
|
||||
padding-right: 20px;
|
||||
flex: 1;
|
||||
width: 10px;
|
||||
|
||||
&::placeholder {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&::-webkit-search-cancel-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.settings {
|
||||
background-color: var(--joplin-background-color4);
|
||||
}
|
||||
}
|
||||
|
||||
> .button {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
height: 100%;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
> .icon {
|
||||
font-size: var(--joplin-toolbar-icon-size);
|
||||
color: var(--joplin-color4);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,9 @@ export default function() {
|
||||
'newNote',
|
||||
'newSubFolder',
|
||||
'newTodo',
|
||||
'newWhiteboard',
|
||||
'toggleWhiteboardEditor',
|
||||
'addNoteToWhiteboard',
|
||||
'openProfileDirectory',
|
||||
'print',
|
||||
'setTags',
|
||||
@@ -66,6 +69,10 @@ export default function() {
|
||||
'editor.sortSelectedLines',
|
||||
'editor.swapLineUp',
|
||||
'editor.swapLineDown',
|
||||
'editor.tableAddRow',
|
||||
'editor.tableAddColumn',
|
||||
'editor.tableDeleteRow',
|
||||
'editor.tableDeleteColumn',
|
||||
'linkToNote',
|
||||
'exportDeletionLog',
|
||||
'toggleSafeMode',
|
||||
|
||||
@@ -220,11 +220,15 @@ test.describe('main', () => {
|
||||
await mainScreen.importHtmlDirectory(electronApp, join(__dirname, 'resources', 'html-import'));
|
||||
const importedFolder = mainScreen.sidebar.container.getByText('html-import');
|
||||
await importedFolder.click();
|
||||
await mainScreen.noteList.focusContent(electronApp);
|
||||
|
||||
const importedNote1 = await mainScreen.noteList.getNoteItemByTitle('test-html-file-with-image');
|
||||
await expect(importedNote1).toBeAttached();
|
||||
const importedNote2 = await mainScreen.noteList.getNoteItemByTitle('test-html-file-2');
|
||||
await expect(importedNote2).toBeAttached();
|
||||
const importedNote1 = mainScreen.noteList.getNoteItemByTitle('test-html-file-with-image');
|
||||
const importedNote2 = mainScreen.noteList.getNoteItemByTitle('test-html-file-2');
|
||||
await expect.poll(async () => importedNote1.count(), { timeout: 60_000 }).toBeGreaterThan(0);
|
||||
await expect.poll(async () => importedNote2.count(), { timeout: 60_000 }).toBeGreaterThan(0);
|
||||
|
||||
await expect(importedNote1).toBeVisible();
|
||||
await expect(importedNote2).toBeVisible();
|
||||
});
|
||||
|
||||
test('should import a single HTML file', async ({ mainWindow, electronApp }) => {
|
||||
@@ -232,8 +236,10 @@ test.describe('main', () => {
|
||||
await mainScreen.waitFor();
|
||||
|
||||
await mainScreen.importHtmlFile(electronApp, join(__dirname, 'resources', 'html-import', 'test-html-file-with-image.html'));
|
||||
const importedNote = await mainScreen.noteList.getNoteItemByTitle('test-html-file-with-image');
|
||||
await expect(importedNote).toBeAttached();
|
||||
|
||||
const importedNote = mainScreen.noteList.getNoteItemByTitle('test-html-file-with-image');
|
||||
await expect.poll(async () => importedNote.count(), { timeout: 60_000 }).toBeGreaterThan(0);
|
||||
await expect(importedNote).toBeVisible({ timeout: 60_000 });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -26,6 +26,10 @@ export default class NoteList {
|
||||
public async focusContent(electronApp: ElectronApplication) {
|
||||
await activateMainMenuItem(electronApp, 'Note list', 'Focus');
|
||||
await expect(this.container.locator(':focus')).toBeAttached();
|
||||
// Wait for the list to have rendered a selected item before the caller
|
||||
// tries to navigate with arrow keys. Without this, the aria-selected
|
||||
// attribute may not be set yet on slow CI runners.
|
||||
await expect(this.container.locator('[aria-selected="true"]')).toBeAttached();
|
||||
}
|
||||
|
||||
// The resultant locator may fail to resolve if the item is not visible
|
||||
|
||||
@@ -34,6 +34,23 @@ const expectNoViolations = async (page: Page) => {
|
||||
// random failure in CI.
|
||||
await expect.poll(async () => {
|
||||
const results = await scanner.analyze();
|
||||
if (results.violations.length > 0) {
|
||||
// Keep CI failures actionable with compact, structured rule/selector details.
|
||||
const violationSummary = results.violations.map(violation => ({
|
||||
rule: violation.id,
|
||||
impact: violation.impact,
|
||||
description: violation.description,
|
||||
help: violation.help,
|
||||
helpUrl: violation.helpUrl,
|
||||
nodes: violation.nodes.map(node => ({
|
||||
target: node.target,
|
||||
failureSummary: node.failureSummary,
|
||||
})),
|
||||
}));
|
||||
|
||||
console.error('WCAG violations detected:');
|
||||
console.error(JSON.stringify(violationSummary, null, 2));
|
||||
}
|
||||
return results.violations;
|
||||
}).toEqual([]);
|
||||
};
|
||||
|
||||
@@ -286,18 +286,18 @@ Component-specific classes
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.master-password-dialog .dialog-root, .enable-encryption-dialog .dialog-root {
|
||||
.master-password-dialog .dialog-root, .enable-encryption-dialog .dialog-root, .disable-encryption-dialog .dialog-root {
|
||||
min-width: 500px;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.master-password-dialog .dialog-content, .enable-encryption-dialog .dialog-content {
|
||||
.master-password-dialog .dialog-content, .enable-encryption-dialog .dialog-content, .disable-encryption-dialog .dialog-content {
|
||||
background-color: var(--joplin-background-color3);
|
||||
padding: 1em;
|
||||
padding-bottom: 1px;
|
||||
}
|
||||
|
||||
.master-password-dialog .current-password-wrapper, .enable-encryption-dialog .current-password-wrapper {
|
||||
.master-password-dialog .current-password-wrapper, .enable-encryption-dialog .current-password-wrapper, .disable-encryption-dialog .current-password-wrapper {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/app-desktop",
|
||||
"version": "3.6.10",
|
||||
"version": "3.6.11",
|
||||
"description": "Joplin for Desktop",
|
||||
"main": "main.bundle.js",
|
||||
"private": true,
|
||||
@@ -140,7 +140,7 @@
|
||||
"homepage": "https://github.com/laurent22/joplin#readme",
|
||||
"devDependencies": {
|
||||
"7zip-bin": "5.2.0",
|
||||
"@axe-core/playwright": "4.11.0",
|
||||
"@axe-core/playwright": "4.11.1",
|
||||
"@electron/notarize": "2.5.0",
|
||||
"@electron/rebuild": "3.7.2",
|
||||
"@fortawesome/fontawesome-free": "5.15.4",
|
||||
@@ -168,7 +168,7 @@
|
||||
"color": "3.2.1",
|
||||
"compare-versions": "6.1.1",
|
||||
"debounce": "1.2.1",
|
||||
"electron": "40.8.3",
|
||||
"electron": "40.9.2",
|
||||
"electron-builder": "24.13.3",
|
||||
"electron-updater": "6.6.8",
|
||||
"electron-window-state": "5.0.3",
|
||||
@@ -208,13 +208,14 @@
|
||||
"taboverride": "4.0.3",
|
||||
"tesseract.js": "6.0.1",
|
||||
"tinymce": "6.8.5",
|
||||
"ts-jest": "29.4.1",
|
||||
"ts-jest": "29.4.6",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "5.8.3"
|
||||
"typescript": "5.9.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@electron/remote": "2.1.3",
|
||||
"@joplin/onenote-converter": "~3.6",
|
||||
"@xyflow/react": "12.10.2",
|
||||
"fs-extra": "11.3.3",
|
||||
"keytar": "7.9.0",
|
||||
"node-fetch": "2.6.7",
|
||||
|
||||
@@ -29,6 +29,12 @@ export default defineConfig({
|
||||
// The CI machines can sometimes be very slow. Increase per-test timeout in CI.
|
||||
timeout: process.env.CI ? 70_000 : 60_000, // milliseconds
|
||||
|
||||
// Raise the default assertion timeout on CI — imports and React state updates
|
||||
// can take longer on slow Ubuntu runners than the built-in 5 s default.
|
||||
expect: {
|
||||
timeout: process.env.CI ? 15_000 : 5_000,
|
||||
},
|
||||
|
||||
// Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions.
|
||||
use: {
|
||||
// Base URL to use in actions like `await page.goto('/')`.
|
||||
|
||||
@@ -28,6 +28,7 @@ export default function stateToWhenClauseContext(state: AppState, options: WhenC
|
||||
sidebarVisible: isMainWindow && !!state.mainLayout && layoutItemProp(state.mainLayout, 'sideBar', 'visible'),
|
||||
noteListHasNotes: !!windowState.notes.length,
|
||||
isAltInstance,
|
||||
activeNoteIsWhiteboard: !!windowState.activeNoteIsWhiteboard,
|
||||
|
||||
// Deprecated
|
||||
sideBarVisible: !!state.mainLayout && layoutItemProp(state.mainLayout, 'sideBar', 'visible'),
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
@use 'gui/EditFolderDialog/style.scss' as edit-folder-dialog;
|
||||
@use 'gui/EncryptionConfigScreen/style.scss' as encryption-config-screen;
|
||||
@use 'gui/PasswordInput/style.scss' as password-input;
|
||||
@use 'gui/lib/SearchInput/style.scss' as search-input;
|
||||
@use 'gui/JoplinCloudConfigScreen.scss' as joplin-cloud-config-screen;
|
||||
@use 'gui/Dropdown/style.scss' as dropdown-control;
|
||||
@use 'gui/ShareFolderDialog/style.scss' as share-folder-dialog;
|
||||
|
||||
@@ -1 +1 @@
|
||||
18
|
||||
22
|
||||
@@ -83,8 +83,8 @@ android {
|
||||
applicationId "net.cozic.joplin"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 2097805
|
||||
versionName "3.6.17"
|
||||
versionCode 2097806
|
||||
versionName "3.6.18"
|
||||
|
||||
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
|
||||
|
||||
|
||||
@@ -10,20 +10,36 @@ describe('newNote', () => {
|
||||
await setupDatabaseAndSynchronizer(1);
|
||||
await switchClient(1);
|
||||
});
|
||||
test('should create and navigate to a new note', async () => {
|
||||
test.each([
|
||||
[null, null],
|
||||
['order', true],
|
||||
['order', false],
|
||||
])('should create and navigate to a new note', async (sortOrderField: string, sortOrderReverse: boolean) => {
|
||||
const dispatchMock = jest.fn();
|
||||
NavService.dispatch = dispatchMock;
|
||||
|
||||
// The command needs an active folder ID.
|
||||
const activeFolder = await Folder.save({ title: 'folder' });
|
||||
const initialNote = await Note.save({ title: 'test', parent_id: activeFolder.id });
|
||||
Setting.setValue('activeFolderId', activeFolder.id);
|
||||
Setting.setValue('notes.sortOrder.field', sortOrderField);
|
||||
Setting.setValue('notes.sortOrder.reverse', sortOrderReverse);
|
||||
|
||||
await runtime().execute(null, 'test note', true);
|
||||
expect(dispatchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Correct note should have been created
|
||||
const noteId = dispatchMock.mock.lastCall[0].noteId;
|
||||
expect(await Note.load(noteId)).toMatchObject({ body: 'test note', parent_id: activeFolder.id });
|
||||
const newNote = await Note.load(noteId);
|
||||
expect(newNote.body).toEqual('test note');
|
||||
expect(newNote.parent_id).toEqual(activeFolder.id);
|
||||
if (sortOrderField === 'order' && !!sortOrderReverse) {
|
||||
expect(newNote.order).toBeGreaterThanOrEqual(initialNote.order + Note.defaultIntevalBetweenNotes);
|
||||
} else if (sortOrderField === 'order' && !sortOrderReverse) {
|
||||
expect(newNote.order).toBeLessThanOrEqual(initialNote.order - Note.defaultIntevalBetweenNotes);
|
||||
} else {
|
||||
expect(newNote.order).toBeLessThan(initialNote.order + Note.defaultIntevalBetweenNotes);
|
||||
}
|
||||
|
||||
// Should have tried to navigate to the note.
|
||||
expect(dispatchMock.mock.lastCall).toMatchObject([
|
||||
|
||||
@@ -3,6 +3,7 @@ import Logger from '@joplin/utils/Logger';
|
||||
import goToNote, { GotoNoteOptions } from './util/goToNote';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
|
||||
const logger = Logger.create('newNoteCommand');
|
||||
|
||||
@@ -19,10 +20,16 @@ export const runtime = (): CommandRuntime => {
|
||||
return;
|
||||
}
|
||||
|
||||
let order;
|
||||
if (Setting.value('notes.sortOrder.field') === 'order') {
|
||||
order = await Note.getNextOrderValue(folder.id);
|
||||
}
|
||||
|
||||
const note = await Note.save({
|
||||
body,
|
||||
parent_id: folder.id,
|
||||
is_todo: todo ? 1 : 0,
|
||||
...(order !== undefined ? { order } : {}),
|
||||
}, { provisional: true });
|
||||
|
||||
logger.info(`Navigating to note ${note.id}`);
|
||||
|
||||
@@ -65,6 +65,7 @@ interface Props {
|
||||
plugins: PluginStates;
|
||||
noteResources: ResourceInfos;
|
||||
editorImageRendering: boolean;
|
||||
editorTableEditing: boolean;
|
||||
editorInlineRendering: boolean;
|
||||
|
||||
onScroll: OnScroll;
|
||||
@@ -283,6 +284,7 @@ const useEditorSettings = (props: Props) => {
|
||||
katexEnabled: Setting.value('markdown.plugin.katex'),
|
||||
spellcheckEnabled: Setting.value('editor.mobile.spellcheckEnabled'),
|
||||
inlineRenderingEnabled,
|
||||
tableEditingEnabled: props.editorTableEditing,
|
||||
imageRenderingEnabled: props.editorImageRendering,
|
||||
language: props.markupLanguage === MarkupLanguage.Html ? EditorLanguageType.Html : EditorLanguageType.Markdown,
|
||||
useExternalSearch: true,
|
||||
@@ -301,7 +303,7 @@ const useEditorSettings = (props: Props) => {
|
||||
indentWithTabs: true,
|
||||
|
||||
editorLabel: _('Markdown editor'),
|
||||
}), [props.themeId, props.readOnly, props.markupLanguage, highlightActiveLine, inlineRenderingEnabled, props.editorImageRendering]);
|
||||
}), [props.themeId, props.readOnly, props.markupLanguage, highlightActiveLine, inlineRenderingEnabled, props.editorImageRendering, props.editorTableEditing]);
|
||||
|
||||
return editorSettings;
|
||||
};
|
||||
@@ -512,6 +514,7 @@ export default connect((state: AppState) => {
|
||||
return {
|
||||
themeId: state.settings.theme,
|
||||
editorInlineRendering: state.settings['editor.inlineRendering'],
|
||||
editorTableEditing: state.settings['editor.tableEditing'],
|
||||
editorImageRendering: state.settings['editor.imageRendering'],
|
||||
};
|
||||
}, null, null, { forwardRef: true })(NoteEditor);
|
||||
|
||||
@@ -12,7 +12,6 @@ const markdownEditorOnlyCommands = [
|
||||
|
||||
|
||||
const richTextEditorOnlyCommands = [
|
||||
EditorCommandType.InsertTable,
|
||||
EditorCommandType.InsertCodeBlock,
|
||||
].map(command => `editor.${command}`);
|
||||
|
||||
|
||||
@@ -56,33 +56,4 @@ describe('deleteProfile', () => {
|
||||
expect(await pathExists(resourceDir)).toBe(false);
|
||||
expect(await pathExists(pluginDataDir)).toBe(false);
|
||||
});
|
||||
|
||||
it('should refuse to delete the default profile', async () => {
|
||||
const config: ProfileConfig = {
|
||||
version: CurrentProfileVersion,
|
||||
currentProfileId: 'test',
|
||||
profiles: [
|
||||
{
|
||||
name: 'Testing',
|
||||
id: DefaultProfileId,
|
||||
},
|
||||
{
|
||||
name: 'Another test',
|
||||
id: 'test',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
try {
|
||||
await deleteProfile({
|
||||
profileConfig: config,
|
||||
toDelete: config.profiles[0],
|
||||
databaseDriver: new MockDatabaseDriver(),
|
||||
});
|
||||
|
||||
expect('did not throw').toBe('threw');
|
||||
} catch (error) {
|
||||
expect(String(error)).toMatch(/The default profile cannot be deleted/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,10 +2,11 @@ import { Profile, ProfileConfig } from '@joplin/lib/services/profileConfig/types
|
||||
import { getDatabaseName, getPluginDataDir, getResourceDir, saveProfileConfig } from '../../../services/profiles';
|
||||
import { deleteProfileById, getCurrentProfile, isSubProfile } from '@joplin/lib/services/profileConfig';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import shim, { MessageBoxType } from '@joplin/lib/shim';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import resolvePathWithinDir from '@joplin/lib/utils/resolvePathWithinDir';
|
||||
import DatabaseDriver from '@joplin/lib/database-driver';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
|
||||
const logger = Logger.create('deleteProfile');
|
||||
|
||||
@@ -17,14 +18,17 @@ interface DeleteProfileOptions {
|
||||
|
||||
const deleteProfile = async (options: DeleteProfileOptions) => {
|
||||
logger.info('Deleting profile config', options.toDelete.id);
|
||||
// This step also verifies that the to-be-deleted profile is not the default profile, etc.
|
||||
const newConfig = deleteProfileById(options.profileConfig, options.toDelete.id);
|
||||
// Save the profile config early. If the later deletion steps fail, this prevents the user from
|
||||
// opening a partially-deleted profile:
|
||||
await saveProfileConfig(newConfig);
|
||||
|
||||
if (options.toDelete.id === options.profileConfig.currentProfileId) throw new Error(_('The active profile cannot be deleted. Switch to a different profile and try again.'));
|
||||
const subProfile = isSubProfile(options.toDelete);
|
||||
if (!subProfile) throw new Error('Deleting a sub-profile is not supported');
|
||||
|
||||
// Deleting the default profile must be handled differently. We can't delete the whole directory because it contains other profiles and global settings
|
||||
if (subProfile) {
|
||||
const newConfig = deleteProfileById(options.profileConfig, options.toDelete.id);
|
||||
// Save the profile config early. If the later deletion steps fail, this prevents the user from
|
||||
// opening a partially-deleted profile. The default profile does not get deleted from the list,
|
||||
// but the data will be cleared
|
||||
await saveProfileConfig(newConfig);
|
||||
}
|
||||
|
||||
// Retrieve and validate both the database name and resources directory
|
||||
// **before** doing any deletion.
|
||||
@@ -42,11 +46,38 @@ const deleteProfile = async (options: DeleteProfileOptions) => {
|
||||
logger.warn('Failed to delete database: ', error, '. Was the profile initialized?');
|
||||
}
|
||||
|
||||
logger.info('Deleting resources directory', resourcesDir);
|
||||
await shim.fsDriver().remove(resourcesDir);
|
||||
if (subProfile) {
|
||||
logger.info('Deleting resources directory', resourcesDir);
|
||||
await shim.fsDriver().remove(resourcesDir);
|
||||
} else {
|
||||
try {
|
||||
const items = await shim.fsDriver().readDirStats(resourcesDir);
|
||||
|
||||
for (const item of items) {
|
||||
if (item.isDirectory()) continue;
|
||||
const fileName = item.path;
|
||||
|
||||
if (/^[a-f0-9]{32}\./.test(fileName)) {
|
||||
const fullPath = `${resourcesDir}/${fileName}`;
|
||||
try {
|
||||
await shim.fsDriver().unlink(fullPath);
|
||||
logger.info('Deleted resource file: ', fullPath);
|
||||
} catch (error) {
|
||||
logger.error('Error deleting resource file: ', fullPath, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error reading resources directory: ', resourcesDir, error);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Deleting plugin data directory', pluginDataDir);
|
||||
await shim.fsDriver().remove(pluginDataDir);
|
||||
|
||||
if (!subProfile) {
|
||||
await shim.showMessageBox(_('The default profile has been reset.'), { type: MessageBoxType.Info });
|
||||
}
|
||||
};
|
||||
|
||||
export default deleteProfile;
|
||||
@@ -70,7 +101,7 @@ const getTargetResourceDirectory = ({ toDelete: target }: DeleteProfileOptions)
|
||||
// Add an extra check here to verify that deleting the other profile's resource directory
|
||||
// doesn't also delete **the active** profile's resource directory. On mobile, the resources
|
||||
// directory can sometimes contain other profile directories (e.g. in the case of the default profile).
|
||||
if (resolvePathWithinDir(resourcesDir, Setting.value('resourceDir')) !== null) {
|
||||
if (isSubProfile(target) && resolvePathWithinDir(resourcesDir, Setting.value('resourceDir')) !== null) {
|
||||
throw new Error('Refusing to delete a directory that contains the active profile\'s resource directory.');
|
||||
}
|
||||
return resourcesDir;
|
||||
@@ -79,7 +110,7 @@ const getTargetResourceDirectory = ({ toDelete: target }: DeleteProfileOptions)
|
||||
|
||||
const getTargetPluginDataDirectory = ({ toDelete: target }: DeleteProfileOptions) => {
|
||||
const pluginDataDir = getPluginDataDir(target, isSubProfile(target));
|
||||
if (resolvePathWithinDir(pluginDataDir, Setting.value('pluginDataDir')) !== null) {
|
||||
if (isSubProfile(target) && resolvePathWithinDir(pluginDataDir, Setting.value('pluginDataDir')) !== null) {
|
||||
throw new Error('Refusing to delete a directory that contains the active profile\'s plugin data directory.');
|
||||
}
|
||||
return pluginDataDir;
|
||||
|
||||
@@ -46,12 +46,18 @@ export default class PluginRunner extends BasePluginRunner {
|
||||
return false;
|
||||
});
|
||||
|
||||
// On native mobile, pass a file path so the WebView can load the
|
||||
// script directly from the filesystem (avoids transferring the full
|
||||
// script text across the React Native bridge). On web, file:// URLs
|
||||
// are blocked by CSP so we pass the script text directly.
|
||||
const scriptFilePath = plugin.scriptText ? '' : `${plugin.baseDir}/index.js`;
|
||||
this.webviewRef.current.injectJS(`
|
||||
pluginBackgroundPage.runPlugin(
|
||||
${JSON.stringify(shim.injectedJs('pluginBackgroundPage'))},
|
||||
${JSON.stringify(plugin.scriptText)},
|
||||
${JSON.stringify(scriptFilePath)},
|
||||
${JSON.stringify(messageChannelId)},
|
||||
${JSON.stringify(plugin.id)},
|
||||
${JSON.stringify(plugin.scriptText)},
|
||||
);
|
||||
`);
|
||||
|
||||
|
||||
@@ -186,6 +186,7 @@ const PluginRunnerWebViewComponent: React.FC<Props> = props => {
|
||||
html={html}
|
||||
injectedJavaScript={injectedJs}
|
||||
hasPluginScripts={true}
|
||||
allowFileAccessFromJs={true}
|
||||
onMessage={pluginRunner.onWebviewMessage}
|
||||
onLoadEnd={onLoadEnd}
|
||||
onLoadStart={onLoadStart}
|
||||
|
||||
@@ -26,14 +26,29 @@ export const stopPlugin = async (pluginId: string) => {
|
||||
delete loadedPlugins[pluginId];
|
||||
};
|
||||
|
||||
export const runPlugin = (
|
||||
pluginBackgroundScript: string, pluginScript: string, messageChannelId: string, pluginId: string,
|
||||
export const runPlugin = async (
|
||||
pluginBackgroundScript: string, scriptFilePath: string, messageChannelId: string, pluginId: string, scriptText = '',
|
||||
) => {
|
||||
if (loadedPlugins[pluginId]) {
|
||||
console.warn(`Plugin already running ${pluginId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// When scriptText is provided (web), use it directly. Otherwise load
|
||||
// the plugin script from the filesystem (native mobile). We use
|
||||
// XMLHttpRequest because fetch() doesn't support file:// URLs on
|
||||
// Android WebView.
|
||||
let pluginScript = scriptText;
|
||||
if (!pluginScript) {
|
||||
pluginScript = await new Promise<string>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', `file://${scriptFilePath}`, true);
|
||||
xhr.onload = () => resolve(xhr.responseText);
|
||||
xhr.onerror = () => reject(new Error(`Failed to load plugin script: ${scriptFilePath}`));
|
||||
xhr.send();
|
||||
});
|
||||
}
|
||||
|
||||
const bodyHtml = '';
|
||||
const initialJavaScript = `
|
||||
"use strict";
|
||||
|
||||
@@ -14,6 +14,7 @@ import ScreenHeader from '../../ScreenHeader';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import BaseScreenComponent from '../../base-screen';
|
||||
import * as shared from '@joplin/lib/components/shared/config/config-shared';
|
||||
import { shouldShowBySearch, hasNormalizedQuery } from '@joplin/lib/components/shared/config/config-search-text';
|
||||
import SyncTargetRegistry from '@joplin/lib/SyncTargetRegistry';
|
||||
import biometricAuthenticate from '../../biometrics/biometricAuthenticate';
|
||||
import configScreenStyles, { ConfigScreenStyles } from './configScreenStyles';
|
||||
@@ -48,6 +49,7 @@ interface ConfigScreenState {
|
||||
changedSettingKeys: string[];
|
||||
|
||||
searchQuery: string;
|
||||
searchSectionFilter: string|null;
|
||||
searching: boolean;
|
||||
|
||||
fixingSearchIndex: boolean;
|
||||
@@ -398,22 +400,7 @@ class ConfigScreenComponent extends BaseScreenComponent<ConfigScreenProps, Confi
|
||||
}
|
||||
|
||||
const matchesSearchQuery = (relatedText: string|string[]) => {
|
||||
let searchThrough;
|
||||
if (Array.isArray(relatedText)) {
|
||||
searchThrough = relatedText.join('\n');
|
||||
} else {
|
||||
searchThrough = relatedText;
|
||||
}
|
||||
searchThrough = searchThrough.toLocaleLowerCase();
|
||||
|
||||
const searchQuery = this.state.searchQuery.toLocaleLowerCase().trim();
|
||||
|
||||
const hasSearchMatches =
|
||||
headerTitle.toLocaleLowerCase() === searchQuery
|
||||
|| searchThrough.includes(searchQuery);
|
||||
|
||||
// Don't show results when the search input is empty
|
||||
return this.state.searchQuery.length > 0 && hasSearchMatches;
|
||||
return shouldShowBySearch(this.state.searchQuery, headerTitle, relatedText);
|
||||
};
|
||||
|
||||
const addSettingComponent = (
|
||||
@@ -421,7 +408,7 @@ class ConfigScreenComponent extends BaseScreenComponent<ConfigScreenProps, Confi
|
||||
relatedText: string|string[],
|
||||
settingMetadata?: { advanced?: boolean },
|
||||
) => {
|
||||
const hiddenBySearch = this.state.searching && !matchesSearchQuery(relatedText);
|
||||
const hiddenBySearch = this.state.searching && hasNormalizedQuery(this.state.searchQuery) && !matchesSearchQuery(relatedText);
|
||||
if (component && !hiddenBySearch) {
|
||||
if (settingMetadata?.advanced) {
|
||||
advancedSettingComps.push(component);
|
||||
|
||||
@@ -163,6 +163,7 @@ class LogScreenComponent extends BaseScreenComponent<Props, State> {
|
||||
|
||||
private async refreshLogEntries(showErrorsOnly: boolean = null) {
|
||||
if (showErrorsOnly === null) showErrorsOnly = this.state.showErrorsOnly;
|
||||
const prevShowErrorsOnly = this.state.showErrorsOnly;
|
||||
|
||||
const limit = 1000;
|
||||
const logEntries = await this.getLogEntries(showErrorsOnly, limit);
|
||||
@@ -171,7 +172,7 @@ class LogScreenComponent extends BaseScreenComponent<Props, State> {
|
||||
logEntries: logEntries,
|
||||
showErrorsOnly: showErrorsOnly,
|
||||
}, () => {
|
||||
if (this.state.filter !== undefined) {
|
||||
if (this.state.filter !== undefined || prevShowErrorsOnly !== showErrorsOnly) {
|
||||
this.logListRef_.current?.scrollToOffset({ offset: 0, animated: false });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -204,7 +204,7 @@ class StatusScreenComponent extends BaseScreenComponent<Props, State> {
|
||||
</View>
|
||||
) : null;
|
||||
|
||||
const textComponent = text ? <Text style={style} role={textRole}>{text}</Text> : null;
|
||||
const textComponent = text ? <Text style={style} role={textRole} numberOfLines={2} ellipsizeMode='tail'>{text}</Text> : null;
|
||||
if (item.isDivider) {
|
||||
return <View style={styles.divider} role='separator' key={item.key} />;
|
||||
} else if (item.listItems) {
|
||||
|
||||
@@ -520,7 +520,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 153;
|
||||
CURRENT_PROJECT_VERSION = 154;
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Joplin/Info.plist;
|
||||
@@ -529,7 +529,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 13.6.4;
|
||||
MARKETING_VERSION = 13.6.5;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
@@ -555,7 +555,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 153;
|
||||
CURRENT_PROJECT_VERSION = 154;
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
INFOPLIST_FILE = Joplin/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
|
||||
@@ -563,7 +563,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 13.6.4;
|
||||
MARKETING_VERSION = 13.6.5;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
@@ -758,7 +758,7 @@
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 153;
|
||||
CURRENT_PROJECT_VERSION = 154;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
@@ -769,7 +769,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 13.6.4;
|
||||
MARKETING_VERSION = 13.6.5;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
OTHER_LDFLAGS = (
|
||||
@@ -801,7 +801,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 153;
|
||||
CURRENT_PROJECT_VERSION = 154;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
@@ -812,7 +812,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 13.6.4;
|
||||
MARKETING_VERSION = 13.6.5;
|
||||
MTL_FAST_MATH = YES;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
"crypto-browserify": "3.12.1",
|
||||
"deprecated-react-native-prop-types": "5.0.0",
|
||||
"events": "3.3.0",
|
||||
"expo": "54.0.32",
|
||||
"expo": "54.0.33",
|
||||
"expo-audio": "1.1.1",
|
||||
"expo-camera": "17.0.10",
|
||||
"expo-image-manipulator": "14.0.8",
|
||||
@@ -69,7 +69,7 @@
|
||||
"react-native-image-picker": "8.2.1",
|
||||
"react-native-localize": "3.6.1",
|
||||
"react-native-modal-datetime-picker": "18.0.0",
|
||||
"react-native-nitro-modules": "0.33.2",
|
||||
"react-native-nitro-modules": "0.33.7",
|
||||
"react-native-paper": "5.14.5",
|
||||
"react-native-popup-menu": "0.17.0",
|
||||
"react-native-quick-actions": "0.3.13",
|
||||
@@ -78,9 +78,9 @@
|
||||
"react-native-rsa-native": "2.0.5",
|
||||
"react-native-safe-area-context": "5.6.2",
|
||||
"react-native-securerandom": "1.0.1",
|
||||
"react-native-share": "12.2.4",
|
||||
"react-native-share": "12.2.5",
|
||||
"react-native-sqlite-storage": "6.0.1",
|
||||
"react-native-svg": "15.15.1",
|
||||
"react-native-svg": "15.15.2",
|
||||
"react-native-url-polyfill": "2.0.0",
|
||||
"react-native-version-info": "1.1.1",
|
||||
"react-native-webview": "13.16.0",
|
||||
@@ -109,7 +109,7 @@
|
||||
"@react-native-community/cli-platform-ios": "20.0.0",
|
||||
"@react-native/babel-preset": "0.81.6",
|
||||
"@react-native/metro-config": "0.81.6",
|
||||
"@react-native/typescript-config": "0.81.6",
|
||||
"@react-native/typescript-config": "0.83.1",
|
||||
"@sqlite.org/sqlite-wasm": "3.46.0-build2",
|
||||
"@testing-library/react-native": "13.2.0",
|
||||
"@types/fs-extra": "11.0.4",
|
||||
@@ -117,7 +117,7 @@
|
||||
"@types/node": "18.19.130",
|
||||
"@types/react": "19.1.10",
|
||||
"@types/react-redux": "7.1.34",
|
||||
"@types/serviceworker": "0.0.179",
|
||||
"@types/serviceworker": "0.0.183",
|
||||
"@types/tar-stream": "3.1.4",
|
||||
"babel-jest": "29.7.0",
|
||||
"babel-loader": "9.1.3",
|
||||
@@ -140,10 +140,10 @@
|
||||
"sharp": "0.34.5",
|
||||
"sqlite3": "5.1.6",
|
||||
"timers-browserify": "2.0.12",
|
||||
"ts-jest": "29.4.1",
|
||||
"ts-jest": "29.4.6",
|
||||
"ts-loader": "9.5.4",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "5.8.3",
|
||||
"typescript": "5.9.3",
|
||||
"url-loader": "4.1.1",
|
||||
"webpack": "5.97.1",
|
||||
"webpack-cli": "5.1.4",
|
||||
|
||||
@@ -153,7 +153,7 @@ const generalMiddleware = (store: any) => (next: any) => async (action: any) =>
|
||||
|
||||
const result = next(action);
|
||||
const newState: AppState = store.getState();
|
||||
let doRefreshFolders = false;
|
||||
let doRefreshFolders: boolean | string = false;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
await reduxSharedMiddleware(store, next, action, storeDispatch as any);
|
||||
@@ -248,9 +248,17 @@ const generalMiddleware = (store: any) => (next: any) => async (action: any) =>
|
||||
Setting.setValue('noteVisiblePanes', newState.noteVisiblePanes);
|
||||
}
|
||||
|
||||
if (action.type === 'SETTING_UPDATE_ONE' && action.key.indexOf('folders.sortOrder') === 0) {
|
||||
doRefreshFolders = 'now';
|
||||
}
|
||||
|
||||
if (doRefreshFolders) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
await scheduleRefreshFolders((action: any) => storeDispatch(action), newState.selectedFolderId);
|
||||
if (doRefreshFolders === 'now') {
|
||||
await refreshFolders(storeDispatch, newState.selectedFolderId);
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
await scheduleRefreshFolders((action: any) => storeDispatch(action), newState.selectedFolderId);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
@@ -312,12 +312,6 @@ const buildStartupTasks = (
|
||||
Setting.setValue('welcome.enabled', false);
|
||||
}
|
||||
|
||||
// Note: for now we hard-code the folder sort order as we need to
|
||||
// create a UI to allow customisation (started in branch mobile_add_sidebar_buttons)
|
||||
Setting.setValue('folders.sortOrder.field', 'title');
|
||||
Setting.setValue('folders.sortOrder.reverse', false);
|
||||
|
||||
|
||||
reg.logger().info(`Sync target: ${Setting.value('sync.target')}`);
|
||||
|
||||
setLocale(Setting.value('locale'));
|
||||
|
||||
@@ -5,6 +5,9 @@ import { WebViewControl } from '../../components/ExtendedWebView/types';
|
||||
import { RefObject } from 'react';
|
||||
import { OnMessageEvent } from '../../components/ExtendedWebView/types';
|
||||
import { Platform } from 'react-native';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
|
||||
const logger = Logger.create('RNToWebViewMessenger');
|
||||
|
||||
const canUseOptimizedPostMessage = Platform.OS === 'web';
|
||||
|
||||
@@ -41,10 +44,22 @@ export default class RNToWebViewMessenger<LocalInterface, RemoteInterface> exten
|
||||
|
||||
public onWebViewMessage = (event: OnMessageEvent) => {
|
||||
if (!this.hasBeenClosed()) {
|
||||
let data;
|
||||
if (canUseOptimizedPostMessage) {
|
||||
void this.onMessage(event.nativeEvent.data);
|
||||
data = event.nativeEvent.data;
|
||||
} else {
|
||||
void this.onMessage(JSON.parse(event.nativeEvent.data));
|
||||
try {
|
||||
data = JSON.parse(event.nativeEvent.data);
|
||||
} catch {
|
||||
logger.warn('Failed to parse message:', event.nativeEvent.data);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof data === 'object' && data !== null && typeof data.kind === 'string') {
|
||||
void this.onMessage(data);
|
||||
} else {
|
||||
logger.info('Unknown message format:', event.nativeEvent.data);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"@types/yargs": "17.0.35",
|
||||
"joplin-plugin-freehand-drawing": "4.3.0",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "5.8.3"
|
||||
"typescript": "5.9.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@joplin/utils": "~3.6",
|
||||
|
||||
@@ -15,6 +15,7 @@ import { vim } from '@replit/codemirror-vim';
|
||||
import { indentUnit } from '@codemirror/language';
|
||||
import insertNewlineContinueMarkup from './editorCommands/insertNewlineContinueMarkup';
|
||||
import renderingExtension from './extensions/rendering/renderingExtension';
|
||||
import renderTables from './extensions/rendering/renderTables';
|
||||
import { RenderedContentContext } from './extensions/rendering/types';
|
||||
import highlightActiveLineExtension from './extensions/highlightActiveLineExtension';
|
||||
import renderBlockImages from './extensions/rendering/renderBlockImages';
|
||||
@@ -112,7 +113,11 @@ const configFromSettings = (settings: EditorSettings, context: RenderedContentCo
|
||||
// Only enable in-editor rendering for Markdown notes. In-editor rendering can result in
|
||||
// confusing output in HTML notes (e.g. some, but not most, tags hidden).
|
||||
if (settings.inlineRenderingEnabled && settings.language === EditorLanguageType.Markdown) {
|
||||
extensions.push(renderingExtension());
|
||||
extensions.push(renderingExtension(settings.tableEditingEnabled));
|
||||
} else if (settings.tableEditingEnabled && settings.language === EditorLanguageType.Markdown) {
|
||||
// Table editing can work independently of inline rendering so users
|
||||
// who disable inline rendering can still use the interactive widget.
|
||||
extensions.push(renderTables);
|
||||
}
|
||||
|
||||
if (settings.imageRenderingEnabled) {
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
toggleBolded, toggleCode,
|
||||
toggleItalicized, toggleMath,
|
||||
} from './editorCommands/markdownCommands';
|
||||
import { tableNextCell, tablePreviousCell } from './editorCommands/tableCommands';
|
||||
import decoratorExtension from './extensions/markdownDecorationExtension';
|
||||
import computeSelectionFormatting from './utils/formatting/computeSelectionFormatting';
|
||||
import { selectionFormattingEqual } from '../SelectionFormatting';
|
||||
@@ -203,6 +204,11 @@ const createEditor = (
|
||||
return false;
|
||||
}
|
||||
|
||||
// Try table cell navigation first
|
||||
if (tableNextCell(view)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (settings.autocompleteMarkup) {
|
||||
return insertOrIncreaseIndent(view);
|
||||
}
|
||||
@@ -214,6 +220,11 @@ const createEditor = (
|
||||
return false;
|
||||
}
|
||||
|
||||
// Try table cell navigation first
|
||||
if (tablePreviousCell(view)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// When at the beginning of the editor, allow shift-tab to act
|
||||
// normally.
|
||||
if (isCursorAtBeginning(view.state)) {
|
||||
|
||||
@@ -14,6 +14,8 @@ import { closeSearchPanel, findNext, findPrevious, openSearchPanel, replaceAll,
|
||||
import { focus } from '@joplin/lib/utils/focusHandler';
|
||||
import { showLinkEditor } from '../utils/handleLinkEditRequests';
|
||||
import jumpToHash from './jumpToHash';
|
||||
import { tableAddRow, tableAddColumn, tableDeleteRow, tableDeleteColumn } from './tableCommands';
|
||||
import { generateTable } from '../utils/markdown/tableUtils';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Commands have varying argument types
|
||||
export type EditorCommandFunction = (editor: EditorView, ...args: any[])=> any;
|
||||
@@ -46,13 +48,7 @@ const editorCommands: Record<EditorCommandType, EditorCommandFunction> = {
|
||||
[EditorCommandType.ToggleHeading5]: toggleHeaderLevel(5),
|
||||
[EditorCommandType.InsertHorizontalRule]: insertHorizontalRule,
|
||||
[EditorCommandType.InsertTable]: editor => {
|
||||
replaceSelectionCommand(editor, [
|
||||
'',
|
||||
'| | |',
|
||||
'|----|----|',
|
||||
'| | |',
|
||||
'',
|
||||
].join('\n'));
|
||||
replaceSelectionCommand(editor, `\n${generateTable(1, 2)}\n\n`);
|
||||
},
|
||||
[EditorCommandType.InsertCodeBlock]: editor => {
|
||||
replaceSelectionCommand(editor, [
|
||||
@@ -128,6 +124,12 @@ const editorCommands: Record<EditorCommandType, EditorCommandFunction> = {
|
||||
[EditorCommandType.JumpToHash]: (editor, hash: string) => {
|
||||
return jumpToHash(editor, hash);
|
||||
},
|
||||
|
||||
// Table editing commands
|
||||
[EditorCommandType.TableAddRow]: tableAddRow,
|
||||
[EditorCommandType.TableAddColumn]: tableAddColumn,
|
||||
[EditorCommandType.TableDeleteRow]: tableDeleteRow,
|
||||
[EditorCommandType.TableDeleteColumn]: tableDeleteColumn,
|
||||
};
|
||||
export default editorCommands;
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user