You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2026-03-21 10:25:18 +02:00
Compare commits
220 Commits
transcribe
...
v3.6.6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3223d9c6f9 | ||
|
|
991cbc4dc0 | ||
|
|
af0318293c | ||
|
|
6033f9c6e6 | ||
|
|
f400f3839d | ||
|
|
c7736f9e80 | ||
|
|
5abd5803fb | ||
|
|
615677cf18 | ||
|
|
21d12a2b46 | ||
|
|
11c17d43eb | ||
|
|
120cdaabac | ||
|
|
3a9fbc7e67 | ||
|
|
c9aaaa952e | ||
|
|
d97121a63a | ||
|
|
4bd98f4819 | ||
|
|
426500437d | ||
|
|
c9dace8b4d | ||
|
|
f9654a3438 | ||
|
|
7d888a50af | ||
|
|
65eb5e3afe | ||
|
|
7a9dc4a607 | ||
|
|
d20cc87756 | ||
|
|
6d310f6b27 | ||
|
|
d2b273bfb0 | ||
|
|
1ff71a64e1 | ||
|
|
e442544070 | ||
|
|
ba93bcc06d | ||
|
|
2d545158d0 | ||
|
|
364ea03e5d | ||
|
|
082aa70a48 | ||
|
|
e9fe4036b1 | ||
|
|
5ab02cfe52 | ||
|
|
ce8d9a1cdf | ||
|
|
745a68f26b | ||
|
|
667ff1797d | ||
|
|
e5274c5cff | ||
|
|
03c3d6ae4a | ||
|
|
9a3673a38f | ||
|
|
99124e4feb | ||
|
|
cf51782f4f | ||
|
|
8bde0bf0ec | ||
|
|
e8372c76aa | ||
|
|
e1dc36c0a5 | ||
|
|
8d168dc330 | ||
|
|
321afbe110 | ||
|
|
2e3daad78e | ||
|
|
2132c2cdf4 | ||
|
|
67aff20e39 | ||
|
|
3719e1eee0 | ||
|
|
4abe83fdb6 | ||
|
|
6ba912e5aa | ||
|
|
8533083730 | ||
|
|
754ff28b36 | ||
|
|
b663c64def | ||
|
|
998b26d9a4 | ||
|
|
b097cf9a6a | ||
|
|
e22c367566 | ||
|
|
71a2e98155 | ||
|
|
714bbd6d23 | ||
|
|
eda03333a6 | ||
|
|
93f17a87fa | ||
|
|
c765306e6f | ||
|
|
f05fe5754d | ||
|
|
d046bfa14b | ||
|
|
2a681008dd | ||
|
|
7214823c74 | ||
|
|
ed5b92a91e | ||
|
|
2c8a9eee61 | ||
|
|
6451305c89 | ||
|
|
5fd0dc23da | ||
|
|
fd3b133b16 | ||
|
|
118bc3edf1 | ||
|
|
d90836bc50 | ||
|
|
9a477dbeb9 | ||
|
|
5271081b3a | ||
|
|
b26370fc5a | ||
|
|
737c7dcdb4 | ||
|
|
04babe0261 | ||
|
|
85e5bbd246 | ||
|
|
f819e1c88b | ||
|
|
79c153c498 | ||
|
|
1db9903926 | ||
|
|
e736e05d1c | ||
|
|
5ef10676d8 | ||
|
|
b38613ca22 | ||
|
|
ea486fbe13 | ||
|
|
d2784aff54 | ||
|
|
7308d9541e | ||
|
|
d6ac709e5f | ||
|
|
b290046e66 | ||
|
|
c2321a04ae | ||
|
|
3df77a4395 | ||
|
|
38fd790719 | ||
|
|
40bfa9dd3d | ||
|
|
8d08e5df60 | ||
|
|
4121c47e18 | ||
|
|
d30e6ad0da | ||
|
|
be712df89d | ||
|
|
f7762c403e | ||
|
|
b89d37de84 | ||
|
|
a7b9af61c0 | ||
|
|
a3186cdfe1 | ||
|
|
0a580493a2 | ||
|
|
7a7bf72aa8 | ||
|
|
a20a584273 | ||
|
|
ae30e8cf00 | ||
|
|
1a7bb9131a | ||
|
|
81ed35b117 | ||
|
|
2704495ac6 | ||
|
|
a96f7c6ee7 | ||
|
|
af706ac1b3 | ||
|
|
766ef933b9 | ||
|
|
35de2aca18 | ||
|
|
c1827e1b9e | ||
|
|
89e3544a0c | ||
|
|
7f40e9e661 | ||
|
|
20405ea95f | ||
|
|
2574e18c2f | ||
|
|
36b25a9517 | ||
|
|
b3e0575361 | ||
|
|
f9f40b3c9b | ||
|
|
b59721f4b3 | ||
|
|
891ab3e317 | ||
|
|
0e156796bc | ||
|
|
f2b558cb75 | ||
|
|
322657ef72 | ||
|
|
c1e99afd2e | ||
|
|
b3822e2700 | ||
|
|
a43f46fc01 | ||
|
|
50a26b63c8 | ||
|
|
02c1c75587 | ||
|
|
345632324d | ||
|
|
8073e03daf | ||
|
|
7feb953c70 | ||
|
|
8e895fb2c0 | ||
|
|
ee97c41309 | ||
|
|
c9a55563b5 | ||
|
|
39f5dc8c95 | ||
|
|
9256ab197a | ||
|
|
ee2869da86 | ||
|
|
e11441cfbc | ||
|
|
2b5be639ce | ||
|
|
cf3d7f5b88 | ||
|
|
595452f30e | ||
|
|
33c4029547 | ||
|
|
eb238efc7b | ||
|
|
513341f103 | ||
|
|
65b7c4be26 | ||
|
|
344a3c2605 | ||
|
|
3fc724c076 | ||
|
|
044fab96c2 | ||
|
|
df10bbdf2d | ||
|
|
65d7d12533 | ||
|
|
814a09035a | ||
|
|
aadc05bd6c | ||
|
|
0c1511f39e | ||
|
|
d75d0df88a | ||
|
|
2249b3aa7f | ||
|
|
5d9a6151ea | ||
|
|
d3ea5bc4a2 | ||
|
|
0ea374cc87 | ||
|
|
a53f196cae | ||
|
|
7b73b4ba87 | ||
|
|
99e6d3961f | ||
|
|
d0f82fb03b | ||
|
|
b1b96e9529 | ||
|
|
18e178e6cf | ||
|
|
075b16a4d2 | ||
|
|
6d50a947dd | ||
|
|
cb12e4efb0 | ||
|
|
c63eac19c9 | ||
|
|
2544a55373 | ||
|
|
932dbbed1a | ||
|
|
af040cbb79 | ||
|
|
9833250bea | ||
|
|
3bcdc1b362 | ||
|
|
036e503d39 | ||
|
|
8667b28db3 | ||
|
|
3c317ccdc1 | ||
|
|
6a0fc3e36e | ||
|
|
a95b5744ad | ||
|
|
33eb2f02f8 | ||
|
|
93732f8df6 | ||
|
|
f589197915 | ||
|
|
55199244ba | ||
|
|
6ea3180aee | ||
|
|
471bb1bf2b | ||
|
|
e3948dab24 | ||
|
|
950cc54bf0 | ||
|
|
56d43fc3a5 | ||
|
|
45ad3ee078 | ||
|
|
8b2b0dfd8b | ||
|
|
90de267c62 | ||
|
|
085fe0a1cf | ||
|
|
009f3ed692 | ||
|
|
2763a219e4 | ||
|
|
01a51589fd | ||
|
|
ba414a4e01 | ||
|
|
2da78b37b8 | ||
|
|
3ef21b0fff | ||
|
|
77353b015e | ||
|
|
b6dc7730fc | ||
|
|
e96b8d1079 | ||
|
|
16abb027c2 | ||
|
|
8af0c451c6 | ||
|
|
9badf985cb | ||
|
|
59c6be2234 | ||
|
|
f418b0cc6f | ||
|
|
9649beea4e | ||
|
|
440b93f40f | ||
|
|
5beccb9a86 | ||
|
|
8aca7445c7 | ||
|
|
89f9c6a5e1 | ||
|
|
b240c7fafc | ||
|
|
d893680a84 | ||
|
|
d1b316516a | ||
|
|
df7a04f552 | ||
|
|
8ad1dfa2bf | ||
|
|
eeaed07a53 | ||
|
|
f497d898bc |
@@ -12,84 +12,99 @@ reviews:
|
||||
auto_apply_labels: true
|
||||
labeling_instructions:
|
||||
- label: "accessibility"
|
||||
instructions: "Apply when the PR contains changes related to accessibility, screen readers, keyboard navigation, or ARIA attributes."
|
||||
instructions: "Apply when the PR contains changes related to accessibility, screen readers, keyboard navigation, or ARIA attributes"
|
||||
- label: "android"
|
||||
instructions: "Apply when the PR contains changes specific to the Android platform or Android app."
|
||||
instructions: "Apply when the PR modifies files under packages/app-mobile/android/. Or when the PR modifies files under packages/app-mobile and the change is specific to Android only"
|
||||
- label: "api"
|
||||
instructions: "Apply when the PR modifies the Joplin API, REST endpoints, or API-related code."
|
||||
instructions: "Apply when the PR modifies files under packages/lib/services/rest/"
|
||||
- label: "bug"
|
||||
instructions: "Apply when the PR fixes a bug or unexpected behaviour."
|
||||
instructions: "Apply when the PR fixes a bug or unexpected behaviour"
|
||||
- label: "ci"
|
||||
instructions: "Apply when the PR modifies CI/CD configuration, GitHub Actions workflows, or build pipelines."
|
||||
instructions: "Apply when the PR modifies files under .github/workflows/ or .circleci/"
|
||||
- label: "cli"
|
||||
instructions: "Apply when the PR contains changes specific to the Joplin CLI (command-line) application."
|
||||
instructions: "Apply when the PR modifies files under packages/app-cli/, except if all the modified files are under packages/app-cli/tests/"
|
||||
- label: "clipper"
|
||||
instructions: "Apply when the PR contains changes to the Joplin Web Clipper browser extension."
|
||||
instructions: "Apply when the PR modifies files under packages/app-clipper/"
|
||||
- label: "database"
|
||||
instructions: "Apply when the PR modifies database schema, migrations, or database-related logic."
|
||||
instructions: "Apply when the PR is mainly about modifying database schema, migrations, or database-related logic"
|
||||
- label: "desktop"
|
||||
instructions: "Apply when the PR contains changes specific to the Joplin desktop (Electron) application."
|
||||
instructions: "Apply when the PR modifies files under packages/app-desktop/"
|
||||
- label: "documentation"
|
||||
instructions: "Apply when the PR adds or updates documentation, README files, or code comments."
|
||||
instructions: "Apply when the PR modifies files under readme/"
|
||||
- label: "draw"
|
||||
instructions: "Apply when the PR contains changes related to the drawing or sketching feature."
|
||||
instructions: "Apply when the PR modifies files under packages/default-plugins and relates to the JS-Draw drawing plugin"
|
||||
- label: "editor"
|
||||
instructions: "Apply when the PR contains changes to the note editor (CodeMirror, TinyMCE, or the editor infrastructure)."
|
||||
instructions: "Apply when the PR modifies files under packages/editor/ or packages/app-mobile/components/NoteEditor/"
|
||||
- label: "enhancement"
|
||||
instructions: "Apply when the PR adds a new feature or improves existing functionality (not a bug fix)."
|
||||
instructions: "Apply when the PR adds a new feature or improves existing functionality (not a bug fix)"
|
||||
- label: "export"
|
||||
instructions: "Apply when the PR contains changes to export functionality (PDF, HTML, JEX, etc.)."
|
||||
instructions: "Apply when the PR is mainly about changes to the export functionality (PDF, HTML, JEX, etc.)"
|
||||
- label: "import"
|
||||
instructions: "Apply when the PR contains changes to import functionality (Evernote, Markdown, etc.)."
|
||||
instructions: "Apply when the PR is mainly about changes to the import functionality (Evernote, Markdown, etc.)"
|
||||
- label: "iOS"
|
||||
instructions: "Apply when the PR contains changes specific to the iOS platform or iOS app."
|
||||
instructions: "Apply when the PR modifies files under packages/app-mobile/ios/. Or when the PR modifies files under packages/app-mobile and the change is specific to iOS only"
|
||||
- label: "linux"
|
||||
instructions: "Apply when the PR contains changes specific to Linux."
|
||||
instructions: "Apply when the PR is mainly about changes specific to Linux"
|
||||
- label: "linux/wayland"
|
||||
instructions: "Apply when the PR contains changes specific to Linux Wayland."
|
||||
instructions: "Apply when the PR is mainly about changes specific to Linux Wayland"
|
||||
- label: "macOS"
|
||||
instructions: "Apply when the PR contains changes specific to macOS."
|
||||
instructions: "Apply when the PR is mainly about changes specific to macOS"
|
||||
- label: "markdown-editor"
|
||||
instructions: "Apply when the PR contains changes to the Markdown editor or Markdown rendering."
|
||||
instructions: "Apply when the PR modifies files under packages/editor/CodeMirror"
|
||||
- label: "mobile"
|
||||
instructions: "Apply when the PR contains changes to the mobile app (iOS or Android)."
|
||||
- label: "multi-window"
|
||||
instructions: "Apply when the PR contains changes related to multi-window support."
|
||||
instructions: "Apply when the PR modifies files under packages/app-mobile/"
|
||||
- label: "OCR"
|
||||
instructions: "Apply when the PR contains changes related to OCR (optical character recognition) functionality."
|
||||
instructions: "Apply when the PR contains changes related to OCR (optical character recognition) functionality"
|
||||
- label: "performance"
|
||||
instructions: "Apply when the PR improves performance, reduces memory usage, or optimises speed."
|
||||
instructions: "Apply when the PR improves performance, reduces memory usage, or optimises speed"
|
||||
- label: "plugins"
|
||||
instructions: "Apply when the PR contains changes to the plugin system, plugin API, or specific plugins."
|
||||
instructions: "Apply when the PR modifies files under packages/lib/services/plugins/ or packages/plugin-repo-cli/"
|
||||
- label: "Regression"
|
||||
instructions: "Apply when the linked issue, if any, has the Regression label."
|
||||
instructions: "Apply when the linked issue, if any, has the Regression label"
|
||||
- label: "renderer"
|
||||
instructions: "Apply when the PR contains changes to the note renderer or how notes are displayed."
|
||||
instructions: "Apply when the PR modifies files under packages/renderer/ or packages/turndown/"
|
||||
- label: "search"
|
||||
instructions: "Apply when the PR contains changes to search functionality."
|
||||
instructions: "Apply when the PR is mainly about changes to the search functionality"
|
||||
- label: "security"
|
||||
instructions: "Apply when the PR addresses a security vulnerability or improves security."
|
||||
instructions: "Apply when the PR is mainly about addressing a security vulnerability or improving security"
|
||||
- label: "server"
|
||||
instructions: "Apply when the PR contains changes to Joplin Server."
|
||||
instructions: "Apply when the PR modifies files under packages/server/"
|
||||
- label: "Sharing"
|
||||
instructions: "Apply when the PR contains changes to note or notebook sharing features."
|
||||
instructions: "Apply when the PR is mainly about changes to the note or notebook/folder sharing features"
|
||||
- label: "sync"
|
||||
instructions: "Apply when the PR contains changes to synchronisation logic or sync targets."
|
||||
instructions: "Apply when the PR modifies files under packages/lib/services/synchronizer/, packages/lib/Sync*.ts or packages/lib/services/e2ee/"
|
||||
- label: "tags"
|
||||
instructions: "Apply when the PR contains changes to tag management or tagging functionality."
|
||||
instructions: "Apply when the PR is mainly about changes to the tag management or tagging functionality"
|
||||
- label: "transcribe"
|
||||
instructions: "Apply when the PR contains changes to audio transcription functionality."
|
||||
instructions: "Apply when the PR modifies files under packages/transcribe"
|
||||
- label: "translation"
|
||||
instructions: "Apply when the PR adds or updates translations or localisation strings."
|
||||
instructions: "Apply when the PR modifies files under packages/tools/locales/ or **/locales/"
|
||||
- label: "Voice typing"
|
||||
instructions: "Apply when the PR contains changes to voice typing functionality."
|
||||
instructions: "Apply when the PR is mainly about changes to the voice typing functionality"
|
||||
- label: "web"
|
||||
instructions: "Apply when the PR contains changes to the Joplin web application or web-related features."
|
||||
instructions: "Apply when the PR modifies files under packages/app-web/. Or when the PR modifies files under packages/app-mobile and the change is specific to the web app only"
|
||||
- label: "windows"
|
||||
instructions: "Apply when the PR contains changes specific to Windows."
|
||||
instructions: "Apply when the PR is mainly about changes specific to Windows"
|
||||
|
||||
pre_merge_checks:
|
||||
description:
|
||||
mode: "warning"
|
||||
custom_checks:
|
||||
- name: "PR Description Must Follow Guidelines"
|
||||
mode: "error"
|
||||
instructions: |
|
||||
Fail if the pull request description does not include clear sections for:
|
||||
- Problem or user-impact description
|
||||
- A high-level Solution explanation
|
||||
- Any Test Plan or verification steps
|
||||
|
||||
The description should align with our PR guidelines
|
||||
at https://github.com/joplin/gsoc/blob/master/pull_request_guidelines.md
|
||||
and should not just restate the diff or implementation details.
|
||||
knowledge_base:
|
||||
code_guidelines:
|
||||
enabled: true
|
||||
filePatterns:
|
||||
- "readme/dev/coding_style.md"
|
||||
- "readme/dev/index.md"
|
||||
- "CLAUDE.md"
|
||||
|
||||
@@ -17,3 +17,4 @@ packages/server/db-*.sqlite
|
||||
packages/server/dist/
|
||||
packages/server/logs/
|
||||
packages/server/temp/
|
||||
packages/transcribe/.env
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
# QUEUE_DATABASE_PASSWORD=transcribe
|
||||
# QUEUE_DATABASE_PORT=5431
|
||||
# HTR_CLI_IMAGES_FOLDER=/home/user/images_storage
|
||||
# HTR_CLI_MODELS_FOLDER=/home/user/transcribe_models
|
||||
|
||||
# =============================================================================
|
||||
# DEV CONFIG EXAMPLE
|
||||
|
||||
@@ -1,35 +1,33 @@
|
||||
# Joplin Transcribe Configuration
|
||||
#
|
||||
# Copy this file to .env-transcribe and update the values.
|
||||
|
||||
# =============================================================================
|
||||
# Required
|
||||
# -----------------------------------------------------------------------------
|
||||
# =============================================================================
|
||||
|
||||
SERVER_PORT=4567
|
||||
# Set a secure API key for authentication
|
||||
API_KEY=changeme
|
||||
|
||||
API_KEY=random-string
|
||||
QUEUE_TTL=900000
|
||||
QUEUE_RETRY_COUNT=2
|
||||
QUEUE_MAINTENANCE_INTERVAL=30000
|
||||
IMAGE_MAX_DIMENSION=400
|
||||
# =============================================================================
|
||||
# Optional (defaults are set in the Docker image)
|
||||
# =============================================================================
|
||||
|
||||
HTR_CLI_DOCKER_IMAGE=joplin/htr-cli:latest
|
||||
# Fullpath to images folder e.g.:
|
||||
#HTR_CLI_IMAGES_FOLDER=/home/user/joplin/packages/transcribe/images
|
||||
HTR_CLI_IMAGES_FOLDER=
|
||||
# Server port (default: 4567)
|
||||
# SERVER_PORT=4567
|
||||
|
||||
QUEUE_DRIVER=pg
|
||||
# Maximum image dimension for processing (default: 400)
|
||||
# IMAGE_MAX_DIMENSION=400
|
||||
|
||||
# Queue driver: sqlite (default) or pg
|
||||
# QUEUE_DRIVER=sqlite
|
||||
|
||||
FILE_STORAGE_MAINTENANCE_INTERVAL=3600000
|
||||
FILE_STORAGE_TTL=604800000 # one week
|
||||
# =============================================================================
|
||||
# PostgreSQL settings (only if QUEUE_DRIVER=pg)
|
||||
# =============================================================================
|
||||
|
||||
# =============================================================================
|
||||
# Queue driver
|
||||
# -----------------------------------------------------------------------------
|
||||
# =============================================================================
|
||||
#
|
||||
# QUEUE_DATABASE_NAME=./queue.sqlite3
|
||||
QUEUE_DATABASE_NAME=transcribe
|
||||
QUEUE_DATABASE_USER=transcribe
|
||||
QUEUE_DATABASE_PASSWORD=transcribe
|
||||
QUEUE_DATABASE_PORT=5432
|
||||
QUEUE_DATABASE_HOST=localhost
|
||||
# QUEUE_DATABASE_NAME=transcribe
|
||||
# QUEUE_DATABASE_USER=transcribe
|
||||
# QUEUE_DATABASE_PASSWORD=transcribe
|
||||
# QUEUE_DATABASE_PORT=5432
|
||||
# QUEUE_DATABASE_HOST=localhost
|
||||
|
||||
@@ -169,6 +169,7 @@ packages/app-desktop/bridge.js
|
||||
packages/app-desktop/checkForUpdates.js
|
||||
packages/app-desktop/commands/copyDevCommand.js
|
||||
packages/app-desktop/commands/copyToClipboard.js
|
||||
packages/app-desktop/commands/createAccessibleDocument.js
|
||||
packages/app-desktop/commands/editProfileConfig.js
|
||||
packages/app-desktop/commands/emptyTrash.js
|
||||
packages/app-desktop/commands/exportDeletionLog.test.js
|
||||
@@ -217,6 +218,8 @@ packages/app-desktop/gui/EditFolderDialog/Dialog.js
|
||||
packages/app-desktop/gui/EditFolderDialog/IconSelector.js
|
||||
packages/app-desktop/gui/EmojiBox.js
|
||||
packages/app-desktop/gui/EncryptionConfigScreen/EncryptionConfigScreen.js
|
||||
packages/app-desktop/gui/EncryptionConfigScreen/enableFlow.test.js
|
||||
packages/app-desktop/gui/EncryptionConfigScreen/enableFlow.js
|
||||
packages/app-desktop/gui/ErrorBoundary.js
|
||||
packages/app-desktop/gui/ExtensionBadge.js
|
||||
packages/app-desktop/gui/FolderIconBox.js
|
||||
@@ -270,6 +273,7 @@ packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/Editor.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/useEditorCommands.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/localisation.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/useContentScriptRegistration.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/useEditorSettings.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/useKeymap.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/useRefocusOnVisiblePaneChange.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/useSyncEditorValue.js
|
||||
@@ -302,6 +306,7 @@ packages/app-desktop/gui/NoteEditor/commands/focusElementNoteTitle.js
|
||||
packages/app-desktop/gui/NoteEditor/commands/focusElementNoteViewer.js
|
||||
packages/app-desktop/gui/NoteEditor/commands/focusElementToolbar.js
|
||||
packages/app-desktop/gui/NoteEditor/commands/index.js
|
||||
packages/app-desktop/gui/NoteEditor/commands/pasteAsMarkdown.js
|
||||
packages/app-desktop/gui/NoteEditor/commands/pasteAsText.js
|
||||
packages/app-desktop/gui/NoteEditor/commands/showLocalSearch.js
|
||||
packages/app-desktop/gui/NoteEditor/commands/showRevisions.js
|
||||
@@ -339,9 +344,11 @@ packages/app-desktop/gui/NoteEditor/utils/useWindowCommandHandler.js
|
||||
packages/app-desktop/gui/NoteList/NoteList2.js
|
||||
packages/app-desktop/gui/NoteList/commands/focusElementNoteList.js
|
||||
packages/app-desktop/gui/NoteList/commands/index.js
|
||||
packages/app-desktop/gui/NoteList/utils/UseAutoScroll.test.js
|
||||
packages/app-desktop/gui/NoteList/utils/canManuallySortNotes.js
|
||||
packages/app-desktop/gui/NoteList/utils/types.js
|
||||
packages/app-desktop/gui/NoteList/utils/useActiveDescendantId.js
|
||||
packages/app-desktop/gui/NoteList/utils/useAutoScroll.js
|
||||
packages/app-desktop/gui/NoteList/utils/useDragAndDrop.js
|
||||
packages/app-desktop/gui/NoteList/utils/useFocusNote.js
|
||||
packages/app-desktop/gui/NoteList/utils/useFocusVisible.js
|
||||
@@ -350,6 +357,8 @@ packages/app-desktop/gui/NoteList/utils/useMoveNote.js
|
||||
packages/app-desktop/gui/NoteList/utils/useOnKeyDown.js
|
||||
packages/app-desktop/gui/NoteList/utils/useOnNoteClick.js
|
||||
packages/app-desktop/gui/NoteList/utils/useOnNoteDoubleClick.js
|
||||
packages/app-desktop/gui/NoteList/utils/useRefocusOnDeletion.test.js
|
||||
packages/app-desktop/gui/NoteList/utils/useRefocusOnDeletion.js
|
||||
packages/app-desktop/gui/NoteList/utils/useScroll.js
|
||||
packages/app-desktop/gui/NoteList/utils/useVisibleRange.test.js
|
||||
packages/app-desktop/gui/NoteList/utils/useVisibleRange.js
|
||||
@@ -371,8 +380,8 @@ packages/app-desktop/gui/NoteListItem/utils/getNoteTitleHtml.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/prepareViewProps.test.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/prepareViewProps.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/types.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/useItemElement.test.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/useItemElement.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/useItemEventHandlers.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/useOnContextMenu.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/useRenderedNote.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/useRootElement.test.js
|
||||
@@ -526,6 +535,8 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/utils/useSyncDialogState.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/useWindowCommands.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/useWindowControl.js
|
||||
packages/app-desktop/gui/dialogs.js
|
||||
packages/app-desktop/gui/hooks/useCtrlWheelZoom.test.js
|
||||
packages/app-desktop/gui/hooks/useCtrlWheelZoom.js
|
||||
packages/app-desktop/gui/hooks/useEffectDebugger.js
|
||||
packages/app-desktop/gui/hooks/useElementHeight.js
|
||||
packages/app-desktop/gui/hooks/useImperativeHandlerDebugger.js
|
||||
@@ -558,6 +569,7 @@ packages/app-desktop/integration-tests/models/NoteEditorScreen.js
|
||||
packages/app-desktop/integration-tests/models/NoteList.js
|
||||
packages/app-desktop/integration-tests/models/SettingsScreen.js
|
||||
packages/app-desktop/integration-tests/models/Sidebar.js
|
||||
packages/app-desktop/integration-tests/multiWindow.spec.js
|
||||
packages/app-desktop/integration-tests/noteList.spec.js
|
||||
packages/app-desktop/integration-tests/pluginApi.spec.js
|
||||
packages/app-desktop/integration-tests/resizableLayout.spec.js
|
||||
@@ -579,6 +591,7 @@ packages/app-desktop/integration-tests/util/setMessageBoxResponse.js
|
||||
packages/app-desktop/integration-tests/util/setSettingValue.js
|
||||
packages/app-desktop/integration-tests/util/test.js
|
||||
packages/app-desktop/integration-tests/util/waitForNextOpenPath.js
|
||||
packages/app-desktop/integration-tests/util/waitForNextWindowMatching.js
|
||||
packages/app-desktop/integration-tests/wcag.spec.js
|
||||
packages/app-desktop/main-html.js
|
||||
packages/app-desktop/main.js
|
||||
@@ -607,10 +620,6 @@ packages/app-desktop/services/plugins/hooks/useViewIsReady.js
|
||||
packages/app-desktop/services/plugins/hooks/useWebviewToPluginMessages.js
|
||||
packages/app-desktop/services/plugins/types.js
|
||||
packages/app-desktop/services/restart.js
|
||||
packages/app-desktop/services/sortOrder/PerFolderSortOrderService.test.js
|
||||
packages/app-desktop/services/sortOrder/PerFolderSortOrderService.js
|
||||
packages/app-desktop/services/sortOrder/notesSortOrderUtils.test.js
|
||||
packages/app-desktop/services/sortOrder/notesSortOrderUtils.js
|
||||
packages/app-desktop/services/spellChecker/SpellCheckerServiceDriverNative.js
|
||||
packages/app-desktop/tools/bundleJs.js
|
||||
packages/app-desktop/tools/copy7Zip.js
|
||||
@@ -646,6 +655,7 @@ packages/app-mobile/commands/newNote.js
|
||||
packages/app-mobile/commands/openItem.js
|
||||
packages/app-mobile/commands/openNote.js
|
||||
packages/app-mobile/commands/scrollToHash.js
|
||||
packages/app-mobile/commands/util/goToFolder.js
|
||||
packages/app-mobile/commands/util/goToNote.js
|
||||
packages/app-mobile/commands/util/showResource.js
|
||||
packages/app-mobile/components/BetaChip.js
|
||||
@@ -694,8 +704,6 @@ packages/app-mobile/components/ExtendedWebView/index.js
|
||||
packages/app-mobile/components/ExtendedWebView/index.web.js
|
||||
packages/app-mobile/components/ExtendedWebView/types.js
|
||||
packages/app-mobile/components/ExtendedWebView/utils/useCss.js
|
||||
packages/app-mobile/components/FeedbackBanner.test.js
|
||||
packages/app-mobile/components/FeedbackBanner.js
|
||||
packages/app-mobile/components/FolderPicker.js
|
||||
packages/app-mobile/components/Icon.js
|
||||
packages/app-mobile/components/IconButton.js
|
||||
@@ -740,7 +748,6 @@ packages/app-mobile/components/ScreenHeader/Menu.js
|
||||
packages/app-mobile/components/ScreenHeader/WarningBanner.test.js
|
||||
packages/app-mobile/components/ScreenHeader/WarningBanner.js
|
||||
packages/app-mobile/components/ScreenHeader/WarningBox.js
|
||||
packages/app-mobile/components/ScreenHeader/WebBetaButton.js
|
||||
packages/app-mobile/components/ScreenHeader/index.js
|
||||
packages/app-mobile/components/SearchInput.js
|
||||
packages/app-mobile/components/SelectDateTimeDialog.js
|
||||
@@ -904,6 +911,7 @@ packages/app-mobile/contentScripts/markdownEditorBundle/useWebViewSetup.js
|
||||
packages/app-mobile/contentScripts/markdownEditorBundle/utils/useCodeMirrorPlugins.js
|
||||
packages/app-mobile/contentScripts/rendererBundle/contentScript/Renderer.test.js
|
||||
packages/app-mobile/contentScripts/rendererBundle/contentScript/Renderer.js
|
||||
packages/app-mobile/contentScripts/rendererBundle/contentScript/index.handleAnchorClick.test.js
|
||||
packages/app-mobile/contentScripts/rendererBundle/contentScript/index.js
|
||||
packages/app-mobile/contentScripts/rendererBundle/contentScript/types.js
|
||||
packages/app-mobile/contentScripts/rendererBundle/contentScript/utils/addPluginAssets.js
|
||||
@@ -1265,6 +1273,7 @@ packages/lib/SyncTargetRegistry.js
|
||||
packages/lib/Synchronizer.js
|
||||
packages/lib/TaskQueue.js
|
||||
packages/lib/WebDavApi.js
|
||||
packages/lib/WelcomeUtils.test.js
|
||||
packages/lib/WelcomeUtils.js
|
||||
packages/lib/array.js
|
||||
packages/lib/callbackUrlUtils.test.js
|
||||
@@ -1293,6 +1302,7 @@ packages/lib/components/EncryptionConfigScreen/utils.test.js
|
||||
packages/lib/components/EncryptionConfigScreen/utils.js
|
||||
packages/lib/components/shared/NoteEditor/WarningBanner/onRichTextDismissLinkClick.js
|
||||
packages/lib/components/shared/NoteEditor/WarningBanner/onRichTextReadMoreLinkClick.js
|
||||
packages/lib/components/shared/NoteEditor/WarningBanner/useEditorTypeMigrationBanner.js
|
||||
packages/lib/components/shared/NoteList/getEmptyFolderMessage.js
|
||||
packages/lib/components/shared/NoteRevisionViewer/getHelpMessage.js
|
||||
packages/lib/components/shared/NoteRevisionViewer/useDeleteHistoryClick.js
|
||||
@@ -1431,6 +1441,7 @@ packages/lib/services/AlarmServiceDriverNode.js
|
||||
packages/lib/services/BaseService.js
|
||||
packages/lib/services/CommandService.test.js
|
||||
packages/lib/services/CommandService.js
|
||||
packages/lib/services/DecryptionWorker.test.js
|
||||
packages/lib/services/DecryptionWorker.js
|
||||
packages/lib/services/ExternalEditWatcher.js
|
||||
packages/lib/services/ExternalEditWatcher/utils.js
|
||||
@@ -1519,6 +1530,7 @@ packages/lib/services/interop/InteropService_Importer_Md.test.js
|
||||
packages/lib/services/interop/InteropService_Importer_Md.js
|
||||
packages/lib/services/interop/InteropService_Importer_Md_frontmatter.test.js
|
||||
packages/lib/services/interop/InteropService_Importer_Md_frontmatter.js
|
||||
packages/lib/services/interop/InteropService_Importer_OneNote.postprocessHtml.test.js
|
||||
packages/lib/services/interop/InteropService_Importer_OneNote.test.js
|
||||
packages/lib/services/interop/InteropService_Importer_OneNote.js
|
||||
packages/lib/services/interop/InteropService_Importer_Raw.test.js
|
||||
@@ -1552,6 +1564,7 @@ packages/lib/services/ocr/OcrService.js
|
||||
packages/lib/services/ocr/drivers/OcrDriverTesseract.js
|
||||
packages/lib/services/ocr/drivers/OcrDriverTranscribe.test.js
|
||||
packages/lib/services/ocr/drivers/OcrDriverTranscribe.js
|
||||
packages/lib/services/ocr/utils/createAccessiblePdf.js
|
||||
packages/lib/services/ocr/utils/filterOcrText.test.js
|
||||
packages/lib/services/ocr/utils/filterOcrText.js
|
||||
packages/lib/services/ocr/utils/types.js
|
||||
@@ -1573,6 +1586,7 @@ packages/lib/services/plugins/api/JoplinCommands.js
|
||||
packages/lib/services/plugins/api/JoplinContentScripts.js
|
||||
packages/lib/services/plugins/api/JoplinData.js
|
||||
packages/lib/services/plugins/api/JoplinFilters.js
|
||||
packages/lib/services/plugins/api/JoplinFs.js
|
||||
packages/lib/services/plugins/api/JoplinImaging.js
|
||||
packages/lib/services/plugins/api/JoplinInterop.js
|
||||
packages/lib/services/plugins/api/JoplinPlugins.js
|
||||
@@ -1671,6 +1685,10 @@ packages/lib/services/share/ShareService.test.js
|
||||
packages/lib/services/share/ShareService.js
|
||||
packages/lib/services/share/invitationRespond.js
|
||||
packages/lib/services/share/reducer.js
|
||||
packages/lib/services/sortOrder/PerFolderSortOrderService.test.js
|
||||
packages/lib/services/sortOrder/PerFolderSortOrderService.js
|
||||
packages/lib/services/sortOrder/notesSortOrderUtils.test.js
|
||||
packages/lib/services/sortOrder/notesSortOrderUtils.js
|
||||
packages/lib/services/spellChecker/SpellCheckerService.js
|
||||
packages/lib/services/spellChecker/SpellCheckerServiceDriverBase.js
|
||||
packages/lib/services/style/cssToTheme.test.js
|
||||
@@ -1889,11 +1907,11 @@ packages/tools/fuzzer/utils/SeededRandom.js
|
||||
packages/tools/fuzzer/utils/diffSortedStringArrays.test.js
|
||||
packages/tools/fuzzer/utils/diffSortedStringArrays.js
|
||||
packages/tools/fuzzer/utils/extractResourceIds.js
|
||||
packages/tools/fuzzer/utils/getBinaryDiffDebugMessage.js
|
||||
packages/tools/fuzzer/utils/getNumberProperty.js
|
||||
packages/tools/fuzzer/utils/getProperty.js
|
||||
packages/tools/fuzzer/utils/getStringProperty.js
|
||||
packages/tools/fuzzer/utils/hangingIndent.js
|
||||
packages/tools/fuzzer/utils/logDiffDebug.js
|
||||
packages/tools/fuzzer/utils/openDebugSession.js
|
||||
packages/tools/fuzzer/utils/randomId.test.js
|
||||
packages/tools/fuzzer/utils/randomId.js
|
||||
@@ -1940,6 +1958,7 @@ packages/tools/update-readme-contributors.js
|
||||
packages/tools/update-readme-download.test.js
|
||||
packages/tools/update-readme-download.js
|
||||
packages/tools/update-readme-sponsors.js
|
||||
packages/tools/updateCanary.js
|
||||
packages/tools/updateMarkdownDoc.js
|
||||
packages/tools/utils/discourse.test.js
|
||||
packages/tools/utils/discourse.js
|
||||
|
||||
@@ -214,7 +214,6 @@ module.exports = {
|
||||
'packages/tools/**',
|
||||
'packages/app-mobile/tools/**',
|
||||
'packages/app-desktop/tools/**',
|
||||
'packages/transcribe/src/tools/**',
|
||||
],
|
||||
'rules': {
|
||||
'no-console': 'off',
|
||||
|
||||
10
.github/PULL_REQUEST_TEMPLATE
vendored
10
.github/PULL_REQUEST_TEMPLATE
vendored
@@ -1,6 +1,12 @@
|
||||
<!--
|
||||
|
||||
Please prefix the title with the platform you are targetting:
|
||||
Before contributing, please read the contribution guidelines: https://github.com/laurent22/joplin/blob/dev/readme/dev/index.md
|
||||
|
||||
If this is a Google Summer of Code pull request, please read the [GSoC pull request guidelines](https://github.com/joplin/gsoc/blob/master/pull_request_guidelines.md).
|
||||
|
||||
---
|
||||
|
||||
**Pull request title**: Please prefix the title with the platform you are targetting.
|
||||
|
||||
Here are some examples of good titles:
|
||||
|
||||
@@ -20,6 +26,4 @@ If it's not related to any platform (such as a translation, change to the docume
|
||||
|
||||
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.
|
||||
|
||||
AND PLEASE READ THE GUIDE: https://github.com/laurent22/joplin/blob/dev/readme/dev/index.md
|
||||
|
||||
-->
|
||||
4
.github/workflows/build-android.yml
vendored
4
.github/workflows/build-android.yml
vendored
@@ -4,6 +4,10 @@
|
||||
name: react-native-android-build-apk
|
||||
on: [push, pull_request]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.ref == 'refs/heads/dev' && github.run_id || format('{0}-{1}', github.workflow, github.ref) }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
AssembleRelease:
|
||||
if: github.repository == 'laurent22/joplin'
|
||||
|
||||
5
.github/workflows/build-macos-m1.yml
vendored
5
.github/workflows/build-macos-m1.yml
vendored
@@ -1,5 +1,10 @@
|
||||
name: Build macOS M1
|
||||
on: [push, pull_request]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.ref == 'refs/heads/dev' && github.run_id || format('{0}-{1}', github.workflow, github.ref) }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
Main:
|
||||
# We always process desktop release tags, because they also publish the release
|
||||
|
||||
5
.github/workflows/github-actions-main.yml
vendored
5
.github/workflows/github-actions-main.yml
vendored
@@ -1,5 +1,10 @@
|
||||
name: Joplin Continuous Integration
|
||||
on: [push, pull_request]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.ref == 'refs/heads/dev' && github.run_id || format('{0}-{1}', github.workflow, github.ref) }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
Main:
|
||||
# We always process server or desktop release tags, because they also publish the release
|
||||
|
||||
5
.github/workflows/ui-tests.yml
vendored
5
.github/workflows/ui-tests.yml
vendored
@@ -1,5 +1,10 @@
|
||||
name: Joplin UI tests
|
||||
on: [push, pull_request]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.ref == 'refs/heads/dev' && github.run_id || format('{0}-{1}', github.workflow, github.ref) }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
|
||||
38
.gitignore
vendored
38
.gitignore
vendored
@@ -142,6 +142,7 @@ packages/app-desktop/bridge.js
|
||||
packages/app-desktop/checkForUpdates.js
|
||||
packages/app-desktop/commands/copyDevCommand.js
|
||||
packages/app-desktop/commands/copyToClipboard.js
|
||||
packages/app-desktop/commands/createAccessibleDocument.js
|
||||
packages/app-desktop/commands/editProfileConfig.js
|
||||
packages/app-desktop/commands/emptyTrash.js
|
||||
packages/app-desktop/commands/exportDeletionLog.test.js
|
||||
@@ -190,6 +191,8 @@ packages/app-desktop/gui/EditFolderDialog/Dialog.js
|
||||
packages/app-desktop/gui/EditFolderDialog/IconSelector.js
|
||||
packages/app-desktop/gui/EmojiBox.js
|
||||
packages/app-desktop/gui/EncryptionConfigScreen/EncryptionConfigScreen.js
|
||||
packages/app-desktop/gui/EncryptionConfigScreen/enableFlow.test.js
|
||||
packages/app-desktop/gui/EncryptionConfigScreen/enableFlow.js
|
||||
packages/app-desktop/gui/ErrorBoundary.js
|
||||
packages/app-desktop/gui/ExtensionBadge.js
|
||||
packages/app-desktop/gui/FolderIconBox.js
|
||||
@@ -243,6 +246,7 @@ packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/Editor.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/useEditorCommands.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/localisation.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/useContentScriptRegistration.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/useEditorSettings.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/useKeymap.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/useRefocusOnVisiblePaneChange.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/useSyncEditorValue.js
|
||||
@@ -275,6 +279,7 @@ packages/app-desktop/gui/NoteEditor/commands/focusElementNoteTitle.js
|
||||
packages/app-desktop/gui/NoteEditor/commands/focusElementNoteViewer.js
|
||||
packages/app-desktop/gui/NoteEditor/commands/focusElementToolbar.js
|
||||
packages/app-desktop/gui/NoteEditor/commands/index.js
|
||||
packages/app-desktop/gui/NoteEditor/commands/pasteAsMarkdown.js
|
||||
packages/app-desktop/gui/NoteEditor/commands/pasteAsText.js
|
||||
packages/app-desktop/gui/NoteEditor/commands/showLocalSearch.js
|
||||
packages/app-desktop/gui/NoteEditor/commands/showRevisions.js
|
||||
@@ -312,9 +317,11 @@ packages/app-desktop/gui/NoteEditor/utils/useWindowCommandHandler.js
|
||||
packages/app-desktop/gui/NoteList/NoteList2.js
|
||||
packages/app-desktop/gui/NoteList/commands/focusElementNoteList.js
|
||||
packages/app-desktop/gui/NoteList/commands/index.js
|
||||
packages/app-desktop/gui/NoteList/utils/UseAutoScroll.test.js
|
||||
packages/app-desktop/gui/NoteList/utils/canManuallySortNotes.js
|
||||
packages/app-desktop/gui/NoteList/utils/types.js
|
||||
packages/app-desktop/gui/NoteList/utils/useActiveDescendantId.js
|
||||
packages/app-desktop/gui/NoteList/utils/useAutoScroll.js
|
||||
packages/app-desktop/gui/NoteList/utils/useDragAndDrop.js
|
||||
packages/app-desktop/gui/NoteList/utils/useFocusNote.js
|
||||
packages/app-desktop/gui/NoteList/utils/useFocusVisible.js
|
||||
@@ -323,6 +330,8 @@ packages/app-desktop/gui/NoteList/utils/useMoveNote.js
|
||||
packages/app-desktop/gui/NoteList/utils/useOnKeyDown.js
|
||||
packages/app-desktop/gui/NoteList/utils/useOnNoteClick.js
|
||||
packages/app-desktop/gui/NoteList/utils/useOnNoteDoubleClick.js
|
||||
packages/app-desktop/gui/NoteList/utils/useRefocusOnDeletion.test.js
|
||||
packages/app-desktop/gui/NoteList/utils/useRefocusOnDeletion.js
|
||||
packages/app-desktop/gui/NoteList/utils/useScroll.js
|
||||
packages/app-desktop/gui/NoteList/utils/useVisibleRange.test.js
|
||||
packages/app-desktop/gui/NoteList/utils/useVisibleRange.js
|
||||
@@ -344,8 +353,8 @@ packages/app-desktop/gui/NoteListItem/utils/getNoteTitleHtml.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/prepareViewProps.test.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/prepareViewProps.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/types.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/useItemElement.test.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/useItemElement.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/useItemEventHandlers.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/useOnContextMenu.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/useRenderedNote.js
|
||||
packages/app-desktop/gui/NoteListItem/utils/useRootElement.test.js
|
||||
@@ -499,6 +508,8 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/utils/useSyncDialogState.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/useWindowCommands.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/useWindowControl.js
|
||||
packages/app-desktop/gui/dialogs.js
|
||||
packages/app-desktop/gui/hooks/useCtrlWheelZoom.test.js
|
||||
packages/app-desktop/gui/hooks/useCtrlWheelZoom.js
|
||||
packages/app-desktop/gui/hooks/useEffectDebugger.js
|
||||
packages/app-desktop/gui/hooks/useElementHeight.js
|
||||
packages/app-desktop/gui/hooks/useImperativeHandlerDebugger.js
|
||||
@@ -531,6 +542,7 @@ packages/app-desktop/integration-tests/models/NoteEditorScreen.js
|
||||
packages/app-desktop/integration-tests/models/NoteList.js
|
||||
packages/app-desktop/integration-tests/models/SettingsScreen.js
|
||||
packages/app-desktop/integration-tests/models/Sidebar.js
|
||||
packages/app-desktop/integration-tests/multiWindow.spec.js
|
||||
packages/app-desktop/integration-tests/noteList.spec.js
|
||||
packages/app-desktop/integration-tests/pluginApi.spec.js
|
||||
packages/app-desktop/integration-tests/resizableLayout.spec.js
|
||||
@@ -552,6 +564,7 @@ packages/app-desktop/integration-tests/util/setMessageBoxResponse.js
|
||||
packages/app-desktop/integration-tests/util/setSettingValue.js
|
||||
packages/app-desktop/integration-tests/util/test.js
|
||||
packages/app-desktop/integration-tests/util/waitForNextOpenPath.js
|
||||
packages/app-desktop/integration-tests/util/waitForNextWindowMatching.js
|
||||
packages/app-desktop/integration-tests/wcag.spec.js
|
||||
packages/app-desktop/main-html.js
|
||||
packages/app-desktop/main.js
|
||||
@@ -580,10 +593,6 @@ packages/app-desktop/services/plugins/hooks/useViewIsReady.js
|
||||
packages/app-desktop/services/plugins/hooks/useWebviewToPluginMessages.js
|
||||
packages/app-desktop/services/plugins/types.js
|
||||
packages/app-desktop/services/restart.js
|
||||
packages/app-desktop/services/sortOrder/PerFolderSortOrderService.test.js
|
||||
packages/app-desktop/services/sortOrder/PerFolderSortOrderService.js
|
||||
packages/app-desktop/services/sortOrder/notesSortOrderUtils.test.js
|
||||
packages/app-desktop/services/sortOrder/notesSortOrderUtils.js
|
||||
packages/app-desktop/services/spellChecker/SpellCheckerServiceDriverNative.js
|
||||
packages/app-desktop/tools/bundleJs.js
|
||||
packages/app-desktop/tools/copy7Zip.js
|
||||
@@ -619,6 +628,7 @@ packages/app-mobile/commands/newNote.js
|
||||
packages/app-mobile/commands/openItem.js
|
||||
packages/app-mobile/commands/openNote.js
|
||||
packages/app-mobile/commands/scrollToHash.js
|
||||
packages/app-mobile/commands/util/goToFolder.js
|
||||
packages/app-mobile/commands/util/goToNote.js
|
||||
packages/app-mobile/commands/util/showResource.js
|
||||
packages/app-mobile/components/BetaChip.js
|
||||
@@ -667,8 +677,6 @@ packages/app-mobile/components/ExtendedWebView/index.js
|
||||
packages/app-mobile/components/ExtendedWebView/index.web.js
|
||||
packages/app-mobile/components/ExtendedWebView/types.js
|
||||
packages/app-mobile/components/ExtendedWebView/utils/useCss.js
|
||||
packages/app-mobile/components/FeedbackBanner.test.js
|
||||
packages/app-mobile/components/FeedbackBanner.js
|
||||
packages/app-mobile/components/FolderPicker.js
|
||||
packages/app-mobile/components/Icon.js
|
||||
packages/app-mobile/components/IconButton.js
|
||||
@@ -713,7 +721,6 @@ packages/app-mobile/components/ScreenHeader/Menu.js
|
||||
packages/app-mobile/components/ScreenHeader/WarningBanner.test.js
|
||||
packages/app-mobile/components/ScreenHeader/WarningBanner.js
|
||||
packages/app-mobile/components/ScreenHeader/WarningBox.js
|
||||
packages/app-mobile/components/ScreenHeader/WebBetaButton.js
|
||||
packages/app-mobile/components/ScreenHeader/index.js
|
||||
packages/app-mobile/components/SearchInput.js
|
||||
packages/app-mobile/components/SelectDateTimeDialog.js
|
||||
@@ -877,6 +884,7 @@ packages/app-mobile/contentScripts/markdownEditorBundle/useWebViewSetup.js
|
||||
packages/app-mobile/contentScripts/markdownEditorBundle/utils/useCodeMirrorPlugins.js
|
||||
packages/app-mobile/contentScripts/rendererBundle/contentScript/Renderer.test.js
|
||||
packages/app-mobile/contentScripts/rendererBundle/contentScript/Renderer.js
|
||||
packages/app-mobile/contentScripts/rendererBundle/contentScript/index.handleAnchorClick.test.js
|
||||
packages/app-mobile/contentScripts/rendererBundle/contentScript/index.js
|
||||
packages/app-mobile/contentScripts/rendererBundle/contentScript/types.js
|
||||
packages/app-mobile/contentScripts/rendererBundle/contentScript/utils/addPluginAssets.js
|
||||
@@ -1238,6 +1246,7 @@ packages/lib/SyncTargetRegistry.js
|
||||
packages/lib/Synchronizer.js
|
||||
packages/lib/TaskQueue.js
|
||||
packages/lib/WebDavApi.js
|
||||
packages/lib/WelcomeUtils.test.js
|
||||
packages/lib/WelcomeUtils.js
|
||||
packages/lib/array.js
|
||||
packages/lib/callbackUrlUtils.test.js
|
||||
@@ -1266,6 +1275,7 @@ packages/lib/components/EncryptionConfigScreen/utils.test.js
|
||||
packages/lib/components/EncryptionConfigScreen/utils.js
|
||||
packages/lib/components/shared/NoteEditor/WarningBanner/onRichTextDismissLinkClick.js
|
||||
packages/lib/components/shared/NoteEditor/WarningBanner/onRichTextReadMoreLinkClick.js
|
||||
packages/lib/components/shared/NoteEditor/WarningBanner/useEditorTypeMigrationBanner.js
|
||||
packages/lib/components/shared/NoteList/getEmptyFolderMessage.js
|
||||
packages/lib/components/shared/NoteRevisionViewer/getHelpMessage.js
|
||||
packages/lib/components/shared/NoteRevisionViewer/useDeleteHistoryClick.js
|
||||
@@ -1404,6 +1414,7 @@ packages/lib/services/AlarmServiceDriverNode.js
|
||||
packages/lib/services/BaseService.js
|
||||
packages/lib/services/CommandService.test.js
|
||||
packages/lib/services/CommandService.js
|
||||
packages/lib/services/DecryptionWorker.test.js
|
||||
packages/lib/services/DecryptionWorker.js
|
||||
packages/lib/services/ExternalEditWatcher.js
|
||||
packages/lib/services/ExternalEditWatcher/utils.js
|
||||
@@ -1492,6 +1503,7 @@ packages/lib/services/interop/InteropService_Importer_Md.test.js
|
||||
packages/lib/services/interop/InteropService_Importer_Md.js
|
||||
packages/lib/services/interop/InteropService_Importer_Md_frontmatter.test.js
|
||||
packages/lib/services/interop/InteropService_Importer_Md_frontmatter.js
|
||||
packages/lib/services/interop/InteropService_Importer_OneNote.postprocessHtml.test.js
|
||||
packages/lib/services/interop/InteropService_Importer_OneNote.test.js
|
||||
packages/lib/services/interop/InteropService_Importer_OneNote.js
|
||||
packages/lib/services/interop/InteropService_Importer_Raw.test.js
|
||||
@@ -1525,6 +1537,7 @@ packages/lib/services/ocr/OcrService.js
|
||||
packages/lib/services/ocr/drivers/OcrDriverTesseract.js
|
||||
packages/lib/services/ocr/drivers/OcrDriverTranscribe.test.js
|
||||
packages/lib/services/ocr/drivers/OcrDriverTranscribe.js
|
||||
packages/lib/services/ocr/utils/createAccessiblePdf.js
|
||||
packages/lib/services/ocr/utils/filterOcrText.test.js
|
||||
packages/lib/services/ocr/utils/filterOcrText.js
|
||||
packages/lib/services/ocr/utils/types.js
|
||||
@@ -1546,6 +1559,7 @@ packages/lib/services/plugins/api/JoplinCommands.js
|
||||
packages/lib/services/plugins/api/JoplinContentScripts.js
|
||||
packages/lib/services/plugins/api/JoplinData.js
|
||||
packages/lib/services/plugins/api/JoplinFilters.js
|
||||
packages/lib/services/plugins/api/JoplinFs.js
|
||||
packages/lib/services/plugins/api/JoplinImaging.js
|
||||
packages/lib/services/plugins/api/JoplinInterop.js
|
||||
packages/lib/services/plugins/api/JoplinPlugins.js
|
||||
@@ -1644,6 +1658,10 @@ packages/lib/services/share/ShareService.test.js
|
||||
packages/lib/services/share/ShareService.js
|
||||
packages/lib/services/share/invitationRespond.js
|
||||
packages/lib/services/share/reducer.js
|
||||
packages/lib/services/sortOrder/PerFolderSortOrderService.test.js
|
||||
packages/lib/services/sortOrder/PerFolderSortOrderService.js
|
||||
packages/lib/services/sortOrder/notesSortOrderUtils.test.js
|
||||
packages/lib/services/sortOrder/notesSortOrderUtils.js
|
||||
packages/lib/services/spellChecker/SpellCheckerService.js
|
||||
packages/lib/services/spellChecker/SpellCheckerServiceDriverBase.js
|
||||
packages/lib/services/style/cssToTheme.test.js
|
||||
@@ -1862,11 +1880,11 @@ packages/tools/fuzzer/utils/SeededRandom.js
|
||||
packages/tools/fuzzer/utils/diffSortedStringArrays.test.js
|
||||
packages/tools/fuzzer/utils/diffSortedStringArrays.js
|
||||
packages/tools/fuzzer/utils/extractResourceIds.js
|
||||
packages/tools/fuzzer/utils/getBinaryDiffDebugMessage.js
|
||||
packages/tools/fuzzer/utils/getNumberProperty.js
|
||||
packages/tools/fuzzer/utils/getProperty.js
|
||||
packages/tools/fuzzer/utils/getStringProperty.js
|
||||
packages/tools/fuzzer/utils/hangingIndent.js
|
||||
packages/tools/fuzzer/utils/logDiffDebug.js
|
||||
packages/tools/fuzzer/utils/openDebugSession.js
|
||||
packages/tools/fuzzer/utils/randomId.test.js
|
||||
packages/tools/fuzzer/utils/randomId.js
|
||||
@@ -1913,6 +1931,7 @@ packages/tools/update-readme-contributors.js
|
||||
packages/tools/update-readme-download.test.js
|
||||
packages/tools/update-readme-download.js
|
||||
packages/tools/update-readme-sponsors.js
|
||||
packages/tools/updateCanary.js
|
||||
packages/tools/updateMarkdownDoc.js
|
||||
packages/tools/utils/discourse.test.js
|
||||
packages/tools/utils/discourse.js
|
||||
@@ -1945,4 +1964,3 @@ packages/tools/website/utils/types.js
|
||||
packages/whisper-voice-typing/src/index.js
|
||||
packages/whisper-voice-typing/src/specs/Whisper.nitro.js
|
||||
# AUTO-GENERATED - EXCLUDED TYPESCRIPT BUILD
|
||||
|
||||
|
||||
24
.yarn/patches/formidable-npm-2.1.2-40ba18d67f.patch
Normal file
24
.yarn/patches/formidable-npm-2.1.2-40ba18d67f.patch
Normal file
@@ -0,0 +1,24 @@
|
||||
# Resolves an issue in which notes and attachments larger than 16 KB
|
||||
# could become corrupted during the upload process.
|
||||
# See https://github.com/laurent22/joplin/issues/14343
|
||||
diff --git a/src/parsers/JSON.js b/src/parsers/JSON.js
|
||||
index 9a096c25778c7c68be1ddd9dd78faa85bd1d8ec3..6d6bfd2d3789313a7adc8966ab8e58c3d3167356 100644
|
||||
--- a/src/parsers/JSON.js
|
||||
+++ b/src/parsers/JSON.js
|
||||
@@ -12,13 +12,14 @@ class JSONParser extends Transform {
|
||||
}
|
||||
|
||||
_transform(chunk, encoding, callback) {
|
||||
- this.chunks.push(String(chunk)); // todo consider using a string decoder
|
||||
+ this.chunks.push(chunk); // type: Uint8Array
|
||||
callback();
|
||||
}
|
||||
|
||||
_flush(callback) {
|
||||
try {
|
||||
- const fields = JSON.parse(this.chunks.join(''));
|
||||
+ const data = Buffer.concat(this.chunks);
|
||||
+ const fields = JSON.parse(data.toString('utf-8'));
|
||||
Object.keys(fields).forEach((key) => {
|
||||
const value = fields[key];
|
||||
this.push({ key, value });
|
||||
@@ -1351,11 +1351,7 @@ footer .bottom-links-row p {
|
||||
ENGLISH VERSION
|
||||
*****************************************************************/
|
||||
|
||||
:lang(en-gb) #made-in-france-section {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:lang(en-gb) .top-section-img-cn {
|
||||
:not(:lang(zh-cn)) .top-section-img-cn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
BIN
Assets/WebsiteAssets/images/sponsors/TheNationOnline.jpg
Normal file
BIN
Assets/WebsiteAssets/images/sponsors/TheNationOnline.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.0 KiB |
@@ -145,7 +145,7 @@ function setupLocaleRedirect() {
|
||||
if (!isRootPage) return;
|
||||
|
||||
// Check if user has explicitly chosen to stay on current locale
|
||||
const localePreference = localStorage.getItem('joplin-locale-preference');
|
||||
const localePreference = (localStorage.getItem('joplin-locale-preference') || '').toLowerCase();
|
||||
if (localePreference === 'en') return;
|
||||
|
||||
// Get user's preferred language from browser
|
||||
@@ -160,9 +160,10 @@ function setupLocaleRedirect() {
|
||||
window.location.href = getLocalePath(langCode) + '/';
|
||||
}
|
||||
|
||||
// Allow users to switch back to English and remember their preference
|
||||
function setLocalePreference(locale) {
|
||||
// Allow users to switch language and remember their preference
|
||||
function setLocalePreference(locale, url) {
|
||||
localStorage.setItem('joplin-locale-preference', locale);
|
||||
window.location.href = url;
|
||||
}
|
||||
|
||||
// Expose globally for language switcher links
|
||||
|
||||
@@ -1,4 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Joplin]]></title><description><![CDATA[Joplin, the open source note-taking application]]></description><link>https://joplinapp.org</link><generator>RSS for Node</generator><lastBuildDate>Tue, 10 Feb 2026 00:00:00 GMT</lastBuildDate><atom:link href="https://joplinapp.org/rss.xml" rel="self" type="application/rss+xml"/><pubDate>Tue, 10 Feb 2026 00:00:00 GMT</pubDate><item><title><![CDATA[Joplin will come preloaded on the HMD Terra M]]></title><description><![CDATA[<div style="overflow: auto;">
|
||||
<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Joplin]]></title><description><![CDATA[Joplin, the open source note-taking application]]></description><link>https://joplinapp.org</link><generator>RSS for Node</generator><lastBuildDate>Mon, 23 Feb 2026 00:00:00 GMT</lastBuildDate><atom:link href="https://joplinapp.org/rss.xml" rel="self" type="application/rss+xml"/><pubDate>Mon, 23 Feb 2026 00:00:00 GMT</pubDate><item><title><![CDATA[Introducing our Warrant Canary]]></title><description><![CDATA[<p>We have introduced a publicly signed warrant canary for Joplin.</p>
|
||||
<p>A warrant canary is a regularly updated statement confirming that, as of the stated date, the project has not received secret legal orders, gag orders, or demands requiring the introduction of backdoors into the software or its infrastructure.</p>
|
||||
<p>The canary is:</p>
|
||||
<ul>
|
||||
<li>
|
||||
<p>Cryptographically signed using a dedicated OpenPGP key</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>Updated every 60 days</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>Published in plain text for independent verification</p>
|
||||
</li>
|
||||
</ul>
|
||||
<p>If the canary is not updated within its stated validity window, it should be considered expired.</p>
|
||||
<p>You can view and verify the current canary here:</p>
|
||||
<p><a href="https://raw.githubusercontent.com/laurent22/joplin/refs/heads/dev/readme/canary.txt">https://raw.githubusercontent.com/laurent22/joplin/refs/heads/dev/readme/canary.txt</a></p>
|
||||
<p>With additional information on how it is generated and managed there:</p>
|
||||
<p><a href="https://github.com/laurent22/joplin/blob/dev/readme/canary.md">https://github.com/laurent22/joplin/blob/dev/readme/canary.md</a></p>
|
||||
<p>This measure is intended to improve transparency and provide an additional signal to the community. It does not prevent legal orders, but it helps ensure that any material change in our legal status cannot occur silently.</p>
|
||||
]]></description><link>https://joplinapp.org/news/20260223-warrant-canary</link><guid isPermaLink="false">20260223-warrant-canary</guid><pubDate>Mon, 23 Feb 2026 00:00:00 GMT</pubDate><twitter-text></twitter-text></item><item><title><![CDATA[Joplin will come preloaded on the HMD Terra M]]></title><description><![CDATA[<div style="overflow: auto;">
|
||||
<img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20260210-hmd-joplin-logo.png" width="200px" style="float: left; margin-right: 16px; margin-bottom: 16px;"/>
|
||||
<p>We’re happy to announce a collaboration with <a href="https://www.hmdsecure.com/">HMD Secure</a>, who will preload Joplin on their upcoming device, the HMD Terra M.</p>
|
||||
<p>This partnership brings Joplin to a new class of rugged, professional devices built for instant reliable communication, and reflects a shared focus on reliability, security, and long-term use.</p>
|
||||
@@ -508,15 +528,4 @@ sys 0m38.013s</p>
|
||||
]]></description><link>https://joplinapp.org/news/20230508-release-2-10</link><guid isPermaLink="false">20230508-release-2-10</guid><pubDate>Wed, 10 May 2023 12:00:00 GMT</pubDate><twitter-text>What's new in Joplin 2.10</twitter-text></item><item><title><![CDATA[Joplin will participate in JdLL 2023!]]></title><description><![CDATA[<p>On 1 and 2 April 2023, we will have a stand for Joplin at the <a href="https://www.jdll.org/">Journées du Logiciel Libre</a> in Lyon, France. The JdLL has been taking place in Lyon for 24 years and is a popular open source conference in France. We had a stand in 2020 and 2021 but that was cancelled due to Covid, so this year is a first for Joplin!</p>
|
||||
<p>Admission is free, so don't hesitate to come and meet us, exchange ideas and learn more about Joplin!</p>
|
||||
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20230202-jdll.jpg" alt="Joplin at JdLL 2023"></p>
|
||||
]]></description><link>https://joplinapp.org/news/20230302-jdll-2023</link><guid isPermaLink="false">20230302-jdll-2023</guid><pubDate>Thu, 02 Mar 2023 00:00:00 GMT</pubDate><twitter-text></twitter-text></item><item><title><![CDATA[Introducing the "GitHub Actions Raw Log Viewer" extension for Chrome]]></title><description><![CDATA[<p>If you've ever used GitHub Actions, you will find that they provide by default a nice coloured output for the log. It looks good and it's even interactive! (You can click to collapse/expand blocks of text) But unfortunately it doesn't scale to large workflows, like we have for Joplin - the log can freeze and it will take forever to search for something. Indeed searching is done in "real time"... which mostly means it will freeze for a minute or two for each letter you type in the search box. Not great.</p>
|
||||
<p>Thankfully GitHub provides an alternative access: the raw logs. This is much better because they will open as plain text, without any styling or JS magic, which means you can use the browser native search and it will be fast.</p>
|
||||
<p>But now the problem is that raw logs look like this:</p>
|
||||
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20230116-ga-raw-log.png" alt="Raw log without extension"></p>
|
||||
<p>While it's not impossible to read, all colours that would display nicely in a terminal are gone and replaced by <a href="https://en.wikipedia.org/wiki/ANSI_escape_code">ANSI codes</a>. You can find what you need in there but it's not particularly easy.</p>
|
||||
<p>This is where the new <strong>GitHub Action Raw Log Viewer</strong> extension for Chrome can help. It will parse your raw log and convert the ANSI codes to proper colours. This results in a much more readable rendering:</p>
|
||||
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20230116-ga-raw-log-colored.png" alt="Raw log with extension"></p>
|
||||
<p>The extension is fast even for very large logs and it's of course easy to search for text since it simply works with your browser built-in search.</p>
|
||||
<p>The extension is open source, with the code available here: <a href="https://github.com/laurent22/github-actions-logs-extension">https://github.com/laurent22/github-actions-logs-extension</a></p>
|
||||
<p>And to install it, follow this link:</p>
|
||||
<p><a href="https://chrome.google.com/webstore/detail/github-action-raw-log-vie/lgejlnoopmcdglhfjblaeldbcfnmjddf"><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20230116-extension-get-it-now.png" alt="Download GitHub Action Raw Log Viewer extension"></a></p>
|
||||
]]></description><link>https://joplinapp.org/news/20230116-github-actions-log-viewer</link><guid isPermaLink="false">20230116-github-actions-log-viewer</guid><pubDate>Mon, 16 Jan 2023 00:00:00 GMT</pubDate><twitter-text>Introducing the "GitHub Action Raw Log Viewer" extension for Chrome</twitter-text></item></channel></rss>
|
||||
]]></description><link>https://joplinapp.org/news/20230302-jdll-2023</link><guid isPermaLink="false">20230302-jdll-2023</guid><pubDate>Thu, 02 Mar 2023 00:00:00 GMT</pubDate><twitter-text></twitter-text></item></channel></rss>
|
||||
@@ -13,6 +13,7 @@
|
||||
</div>
|
||||
<div class="col-9 text-right d-none d-md-block">
|
||||
{{> twitterLink}}
|
||||
<a href="{{baseUrl}}/plugins/" class="fw500">Plugins</a>
|
||||
<a href="{{baseUrl}}/news/" class="fw500">News</a>
|
||||
<a href="{{baseUrl}}/help/" class="fw500">Help</a>
|
||||
<a href="{{forumUrl}}" class="fw500">Forum</a>
|
||||
@@ -23,7 +24,7 @@
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
{{#availableLocales}}
|
||||
<li><a class="dropdown-item {{#isActive}}active{{/isActive}}" href="{{baseUrl}}/{{pathPrefix}}" onclick="setLocalePreference('{{code}}')">{{name}}</a></li>
|
||||
<li><a class="dropdown-item {{#isActive}}active{{/isActive}}" href="{{baseUrl}}/{{pathPrefix}}" onclick="setLocalePreference('{{code}}', this.href); return false;">{{name}}</a></li>
|
||||
{{/availableLocales}}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -58,6 +59,7 @@
|
||||
</div>
|
||||
|
||||
<div class="text-center menu-mobile-top">
|
||||
<a href="{{baseUrl}}/plugins/" class="fw500 mobile-menu-link">Plugins</a>
|
||||
<a href="{{baseUrl}}/news/" class="fw500 mobile-menu-link">News</a>
|
||||
<a href="{{baseUrl}}/help/" class="fw500 mobile-menu-link">Help</a>
|
||||
<a href="{{forumUrl}}" class="fw500 mobile-menu-link">Forum</a>
|
||||
@@ -73,7 +75,7 @@
|
||||
<div class="text-center menu-mobile-language">
|
||||
<p class="fw500 mobile-menu-language-label"><i class="fas fa-globe"></i> Language</p>
|
||||
{{#availableLocales}}
|
||||
<a href="{{baseUrl}}/{{pathPrefix}}" class="fw500 mobile-menu-link mobile-language-link {{#isActive}}active{{/isActive}}" onclick="setLocalePreference('{{code}}')">{{name}}</a>
|
||||
<a href="{{baseUrl}}/{{pathPrefix}}" class="fw500 mobile-menu-link mobile-language-link {{#isActive}}active{{/isActive}}" onclick="setLocalePreference('{{code}}', this.href); return false;">{{name}}</a>
|
||||
{{/availableLocales}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
<a class="social-link-bluesky" href="https://bsky.app/profile/joplinapp.bsky.social" title="Joplin Bluesky feed"><i class="fa-brands fa-bluesky"></i></a>
|
||||
<a class="social-link-mastodon" href="https://mastodon.social/@joplinapp" title="Joplin Mastodon feed"><i class="fab fa-mastodon"></i></a>
|
||||
<a class="social-link-patreon" href="https://www.patreon.com/joplin" title="Joplin Patreon"><i class="fab fa-patreon"></i></a>
|
||||
<a class="social-link-youtube" href="https://youtube.com/@joplinapp" title="Joplin YouTube channel"><i class="fab fa-youtube"></i></a>
|
||||
<a class="social-link-discord" href="https://discord.gg/VSj7AFHvpq" title="Joplin Discord chat"><i class="fab fa-discord"></i></a>
|
||||
<a class="social-link-linkedin" href="https://www.linkedin.com/company/joplin" title="Joplin LinkedIn Feed"><i class="fab fa-linkedin"></i></a>
|
||||
<a class="social-link-lemmy" href="https://sopuli.xyz/c/joplinapp" title="Joplin Lemmy Community"><i class="fas fa-otter"></i></a>
|
||||
<a class="social-link-github" href="https://github.com/laurent22/joplin/" title="Joplin GitHub repository"><i class="fab fa-github"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
14
Assets/keys/joplin-canary-signing-key.asc
Normal file
14
Assets/keys/joplin-canary-signing-key.asc
Normal file
@@ -0,0 +1,14 @@
|
||||
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
|
||||
mDMEaZWFlBYJKwYBBAHaRw8BAQdAIh3xQbjaS0EC+8WuKXNPjVF/ayq0/2GZlheR
|
||||
qj1G3Qe0RUpvcGxpbiBDYW5hcnkgU2lnbmluZyBLZXkgKFdhcnJhbnQgQ2FuYXJ5
|
||||
IEtleSkgPGNhbmFyeUBqb3BsaW5hcHAub3JnPoiZBBMWCgBBFiEE+CD4MG3QBaEC
|
||||
0YzVlGrp+lkV71MFAmmVhZQCGwMFCQPCZwAFCwkIBwICIgIGFQoJCAsCBBYCAwEC
|
||||
HgcCF4AACgkQlGrp+lkV71MZtwD/Ufd4OAcgkl5T6MSB+WDFg8BXvpaBZfNnZkoo
|
||||
LrOoqNAA/iqGiiBRoarlus2ATOiWhyXaEpRUQcEeaRhhqHW0BGcCuDgEaZWFlBIK
|
||||
KwYBBAGXVQEFAQEHQFORKWRLp4hDYzR8Q5IRyF9AIjoziR+sj4icUdvZx4Z6AwEI
|
||||
B4h+BBgWCgAmFiEE+CD4MG3QBaEC0YzVlGrp+lkV71MFAmmVhZQCGwwFCQPCZwAA
|
||||
CgkQlGrp+lkV71Nu+AD9Gw4qEmL8WNCNs7idc8CRpGpS2DhasNTV398lbKYzco0B
|
||||
ANlMrGlMc0w1KhuFxdU4fF3s/ktUUnjJwosxK94l5/MJ
|
||||
=C9VN
|
||||
-----END PGP PUBLIC KEY BLOCK-----
|
||||
18
CLAUDE.md
Normal file
18
CLAUDE.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# Joplin Guidelines
|
||||
|
||||
## Quick Reference
|
||||
|
||||
- Tabs for indentation
|
||||
- Single quotes for strings
|
||||
- Proper TypeScript types (avoid `any`)
|
||||
- Comments should be only with `//` and should not contain jsdoc syntax
|
||||
- If you duplicate a substantial block of code, add a comment above it noting the duplication and referencing the original location.
|
||||
- When creating Jest tests, there should be only one `describe()` statement in the file.
|
||||
- Focus on testing essential behaviour and edge cases — avoid adding tests for every minor detail.
|
||||
- Avoid duplicating code in tests; when testing the same logic with different inputs, use `test.each` or shared helpers instead of repeating similar test blocks.
|
||||
- Do not make white space changes - do not add unnecessary new lines, or spaces to existing code, or wrap existing code.
|
||||
|
||||
## Full Documentation
|
||||
|
||||
- Coding style: [readme/dev/coding_style.md](readme/dev/coding_style.md)
|
||||
- Contributing: [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||
@@ -1,26 +1,25 @@
|
||||
FROM node:24-bullseye
|
||||
FROM node:24-bookworm
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y \
|
||||
ca-certificates curl \
|
||||
python3 tini
|
||||
|
||||
## install docker
|
||||
RUN install -m 0755 -d /etc/apt/keyrings
|
||||
RUN curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
|
||||
RUN chmod a+r /etc/apt/keyrings/docker.asc
|
||||
RUN echo \
|
||||
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian \
|
||||
$(. /etc/os-release && echo bullseye) stable" | \
|
||||
tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin \
|
||||
ca-certificates curl wget unzip \
|
||||
python3 tini \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
# Download llama.cpp binary
|
||||
WORKDIR /opt/llama
|
||||
RUN wget -q https://github.com/ggml-org/llama.cpp/releases/download/b5449/llama-b5449-bin-ubuntu-x64.zip \
|
||||
&& unzip llama-b5449-bin-ubuntu-x64.zip \
|
||||
&& rm llama-b5449-bin-ubuntu-x64.zip \
|
||||
&& chmod +x /opt/llama/build/bin/llama-mtmd-cli
|
||||
|
||||
# Create non-root user for security
|
||||
RUN groupadd -r transcribe && useradd -r -g transcribe -m transcribe
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY .yarn/releases ./.yarn/releases
|
||||
@@ -44,7 +43,21 @@ RUN BUILD_SEQUENCIAL=1 yarn install --inline-builds \
|
||||
&& yarn cache clean \
|
||||
&& rm -rf .yarn/berry
|
||||
|
||||
# Create data directory and set permissions
|
||||
RUN mkdir -p /data/images \
|
||||
&& chown -R transcribe:transcribe /data
|
||||
|
||||
WORKDIR /app/packages/transcribe
|
||||
|
||||
# Switch to non-root user
|
||||
USER transcribe
|
||||
|
||||
# Set environment variables
|
||||
ENV HTR_CLI_BINARY_PATH=/opt/llama/build/bin/llama-mtmd-cli
|
||||
ENV LD_LIBRARY_PATH=/opt/llama/build/bin
|
||||
ENV DATA_DIR=/data
|
||||
ENV QUEUE_DRIVER=sqlite
|
||||
|
||||
# Start the Node.js application
|
||||
ENTRYPOINT ["/usr/bin/tini", "--"]
|
||||
CMD ["yarn", "start"]
|
||||
|
||||
16
README.md
16
README.md
@@ -31,7 +31,7 @@ Please see the [donation page](https://github.com/laurent22/joplin/blob/dev/read
|
||||
# Sponsors
|
||||
|
||||
<!-- SPONSORS-ORG -->
|
||||
<a href="https://seirei.ne.jp"><img title="Serei Network" width="256" src="https://joplinapp.org/images/sponsors/SeireiNetwork.png"/></a> <a href="https://citricsheep.com"><img title="Citric Sheep" width="256" src="https://joplinapp.org/images/sponsors/CitricSheep.png"/></a> <a href="https://sorted.travel/?utm_source=joplinapp"><img title="Sorted Travel" width="256" src="https://joplinapp.org/images/sponsors/SortedTravel.png"/></a> <a href="https://celebian.com"><img title="Celebian" width="256" src="https://joplinapp.org/images/sponsors/Celebian.png"/></a> <a href="https://bestkru.com"><img title="BestKru" width="256" src="https://joplinapp.org/images/sponsors/BestKru.png"/></a> <a href="https://www.socialfollowers.uk/buy-tiktok-followers/"><img title="Social Followers" width="256" src="https://joplinapp.org/images/sponsors/SocialFollowers.png"/></a> <a href="https://stormlikes.com/"><img title="Stormlikes" width="256" src="https://joplinapp.org/images/sponsors/Stormlikes.png"/></a> <a href="https://route4me.com"><img title="Route4Me" width="256" src="https://joplinapp.org/images/sponsors/Route4Me.png"/></a> <a href="https://topagency.webflow.io"><img title="WebDesignAgency" width="256" src="https://joplinapp.org/images/sponsors/WebDesignAgency.png" alt="topagency"/></a> <a href="https://www.slotozilla.com/nz/no-deposit-bonus"><img title="casino without making any upfront cost" width="256" src="https://joplinapp.org/images/sponsors/Slotozilla.png" alt="casino without making any upfront cost"/></a> <a href="https://writepaper.com/"><img title="best service to write my paper for me" width="256" src="https://joplinapp.org/images/sponsors/WritePaper.png" alt="best service to write my paper for me"/></a> <a href="https://paperwriter.com/"><img title="high-quality paper writing service PaperWriter" width="256" src="https://joplinapp.org/images/sponsors/PaperWriter.png" alt="high-quality paper writing service PaperWriter"/></a> <a href="https://www.bestetf.net/"><img title="BestETF" width="256" src="https://joplinapp.org/images/sponsors/BestEtf.png" alt="BestETF"/></a> <a href="https://freespinny.io/free-spins-no-deposit/"><img title="Freespinny.io Free Spins Bonus site" width="256" src="https://joplinapp.org/images/sponsors/Freespinny.png" alt="Freespinny.io Free Spins Bonus site"/></a> <a href="https://essayshark.com"><img title="EssayShark - essay writers for hire" width="256" src="https://joplinapp.org/images/sponsors/EssayShark.png" alt="EssayShark - essay writers for hire"/></a> <a href="https://pokieslab1.com/real-money-pokies/"><img title="Australian Real Money Pokies" width="256" src="https://joplinapp.org/images/sponsors/PokiesLab.png" alt="Australian Real Money Pokies"/></a> <a href="https://pokiesman1.net/real-money-pokies/"><img title="Australian Real Money Pokies" width="256" src="https://joplinapp.org/images/sponsors/Pokiesman.png" alt="Australian Real Money Pokies"/></a> <a href="https://domyessay.com"><img title="Essay writers DoMyEssay are dedicated to providing top-notch, custom-written papers that meet your academic requirements" width="256" src="https://joplinapp.org/images/sponsors/DoMyEssay.png" alt="DoMyEssay"/></a> <a href="https://essaypro.com/"><img title="best essay writing service" width="256" src="https://joplinapp.org/images/sponsors/EssayPro.png" alt="best essay writing service"/></a> <a href="https://socialkings.online"><img title="Boost your reach and buy real followers" width="256" src="https://joplinapp.org/images/sponsors/SocialKings.png" alt="Boost your reach and buy real followers"/></a> <a href="https://uk.notgamstop.com/bonuses/free-spins-no-deposit-no-gamstop/"><img title="free spins no deposit at NotGamstop" width="256" src="https://joplinapp.org/images/sponsors/NotGamStop.jpg" alt="free spins no deposit at NotGamstop"/></a> <a href="https://www.writemyessay.com/"><img title="writing service for students WriteMyEssay" width="256" src="https://joplinapp.org/images/sponsors/WriteMyEssay.png" alt="writing service for students WriteMyEssay"/></a> <a href="https://essayservice.com/"><img title="For those in need of immediate academic assistance, EssayService offers a fast and reliable service to write my essay for me now, ensuring high-quality results within tight deadlines" width="256" src="https://joplinapp.org/images/sponsors/EssayService.png" alt="For those in need of immediate academic assistance, EssayService offers a fast and reliable service to write my essay for me now, ensuring high-quality results within tight deadlines"/></a>
|
||||
<a href="https://seirei.ne.jp"><img title="Serei Network" width="256" src="https://joplinapp.org/images/sponsors/SeireiNetwork.png"/></a> <a href="https://citricsheep.com"><img title="Citric Sheep" width="256" src="https://joplinapp.org/images/sponsors/CitricSheep.png"/></a> <a href="https://sorted.travel/?utm_source=joplinapp"><img title="Sorted Travel" width="256" src="https://joplinapp.org/images/sponsors/SortedTravel.png"/></a> <a href="https://celebian.com"><img title="Celebian" width="256" src="https://joplinapp.org/images/sponsors/Celebian.png"/></a> <a href="https://bestkru.com"><img title="BestKru" width="256" src="https://joplinapp.org/images/sponsors/BestKru.png"/></a> <a href="https://www.socialfollowers.uk/buy-tiktok-followers/"><img title="Social Followers" width="256" src="https://joplinapp.org/images/sponsors/SocialFollowers.png"/></a> <a href="https://stormlikes.com/"><img title="Stormlikes" width="256" src="https://joplinapp.org/images/sponsors/Stormlikes.png"/></a> <a href="https://route4me.com"><img title="Route4Me" width="256" src="https://joplinapp.org/images/sponsors/Route4Me.png"/></a> <a href="https://topagency.webflow.io"><img title="WebDesignAgency" width="256" src="https://joplinapp.org/images/sponsors/WebDesignAgency.png" alt="topagency"/></a> <a href="https://essayshark.com"><img title="EssayShark - essay writers for hire" width="256" src="https://joplinapp.org/images/sponsors/EssayShark.png" alt="EssayShark - essay writers for hire"/></a> <a href="https://pokieslab1.com/real-money-pokies/"><img title="Australian Real Money Pokies" width="256" src="https://joplinapp.org/images/sponsors/PokiesLab.png" alt="Australian Real Money Pokies"/></a> <a href="https://pokiesman1.net/real-money-pokies/"><img title="Australian Real Money Pokies" width="256" src="https://joplinapp.org/images/sponsors/Pokiesman.png" alt="Australian Real Money Pokies"/></a> <a href="https://socialkings.online"><img title="Boost your reach and buy real followers" width="256" src="https://joplinapp.org/images/sponsors/SocialKings.png" alt="Boost your reach and buy real followers"/></a> <a href="https://essayservice.com/"><img title="For those in need of immediate academic assistance, EssayService offers a fast and reliable service to write my essay for me now, ensuring high-quality results within tight deadlines" width="256" src="https://joplinapp.org/images/sponsors/EssayService.png" alt="For those in need of immediate academic assistance, EssayService offers a fast and reliable service to write my essay for me now, ensuring high-quality results within tight deadlines"/></a> <a href="https://thenationonlineng.net/casino-en-ligne/casino-en-ligne-payant-au-canada/"><img title="casino en ligne le plus payant" width="256" src="https://joplinapp.org/images/sponsors/TheNationOnline.jpg" alt="casino en ligne le plus payant"/></a>
|
||||
<!-- SPONSORS-ORG -->
|
||||
|
||||
* * *
|
||||
@@ -39,9 +39,9 @@ Please see the [donation page](https://github.com/laurent22/joplin/blob/dev/read
|
||||
<!-- SPONSORS-GITHUB -->
|
||||
| | | | |
|
||||
| :---: | :---: | :---: | :---: |
|
||||
| <img width="50" src="https://avatars2.githubusercontent.com/u/97193607?s=96&v=4"/></br>[Akhil-CM](https://github.com/Akhil-CM) | <img width="50" src="https://avatars2.githubusercontent.com/u/552452?s=96&v=4"/></br>[andypiper](https://github.com/andypiper) | <img width="50" src="https://avatars2.githubusercontent.com/u/215668?s=96&v=4"/></br>[avanderberg](https://github.com/avanderberg) | <img width="50" src="https://avatars2.githubusercontent.com/u/67130?s=96&v=4"/></br>[chr15m](https://github.com/chr15m) |
|
||||
| <img width="50" src="https://avatars2.githubusercontent.com/u/1177810?s=96&v=4"/></br>[felixstorm](https://github.com/felixstorm) | <img width="50" src="https://avatars2.githubusercontent.com/u/11947658?s=96&v=4"/></br>[KentBrockman](https://github.com/KentBrockman) | <img width="50" src="https://avatars2.githubusercontent.com/u/42319182?s=96&v=4"/></br>[marcdw1289](https://github.com/marcdw1289) | <img width="50" src="https://avatars2.githubusercontent.com/u/668977?s=96&v=4"/></br>[ugoertz](https://github.com/ugoertz) |
|
||||
| | | | |
|
||||
| <img width="50" src="https://avatars2.githubusercontent.com/u/552452?s=96&v=4"/></br>[andypiper](https://github.com/andypiper) | <img width="50" src="https://avatars2.githubusercontent.com/u/215668?s=96&v=4"/></br>[avanderberg](https://github.com/avanderberg) | <img width="50" src="https://avatars2.githubusercontent.com/u/67130?s=96&v=4"/></br>[chr15m](https://github.com/chr15m) | <img width="50" src="https://avatars2.githubusercontent.com/u/1177810?s=96&v=4"/></br>[felixstorm](https://github.com/felixstorm) |
|
||||
| <img width="50" src="https://avatars2.githubusercontent.com/u/8030470?s=96&v=4"/></br>[Galliver7](https://github.com/Galliver7) | <img width="50" src="https://avatars2.githubusercontent.com/u/4721118?s=96&v=4"/></br>[GPrimola](https://github.com/GPrimola) | <img width="50" src="https://avatars2.githubusercontent.com/u/64712218?s=96&v=4"/></br>[Hegghammer](https://github.com/Hegghammer) | <img width="50" src="https://avatars2.githubusercontent.com/u/42319182?s=96&v=4"/></br>[marcdw1289](https://github.com/marcdw1289) |
|
||||
| <img width="50" src="https://avatars2.githubusercontent.com/u/668977?s=96&v=4"/></br>[ugoertz](https://github.com/ugoertz) | | | |
|
||||
<!-- SPONSORS-GITHUB -->
|
||||
|
||||
# Community
|
||||
@@ -61,6 +61,14 @@ Name | Description
|
||||
|
||||
Please see the guide for information on how to contribute to the development of Joplin: https://github.com/laurent22/joplin/blob/dev/readme/dev/index.md
|
||||
|
||||
## Warrant Canary Signing Key
|
||||
|
||||
Fingerprint:
|
||||
|
||||
F820 F830 6DD0 05A1 02D1 8CD5 946A E9FA 5915 EF53
|
||||
|
||||
Public key: https://github.com/laurent22/joplin/raw/dev/Assets/keys/joplin-canary-signing-key.asc
|
||||
|
||||
# Contributors
|
||||
|
||||
Thank you to everyone who've contributed to Joplin's source code!
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"/readme/_i18n",
|
||||
"/readme/about/changelog/desktop.md",
|
||||
"/readme/licenses.md",
|
||||
"/readme/canary.txt",
|
||||
"/readme/i18n",
|
||||
"cspell.json",
|
||||
"node_modules"
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"vips.dev": {
|
||||
"platforms": ["aarch64-darwin"],
|
||||
},
|
||||
"nodejs": "24.9.0",
|
||||
"nodejs": "24.11.1",
|
||||
"pkg-config": "latest",
|
||||
"python": "3.13.3",
|
||||
"bat": "latest",
|
||||
@@ -17,7 +17,7 @@
|
||||
"version": "latest",
|
||||
"excluded_platforms": ["aarch64-darwin", "x86_64-darwin"],
|
||||
},
|
||||
"git": "2.50.1",
|
||||
"git": "2.51.0",
|
||||
},
|
||||
"shell": {
|
||||
"init_hook": [
|
||||
|
||||
@@ -84,8 +84,8 @@ services:
|
||||
profiles:
|
||||
- full
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ${HTR_CLI_IMAGES_FOLDER}:/app/packages/transcribe/images
|
||||
- ${HTR_CLI_MODELS_FOLDER}:/opt/models:ro
|
||||
depends_on:
|
||||
- transcribe-db
|
||||
ports:
|
||||
@@ -94,6 +94,16 @@ services:
|
||||
- transcribe-network
|
||||
- shared-network
|
||||
restart: unless-stopped
|
||||
# Security: limit resources to prevent runaway processes
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 16G
|
||||
cpus: '4'
|
||||
# Security: read-only root filesystem with only images folder writable
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp
|
||||
environment:
|
||||
- APP_PORT=4567
|
||||
- DB_CLIENT=pg
|
||||
@@ -103,5 +113,6 @@ services:
|
||||
- QUEUE_DATABASE_PORT=${QUEUE_DATABASE_PORT}
|
||||
- QUEUE_DATABASE_HOST=transcribe-db
|
||||
- API_KEY=${TRANSCRIBE_API_KEY}
|
||||
- HTR_CLI_IMAGES_FOLDER=${HTR_CLI_IMAGES_FOLDER}
|
||||
- HTR_CLI_IMAGES_FOLDER=/app/packages/transcribe/images
|
||||
- HTR_CLI_MODELS_FOLDER=/opt/models
|
||||
|
||||
|
||||
44
docker-compose.transcribe.yml
Normal file
44
docker-compose.transcribe.yml
Normal file
@@ -0,0 +1,44 @@
|
||||
# Standalone docker-compose for Joplin Transcribe
|
||||
#
|
||||
# Uses SQLite for the queue (no external database needed).
|
||||
# Data is stored in a named volume for proper permissions.
|
||||
#
|
||||
# Usage:
|
||||
#
|
||||
# 1. Download models:
|
||||
# mkdir -p ./data/models
|
||||
# wget -O ./data/models/Model-7.6B-Q4_K_M.gguf https://huggingface.co/openbmb/MiniCPM-o-2_6-gguf/resolve/main/Model-7.6B-Q4_K_M.gguf
|
||||
# wget -O ./data/models/mmproj-model-f16.gguf https://huggingface.co/openbmb/MiniCPM-o-2_6-gguf/resolve/main/mmproj-model-f16.gguf
|
||||
#
|
||||
# 2. Configure:
|
||||
# cp .env-transcribe-sample .env
|
||||
# # Edit .env and set API_KEY
|
||||
#
|
||||
# 3. Run:
|
||||
# docker compose -f docker-compose.transcribe.yml up
|
||||
|
||||
volumes:
|
||||
transcribe-data:
|
||||
|
||||
services:
|
||||
transcribe:
|
||||
image: joplin/transcribe:amd64-latest
|
||||
ports:
|
||||
- "4567:4567"
|
||||
volumes:
|
||||
- transcribe-data:/data
|
||||
- ./data/models:/data/models:ro
|
||||
restart: unless-stopped
|
||||
# Security: limit resources to prevent runaway processes
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 16G
|
||||
cpus: '4'
|
||||
# Security: read-only root filesystem
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp
|
||||
- /home/transcribe/.cache
|
||||
env_file:
|
||||
- .env
|
||||
@@ -60,6 +60,7 @@
|
||||
"test": "yarn workspaces foreach --worktree --parallel --verbose --interlaced --jobs 2 run test",
|
||||
"tsc": "yarn workspaces foreach --worktree --parallel --verbose --interlaced run tsc",
|
||||
"updateIgnored": "node packages/tools/gulp/tasks/updateIgnoredTypeScriptBuildRun.js",
|
||||
"updateCanary": "node ./packages/tools/updateCanary",
|
||||
"updateMarkdownDoc": "node ./packages/tools/updateMarkdownDoc",
|
||||
"updateNews": "node ./packages/tools/website/updateNews",
|
||||
"updatePluginTypes": "./packages/generator-joplin/updateTypes.sh",
|
||||
@@ -123,6 +124,7 @@
|
||||
"depd@npm:~1.1.2": "patch:depd@npm%3A2.0.0#~/.yarn/patches/depd-npm-2.0.0-b6c51a4b43.patch",
|
||||
"depd@npm:2.0.0": "patch:depd@npm%3A2.0.0#~/.yarn/patches/depd-npm-2.0.0-b6c51a4b43.patch",
|
||||
"depd@npm:^1.1.2": "patch:depd@npm%3A2.0.0#~/.yarn/patches/depd-npm-2.0.0-b6c51a4b43.patch",
|
||||
"depd@npm:^1.1.0": "patch:depd@npm%3A2.0.0#~/.yarn/patches/depd-npm-2.0.0-b6c51a4b43.patch"
|
||||
"depd@npm:^1.1.0": "patch:depd@npm%3A2.0.0#~/.yarn/patches/depd-npm-2.0.0-b6c51a4b43.patch",
|
||||
"formidable@npm:^2.0.1": "patch:formidable@npm%3A2.1.2#~/.yarn/patches/formidable-npm-2.1.2-40ba18d67f.patch"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,9 +31,14 @@ cliUtils.printArray = function(logFunction, rows) {
|
||||
const line = [];
|
||||
for (let col = 0; col < colWidths.length; col++) {
|
||||
const item = rows[row][col];
|
||||
const width = colWidths[col];
|
||||
const dir = colAligns[col] === ALIGN_LEFT ? stringPadding.RIGHT : stringPadding.LEFT;
|
||||
line.push(stringPadding(item, width, ' ', dir));
|
||||
const isLastCol = col === colWidths.length - 1;
|
||||
if (isLastCol) {
|
||||
line.push(item ? item.toString() : '');
|
||||
} else {
|
||||
const width = colWidths[col];
|
||||
const dir = colAligns[col] === ALIGN_LEFT ? stringPadding.RIGHT : stringPadding.LEFT;
|
||||
line.push(stringPadding(item, width, ' ', dir));
|
||||
}
|
||||
}
|
||||
logFunction(line.join(' '));
|
||||
}
|
||||
|
||||
@@ -45,6 +45,10 @@ describe('HtmlToMd', () => {
|
||||
htmlToMdOptions.preserveColorStyles = true;
|
||||
}
|
||||
|
||||
if (htmlFilename.indexOf('table_with') === 0 || htmlFilename.indexOf('table_default') === 0) {
|
||||
htmlToMdOptions.preserveTableStyles = true;
|
||||
}
|
||||
|
||||
const html = await readFile(htmlPath, 'utf8');
|
||||
let expectedMd = await readFile(mdPath, 'utf8');
|
||||
|
||||
@@ -96,4 +100,34 @@ describe('HtmlToMd', () => {
|
||||
expect(htmlToMd.parse('> 1 _2_ 3.pdf', { disableEscapeContent: false })).toBe('\\> 1 \\_2_ 3.pdf');
|
||||
});
|
||||
|
||||
it('should support tightLists option', async () => {
|
||||
const htmlToMd = new HtmlToMd();
|
||||
const html = '<ul><li><p><strong>Item 1</strong></p></li><li><p><strong>Item 2</strong></p></li><li><p><strong>Item 3</strong></p></li></ul>';
|
||||
|
||||
// Without tightLists, paragraphs inside list items produce extra blank lines
|
||||
const looseResult = htmlToMd.parse(html, { tightLists: false });
|
||||
expect(looseResult).toContain('\n \n');
|
||||
|
||||
// With tightLists, list items are compact without blank lines
|
||||
const tightResult = htmlToMd.parse(html, { tightLists: true });
|
||||
expect(tightResult).toBe('- **Item 1**\n- **Item 2**\n- **Item 3**');
|
||||
});
|
||||
|
||||
it('should support collapseMultipleBlankLines option', async () => {
|
||||
const htmlToMd = new HtmlToMd();
|
||||
const html = '<p>First</p><br><br><br><p>Second</p>';
|
||||
|
||||
// Without collapseMultipleBlankLines, multiple blank lines are preserved
|
||||
const looseResult = htmlToMd.parse(html, { collapseMultipleBlankLines: false });
|
||||
expect(looseResult).toContain('\n\n \n');
|
||||
|
||||
// With collapseMultipleBlankLines, multiple blank lines are collapsed into one
|
||||
const collapsedResult = htmlToMd.parse(html, { collapseMultipleBlankLines: true });
|
||||
expect(collapsedResult).not.toContain('\n\n\n');
|
||||
expect(collapsedResult).not.toContain('\n\n \n');
|
||||
|
||||
// Verify that a single blank line is preserved (not fully removed)
|
||||
expect(collapsedResult).toContain('\n\n');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
7
packages/app-cli/tests/enex_to_md/list_with_br.html
Normal file
7
packages/app-cli/tests/enex_to_md/list_with_br.html
Normal file
@@ -0,0 +1,7 @@
|
||||
<ul>
|
||||
<li>First line<br/>Second line</li>
|
||||
<li>Normal item</li>
|
||||
<li>With sub-list<ul>
|
||||
<li>Sub-list<br/>Paragraph<br/>Also another line</li>
|
||||
</ul></li>
|
||||
</ul>
|
||||
8
packages/app-cli/tests/enex_to_md/list_with_br.md
Normal file
8
packages/app-cli/tests/enex_to_md/list_with_br.md
Normal file
@@ -0,0 +1,8 @@
|
||||
- First line
|
||||
Second line
|
||||
|
||||
- Normal item
|
||||
- With sub-list
|
||||
- Sub-list
|
||||
Paragraph
|
||||
Also another line
|
||||
@@ -0,0 +1 @@
|
||||
<a href="#section" style="text-decoration: underline">Section Link</a>
|
||||
@@ -0,0 +1 @@
|
||||
[Section Link](#section)
|
||||
@@ -0,0 +1,18 @@
|
||||
<table style="border-collapse: collapse; width: 100%;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 50%;">Name</th>
|
||||
<th style="width: 50%;">Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width: 50%;">Cell A</td>
|
||||
<td style="width: 50%;">Cell B</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="width: 50%;">Cell C</td>
|
||||
<td style="width: 50%;">Cell D</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -0,0 +1,4 @@
|
||||
| Name | Value |
|
||||
| --- | --- |
|
||||
| Cell A | Cell B |
|
||||
| Cell C | Cell D |
|
||||
@@ -0,0 +1,18 @@
|
||||
<table bgcolor="#f0f0f0" cellpadding="8">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Cell A</td>
|
||||
<td>Cell B</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Cell C</td>
|
||||
<td>Cell D</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -0,0 +1 @@
|
||||
<div class="joplin-table-wrapper"><table bgcolor="#f0f0f0" cellpadding="8"><thead><tr><th>Name</th><th>Value</th></tr></thead><tbody><tr><td>Cell A</td><td>Cell B</td></tr><tr><td>Cell C</td><td>Cell D</td></tr></tbody></table></div>
|
||||
@@ -0,0 +1,18 @@
|
||||
<table style="border-collapse: collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="background-color: #e03e2d">Red cell</td>
|
||||
<td style="padding: 10px 15px">Padded cell</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="border-color: #2dc26b; border-style: solid">Green border</td>
|
||||
<td>Normal cell</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -0,0 +1 @@
|
||||
<div class="joplin-table-wrapper"><table style="border-collapse: collapse"><thead><tr><th>Name</th><th>Value</th></tr></thead><tbody><tr><td style="background-color: #e03e2d">Red cell</td><td style="padding: 10px 15px">Padded cell</td></tr><tr><td style="border-color: #2dc26b; border-style: solid">Green border</td><td>Normal cell</td></tr></tbody></table></div>
|
||||
@@ -10,6 +10,7 @@ import Folder from '@joplin/lib/models/Folder';
|
||||
import { expectNotThrow, setupDatabaseAndSynchronizer, switchClient, expectThrow, createTempDir, supportDir, mockMobilePlatform } from '@joplin/lib/testing/test-utils';
|
||||
import { newPluginScript } from '../../testUtils';
|
||||
import { join } from 'path';
|
||||
import { PluginManifest } from '@joplin/lib/services/plugins/utils/types';
|
||||
|
||||
const testPluginDir = `${supportDir}/plugins`;
|
||||
|
||||
@@ -472,4 +473,18 @@ describe('services_PluginService', () => {
|
||||
await fs.remove(testDir);
|
||||
}
|
||||
});
|
||||
|
||||
it('should report a missing app_min_version field specifically', () => {
|
||||
const service = newPluginService();
|
||||
const manifest = {
|
||||
manifest_version: 1,
|
||||
id: 'test.plugin',
|
||||
name: 'Test Plugin',
|
||||
version: '1.0.0',
|
||||
// Missing app_min_version
|
||||
};
|
||||
|
||||
const error = service.describeIncompatibility(manifest as unknown as PluginManifest);
|
||||
expect(error).toContain('Invalid plugin manifest: Missing required field: app_min_version');
|
||||
});
|
||||
});
|
||||
|
||||
BIN
packages/app-cli/tests/support/onenote/truncated.zip
Normal file
BIN
packages/app-cli/tests/support/onenote/truncated.zip
Normal file
Binary file not shown.
@@ -6,7 +6,7 @@ const shim: typeof ShimType = require('@joplin/lib/shim').default;
|
||||
import { isCallbackUrl } from '@joplin/lib/callbackUrlUtils';
|
||||
import { FileLocker } from '@joplin/utils/fs';
|
||||
import { IpcMessageHandler, IpcServer, Message, newHttpError, sendMessage, SendMessageOptions, startServer, stopServer } from '@joplin/utils/ipc';
|
||||
import { BrowserWindow, Tray, WebContents, screen, App, nativeTheme } from 'electron';
|
||||
import { BrowserWindow, Tray, WebContents, screen, App, nativeTheme, powerMonitor } from 'electron';
|
||||
import bridge from './bridge';
|
||||
import * as url from 'url';
|
||||
const path = require('path');
|
||||
@@ -176,6 +176,10 @@ export default class ElectronAppWrapper {
|
||||
public async handleAppFailure(errorMessage: string, canIgnore: boolean, isTesting?: boolean) {
|
||||
await bridge().captureException(new Error(errorMessage));
|
||||
|
||||
if (this.win_ && this.win_.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const buttons = [];
|
||||
buttons.push(_('Quit'));
|
||||
const exitIndex = 0;
|
||||
@@ -199,7 +203,7 @@ export default class ElectronAppWrapper {
|
||||
//
|
||||
// Also only run this if not testing (crashing the renderer breaks automated
|
||||
// tests).
|
||||
if (this.win_ && !this.win_.webContents.isCrashed() && !isTesting) {
|
||||
if (this.win_ && !this.win_.isDestroyed() && !this.win_.webContents.isCrashed() && !isTesting) {
|
||||
this.win_.webContents.forcefullyCrashRenderer();
|
||||
}
|
||||
} else if (response === exitIndex) {
|
||||
@@ -401,6 +405,15 @@ export default class ElectronAppWrapper {
|
||||
};
|
||||
addWindowEventHandlers(this.win_.webContents);
|
||||
|
||||
// BrowserWindow 'focus' fires when the OS gives focus to the application window
|
||||
// (i.e. coming from another app or from the taskbar), not on intra-app focus switches.
|
||||
// We use a dedicated IPC channel so the renderer can trigger an immediate sync on
|
||||
// OS-level focus gain without conflating it with the 'window-focused' channel that
|
||||
// handles Joplin-internal window routing.
|
||||
this.win_.on('focus', () => {
|
||||
this.win_?.webContents.send('main-window-focused');
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
this.win_.on('close', (event: any) => {
|
||||
// If it's on macOS, the app is completely closed only if the user chooses to close the app (willQuitApp_ will be true)
|
||||
@@ -892,6 +905,11 @@ export default class ElectronAppWrapper {
|
||||
event.preventDefault();
|
||||
void this.openCallbackUrl(url);
|
||||
});
|
||||
|
||||
// When the OS wakes from sleep, notify the renderer so it can trigger an immediate sync.
|
||||
powerMonitor.on('resume', () => {
|
||||
this.win_?.webContents.send('system-resumed');
|
||||
});
|
||||
}
|
||||
|
||||
public async openCallbackUrl(url: string) {
|
||||
|
||||
@@ -43,7 +43,7 @@ const electronContextMenu = require('./services/electron-context-menu');
|
||||
// Commands that are not tied to any particular component.
|
||||
// The runtime for these commands can be loaded when the app starts.
|
||||
|
||||
import PerFolderSortOrderService from './services/sortOrder/PerFolderSortOrderService';
|
||||
import PerFolderSortOrderService from '@joplin/lib/services/sortOrder/PerFolderSortOrderService';
|
||||
import ShareService from '@joplin/lib/services/share/ShareService';
|
||||
import checkForUpdates from './checkForUpdates';
|
||||
import { AppState } from './app.reducer';
|
||||
@@ -638,18 +638,23 @@ class Application extends BaseApplication {
|
||||
|
||||
if (Setting.value('env') === 'dev') {
|
||||
void AlarmService.updateAllNotifications();
|
||||
RevisionService.instance().runInBackground();
|
||||
} else {
|
||||
// eslint-disable-next-line promise/prefer-await-to-then -- Old code before rule was applied
|
||||
void reg.scheduleSync(1000).then(() => {
|
||||
// Wait for the first sync before updating the notifications, since synchronisation
|
||||
// might change the notifications.
|
||||
void AlarmService.updateAllNotifications();
|
||||
setTimeout(() => {
|
||||
// Schedule sync with a delay of 0 and wrap with the desired timeout, as shim.setTimeout may not fire on first run or after an upgrade
|
||||
// eslint-disable-next-line promise/prefer-await-to-then -- Old code before rule was applied
|
||||
void reg.scheduleSync(0).then(() => {
|
||||
// Wait for the first sync before updating the notifications, since synchronisation
|
||||
// might change the notifications.
|
||||
void AlarmService.updateAllNotifications();
|
||||
|
||||
void DecryptionWorker.instance().scheduleStart();
|
||||
});
|
||||
void DecryptionWorker.instance().scheduleStart();
|
||||
|
||||
RevisionService.instance().runInBackground();
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
RevisionService.instance().runInBackground();
|
||||
this.startRotatingLogMaintenance(Setting.value('profileDir'));
|
||||
});
|
||||
|
||||
@@ -728,6 +733,23 @@ class Application extends BaseApplication {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Trigger an immediate sync when the main window gains OS-level focus (i.e. the user
|
||||
// switches back to Joplin from another application) or when the system wakes from sleep.
|
||||
// A 30-second cool-down prevents duplicate syncs during rapid focus-in/focus-out cycles.
|
||||
const minResumeSyncIntervalMs = 30_000;
|
||||
let lastFocusSyncTime = 0;
|
||||
|
||||
const scheduleResumeSync = () => {
|
||||
const now = Date.now();
|
||||
if (now - lastFocusSyncTime > minResumeSyncIntervalMs) {
|
||||
lastFocusSyncTime = now;
|
||||
void reg.scheduleSync(0);
|
||||
}
|
||||
};
|
||||
|
||||
ipcRenderer.on('main-window-focused', scheduleResumeSync);
|
||||
ipcRenderer.on('system-resumed', scheduleResumeSync);
|
||||
});
|
||||
|
||||
addTask('app/initPluginService', () => this.initPluginService());
|
||||
|
||||
@@ -583,6 +583,11 @@ export class Bridge {
|
||||
execPath: process.env.PORTABLE_EXECUTABLE_FILE,
|
||||
};
|
||||
app.relaunch(options);
|
||||
} else if (process.env.APPIMAGE && !this.altInstanceId_) {
|
||||
app.relaunch({
|
||||
execPath: process.env.APPIMAGE,
|
||||
args: ['--appimage-extract-and-run'],
|
||||
});
|
||||
} else if (this.altInstanceId_) {
|
||||
// Couldn't get it to work using relaunch() - it would just "close" the app, but it
|
||||
// would still be open in the tray except unusable. Or maybe it reopens it quickly but
|
||||
|
||||
@@ -4,7 +4,7 @@ import { _ } from '@joplin/lib/locale';
|
||||
import bridge from './services/bridge';
|
||||
import KvStore from '@joplin/lib/services/KvStore';
|
||||
import * as ArrayUtils from '@joplin/lib/ArrayUtils';
|
||||
import { CheckForUpdateOptions, extractVersionInfo, GitHubRelease } from './utils/checkForUpdatesUtils';
|
||||
import { CheckForUpdateOptions, extractVersionInfo, GitHubRelease, handleReleaseResponseError } from './utils/checkForUpdatesUtils';
|
||||
import { PackageInfo } from '@joplin/lib/versionInfo';
|
||||
import { compareVersions } from 'compare-versions';
|
||||
const packageInfo: PackageInfo = require('./packageInfo.js');
|
||||
@@ -29,7 +29,8 @@ async function fetchLatestReleases() {
|
||||
|
||||
if (!response.ok) {
|
||||
const responseText = await response.text();
|
||||
throw new Error(`Cannot get latest release info: ${responseText.substr(0, 500)}`);
|
||||
logger.error(`Cannot get latest release info (${response.status}): ${responseText.substr(0, 500)}`);
|
||||
handleReleaseResponseError(response.status, responseText);
|
||||
}
|
||||
|
||||
return (await response.json()) as GitHubRelease[];
|
||||
|
||||
@@ -13,7 +13,8 @@ export const runtime = (): CommandRuntime => {
|
||||
return {
|
||||
execute: async () => {
|
||||
const appPath = app.getPath('exe');
|
||||
const cmd = `${appPath} --env dev`;
|
||||
// Quote the path so it works when it contains spaces (e.g. "C:\Program Files\Joplin\Joplin.exe" on Windows)
|
||||
const cmd = `"${appPath}" --env dev`;
|
||||
clipboard.writeText(cmd);
|
||||
await shim.showMessageBox(`The dev mode command has been copied to clipboard:\n\n${cmd}`, { type: MessageBoxType.Info });
|
||||
},
|
||||
|
||||
79
packages/app-desktop/commands/createAccessibleDocument.ts
Normal file
79
packages/app-desktop/commands/createAccessibleDocument.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { CommandRuntime, CommandDeclaration } from '@joplin/lib/services/CommandService';
|
||||
import Resource from '@joplin/lib/models/Resource';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { ResourceOcrStatus } from '@joplin/lib/services/database/types';
|
||||
import bridge from '../services/bridge';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
|
||||
const logger = Logger.create('createAccessibleDocument');
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'createAccessibleDocument',
|
||||
};
|
||||
|
||||
export const runtime = (): CommandRuntime => {
|
||||
return {
|
||||
execute: async (_context: unknown, resourceId: string) => {
|
||||
const resource = await Resource.load(resourceId);
|
||||
if (!resource) {
|
||||
bridge().showErrorMessageBox(_('Resource not found'));
|
||||
return;
|
||||
}
|
||||
|
||||
const resourcePath = Resource.fullPath(resource);
|
||||
|
||||
if (resource.mime !== 'application/pdf') {
|
||||
bridge().showInfoMessageBox(_('This feature is only available for PDF files.'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (resource.ocr_status !== ResourceOcrStatus.Done) {
|
||||
bridge().showInfoMessageBox(_('OCR is not complete. Please wait for OCR to finish before creating an accessible document.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const ocrDetails = resource.ocr_details;
|
||||
|
||||
// If ocr_details is missing (legacy PDF processed before this feature),
|
||||
// automatically re-run OCR to get the coordinate data
|
||||
if (!ocrDetails) {
|
||||
const result = await bridge().showMessageBox(_('OCR needs to run to generate an accessible document. This may take a moment. Would you like to continue?'), {
|
||||
buttons: [_('Run OCR'), _('Cancel')],
|
||||
});
|
||||
|
||||
if (result === 1) return; // User cancelled
|
||||
|
||||
// Trigger OCR re-run with TodoAccessible status to request full OCR details
|
||||
await Resource.save({
|
||||
id: resource.id,
|
||||
ocr_status: ResourceOcrStatus.TodoAccessible,
|
||||
ocr_details: '',
|
||||
ocr_error: '',
|
||||
ocr_text: '',
|
||||
});
|
||||
|
||||
bridge().showInfoMessageBox(_('OCR has been queued. Please wait for it to complete and then try again.'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Show save dialog
|
||||
const defaultFilename = `${(resource.filename || resource.title || resource.id).replace(/\.pdf$/i, '')}_accessible.pdf`;
|
||||
const outputPath = await bridge().showSaveDialog({
|
||||
defaultPath: defaultFilename,
|
||||
filters: [{ name: 'PDF', extensions: ['pdf'] }],
|
||||
});
|
||||
|
||||
if (!outputPath) return;
|
||||
|
||||
try {
|
||||
await shim.createAccessiblePdf(resourcePath, ocrDetails, outputPath, Setting.value('tempDir'));
|
||||
await bridge().openItem(outputPath);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
bridge().showErrorMessageBox(_('Failed to create accessible document: %s', error.message));
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
// AUTO-GENERATED using `gulp buildScriptIndexes`
|
||||
import * as copyDevCommand from './copyDevCommand';
|
||||
import * as copyToClipboard from './copyToClipboard';
|
||||
import * as createAccessibleDocument from './createAccessibleDocument';
|
||||
import * as editProfileConfig from './editProfileConfig';
|
||||
import * as emptyTrash from './emptyTrash';
|
||||
import * as exportDeletionLog from './exportDeletionLog';
|
||||
@@ -27,6 +28,7 @@ import * as toggleTabMovesFocus from './toggleTabMovesFocus';
|
||||
const index: any[] = [
|
||||
copyDevCommand,
|
||||
copyToClipboard,
|
||||
createAccessibleDocument,
|
||||
editProfileConfig,
|
||||
emptyTrash,
|
||||
exportDeletionLog,
|
||||
|
||||
@@ -19,7 +19,7 @@ import shouldShowMissingPasswordWarning from '@joplin/lib/components/shared/conf
|
||||
import MacOSMissingPasswordHelpLink from './controls/MissingPasswordHelpLink';
|
||||
const { KeymapConfigScreen } = require('../KeymapConfig/KeymapConfigScreen');
|
||||
import SettingComponent, { UpdateSettingValueEvent } from './controls/SettingComponent';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import shim, { MessageBoxType } from '@joplin/lib/shim';
|
||||
|
||||
|
||||
interface Font {
|
||||
@@ -145,8 +145,16 @@ class ConfigScreenComponent extends React.Component<any, any> {
|
||||
screenName = section.name;
|
||||
|
||||
if (this.hasChanges()) {
|
||||
const ok = await shim.showConfirmationDialog(_('This will open a new screen. Save your current changes?'));
|
||||
if (ok) {
|
||||
const answer = await shim.showMessageBox(
|
||||
_('This will open a new screen. Save your current changes?'),
|
||||
{
|
||||
type: MessageBoxType.Confirm,
|
||||
buttons: [_('Save changes'), _('Discard changes')],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
},
|
||||
);
|
||||
if (answer === 0) {
|
||||
await shared.saveSettings(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,18 +10,21 @@ import { MasterKeyEntity } from '@joplin/lib/services/e2ee/types';
|
||||
import { getEncryptionEnabled, masterKeyEnabled, SyncInfo } from '@joplin/lib/services/synchronizer/syncInfoUtils';
|
||||
import { getDefaultMasterKey, getMasterPasswordStatusMessage, masterPasswordIsValid, toggleAndSetupEncryption } from '@joplin/lib/services/e2ee/utils';
|
||||
import Button, { ButtonLevel } from '../Button/Button';
|
||||
import { useCallback, useId, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { AppState } from '../../app.reducer';
|
||||
import { AppState, AppStateDialogName } from '../../app.reducer';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import { PublicPrivateKeyPair } from '@joplin/lib/services/e2ee/ppk/ppk';
|
||||
import ToggleAdvancedSettingsButton from '../ConfigScreen/controls/ToggleAdvancedSettingsButton';
|
||||
import MacOSMissingPasswordHelpLink from '../ConfigScreen/controls/MissingPasswordHelpLink';
|
||||
import { Dispatch } from 'redux';
|
||||
import { shouldCancelPendingEnableAfterMasterPasswordDialog, shouldOpenMasterPasswordDialogForEnable, shouldResumeEnableAfterMasterPasswordDialog } from './enableFlow';
|
||||
|
||||
interface Props {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
themeId: any;
|
||||
dispatch: Dispatch;
|
||||
masterKeys: MasterKeyEntity[];
|
||||
passwords: Record<string, string>;
|
||||
notLoadedMasterKeys: string[];
|
||||
@@ -30,10 +33,13 @@ interface Props {
|
||||
activeMasterKeyId: string;
|
||||
masterPassword: string;
|
||||
ppk: PublicPrivateKeyPair;
|
||||
masterPasswordDialogOpen: boolean;
|
||||
}
|
||||
|
||||
const EncryptionConfigScreen = (props: Props) => {
|
||||
export const EncryptionConfigScreen = (props: Props) => {
|
||||
const { inputPasswords, onInputPasswordChange } = useInputPasswords(props.passwords);
|
||||
const [pendingEnableEncryption, setPendingEnableEncryption] = useState(false);
|
||||
const wasMasterPasswordDialogOpen = useRef(props.masterPasswordDialogOpen);
|
||||
|
||||
const theme = useMemo(() => {
|
||||
return themeStyle(props.themeId);
|
||||
@@ -44,6 +50,41 @@ const EncryptionConfigScreen = (props: Props) => {
|
||||
const { showDisabledMasterKeys, toggleShowDisabledMasterKeys } = useToggleShowDisabledMasterKeys();
|
||||
const needMasterPassword = useNeedMasterPassword(passwordChecks, props.masterKeys);
|
||||
|
||||
useEffect(() => {
|
||||
const wasOpen = wasMasterPasswordDialogOpen.current;
|
||||
wasMasterPasswordDialogOpen.current = props.masterPasswordDialogOpen;
|
||||
|
||||
if (shouldCancelPendingEnableAfterMasterPasswordDialog({
|
||||
pendingEnableEncryption,
|
||||
wasMasterPasswordDialogOpen: wasOpen,
|
||||
masterPasswordDialogOpen: props.masterPasswordDialogOpen,
|
||||
masterPassword: props.masterPassword,
|
||||
})) {
|
||||
setPendingEnableEncryption(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shouldResumeEnableAfterMasterPasswordDialog({
|
||||
pendingEnableEncryption,
|
||||
wasMasterPasswordDialogOpen: wasOpen,
|
||||
masterPasswordDialogOpen: props.masterPasswordDialogOpen,
|
||||
masterPassword: props.masterPassword,
|
||||
})) return;
|
||||
|
||||
const masterKey = getDefaultMasterKey();
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
await toggleAndSetupEncryption(EncryptionService.instance(), true, masterKey, props.masterPassword);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
await dialogs.alert(message);
|
||||
} finally {
|
||||
setPendingEnableEncryption(false);
|
||||
}
|
||||
})();
|
||||
}, [pendingEnableEncryption, props.masterPasswordDialogOpen, props.masterPassword]);
|
||||
|
||||
const onUpgradeMasterKey = useCallback(async (mk: MasterKeyEntity) => {
|
||||
const password = determineKeyPassword(mk.id, masterPasswordKeys, props.masterPassword, props.passwords);
|
||||
const result = await upgradeMasterKey(mk, password);
|
||||
@@ -200,6 +241,18 @@ const EncryptionConfigScreen = (props: Props) => {
|
||||
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?'));
|
||||
if (!answer) return;
|
||||
} else {
|
||||
if (shouldOpenMasterPasswordDialogForEnable({
|
||||
hasMasterPassword,
|
||||
masterPasswordDialogOpen: props.masterPasswordDialogOpen,
|
||||
})) {
|
||||
setPendingEnableEncryption(true);
|
||||
props.dispatch({
|
||||
type: 'DIALOG_OPEN',
|
||||
name: AppStateDialogName.MasterPassword,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const msg = enableEncryptionConfirmationMessages(masterKey, hasMasterPassword);
|
||||
newPassword = await dialogs.prompt(msg.join('\n\n'), '', '', { type: 'password' });
|
||||
}
|
||||
@@ -216,7 +269,7 @@ const EncryptionConfigScreen = (props: Props) => {
|
||||
} catch (error) {
|
||||
await dialogs.alert(error.message);
|
||||
}
|
||||
}, [props.masterPassword]);
|
||||
}, [props.dispatch, props.masterPassword, props.masterPasswordDialogOpen]);
|
||||
|
||||
const renderEncryptionSection = () => {
|
||||
const decryptedItemsInfo = <p>{decryptedStatText(stats)}</p>;
|
||||
@@ -415,6 +468,7 @@ const mapStateToProps = (state: AppState) => {
|
||||
notLoadedMasterKeys: state.notLoadedMasterKeys,
|
||||
masterPassword: state.settings['encryption.masterPassword'],
|
||||
ppk: syncInfo.ppk,
|
||||
masterPasswordDialogOpen: !!state.dialogs.find(dialog => dialog.name === AppStateDialogName.MasterPassword),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { shouldCancelPendingEnableAfterMasterPasswordDialog, shouldOpenMasterPasswordDialogForEnable, shouldResumeEnableAfterMasterPasswordDialog } from './enableFlow';
|
||||
|
||||
describe('enableFlow', () => {
|
||||
test('opens the master password dialog when enabling encryption without a stored master password', () => {
|
||||
expect(shouldOpenMasterPasswordDialogForEnable({
|
||||
hasMasterPassword: false,
|
||||
masterPasswordDialogOpen: false,
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
test('does not reopen the master password dialog if it is already open', () => {
|
||||
expect(shouldOpenMasterPasswordDialogForEnable({
|
||||
hasMasterPassword: false,
|
||||
masterPasswordDialogOpen: true,
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
test('does not open the master password dialog when a master password already exists', () => {
|
||||
expect(shouldOpenMasterPasswordDialogForEnable({
|
||||
hasMasterPassword: true,
|
||||
masterPasswordDialogOpen: false,
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
test('resumes enabling encryption after the dialog closes with a saved password', () => {
|
||||
expect(shouldResumeEnableAfterMasterPasswordDialog({
|
||||
pendingEnableEncryption: true,
|
||||
wasMasterPasswordDialogOpen: true,
|
||||
masterPasswordDialogOpen: false,
|
||||
masterPassword: 'new-password',
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
test('cancels the pending enable flow if the dialog closes without a password', () => {
|
||||
expect(shouldCancelPendingEnableAfterMasterPasswordDialog({
|
||||
pendingEnableEncryption: true,
|
||||
wasMasterPasswordDialogOpen: true,
|
||||
masterPasswordDialogOpen: false,
|
||||
masterPassword: '',
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
test('does not resume while the dialog is still open', () => {
|
||||
expect(shouldResumeEnableAfterMasterPasswordDialog({
|
||||
pendingEnableEncryption: true,
|
||||
wasMasterPasswordDialogOpen: true,
|
||||
masterPasswordDialogOpen: true,
|
||||
masterPassword: 'new-password',
|
||||
})).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
interface OpenDialogInput {
|
||||
hasMasterPassword: boolean;
|
||||
masterPasswordDialogOpen: boolean;
|
||||
}
|
||||
|
||||
interface ResumeEnableInput {
|
||||
pendingEnableEncryption: boolean;
|
||||
wasMasterPasswordDialogOpen: boolean;
|
||||
masterPasswordDialogOpen: boolean;
|
||||
masterPassword: string;
|
||||
}
|
||||
|
||||
export const shouldOpenMasterPasswordDialogForEnable = ({ hasMasterPassword, masterPasswordDialogOpen }: OpenDialogInput) => {
|
||||
return !hasMasterPassword && !masterPasswordDialogOpen;
|
||||
};
|
||||
|
||||
export const shouldResumeEnableAfterMasterPasswordDialog = ({ pendingEnableEncryption, wasMasterPasswordDialogOpen, masterPasswordDialogOpen, masterPassword }: ResumeEnableInput) => {
|
||||
return pendingEnableEncryption && wasMasterPasswordDialogOpen && !masterPasswordDialogOpen && !!masterPassword;
|
||||
};
|
||||
|
||||
export const shouldCancelPendingEnableAfterMasterPasswordDialog = ({ pendingEnableEncryption, wasMasterPasswordDialogOpen, masterPasswordDialogOpen, masterPassword }: ResumeEnableInput) => {
|
||||
return pendingEnableEncryption && wasMasterPasswordDialogOpen && !masterPasswordDialogOpen && !masterPassword;
|
||||
};
|
||||
@@ -41,7 +41,7 @@ export default function(props: Props) {
|
||||
if (mode === Mode.Reset) return false;
|
||||
return true;
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [status]);
|
||||
}, [status, mode]);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
props.dispatch({
|
||||
@@ -90,10 +90,12 @@ export default function(props: Props) {
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [currentPassword, password1, onClose, mode]);
|
||||
|
||||
// Show the "Re-enter password" confirmation field
|
||||
const needToRepeatPassword = useMemo(() => {
|
||||
if (mode === Mode.Reset) return true;
|
||||
if (showCurrentPassword) return true;
|
||||
return !hasMasterPasswordEncryptedData;
|
||||
}, [hasMasterPasswordEncryptedData, mode]);
|
||||
}, [mode, showCurrentPassword, hasMasterPasswordEncryptedData]);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const onCurrentPasswordChange = useCallback((event: any) => {
|
||||
@@ -138,6 +140,7 @@ export default function(props: Props) {
|
||||
}, [currentPassword]);
|
||||
|
||||
function renderPasswordForm() {
|
||||
const passwordsMatch = password1 === password2;
|
||||
const renderCurrentPassword = () => {
|
||||
if (!showCurrentPassword) return null;
|
||||
|
||||
@@ -161,11 +164,11 @@ export default function(props: Props) {
|
||||
const renderResetMasterPasswordLink = () => {
|
||||
if (mode === Mode.Reset) return null;
|
||||
if (status === MasterPasswordStatus.Valid) return null;
|
||||
return <p><a href="#" onClick={onToggleMode}>Reset master password</a></p>;
|
||||
return <p><a href="#" onClick={onToggleMode}>{_('Reset master password')}</a></p>;
|
||||
};
|
||||
|
||||
if (showPasswordForm) {
|
||||
const enterPasswordLabel = [MasterPasswordStatus.Loaded, MasterPasswordStatus.Valid].includes(status) ? 'Enter new password' : 'Enter password';
|
||||
const enterPasswordLabel = [MasterPasswordStatus.Loaded, MasterPasswordStatus.Valid].includes(status) ? _('Enter new password') : _('Enter password');
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -176,22 +179,32 @@ export default function(props: Props) {
|
||||
value={password1}
|
||||
onChange={onPasswordChange1}
|
||||
/>
|
||||
|
||||
{needToRepeatPassword && (
|
||||
<LabelledPasswordInput
|
||||
labelText={_('Re-enter password')}
|
||||
value={password2}
|
||||
onChange={onPasswordChange2}
|
||||
/>
|
||||
<>
|
||||
<LabelledPasswordInput
|
||||
labelText={_('Re-enter password')}
|
||||
value={password2}
|
||||
onChange={onPasswordChange2}
|
||||
valid={password2 ? passwordsMatch : undefined}
|
||||
/>
|
||||
|
||||
{password2 && !passwordsMatch && (
|
||||
<p className="error-message">
|
||||
{_('Passwords do not match')}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p className="bold">Please make sure you remember your password. For security reasons, it is not possible to recover it if it is lost.</p>
|
||||
<p className="bold">{_('Please make sure you remember your password. For security reasons, it is not possible to recover it if it is lost.')}</p>
|
||||
{renderResetMasterPasswordLink()}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<p>
|
||||
<a onClick={onShowPasswordForm} href="#">Change master password</a>
|
||||
<a onClick={onShowPasswordForm} href="#">{_('Change master password')}</a>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -201,16 +214,16 @@ export default function(props: Props) {
|
||||
if (mode === Mode.Reset) {
|
||||
return (
|
||||
<div className="dialog-content">
|
||||
<p>Attention: After resetting your password it will no longer be possible to decrypt any data encrypted with your current password. All encrypted shared notebooks will also be unshared, so please ask the notebook owner to share it again with you.</p>
|
||||
<p>{_('Attention: After resetting your password it will no longer be possible to decrypt any data encrypted with your current password. All encrypted shared notebooks will also be unshared, so please ask the notebook owner to share it again with you.')}</p>
|
||||
{renderPasswordForm()}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className="dialog-content">
|
||||
<p>Your master password is used to protect sensitive information. In particular, it is used to encrypt your notes when end-to-end encryption (E2EE) is enabled, or to share and encrypt notes with someone who has E2EE enabled.</p>
|
||||
<p>{_('Your master password is used to protect sensitive information. In particular, it is used to encrypt your notes when end-to-end encryption (E2EE) is enabled, or to share and encrypt notes with someone who has E2EE enabled.')}</p>
|
||||
<p>
|
||||
<span>{'Master password status:'}</span> <span className="bold">{getMasterPasswordStatusMessage(status)}</span>
|
||||
<span>{_('Master password status:')}</span> <span className="bold">{getMasterPasswordStatusMessage(status)}</span>
|
||||
</p>
|
||||
{renderPasswordForm()}
|
||||
</div>
|
||||
|
||||
@@ -709,6 +709,7 @@ function useMenu(props: Props) {
|
||||
menuItemDic.textCut,
|
||||
menuItemDic.textPaste,
|
||||
menuItemDic.pasteAsText,
|
||||
menuItemDic.pasteAsMarkdown,
|
||||
menuItemDic.textSelectAll,
|
||||
separator(),
|
||||
menuItemDic.globalUndo,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AppState, AppStateRoute } from '../app.reducer';
|
||||
import bridge from '../services/bridge';
|
||||
import { useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { WindowIdContext } from './NewWindowOrIFrame';
|
||||
import useCtrlWheelZoom from './hooks/useCtrlWheelZoom';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Partial refactor of code from before rule was applied
|
||||
type ScreenProps = any;
|
||||
@@ -98,6 +99,7 @@ const NavigatorComponent: React.FC<Props> = props => {
|
||||
|
||||
useWindowTitleManager(screenInfo);
|
||||
useWindowRefocusManager(route);
|
||||
useCtrlWheelZoom();
|
||||
const size = useContainerSize(container);
|
||||
|
||||
if (!route) throw new Error('Route must not be null');
|
||||
|
||||
@@ -4,62 +4,49 @@ describe('useContextMenu', () => {
|
||||
const resourceId = 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4';
|
||||
const resourceId2 = 'b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5';
|
||||
|
||||
it('should return resource ID when cursor is inside markdown image', () => {
|
||||
it('should return type=image when cursor is inside markdown image', () => {
|
||||
const line = ``;
|
||||
expect(getResourceIdFromMarkup(line, 0)).toBe(resourceId);
|
||||
expect(getResourceIdFromMarkup(line, 15)).toBe(resourceId);
|
||||
expect(getResourceIdFromMarkup(line, line.length - 1)).toBe(resourceId);
|
||||
const result = getResourceIdFromMarkup(line, 15);
|
||||
expect(result.resourceId).toBe(resourceId);
|
||||
expect(result.type).toBe('image');
|
||||
expect(line.substring(result.markupStart, result.markupEnd)).toBe(line);
|
||||
});
|
||||
|
||||
it('should return null when cursor is outside markdown image', () => {
|
||||
it('should return type=file when cursor is inside markdown link', () => {
|
||||
const line = `[document.pdf](:/${resourceId})`;
|
||||
const result = getResourceIdFromMarkup(line, 15);
|
||||
expect(result.resourceId).toBe(resourceId);
|
||||
expect(result.type).toBe('file');
|
||||
expect(line.substring(result.markupStart, result.markupEnd)).toBe(line);
|
||||
});
|
||||
|
||||
it('should return null when cursor is outside markup', () => {
|
||||
const line = `Some text  more text`;
|
||||
expect(getResourceIdFromMarkup(line, 5)).toBeNull();
|
||||
expect(getResourceIdFromMarkup(line, line.length - 5)).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle markdown image without alt text', () => {
|
||||
const line = ``;
|
||||
expect(getResourceIdFromMarkup(line, 5)).toBe(resourceId);
|
||||
});
|
||||
it('should correctly distinguish between image and file on same line', () => {
|
||||
const line = ` [file](:/${resourceId2})`;
|
||||
const imageResult = getResourceIdFromMarkup(line, 10);
|
||||
expect(imageResult.resourceId).toBe(resourceId);
|
||||
expect(imageResult.type).toBe('image');
|
||||
|
||||
it('should return resource ID when cursor is inside HTML img tag', () => {
|
||||
const line = `<img src=":/${resourceId}" />`;
|
||||
expect(getResourceIdFromMarkup(line, 10)).toBe(resourceId);
|
||||
});
|
||||
|
||||
it('should handle HTML img tag with additional attributes', () => {
|
||||
const line = `<img alt="test" src=":/${resourceId}" width="100" />`;
|
||||
expect(getResourceIdFromMarkup(line, 25)).toBe(resourceId);
|
||||
});
|
||||
|
||||
it('should return null when cursor is outside HTML img tag', () => {
|
||||
const line = `text <img src=":/${resourceId}" /> more`;
|
||||
expect(getResourceIdFromMarkup(line, 2)).toBeNull();
|
||||
expect(getResourceIdFromMarkup(line, line.length - 2)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return correct resource ID when multiple images on same line', () => {
|
||||
const line = ` `;
|
||||
expect(getResourceIdFromMarkup(line, 10)).toBe(resourceId);
|
||||
expect(getResourceIdFromMarkup(line, 50)).toBe(resourceId2);
|
||||
const fileResult = getResourceIdFromMarkup(line, 48);
|
||||
expect(fileResult.resourceId).toBe(resourceId2);
|
||||
expect(fileResult.type).toBe('file');
|
||||
});
|
||||
|
||||
it('should return null for empty line', () => {
|
||||
expect(getResourceIdFromMarkup('', 0)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for line without images', () => {
|
||||
it('should return null for line without resources', () => {
|
||||
expect(getResourceIdFromMarkup('Just some regular text', 10)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for non-resource links', () => {
|
||||
const line = '';
|
||||
expect(getResourceIdFromMarkup(line, 10)).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle cursor at exact boundaries of image markup', () => {
|
||||
const line = ``;
|
||||
expect(getResourceIdFromMarkup(line, 0)).toBe(resourceId);
|
||||
expect(getResourceIdFromMarkup(line, line.length)).toBe(resourceId);
|
||||
it('should return null for non-resource URLs', () => {
|
||||
expect(getResourceIdFromMarkup('', 10)).toBeNull();
|
||||
expect(getResourceIdFromMarkup('[link](https://example.com)', 10)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,15 +11,24 @@ import type CodeMirrorControl from '@joplin/editor/CodeMirror/CodeMirrorControl'
|
||||
import bridge from '../../../../../services/bridge';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import Resource from '@joplin/lib/models/Resource';
|
||||
import { ContextMenuItemType, ContextMenuOptions, buildMenuItems, handleEditorContextMenuFilter } from '../../../utils/contextMenuUtils';
|
||||
import { ContextMenuItemType, ContextMenuOptions, buildMenuItems, handleEditorContextMenuFilter, resolveContextMenuItemType } from '../../../utils/contextMenuUtils';
|
||||
import { menuItems } from '../../../utils/contextMenu';
|
||||
import isItemId from '@joplin/lib/models/utils/isItemId';
|
||||
import { extractResourceUrls } from '@joplin/lib/urlUtils';
|
||||
import { WindowIdContext } from '../../../../NewWindowOrIFrame';
|
||||
|
||||
// Extract resource ID from image markup at a given cursor position within a line.
|
||||
// Returns the resource ID if the cursor is within an image markup, null otherwise.
|
||||
export const getResourceIdFromMarkup = (lineContent: string, cursorPosInLine: number): string | null => {
|
||||
export type ResourceMarkupType = 'image' | 'file';
|
||||
|
||||
export interface ResourceMarkupInfo {
|
||||
resourceId: string;
|
||||
type: ResourceMarkupType;
|
||||
markupStart: number;
|
||||
markupEnd: number;
|
||||
}
|
||||
|
||||
// Extract resource ID from resource markup (images or file attachments) at a given cursor position within a line.
|
||||
// Returns the resource ID and its type if the cursor is within a resource markup, null otherwise.
|
||||
export const getResourceIdFromMarkup = (lineContent: string, cursorPosInLine: number): ResourceMarkupInfo | null => {
|
||||
const resourceUrls = extractResourceUrls(lineContent);
|
||||
if (!resourceUrls.length) return null;
|
||||
|
||||
@@ -27,16 +36,38 @@ export const getResourceIdFromMarkup = (lineContent: string, cursorPosInLine: nu
|
||||
const resourcePattern = new RegExp(`[:](/?${resourceInfo.itemId})`, 'g');
|
||||
let match;
|
||||
while ((match = resourcePattern.exec(lineContent)) !== null) {
|
||||
// Look backwards for ![ or <img
|
||||
let markupStart = lineContent.lastIndexOf('![', match.index);
|
||||
// Look backwards for ![, [, <img, or <a
|
||||
const imageMarkupStart = lineContent.lastIndexOf('![', match.index);
|
||||
const linkMarkupStart = lineContent.lastIndexOf('[', match.index);
|
||||
const imgTagStart = lineContent.lastIndexOf('<img', match.index);
|
||||
if (imgTagStart > markupStart) markupStart = imgTagStart;
|
||||
const aTagStart = lineContent.lastIndexOf('<a', match.index);
|
||||
|
||||
// Find the closest markup start and determine type
|
||||
let markupStart = -1;
|
||||
let markupType: ResourceMarkupType = 'file';
|
||||
|
||||
if (imageMarkupStart !== -1 && imageMarkupStart > markupStart) {
|
||||
markupStart = imageMarkupStart;
|
||||
markupType = 'image';
|
||||
}
|
||||
if (linkMarkupStart !== -1 && linkMarkupStart > markupStart && lineContent[linkMarkupStart - 1] !== '!') {
|
||||
markupStart = linkMarkupStart;
|
||||
markupType = 'file';
|
||||
}
|
||||
if (imgTagStart !== -1 && imgTagStart > markupStart) {
|
||||
markupStart = imgTagStart;
|
||||
markupType = 'image';
|
||||
}
|
||||
if (aTagStart !== -1 && aTagStart > markupStart) {
|
||||
markupStart = aTagStart;
|
||||
markupType = 'file';
|
||||
}
|
||||
|
||||
if (markupStart === -1) continue;
|
||||
|
||||
// Find the end of the markup
|
||||
let markupEnd: number;
|
||||
if (lineContent[markupStart] === '!') {
|
||||
if (lineContent[markupStart] === '!' || lineContent[markupStart] === '[') {
|
||||
markupEnd = lineContent.indexOf(')', match.index);
|
||||
if (markupEnd !== -1) markupEnd += 1;
|
||||
} else {
|
||||
@@ -45,7 +76,7 @@ export const getResourceIdFromMarkup = (lineContent: string, cursorPosInLine: nu
|
||||
}
|
||||
|
||||
if (markupEnd !== -1 && cursorPosInLine >= markupStart && cursorPosInLine <= markupEnd) {
|
||||
return resourceInfo.itemId;
|
||||
return { resourceId: resourceInfo.itemId, type: markupType, markupStart, markupEnd };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -84,6 +115,9 @@ const useContextMenu = (props: ContextMenuProps) => {
|
||||
// It might be buggy, refer to the below issue
|
||||
// https://github.com/laurent22/joplin/pull/3974#issuecomment-718936703
|
||||
useEffect(() => {
|
||||
const targetWindow = bridge().windowById(windowId);
|
||||
if (!targetWindow) return ()=> {};
|
||||
|
||||
const isAncestorOfCodeMirrorEditor = (elem: Element) => {
|
||||
for (; elem.parentElement; elem = elem.parentElement) {
|
||||
if (elem.classList.contains(props.editorClassName)) {
|
||||
@@ -132,30 +166,24 @@ const useContextMenu = (props: ContextMenuProps) => {
|
||||
return clickedElement?.closest(`.${imageClassName}`) as HTMLElement | null;
|
||||
};
|
||||
|
||||
// Get resource ID from image markup at click position (not cursor position)
|
||||
const getResourceIdAtClickPos = (params: ContextMenuParams): string | null => {
|
||||
if (!editorRef.current) return null;
|
||||
|
||||
const editor = editorRef.current.editor;
|
||||
if (!editor) return null;
|
||||
|
||||
const zoom = Setting.value('windowContentZoomFactor');
|
||||
const x = convertFromScreenCoordinates(zoom, params.x);
|
||||
const y = convertFromScreenCoordinates(zoom, params.y);
|
||||
|
||||
const clickPos = editor.posAtCoords({ x, y });
|
||||
if (clickPos === null) return null;
|
||||
|
||||
const line = editor.state.doc.lineAt(clickPos);
|
||||
return getResourceIdFromMarkup(line.text, clickPos - line.from);
|
||||
const appendEditMenuItems = (menu: typeof Menu.prototype) => {
|
||||
const hasSelectedText = editorRef.current && !!editorRef.current.getSelection();
|
||||
const isReadOnly = editorRef.current?.editor?.state.readOnly ?? false;
|
||||
menu.append(new MenuItem({ label: _('Cut'), enabled: hasSelectedText && !isReadOnly, click: () => props.editorCutText() }));
|
||||
menu.append(new MenuItem({ label: _('Copy'), enabled: hasSelectedText, click: () => props.editorCopyText() }));
|
||||
menu.append(new MenuItem({ label: _('Paste'), enabled: !isReadOnly, click: () => props.editorPaste() }));
|
||||
menu.append(new MenuItem({ label: _('Paste as Markdown'), enabled: !isReadOnly, click: () => CommandService.instance().execute('pasteAsMarkdown') }));
|
||||
};
|
||||
|
||||
const targetWindow = bridge().windowById(windowId);
|
||||
|
||||
const showImageContextMenu = async (resourceId: string) => {
|
||||
const showResourceContextMenu = async (resourceId: string, type: ResourceMarkupType) => {
|
||||
const menu = new Menu();
|
||||
|
||||
// Add resource-specific options first
|
||||
const baseType = type === 'image' ? ContextMenuItemType.Image : ContextMenuItemType.Resource;
|
||||
const itemType = await resolveContextMenuItemType(baseType, resourceId);
|
||||
const isReadOnly = editorRef.current?.editor?.state.readOnly ?? false;
|
||||
const contextMenuOptions: ContextMenuOptions = {
|
||||
itemType: ContextMenuItemType.Image,
|
||||
itemType,
|
||||
resourceId,
|
||||
filename: null,
|
||||
mime: null,
|
||||
@@ -163,18 +191,34 @@ const useContextMenu = (props: ContextMenuProps) => {
|
||||
linkToOpen: null,
|
||||
textToCopy: null,
|
||||
htmlToCopy: null,
|
||||
insertContent: () => {},
|
||||
isReadOnly: true,
|
||||
insertContent: () => { editorRef.current?.insertText(''); },
|
||||
isReadOnly,
|
||||
fireEditorEvent: () => {},
|
||||
htmlToMd: null,
|
||||
mdToHtml: null,
|
||||
};
|
||||
|
||||
const imageMenuItems = await buildMenuItems(menuItems(props.dispatch), contextMenuOptions);
|
||||
for (const item of imageMenuItems) {
|
||||
const resourceMenuItems = await buildMenuItems(menuItems(props.dispatch), contextMenuOptions, { excludeEditItems: true, excludePluginItems: true });
|
||||
for (const item of resourceMenuItems) {
|
||||
menu.append(item);
|
||||
}
|
||||
|
||||
// Add edit items
|
||||
menu.append(new MenuItem({ type: 'separator' }));
|
||||
appendEditMenuItems(menu);
|
||||
|
||||
// Add plugin items last
|
||||
const extraItems = await handleEditorContextMenuFilter({
|
||||
resourceId,
|
||||
itemType,
|
||||
});
|
||||
if (extraItems.length) {
|
||||
menu.append(new MenuItem({ type: 'separator' }));
|
||||
for (const item of extraItems) {
|
||||
menu.append(item);
|
||||
}
|
||||
}
|
||||
|
||||
menu.popup({ window: targetWindow });
|
||||
};
|
||||
|
||||
@@ -196,7 +240,25 @@ const useContextMenu = (props: ContextMenuProps) => {
|
||||
});
|
||||
};
|
||||
|
||||
interface ResourceContextInfo {
|
||||
resourceId: string;
|
||||
type: ResourceMarkupType;
|
||||
}
|
||||
|
||||
const getResourceInfoAtPos = (docPos: number): ResourceContextInfo | null => {
|
||||
const editor = editorRef.current?.editor;
|
||||
if (!editor) return null;
|
||||
|
||||
const line = editor.state.doc.lineAt(docPos);
|
||||
const info = getResourceIdFromMarkup(line.text, docPos - line.from);
|
||||
if (!info) return null;
|
||||
|
||||
return { resourceId: info.resourceId, type: info.type };
|
||||
};
|
||||
|
||||
const onContextMenu = async (event: Event, params: ContextMenuParams) => {
|
||||
let resourceInfo: ResourceContextInfo | null = null;
|
||||
|
||||
// Check if right-clicking on a rendered image first (images may not be "editable")
|
||||
const imageContainer = getClickedImageContainer(params);
|
||||
if (imageContainer && pointerInsideEditor(params, true)) {
|
||||
@@ -204,19 +266,40 @@ const useContextMenu = (props: ContextMenuProps) => {
|
||||
if (imgElement) {
|
||||
const resourceId = pathToId(imgElement.src);
|
||||
if (resourceId) {
|
||||
event.preventDefault();
|
||||
moveCursorToImageLine(imageContainer);
|
||||
await showImageContextMenu(resourceId);
|
||||
return;
|
||||
const sourceFrom = imageContainer.dataset.sourceFrom;
|
||||
if (sourceFrom !== undefined) {
|
||||
const editor = editorRef.current?.editor;
|
||||
if (editor) {
|
||||
const pos = Math.min(Number(sourceFrom), editor.state.doc.length);
|
||||
resourceInfo = getResourceInfoAtPos(pos);
|
||||
}
|
||||
}
|
||||
// Fallback if we couldn't get markup info
|
||||
if (!resourceInfo) {
|
||||
resourceInfo = { resourceId, type: 'image' };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if right-clicking on image markup text
|
||||
const markupResourceId = getResourceIdAtClickPos(params);
|
||||
if (markupResourceId && pointerInsideEditor(params)) {
|
||||
// Check if right-clicking on resource markup text (images or file attachments)
|
||||
if (!resourceInfo && pointerInsideEditor(params)) {
|
||||
const editor = editorRef.current?.editor;
|
||||
if (editor) {
|
||||
const zoom = Setting.value('windowContentZoomFactor');
|
||||
const x = convertFromScreenCoordinates(zoom, params.x);
|
||||
const y = convertFromScreenCoordinates(zoom, params.y);
|
||||
const clickPos = editor.posAtCoords({ x, y });
|
||||
if (clickPos !== null) {
|
||||
resourceInfo = getResourceInfoAtPos(clickPos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (resourceInfo) {
|
||||
event.preventDefault();
|
||||
await showImageContextMenu(markupResourceId);
|
||||
await showResourceContextMenu(resourceInfo.resourceId, resourceInfo.type);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -227,38 +310,7 @@ const useContextMenu = (props: ContextMenuProps) => {
|
||||
event.preventDefault();
|
||||
|
||||
const menu = new Menu();
|
||||
|
||||
const hasSelectedText = editorRef.current && !!editorRef.current.getSelection() ;
|
||||
|
||||
menu.append(
|
||||
new MenuItem({
|
||||
label: _('Cut'),
|
||||
enabled: hasSelectedText,
|
||||
click: async () => {
|
||||
props.editorCutText();
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
menu.append(
|
||||
new MenuItem({
|
||||
label: _('Copy'),
|
||||
enabled: hasSelectedText,
|
||||
click: async () => {
|
||||
props.editorCopyText();
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
menu.append(
|
||||
new MenuItem({
|
||||
label: _('Paste'),
|
||||
enabled: true,
|
||||
click: async () => {
|
||||
props.editorPaste();
|
||||
},
|
||||
}),
|
||||
);
|
||||
appendEditMenuItems(menu);
|
||||
|
||||
const spellCheckerMenuItems = SpellCheckerService.instance().contextMenuItems(params.misspelledWord, params.dictionarySuggestions);
|
||||
|
||||
|
||||
@@ -221,7 +221,14 @@ const translateLE_ = (codeMirror: any, percent: number, l2e: boolean) => {
|
||||
linInterp = percent * lineCount - lineU;
|
||||
result = ePercentU + (ePercentL - ePercentU) * linInterp;
|
||||
} else {
|
||||
linInterp = Math.max(0, Math.min(1, (percent - ePercentU) / (ePercentL - ePercentU))) || 0;
|
||||
const rawLinInterp = (percent - ePercentU) / (ePercentL - ePercentU);
|
||||
if (ePercentL === ePercentU) {
|
||||
// Prevents the Viewer from jumping to the bottom of
|
||||
// the document when there is division by zero.
|
||||
linInterp = percent;
|
||||
} else {
|
||||
linInterp = Math.max(0, Math.min(1, rawLinInterp)) || 0;
|
||||
}
|
||||
result = (lineU + linInterp) / lineCount;
|
||||
}
|
||||
return Math.max(0, Math.min(1, result));
|
||||
|
||||
@@ -338,7 +338,7 @@ function CodeMirror(props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
|
||||
}, [editorPasteText, onEditorPaste]);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const loadScript = async (script: any) => {
|
||||
const loadScript = async (script: any, document: Document) => {
|
||||
return new Promise((resolve) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
let element: any = document.createElement('script');
|
||||
@@ -367,6 +367,7 @@ function CodeMirror(props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!editorRoot) return () => { };
|
||||
let cancelled = false;
|
||||
|
||||
async function loadScripts() {
|
||||
@@ -393,13 +394,14 @@ function CodeMirror(props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
|
||||
});
|
||||
}
|
||||
|
||||
const ownerDoc = editorRoot.ownerDocument;
|
||||
for (const s of scriptsToLoad) {
|
||||
if (document.getElementById(s.id)) {
|
||||
if (ownerDoc.getElementById(s.id)) {
|
||||
s.loaded = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
await loadScript(s);
|
||||
await loadScript(s, ownerDoc);
|
||||
if (cancelled) return;
|
||||
|
||||
s.loaded = true;
|
||||
@@ -411,7 +413,7 @@ function CodeMirror(props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [styles.editor.codeMirrorTheme]);
|
||||
}, [styles.editor.codeMirrorTheme, editorRoot]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editorRoot) return () => {};
|
||||
@@ -646,6 +648,7 @@ function CodeMirror(props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
|
||||
useCustomPdfViewer: props.useCustomPdfViewer,
|
||||
noteId: props.noteId,
|
||||
vendorDir: bridge().vendorDir(),
|
||||
showNoteLinkIcon: props.showNoteLinkIcon,
|
||||
}));
|
||||
|
||||
if (cancelled) return;
|
||||
@@ -666,7 +669,7 @@ function CodeMirror(props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
|
||||
shim.clearTimeout(timeoutId);
|
||||
};
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [props.content, props.contentKey, renderedBodyContentKey, props.contentMarkupLanguage, props.visiblePanes, props.resourceInfos, props.markupToHtml]);
|
||||
}, [props.content, props.contentKey, renderedBodyContentKey, props.contentMarkupLanguage, props.visiblePanes, props.resourceInfos, props.markupToHtml, props.showNoteLinkIcon]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!webviewReady) return;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import { useState, useEffect, useRef, forwardRef, useCallback, useImperativeHandle, useMemo, ForwardedRef, useContext } from 'react';
|
||||
import { useState, useEffect, useRef, forwardRef, useCallback, useImperativeHandle, ForwardedRef, useContext } from 'react';
|
||||
|
||||
import { EditorCommand, MarkupToHtmlOptions, NoteBodyEditorProps, NoteBodyEditorRef, OnChangeEvent } from '../../../utils/types';
|
||||
import { getResourcesFromPasteEvent } from '../../../utils/resourceHandling';
|
||||
@@ -12,11 +12,10 @@ import Note from '@joplin/lib/models/Note';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import bridge from '../../../../../services/bridge';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import { MarkupToHtml } from '@joplin/renderer';
|
||||
import { clipboard } from 'electron';
|
||||
import { reg } from '@joplin/lib/registry';
|
||||
import ErrorBoundary from '../../../../ErrorBoundary';
|
||||
import { EditorKeymap, EditorLanguageType, EditorSettings, SearchState, UserEventSource } from '@joplin/editor/types';
|
||||
import { SearchState, UserEventSource } from '@joplin/editor/types';
|
||||
import useStyles from '../utils/useStyles';
|
||||
import { EditorEvent, EditorEventType } from '@joplin/editor/events';
|
||||
import useScrollHandler from '../utils/useScrollHandler';
|
||||
@@ -33,6 +32,7 @@ import { WindowIdContext } from '../../../../NewWindowOrIFrame';
|
||||
import eventManager, { EventName, ResourceChangeEvent } from '@joplin/lib/eventManager';
|
||||
import useSyncEditorValue from './utils/useSyncEditorValue';
|
||||
import { getGlobalSettings } from '@joplin/renderer/types';
|
||||
import useEditorSettings from './utils/useEditorSettings';
|
||||
|
||||
const logger = Logger.create('CodeMirror6');
|
||||
const logDebug = (message: string) => logger.debug(message);
|
||||
@@ -222,6 +222,7 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
|
||||
noteId: props.noteId,
|
||||
vendorDir: bridge().vendorDir(),
|
||||
globalSettings: getGlobalSettings(Setting),
|
||||
showNoteLinkIcon: props.showNoteLinkIcon,
|
||||
}));
|
||||
|
||||
if (cancelled) return;
|
||||
@@ -244,7 +245,7 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
|
||||
}, [
|
||||
props.content, props.contentKey, renderedBodyContentKey, props.contentMarkupLanguage,
|
||||
props.visiblePanes, props.resourceInfos, props.markupToHtml, props.contentMaxWidth,
|
||||
props.noteId, props.useCustomPdfViewer,
|
||||
props.noteId, props.useCustomPdfViewer, props.showNoteLinkIcon,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -337,46 +338,6 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
|
||||
void CommandService.instance().execute('focusElement', 'noteTitle');
|
||||
}, []);
|
||||
|
||||
const editorSettings = useMemo((): EditorSettings => {
|
||||
const isHTMLNote = props.contentMarkupLanguage === MarkupToHtml.MARKUP_LANGUAGE_HTML;
|
||||
|
||||
let keyboardMode = EditorKeymap.Default;
|
||||
if (props.keyboardMode === 'vim') {
|
||||
keyboardMode = EditorKeymap.Vim;
|
||||
} else if (props.keyboardMode === 'emacs') {
|
||||
keyboardMode = EditorKeymap.Emacs;
|
||||
}
|
||||
|
||||
return {
|
||||
language: isHTMLNote ? EditorLanguageType.Html : EditorLanguageType.Markdown,
|
||||
readOnly: props.disabled,
|
||||
markdownMarkEnabled: Setting.value('markdown.plugin.mark'),
|
||||
katexEnabled: Setting.value('markdown.plugin.katex'),
|
||||
inlineRenderingEnabled: Setting.value('editor.inlineRendering'),
|
||||
imageRenderingEnabled: Setting.value('editor.imageRendering'),
|
||||
highlightActiveLine: Setting.value('editor.highlightActiveLine'),
|
||||
themeData: {
|
||||
...styles.globalTheme,
|
||||
marginLeft: 0,
|
||||
marginRight: 0,
|
||||
monospaceFont: Setting.value('style.editor.monospaceFontFamily'),
|
||||
},
|
||||
automatchBraces: Setting.value('editor.autoMatchingBraces'),
|
||||
autocompleteMarkup: Setting.value('editor.autocompleteMarkup'),
|
||||
useExternalSearch: false,
|
||||
ignoreModifiers: true,
|
||||
spellcheckEnabled: Setting.value('editor.spellcheckBeta'),
|
||||
keymap: keyboardMode,
|
||||
preferMacShortcuts: shim.isMac(),
|
||||
indentWithTabs: true,
|
||||
tabMovesFocus: props.tabMovesFocus,
|
||||
editorLabel: _('Markdown editor'),
|
||||
};
|
||||
}, [
|
||||
props.contentMarkupLanguage, props.disabled, props.keyboardMode, styles.globalTheme,
|
||||
props.tabMovesFocus,
|
||||
]);
|
||||
|
||||
const initialCursorLocationRef = useRef(0);
|
||||
initialCursorLocationRef.current = props.initialCursorLocation.markdown ?? 0;
|
||||
|
||||
@@ -389,6 +350,14 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
|
||||
initialCursorLocationRef,
|
||||
});
|
||||
|
||||
const settings = useEditorSettings({
|
||||
baseTheme: styles.globalTheme,
|
||||
contentMarkupLanguage: props.contentMarkupLanguage,
|
||||
disabled: props.disabled,
|
||||
keyboardMode: props.keyboardMode,
|
||||
tabMovesFocus: props.tabMovesFocus,
|
||||
});
|
||||
|
||||
const renderEditor = () => {
|
||||
return (
|
||||
<div className='editor'>
|
||||
@@ -398,7 +367,7 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
|
||||
initialSelectionRef={initialCursorLocationRef}
|
||||
initialNoteId={props.noteId}
|
||||
ref={editorRef}
|
||||
settings={editorSettings}
|
||||
settings={settings}
|
||||
pluginStates={props.plugins}
|
||||
onPasteFile={null}
|
||||
onEvent={onEditorEvent}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { EditorKeymap, EditorLanguageType, EditorSettings, EditorTheme } from '@joplin/editor/types';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import { MarkupLanguage, MarkupToHtml } from '@joplin/renderer';
|
||||
import { useMemo } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { AppState } from '../../../../../../app.reducer';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
|
||||
interface EditorSettingsProps {
|
||||
contentMarkupLanguage: MarkupLanguage;
|
||||
keyboardMode: string;
|
||||
disabled: boolean;
|
||||
tabMovesFocus: boolean;
|
||||
baseTheme: EditorTheme;
|
||||
}
|
||||
|
||||
const useEditorSettings = (props: EditorSettingsProps) => {
|
||||
const stateToSettings = (state: AppState) => ({
|
||||
markdownMark: state.settings['markdown.plugin.mark'],
|
||||
markdownInsert: state.settings['markdown.plugin.insert'],
|
||||
katex: state.settings['markdown.plugin.katex'],
|
||||
inlineRendering: state.settings['editor.inlineRendering'],
|
||||
imageRendering: state.settings['editor.imageRendering'],
|
||||
highlightActiveLine: state.settings['editor.highlightActiveLine'],
|
||||
monospaceFont: state.settings['style.editor.monospaceFontFamily'],
|
||||
automatchBraces: state.settings['editor.autoMatchingBraces'],
|
||||
autocompleteMarkup: state.settings['editor.autocompleteMarkup'],
|
||||
spellcheckEnabled: state.settings['editor.spellcheckBeta'],
|
||||
});
|
||||
type SelectedSettings = ReturnType<typeof stateToSettings>;
|
||||
const settings = useSelector<AppState, SelectedSettings>(stateToSettings, isDeepStrictEqual);
|
||||
|
||||
return useMemo((): EditorSettings => {
|
||||
const isHTMLNote = props.contentMarkupLanguage === MarkupToHtml.MARKUP_LANGUAGE_HTML;
|
||||
|
||||
let keyboardMode = EditorKeymap.Default;
|
||||
if (props.keyboardMode === 'vim') {
|
||||
keyboardMode = EditorKeymap.Vim;
|
||||
} else if (props.keyboardMode === 'emacs') {
|
||||
keyboardMode = EditorKeymap.Emacs;
|
||||
}
|
||||
|
||||
return {
|
||||
language: isHTMLNote ? EditorLanguageType.Html : EditorLanguageType.Markdown,
|
||||
readOnly: props.disabled,
|
||||
markdownMarkEnabled: settings.markdownMark,
|
||||
markdownInsertEnabled: settings.markdownInsert,
|
||||
katexEnabled: settings.katex,
|
||||
inlineRenderingEnabled: settings.inlineRendering,
|
||||
imageRenderingEnabled: settings.imageRendering,
|
||||
highlightActiveLine: settings.highlightActiveLine,
|
||||
themeData: {
|
||||
...props.baseTheme,
|
||||
marginLeft: 0,
|
||||
marginRight: 0,
|
||||
monospaceFont: settings.monospaceFont,
|
||||
},
|
||||
automatchBraces: settings.automatchBraces,
|
||||
autocompleteMarkup: settings.autocompleteMarkup,
|
||||
useExternalSearch: false,
|
||||
ignoreModifiers: true,
|
||||
spellcheckEnabled: settings.spellcheckEnabled,
|
||||
keymap: keyboardMode,
|
||||
preferMacShortcuts: shim.isMac(),
|
||||
indentWithTabs: true,
|
||||
tabMovesFocus: props.tabMovesFocus,
|
||||
editorLabel: _('Markdown editor'),
|
||||
};
|
||||
}, [
|
||||
props.contentMarkupLanguage, props.disabled, props.keyboardMode, props.baseTheme,
|
||||
props.tabMovesFocus, settings,
|
||||
]);
|
||||
};
|
||||
|
||||
export default useEditorSettings;
|
||||
@@ -705,6 +705,15 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const containerWindow = editorContainerDom.defaultView as any;
|
||||
const isDefaultEnglishLocale = ['en_US', 'en_GB'].includes(language);
|
||||
|
||||
if (!isDefaultEnglishLocale) {
|
||||
await loadScript({
|
||||
id: `tinyMceLang_${language}`,
|
||||
src: `${bridge().vendorDir()}/lib/tinymce/langs/${language}.js`,
|
||||
}, editorContainerDom);
|
||||
}
|
||||
|
||||
const editors = await containerWindow.tinymce.init({
|
||||
selector: `#${editorContainer.id}`,
|
||||
|
||||
@@ -735,7 +744,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
|
||||
// Handle the first table row as table header.
|
||||
// https://www.tiny.cloud/docs/plugins/table/#table_header_type
|
||||
table_header_type: 'sectionCells',
|
||||
language_url: ['en_US', 'en_GB'].includes(language) ? undefined : `${bridge().vendorDir()}/lib/tinymce/langs/${language}`,
|
||||
language: isDefaultEnglishLocale ? undefined : language,
|
||||
toolbar: toolbar.join(' '),
|
||||
localization_function: _,
|
||||
// See https://www.tiny.cloud/docs/tinymce/latest/tinymce-and-csp/#content_security_policy
|
||||
@@ -887,6 +896,30 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
|
||||
editor.addShortcut('Meta+Shift+8', '', () => editor.execCommand('InsertUnorderedList'));
|
||||
editor.addShortcut('Meta+Shift+9', '', () => editor.execCommand('InsertJoplinChecklist'));
|
||||
|
||||
// Override ScrollIntoView to scroll to the cursor's character position
|
||||
// instead of the start of the paragraph.
|
||||
// See: https://github.com/laurent22/joplin/issues/14143
|
||||
editor.on('ScrollIntoView', (event) => {
|
||||
const sel = editor.getDoc().getSelection();
|
||||
if (!sel || sel.rangeCount === 0) return;
|
||||
|
||||
const rect = sel.getRangeAt(0).getBoundingClientRect();
|
||||
const win = editor.getWin();
|
||||
const viewHeight = win.innerHeight;
|
||||
|
||||
if (rect.top < 0) {
|
||||
win.scrollBy(0, rect.top);
|
||||
} else if (rect.bottom > viewHeight) {
|
||||
win.scrollBy(0, rect.bottom - viewHeight);
|
||||
} else if (rect.top === 0 && rect.height === 0) {
|
||||
// Handles edge case where rect is not rendered
|
||||
// See: https://stackoverflow.com/a/14384220/5757550
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
return;
|
||||
});
|
||||
|
||||
// TODO: remove event on unmount?
|
||||
editor.on('drop', (event) => {
|
||||
// Prevent the message "Dropped file type is not supported" from showing up.
|
||||
@@ -1326,13 +1359,35 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const onSetAttrib = (event: EditorEvent<any>) => {
|
||||
// Dispatch onChange when a link is edited
|
||||
// Dispatch onChange when a link or table-related formatting is edited
|
||||
const target = Array.isArray(event.attrElm) ? event.attrElm[0] : event.attrElm;
|
||||
if (!target) return;
|
||||
|
||||
if (target.nodeName === 'A') {
|
||||
if (event.attrName === 'title' || event.attrName === 'href' || event.attrName === 'rel') {
|
||||
onChangeHandler();
|
||||
}
|
||||
}
|
||||
|
||||
if (['TABLE', 'TR', 'TD', 'TH'].includes(target.nodeName)) {
|
||||
const attributeName = (event.attrName ?? '').toLowerCase();
|
||||
if (
|
||||
attributeName === 'style' ||
|
||||
attributeName === 'class' ||
|
||||
attributeName === 'bgcolor' ||
|
||||
attributeName === 'bordercolor' ||
|
||||
attributeName === 'background' ||
|
||||
attributeName === 'cellpadding' ||
|
||||
attributeName === 'cellspacing'
|
||||
) {
|
||||
onChangeHandler();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Table plugin fires this on structure/style changes from dialogs.
|
||||
const onTableModified = () => {
|
||||
onChangeHandler();
|
||||
};
|
||||
|
||||
// Keypress means that a printable key (letter, digit, etc.) has been
|
||||
@@ -1481,6 +1536,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
|
||||
editor.on(TinyMceEditorEvents.Redo, onChangeHandler);
|
||||
editor.on(TinyMceEditorEvents.ExecCommand, onExecCommand);
|
||||
editor.on(TinyMceEditorEvents.SetAttrib, onSetAttrib);
|
||||
editor.on('TableModified', onTableModified);
|
||||
|
||||
return () => {
|
||||
try {
|
||||
@@ -1497,6 +1553,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
|
||||
editor.off(TinyMceEditorEvents.Redo, onChangeHandler);
|
||||
editor.off(TinyMceEditorEvents.ExecCommand, onExecCommand);
|
||||
editor.off(TinyMceEditorEvents.SetAttrib, onSetAttrib);
|
||||
editor.off('TableModified', onTableModified);
|
||||
} catch (error) {
|
||||
console.warn('Error removing events', error);
|
||||
}
|
||||
|
||||
@@ -35,8 +35,10 @@ export default function(editor: Editor, plugins: PluginStates, dispatch: Dispatc
|
||||
useEffect(() => {
|
||||
if (!editor) return () => {};
|
||||
|
||||
const contextMenuItems = menuItems(dispatch);
|
||||
const targetWindow = bridge().windowById(windowId);
|
||||
if (!targetWindow) return () => {};
|
||||
|
||||
const contextMenuItems = menuItems(dispatch);
|
||||
|
||||
const makeMainMenuItems = async (element: Element) => {
|
||||
let itemType: ContextMenuItemType = ContextMenuItemType.None;
|
||||
|
||||
@@ -14,7 +14,7 @@ import useFormNote, { OnLoadEvent, OnSetFormNote } from './utils/useFormNote';
|
||||
import useEffectiveNoteId from './utils/useEffectiveNoteId';
|
||||
import useFolder from './utils/useFolder';
|
||||
import styles_ from './styles';
|
||||
import { NoteEditorProps, FormNote, OnChangeEvent, AllAssetsOptions, NoteBodyEditorRef, NoteBodyEditorPropsAndRef } from './utils/types';
|
||||
import { NoteEditorProps, FormNote, OnChangeEvent, AllAssetsOptions, NoteBodyEditorRef, NoteBodyEditorPropsAndRef, NoteBodyEditorType } from './utils/types';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import Button, { ButtonLevel } from '../Button/Button';
|
||||
import eventManager, { EventName } from '@joplin/lib/eventManager';
|
||||
@@ -474,6 +474,7 @@ function NoteEditorContent(props: NoteEditorProps) {
|
||||
noteId: props.noteId,
|
||||
watchedNoteFiles: props.watchedNoteFiles,
|
||||
enableHtmlToMarkdownBanner: props.enableHtmlToMarkdownBanner,
|
||||
showNoteLinkIcon: props.showNoteLinkIcon,
|
||||
};
|
||||
|
||||
let editor = null;
|
||||
@@ -715,11 +716,11 @@ const mapStateToProps = (state: AppState, ownProps: ConnectProps) => {
|
||||
const windowState = stateUtils.windowStateById(state, ownProps.windowId);
|
||||
const noteId = stateUtils.selectedNoteId(windowState);
|
||||
|
||||
let bodyEditor = windowState.editorCodeView ? 'CodeMirror6' : 'TinyMCE';
|
||||
let bodyEditor = windowState.editorCodeView ? NoteBodyEditorType.CodeMirror6 : NoteBodyEditorType.TinyMce;
|
||||
if (state.settings.isSafeMode) {
|
||||
bodyEditor = 'PlainText';
|
||||
bodyEditor = NoteBodyEditorType.PlainText;
|
||||
} else if (windowState.editorCodeView && state.settings['editor.legacyMarkdown']) {
|
||||
bodyEditor = 'CodeMirror5';
|
||||
bodyEditor = NoteBodyEditorType.CodeMirror5;
|
||||
}
|
||||
|
||||
const mainWindowState = stateUtils.windowStateById(state, defaultWindowId);
|
||||
@@ -766,6 +767,8 @@ const mapStateToProps = (state: AppState, ownProps: ConnectProps) => {
|
||||
shareCacheSetting: state.settings['sync.shareCache'],
|
||||
searchResults: state.searchResults,
|
||||
enableHtmlToMarkdownBanner: state.settings['editor.enableHtmlToMarkdownBanner'],
|
||||
enableInEditorRendering: state.settings['editor.inlineRendering'],
|
||||
showNoteLinkIcon: state.settings['notes.showNoteLinkIcon'],
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ interface Props {
|
||||
acceptMessage: string;
|
||||
onAccept: ()=> void;
|
||||
onDismiss?: ()=> void;
|
||||
dismissMessage?: string;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
@@ -17,7 +18,7 @@ const BannerContent: React.FC<Props> = props => {
|
||||
return <div className='warning-banner'>
|
||||
{props.children}
|
||||
<a onClick={props.onAccept} className='warning-banner-link' href="#">[ {props.acceptMessage} ]</a>
|
||||
{ props.onDismiss ? <a onClick={props.onDismiss} className='warning-banner-link' href="#">[ {_('Dismiss')} ]</a> : null }
|
||||
{ props.onDismiss ? <a onClick={props.onDismiss} className='warning-banner-link' href="#">[ {props.dismissMessage ?? _('Dismiss')} ]</a> : null }
|
||||
</div>;
|
||||
};
|
||||
|
||||
|
||||
@@ -6,13 +6,17 @@ import BannerContent from './BannerContent';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import onRichTextReadMoreLinkClick from '@joplin/lib/components/shared/NoteEditor/WarningBanner/onRichTextReadMoreLinkClick';
|
||||
import onRichTextDismissLinkClick from '@joplin/lib/components/shared/NoteEditor/WarningBanner/onRichTextDismissLinkClick';
|
||||
import useEditorTypeMigrationBanner from '@joplin/lib/components/shared/NoteEditor/WarningBanner/useEditorTypeMigrationBanner';
|
||||
import { useMemo } from 'react';
|
||||
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
|
||||
import PluginService from '@joplin/lib/services/plugins/PluginService';
|
||||
import { NoteBodyEditorType } from '../utils/types';
|
||||
|
||||
interface Props {
|
||||
bodyEditor: string;
|
||||
bodyEditor: NoteBodyEditorType;
|
||||
editorMigrationVersion: number;
|
||||
richTextBannerDismissed: boolean;
|
||||
inEditorRenderingEnabled: boolean;
|
||||
pluginCompatibilityBannerDismissedFor: string[];
|
||||
plugins: PluginStates;
|
||||
}
|
||||
@@ -35,6 +39,22 @@ const incompatiblePluginIds = [
|
||||
];
|
||||
|
||||
const WarningBanner: React.FC<Props> = props => {
|
||||
|
||||
const editorMigrationMessage = useEditorTypeMigrationBanner({
|
||||
markdownEditorEnabled: props.bodyEditor === 'CodeMirror6',
|
||||
editorMigrationVersion: props.editorMigrationVersion,
|
||||
inEditorRenderingEnabled: props.inEditorRenderingEnabled,
|
||||
});
|
||||
const editorMigrationBanner = (
|
||||
<BannerContent
|
||||
visible={editorMigrationMessage.enabled}
|
||||
acceptMessage={editorMigrationMessage.keepEnabled.label}
|
||||
onAccept={editorMigrationMessage.keepEnabled.onPress}
|
||||
onDismiss={editorMigrationMessage.disable.onPress}
|
||||
dismissMessage={editorMigrationMessage.disable.label}
|
||||
>{editorMigrationMessage.label}</BannerContent>
|
||||
);
|
||||
|
||||
const wysiwygBanner = (
|
||||
<BannerContent
|
||||
acceptMessage={_('Read more about it')}
|
||||
@@ -83,6 +103,7 @@ const WarningBanner: React.FC<Props> = props => {
|
||||
return <>
|
||||
{wysiwygBanner}
|
||||
{markdownPluginBanner}
|
||||
{editorMigrationBanner}
|
||||
</>;
|
||||
};
|
||||
|
||||
@@ -91,5 +112,7 @@ export default connect((state: AppState) => {
|
||||
richTextBannerDismissed: state.settings.richTextBannerDismissed,
|
||||
pluginCompatibilityBannerDismissedFor: state.settings['editor.pluginCompatibilityBannerDismissedFor'],
|
||||
plugins: state.pluginService.plugins,
|
||||
editorMigrationVersion: state.settings['editor.migration'],
|
||||
inEditorRenderingEnabled: state.settings['editor.inlineRendering'],
|
||||
};
|
||||
})(WarningBanner);
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as focusElementNoteBody from './focusElementNoteBody';
|
||||
import * as focusElementNoteTitle from './focusElementNoteTitle';
|
||||
import * as focusElementNoteViewer from './focusElementNoteViewer';
|
||||
import * as focusElementToolbar from './focusElementToolbar';
|
||||
import * as pasteAsMarkdown from './pasteAsMarkdown';
|
||||
import * as pasteAsText from './pasteAsText';
|
||||
import * as showLocalSearch from './showLocalSearch';
|
||||
import * as showRevisions from './showRevisions';
|
||||
@@ -12,6 +13,7 @@ const index: any[] = [
|
||||
focusElementNoteTitle,
|
||||
focusElementNoteViewer,
|
||||
focusElementToolbar,
|
||||
pasteAsMarkdown,
|
||||
pasteAsText,
|
||||
showLocalSearch,
|
||||
showRevisions,
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { CommandRuntime, CommandDeclaration } from '@joplin/lib/services/CommandService';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import HtmlToMd from '@joplin/lib/HtmlToMd';
|
||||
import { processImagesInPastedHtml } from '../utils/resourceHandling';
|
||||
|
||||
const { clipboard } = require('electron');
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'pasteAsMarkdown',
|
||||
label: () => _('Paste as Markdown'),
|
||||
};
|
||||
|
||||
let htmlToMd_: HtmlToMd | null = null;
|
||||
|
||||
const htmlToMd = () => {
|
||||
if (!htmlToMd_) {
|
||||
htmlToMd_ = new HtmlToMd();
|
||||
}
|
||||
return htmlToMd_;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Props passed from NoteEditor component
|
||||
export const runtime = (comp: any): CommandRuntime => {
|
||||
return {
|
||||
execute: async () => {
|
||||
let html = clipboard.readHTML();
|
||||
if (html) {
|
||||
// Download images and convert them to Joplin resources
|
||||
html = await processImagesInPastedHtml(html, { useInternalUrls: true });
|
||||
const markdown = htmlToMd().parse(html, { tightLists: true, collapseMultipleBlankLines: true });
|
||||
comp.editorRef.current.execCommand({ name: 'insertText', value: markdown });
|
||||
} else {
|
||||
// Fall back to plain text if no HTML is available
|
||||
const text = clipboard.readText();
|
||||
if (text) {
|
||||
comp.editorRef.current.execCommand({ name: 'insertText', value: text });
|
||||
}
|
||||
}
|
||||
},
|
||||
enabledCondition: 'oneNoteSelected && markdownEditorVisible',
|
||||
};
|
||||
};
|
||||
@@ -103,10 +103,17 @@ export function menuItems(dispatch: Function): ContextMenuItems {
|
||||
}
|
||||
},
|
||||
isActive: (itemType: ContextMenuItemType, options: ContextMenuOptions) => (
|
||||
(!options.textToCopy && (itemType === ContextMenuItemType.Image || itemType === ContextMenuItemType.Resource))
|
||||
(!options.textToCopy && (itemType === ContextMenuItemType.Image || itemType === ContextMenuItemType.Resource || itemType === ContextMenuItemType.NoteLink))
|
||||
|| (!!options.linkToOpen && itemType === ContextMenuItemType.Link)
|
||||
),
|
||||
},
|
||||
openNoteInNewWindow: {
|
||||
label: _('Open in new window'),
|
||||
onAction: async (options: ContextMenuOptions) => {
|
||||
await CommandService.instance().execute('openNoteInNewWindow', options.resourceId);
|
||||
},
|
||||
isActive: (itemType: ContextMenuItemType) => itemType === ContextMenuItemType.NoteLink,
|
||||
},
|
||||
saveAs: {
|
||||
label: _('Save as...'),
|
||||
onAction: async (options: ContextMenuOptions) => {
|
||||
@@ -207,6 +214,16 @@ export function menuItems(dispatch: Function): ContextMenuItems {
|
||||
return itemType === ContextMenuItemType.Resource || (itemType === ContextMenuItemType.Image && options.resourceId);
|
||||
},
|
||||
},
|
||||
createAccessibleDocument: {
|
||||
label: _('Create accessible document'),
|
||||
onAction: async (options: ContextMenuOptions) => {
|
||||
const { resource } = await resourceInfo(options);
|
||||
await CommandService.instance().execute('createAccessibleDocument', resource.id);
|
||||
},
|
||||
isActive: (itemType: ContextMenuItemType, options: ContextMenuOptions) => {
|
||||
return itemType === ContextMenuItemType.Resource || (itemType === ContextMenuItemType.Image && options.resourceId);
|
||||
},
|
||||
},
|
||||
separator3: makeSeparator(),
|
||||
copyPathToClipboard: {
|
||||
label: _('Copy path to clipboard'),
|
||||
|
||||
@@ -6,6 +6,8 @@ import { ContextMenuItemType, EditContextMenuFilterObject } from '@joplin/lib/se
|
||||
import eventManager from '@joplin/lib/eventManager';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import { type MenuItem as MenuItemType } from 'electron';
|
||||
import BaseItem from '@joplin/lib/models/BaseItem';
|
||||
import { ModelType } from '@joplin/lib/BaseModel';
|
||||
|
||||
const MenuItem = bridge().MenuItem;
|
||||
const logger = Logger.create('contextMenuUtils');
|
||||
@@ -13,6 +15,19 @@ const logger = Logger.create('contextMenuUtils');
|
||||
// Re-export for backward compatibility
|
||||
export { ContextMenuItemType };
|
||||
|
||||
// Resolves whether a resource-type item is actually a note link.
|
||||
// Falls back to Resource on error or if the item is not found.
|
||||
export const resolveContextMenuItemType = async (itemType: ContextMenuItemType, resourceId: string): Promise<ContextMenuItemType> => {
|
||||
if (itemType !== ContextMenuItemType.Resource || !resourceId) return itemType;
|
||||
try {
|
||||
const item = await BaseItem.loadItemById(resourceId);
|
||||
if (item?.type_ === ModelType.Note) return ContextMenuItemType.NoteLink;
|
||||
} catch (error) {
|
||||
logger.warn('resolveContextMenuItemType: failed to load item, defaulting to Resource', error);
|
||||
}
|
||||
return ContextMenuItemType.Resource;
|
||||
};
|
||||
|
||||
export interface ContextMenuOptions {
|
||||
itemType: ContextMenuItemType;
|
||||
resourceId: string;
|
||||
@@ -182,39 +197,48 @@ export const handleEditorContextMenuFilter = async (context?: EditorContextMenuF
|
||||
return output;
|
||||
};
|
||||
|
||||
export const buildMenuItems = async (items: ContextMenuItems, options: ContextMenuOptions) => {
|
||||
export interface BuildMenuItemsOptions {
|
||||
excludeEditItems?: boolean;
|
||||
excludePluginItems?: boolean;
|
||||
}
|
||||
|
||||
export const buildMenuItems = async (items: ContextMenuItems, options: ContextMenuOptions, buildOptions?: BuildMenuItemsOptions) => {
|
||||
const editItemKeys = ['cut', 'copy', 'paste', 'pasteAsText', 'separator4'];
|
||||
const activeItems: ContextMenuItem[] = [];
|
||||
for (const itemKey in items) {
|
||||
if (buildOptions?.excludeEditItems && editItemKeys.includes(itemKey)) continue;
|
||||
const item = items[itemKey];
|
||||
if (item.isActive(options.itemType, options)) {
|
||||
activeItems.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
const extraItems = await handleEditorContextMenuFilter({
|
||||
resourceId: options.resourceId,
|
||||
itemType: options.itemType,
|
||||
textToCopy: options.textToCopy,
|
||||
});
|
||||
|
||||
if (extraItems.length) {
|
||||
activeItems.push({
|
||||
isActive: () => true,
|
||||
label: '',
|
||||
onAction: () => {},
|
||||
isSeparator: true,
|
||||
if (!buildOptions?.excludePluginItems) {
|
||||
const extraItems = await handleEditorContextMenuFilter({
|
||||
resourceId: options.resourceId,
|
||||
itemType: options.itemType,
|
||||
textToCopy: options.textToCopy,
|
||||
});
|
||||
}
|
||||
|
||||
for (const [, extraItem] of extraItems.entries()) {
|
||||
activeItems.push({
|
||||
isActive: () => true,
|
||||
label: extraItem.label,
|
||||
onAction: () => {
|
||||
extraItem.click();
|
||||
},
|
||||
isSeparator: extraItem.type === 'separator',
|
||||
});
|
||||
if (extraItems.length) {
|
||||
activeItems.push({
|
||||
isActive: () => true,
|
||||
label: '',
|
||||
onAction: () => {},
|
||||
isSeparator: true,
|
||||
});
|
||||
}
|
||||
|
||||
for (const [, extraItem] of extraItems.entries()) {
|
||||
activeItems.push({
|
||||
isActive: () => true,
|
||||
label: extraItem.label,
|
||||
onAction: () => {
|
||||
extraItem.click();
|
||||
},
|
||||
isSeparator: extraItem.type === 'separator',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const filteredItems = filterSeparators(activeItems, item => item.isSeparator);
|
||||
|
||||
@@ -13,6 +13,7 @@ export async function htmlToMarkdown(markupLanguage: number, html: string, origi
|
||||
newBody = htmlToMd.parse(html, {
|
||||
preserveImageTagsWithSize: true,
|
||||
preserveNestedTables: true,
|
||||
preserveTableStyles: true,
|
||||
preserveColorStyles: true,
|
||||
...parseOptions,
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { processPastedHtml } from './resourceHandling';
|
||||
import { processImagesInPastedHtml, processPastedHtml } from './resourceHandling';
|
||||
import markupLanguageUtils from '@joplin/lib/markupLanguageUtils';
|
||||
import HtmlToMd from '@joplin/lib/HtmlToMd';
|
||||
import { HtmlToMarkdownHandler, MarkupToHtmlHandler } from './types';
|
||||
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
|
||||
|
||||
const createTestMarkupConverters = () => {
|
||||
const markupToHtml: MarkupToHtmlHandler = async (markupLanguage, markup, options) => {
|
||||
@@ -63,4 +64,69 @@ describe('resourceHandling', () => {
|
||||
const html = `<img src="file://${encodeURI(Setting.value('resourceDir'))}/resource.png" alt="test"/>`;
|
||||
expect(await processPastedHtml(html, htmlToMd, markupToHtml)).toBe(html);
|
||||
});
|
||||
|
||||
it('should normalize HTML-encoded newlines in image alt attributes', async () => {
|
||||
// Word encodes newlines in alt text as HTML entities. These must be
|
||||
// normalized to spaces before Turndown processes the HTML, otherwise
|
||||
// node.outerHTML (returned verbatim for images with width/height) embeds
|
||||
// literal newlines that break Markdown raw HTML block parsing.
|
||||
const resourceSrc = `file://${encodeURI(Setting.value('resourceDir'))}/resource.png`;
|
||||
const testCases: [string, string][] = [
|
||||
// HTML entity newlines (Word clipboard format: = LF)
|
||||
[
|
||||
`<img src="${resourceSrc}" alt="A screenshot AI-generated content."/>`,
|
||||
`<img src="${resourceSrc}" alt="A screenshot AI-generated content."/>`,
|
||||
],
|
||||
// Literal newlines in the raw HTML attribute value
|
||||
[
|
||||
`<img src="${resourceSrc}" alt="hello\nworld"/>`,
|
||||
`<img src="${resourceSrc}" alt="hello world"/>`,
|
||||
],
|
||||
];
|
||||
|
||||
for (const [html, expected] of testCases) {
|
||||
expect(await processPastedHtml(html, null, null)).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it('should render Word-pasted images with newlines in alt as img elements, not broken text', async () => {
|
||||
// When Word pastes an image with width/height attributes and in the alt,
|
||||
// Turndown returns node.outerHTML verbatim (preserveImageTagsWithSize=true).
|
||||
// Without normalization, literal newlines inside the Markdown raw HTML block
|
||||
// would terminate the block early, causing the <img> to render as plain text.
|
||||
const { markupToHtml, htmlToMd } = createTestMarkupConverters();
|
||||
const resourceSrc = `file://${encodeURI(Setting.value('resourceDir'))}/resource.png`;
|
||||
|
||||
const testCases = [
|
||||
// Word-style: width/height present, alt has entities
|
||||
`<img width="625" height="284" src="${resourceSrc}" alt="A screenshot AI-generated content."/>`,
|
||||
// Multiple consecutive newline entities collapsed to single space
|
||||
`<img width="100" height="100" src="${resourceSrc}" alt="line1 line2"/>`,
|
||||
];
|
||||
|
||||
for (const html of testCases) {
|
||||
const result = await processPastedHtml(html, htmlToMd, markupToHtml);
|
||||
// The image must be rendered as an <img> element, not as escaped/broken text
|
||||
expect(result).toContain('<img');
|
||||
// The alt text after normalization must not contain literal newlines
|
||||
expect(result).not.toMatch(/alt="[^"]*\n/);
|
||||
}
|
||||
});
|
||||
|
||||
// Regression test: base64 branch was hardcoding file:// and ignoring useInternalUrls
|
||||
// 1x1 transparent PNG — smallest valid base64-encoded image for testing
|
||||
const minimalPng = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
|
||||
|
||||
test.each([
|
||||
{ useInternalUrls: true, expectMatch: /src=":\/[a-f0-9]+"/, expectAbsent: 'file://' },
|
||||
{ useInternalUrls: false, expectMatch: /src="file:\/\//, expectAbsent: 'data:' },
|
||||
])('should convert base64 image using resourceUrl (useInternalUrls=$useInternalUrls)', async ({ useInternalUrls, expectMatch, expectAbsent }) => {
|
||||
await setupDatabaseAndSynchronizer(1);
|
||||
await switchClient(1);
|
||||
const html = `<img src="data:image/png;base64,${minimalPng}"/>`;
|
||||
const result = await processImagesInPastedHtml(html, { useInternalUrls });
|
||||
expect(result).toMatch(expectMatch);
|
||||
expect(result).not.toContain(expectAbsent);
|
||||
expect(result).not.toContain('data:');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import shim from '@joplin/lib/shim';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import Resource from '@joplin/lib/models/Resource';
|
||||
import { ResourceEntity } from '@joplin/lib/services/database/types';
|
||||
import ResourceFetcher from '@joplin/lib/services/ResourceFetcher';
|
||||
import htmlUtils from '@joplin/lib/htmlUtils';
|
||||
import rendererHtmlUtils, { extractHtmlBody, removeWrappingParagraphAndTrailingEmptyElements } from '@joplin/renderer/htmlUtils';
|
||||
@@ -120,10 +121,21 @@ export async function getResourcesFromPasteEvent(event: any) {
|
||||
}
|
||||
|
||||
|
||||
const processImagesInPastedHtml = async (html: string) => {
|
||||
export interface ProcessImagesOptions {
|
||||
// When true, returns Joplin internal URLs (:/resourceId) instead of file:// URLs
|
||||
useInternalUrls?: boolean;
|
||||
}
|
||||
|
||||
export const processImagesInPastedHtml = async (html: string, options: ProcessImagesOptions = {}) => {
|
||||
const allImageUrls: string[] = [];
|
||||
const mappedResources: Record<string, string> = {};
|
||||
|
||||
const resourceUrl = (resource: ResourceEntity) => {
|
||||
return options.useInternalUrls
|
||||
? Resource.internalUrl(resource)
|
||||
: `file://${encodeURI(Resource.fullPath(resource))}`;
|
||||
};
|
||||
|
||||
htmlUtils.replaceImageUrls(html, (src: string) => {
|
||||
allImageUrls.push(src);
|
||||
});
|
||||
@@ -138,7 +150,7 @@ const processImagesInPastedHtml = async (html: string) => {
|
||||
await shim.fetchBlob(imageSrc, { path: filePath });
|
||||
const createdResource = await shim.createResourceFromPath(filePath);
|
||||
await shim.fsDriver().remove(filePath);
|
||||
mappedResources[imageSrc] = `file://${encodeURI(Resource.fullPath(createdResource))}`;
|
||||
mappedResources[imageSrc] = resourceUrl(createdResource);
|
||||
} catch (error) {
|
||||
logger.warn(`Error creating a resource for ${imageSrc}.`, error);
|
||||
mappedResources[imageSrc] = imageSrc;
|
||||
@@ -155,14 +167,49 @@ const processImagesInPastedHtml = async (html: string) => {
|
||||
const imageFilePath = path.normalize(fileUriToPath(imageSrc));
|
||||
const resourceDirPath = path.normalize(Setting.value('resourceDir'));
|
||||
|
||||
if (imageFilePath.startsWith(resourceDirPath)) {
|
||||
mappedResources[imageSrc] = imageSrc;
|
||||
// Use path.relative for robust containment check - startsWith can falsely match sibling paths
|
||||
const rel = path.relative(resourceDirPath, imageFilePath);
|
||||
const isInsideResourceDir = rel && !rel.startsWith('..') && !path.isAbsolute(rel);
|
||||
if (isInsideResourceDir) {
|
||||
if (options.useInternalUrls) {
|
||||
const resourceId = Resource.pathToId(imageFilePath);
|
||||
mappedResources[imageSrc] = `:/${resourceId}`;
|
||||
} else {
|
||||
mappedResources[imageSrc] = imageSrc;
|
||||
}
|
||||
} else {
|
||||
const createdResource = await shim.createResourceFromPath(imageFilePath);
|
||||
mappedResources[imageSrc] = `file://${encodeURI(Resource.fullPath(createdResource))}`;
|
||||
mappedResources[imageSrc] = resourceUrl(createdResource);
|
||||
}
|
||||
} else if (imageSrc.startsWith('data:')) {
|
||||
mappedResources[imageSrc] = imageSrc;
|
||||
// Word encodes base64 with MIME line breaks every ~76 chars.
|
||||
// Strip whitespace before decoding, then save as a Joplin resource
|
||||
// so Turndown's outerHTML (used for images with width/height) gets
|
||||
// a short URL instead of 200KB of base64.
|
||||
const cleanSrc = imageSrc.replace(/\s/g, '');
|
||||
const dataUrlMatch = cleanSrc.match(/^data:((?!image\/svg\+xml)[^;]+);base64,(.+)$/);
|
||||
if (dataUrlMatch) {
|
||||
const mimeType = dataUrlMatch[1];
|
||||
const base64Data = dataUrlMatch[2];
|
||||
const fileExt = mimeUtils.toFileExtension(mimeType) || 'bin';
|
||||
const filePath = `${Setting.value('tempDir')}/${md5(Date.now() + Math.random())}.${fileExt}`;
|
||||
try {
|
||||
await shim.fsDriver().writeFile(filePath, base64Data, 'base64');
|
||||
const createdResource = await shim.createResourceFromPath(filePath);
|
||||
mappedResources[imageSrc] = resourceUrl(createdResource);
|
||||
} catch (writeError) {
|
||||
writeError.message = `processPastedHtml: Failed to write or create resource from pasted image: ${writeError.message}`;
|
||||
throw writeError;
|
||||
} finally {
|
||||
try {
|
||||
await shim.fsDriver().remove(filePath);
|
||||
} catch (cleanupError) {
|
||||
logger.warn('processPastedHtml: Error removing temporary file.', cleanupError);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
mappedResources[imageSrc] = imageSrc;
|
||||
}
|
||||
} else {
|
||||
downloadImages.push(downloadImage(imageSrc));
|
||||
}
|
||||
@@ -188,6 +235,27 @@ export async function processPastedHtml(html: string, htmlToMd: HtmlToMarkdownHa
|
||||
|
||||
html = await processImagesInPastedHtml(html);
|
||||
|
||||
// Word encodes newlines in alt attributes as HTML entities ( 
 etc.).
|
||||
// These get decoded to literal newline characters by JSDOM when Turndown processes
|
||||
// the HTML. With preserveImageTagsWithSize=true, Turndown returns node.outerHTML
|
||||
// verbatim — embedding literal newlines inside an HTML attribute value, which
|
||||
// breaks the Markdown raw HTML block (a blank line ends the block, making the
|
||||
// parser treat the <img> as plain text). Normalize them to spaces here.
|
||||
html = html.replace(
|
||||
/(\balt\s*=\s*)(["'])([\s\S]*?)\2/gi,
|
||||
(_m, prefix, quote, altText) => {
|
||||
// Replace HTML-encoded newlines/control chars and literal ones with a space
|
||||
const normalized = altText
|
||||
.replace(/&#(?:10|13);|&#x(?:0*[aAdD]);/gi, ' ')
|
||||
// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional sanitisation of control chars
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/[\r\n\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ' ')
|
||||
.replace(/ {2,}/g, ' ')
|
||||
.trim();
|
||||
return `${prefix}${quote}${normalized}${quote}`;
|
||||
},
|
||||
);
|
||||
|
||||
// TinyMCE can accept any type of HTML, including HTML that may not be preserved once saved as
|
||||
// Markdown. For example the content may have a dark background which would be supported by
|
||||
// TinyMCE, but lost once the note is saved. So here we convert the HTML to Markdown then back
|
||||
|
||||
@@ -26,6 +26,13 @@ export interface ToolbarButtonInfos {
|
||||
[key: string]: ToolbarButtonInfo;
|
||||
}
|
||||
|
||||
export enum NoteBodyEditorType {
|
||||
CodeMirror6 = 'CodeMirror6',
|
||||
CodeMirror5 = 'CodeMirror5',
|
||||
TinyMce = 'TinyMCE',
|
||||
PlainText = 'PlainText',
|
||||
}
|
||||
|
||||
export interface NoteEditorProps {
|
||||
noteId: string;
|
||||
themeId: number;
|
||||
@@ -65,9 +72,10 @@ export interface NoteEditorProps {
|
||||
searchResults: ProcessResultsRow[];
|
||||
pluginHtmlContents: PluginHtmlContents;
|
||||
onTitleChange?: (title: string)=> void;
|
||||
bodyEditor: string;
|
||||
bodyEditor: NoteBodyEditorType;
|
||||
startupPluginsLoaded: boolean;
|
||||
enableHtmlToMarkdownBanner: boolean;
|
||||
showNoteLinkIcon: boolean;
|
||||
}
|
||||
|
||||
export interface NoteBodyEditorRef {
|
||||
@@ -149,6 +157,7 @@ export interface NoteBodyEditorProps {
|
||||
useCustomPdfViewer: boolean;
|
||||
watchedNoteFiles: string[];
|
||||
enableHtmlToMarkdownBanner: boolean;
|
||||
showNoteLinkIcon: boolean;
|
||||
}
|
||||
|
||||
export interface NoteBodyEditorPropsAndRef extends NoteBodyEditorProps {
|
||||
|
||||
@@ -95,12 +95,18 @@ const useConnectToEditorPlugin = ({
|
||||
}, [activeEditorView, editorPluginHandler]);
|
||||
|
||||
const formNoteBody = formNote.body;
|
||||
const formNoteId = formNote.id;
|
||||
useEffect(() => {
|
||||
// Don't emit updates when formNote hasn't loaded the current note yet.
|
||||
// This can happen during note navigation when effectiveNoteId updates
|
||||
// immediately but formNote still contains the previous note's data.
|
||||
if (formNoteId !== effectiveNoteId) return;
|
||||
|
||||
editorPluginHandler.emitUpdate({
|
||||
noteId: effectiveNoteId,
|
||||
newBody: formNoteBody,
|
||||
}, shownEditorViewIds);
|
||||
}, [effectiveNoteId, formNoteBody, editorPluginHandler, shownEditorViewIds]);
|
||||
}, [effectiveNoteId, formNoteId, formNoteBody, editorPluginHandler, shownEditorViewIds]);
|
||||
};
|
||||
|
||||
export default useConnectToEditorPlugin;
|
||||
|
||||
@@ -6,6 +6,7 @@ import PostMessageService from '@joplin/lib/services/PostMessageService';
|
||||
import ResourceFetcher from '@joplin/lib/services/ResourceFetcher';
|
||||
import { reg } from '@joplin/lib/registry';
|
||||
import bridge from '../../../services/bridge';
|
||||
import { resolveContextMenuItemType } from './contextMenuUtils';
|
||||
|
||||
export default function useMessageHandler(
|
||||
scrollWhenReadyRef: RefObject<ScrollOptions|null>,
|
||||
@@ -46,9 +47,11 @@ export default function useMessageHandler(
|
||||
if (s.length < 2) throw new Error(`Invalid message: ${msg}`);
|
||||
void ResourceFetcher.instance().markForDownload(s[1]);
|
||||
} else if (msg === 'contextMenu') {
|
||||
const resourceId = arg0.resourceId;
|
||||
const itemType = await resolveContextMenuItemType(arg0 && arg0.type, resourceId);
|
||||
const menu = await contextMenu({
|
||||
itemType: arg0 && arg0.type,
|
||||
resourceId: arg0.resourceId,
|
||||
itemType,
|
||||
resourceId: resourceId,
|
||||
filename: arg0.filename,
|
||||
mime: arg0.mime,
|
||||
linkToOpen: null,
|
||||
|
||||
@@ -13,6 +13,7 @@ const commandsWithDependencies = [
|
||||
require('../commands/focusElementNoteViewer'),
|
||||
require('../commands/focusElementToolbar'),
|
||||
require('../commands/pasteAsText'),
|
||||
require('../commands/pasteAsMarkdown'),
|
||||
];
|
||||
|
||||
type OnBodyChange = (event: OnChangeEvent)=> void;
|
||||
|
||||
@@ -30,6 +30,8 @@ import useFocusVisible from './utils/useFocusVisible';
|
||||
import { stateUtils } from '@joplin/lib/reducer';
|
||||
import { connect } from 'react-redux';
|
||||
import useOnNoteDoubleClick from './utils/useOnNoteDoubleClick';
|
||||
import useAutoScroll from './utils/useAutoScroll';
|
||||
import useRefocusOnDeletion from './utils/useRefocusOnDeletion';
|
||||
|
||||
const commands = {
|
||||
focusElementNoteList,
|
||||
@@ -73,6 +75,7 @@ const NoteList = (props: Props) => {
|
||||
|
||||
const { activeNoteId, setActiveNoteId } = useActiveDescendantId(props.selectedFolderId, props.selectedNoteIds);
|
||||
const focusNote = useFocusNote(listRef, props.notes, makeItemIndexVisible, setActiveNoteId);
|
||||
useRefocusOnDeletion(props.notes.length, props.selectedNoteIds, props.focusedField, props.selectedFolderId, focusNote);
|
||||
|
||||
const moveNote = useMoveNote(
|
||||
props.notesParentType,
|
||||
@@ -131,6 +134,10 @@ const NoteList = (props: Props) => {
|
||||
};
|
||||
}, [focusNote]);
|
||||
|
||||
const selectedNoteId = props.selectedNoteIds.length === 1 ? props.selectedNoteIds[0] : '';
|
||||
const targetIndex = props.notes.findIndex(note => note.id === selectedNoteId);
|
||||
useAutoScroll(selectedNoteId, props.selectedFolderId, targetIndex, makeItemIndexVisible);
|
||||
|
||||
const onItemContextMenu = useOnContextMenu(
|
||||
props.selectedNoteIds,
|
||||
props.selectedFolderId,
|
||||
|
||||
106
packages/app-desktop/gui/NoteList/utils/UseAutoScroll.test.ts
Normal file
106
packages/app-desktop/gui/NoteList/utils/UseAutoScroll.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import useAutoScroll from './useAutoScroll';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
type Props = {
|
||||
selectedNoteId: string;
|
||||
selectedFolderId: string;
|
||||
targetIndex: number;
|
||||
makeItemIndexVisible: (index: number)=> void;
|
||||
};
|
||||
|
||||
describe('useAutoScroll', () => {
|
||||
|
||||
test('scrolls to the note when a new note is selected', () => {
|
||||
const makeItemIndexVisible = jest.fn();
|
||||
|
||||
renderHook(() => useAutoScroll('note-1', 'folder-1', 5, makeItemIndexVisible));
|
||||
|
||||
expect(makeItemIndexVisible).toHaveBeenCalledTimes(1);
|
||||
expect(makeItemIndexVisible).toHaveBeenCalledWith(5);
|
||||
});
|
||||
|
||||
test('does not scroll when the same note is already selected', () => {
|
||||
const makeItemIndexVisible = jest.fn();
|
||||
|
||||
const { rerender } = renderHook(() =>
|
||||
useAutoScroll('note-1', 'folder-1', 5, makeItemIndexVisible),
|
||||
);
|
||||
|
||||
makeItemIndexVisible.mockClear();
|
||||
rerender();
|
||||
|
||||
expect(makeItemIndexVisible).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('does not scroll for multi-selection or no selection', () => {
|
||||
const makeItemIndexVisible = jest.fn();
|
||||
|
||||
renderHook(() => useAutoScroll('', 'folder-1', -1, makeItemIndexVisible));
|
||||
|
||||
expect(makeItemIndexVisible).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('defers scroll until notes load after folder change', () => {
|
||||
const makeItemIndexVisible = jest.fn();
|
||||
|
||||
const { rerender } = renderHook(
|
||||
(props: Props) => useAutoScroll(
|
||||
props.selectedNoteId,
|
||||
props.selectedFolderId,
|
||||
props.targetIndex,
|
||||
props.makeItemIndexVisible,
|
||||
),
|
||||
{ initialProps: { selectedNoteId: 'note-1', selectedFolderId: 'folder-2', targetIndex: -1, makeItemIndexVisible } },
|
||||
);
|
||||
|
||||
expect(makeItemIndexVisible).not.toHaveBeenCalled();
|
||||
|
||||
rerender({ selectedNoteId: 'note-1', selectedFolderId: 'folder-2', targetIndex: 3, makeItemIndexVisible });
|
||||
|
||||
expect(makeItemIndexVisible).toHaveBeenCalledTimes(1);
|
||||
expect(makeItemIndexVisible).toHaveBeenCalledWith(3);
|
||||
});
|
||||
|
||||
test('scrolls again when the folder changes even if note ID is the same', () => {
|
||||
const makeItemIndexVisible = jest.fn();
|
||||
|
||||
const { rerender } = renderHook(
|
||||
(props: Props) => useAutoScroll(
|
||||
props.selectedNoteId,
|
||||
props.selectedFolderId,
|
||||
props.targetIndex,
|
||||
props.makeItemIndexVisible,
|
||||
),
|
||||
{ initialProps: { selectedNoteId: 'note-1', selectedFolderId: 'folder-1', targetIndex: 2, makeItemIndexVisible } },
|
||||
);
|
||||
|
||||
expect(makeItemIndexVisible).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender({ selectedNoteId: 'note-1', selectedFolderId: 'folder-2', targetIndex: 2, makeItemIndexVisible });
|
||||
|
||||
expect(makeItemIndexVisible).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('does not scroll again when targetIndex changes after the pending flag is cleared', () => {
|
||||
// Covers the case where a sort or filter changes targetIndex without a new selection.
|
||||
// Without this guard, arrow-key navigation would trigger a spurious second scroll.
|
||||
const makeItemIndexVisible = jest.fn();
|
||||
|
||||
const { rerender } = renderHook(
|
||||
(props: Props) => useAutoScroll(
|
||||
props.selectedNoteId,
|
||||
props.selectedFolderId,
|
||||
props.targetIndex,
|
||||
props.makeItemIndexVisible,
|
||||
),
|
||||
{ initialProps: { selectedNoteId: 'note-1', selectedFolderId: 'folder-1', targetIndex: 5, makeItemIndexVisible } },
|
||||
);
|
||||
|
||||
expect(makeItemIndexVisible).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender({ selectedNoteId: 'note-1', selectedFolderId: 'folder-1', targetIndex: 7, makeItemIndexVisible });
|
||||
|
||||
expect(makeItemIndexVisible).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
});
|
||||
43
packages/app-desktop/gui/NoteList/utils/useAutoScroll.ts
Normal file
43
packages/app-desktop/gui/NoteList/utils/useAutoScroll.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useRef, useEffect } from 'react';
|
||||
|
||||
// Auto-scrolls the note list to the selected note when selection changes. Uses a pending flag
|
||||
// to handle cross-folder navigation where notes may not be loaded on the first render.
|
||||
const useAutoScroll = (
|
||||
selectedNoteId: string,
|
||||
selectedFolderId: string,
|
||||
targetIndex: number,
|
||||
makeItemIndexVisible: (index: number)=> void,
|
||||
) => {
|
||||
const lastNoteIdRef = useRef('');
|
||||
const lastFolderIdRef = useRef('');
|
||||
const scrollPendingRef = useRef(false); // true when scroll requested but notes not yet loaded
|
||||
|
||||
useEffect(() => {
|
||||
// No selection or multi-selection — reset tracking state.
|
||||
if (!selectedNoteId) {
|
||||
lastNoteIdRef.current = '';
|
||||
lastFolderIdRef.current = selectedFolderId;
|
||||
scrollPendingRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const isNewNote = selectedNoteId !== lastNoteIdRef.current;
|
||||
const isFolderChange = selectedFolderId !== lastFolderIdRef.current;
|
||||
|
||||
if (isNewNote || isFolderChange) {
|
||||
lastNoteIdRef.current = selectedNoteId;
|
||||
lastFolderIdRef.current = selectedFolderId;
|
||||
scrollPendingRef.current = true;
|
||||
}
|
||||
|
||||
// targetIndex is -1 until the new folder's notes load — re-runs automatically when they do.
|
||||
if (!scrollPendingRef.current || targetIndex === -1) return;
|
||||
|
||||
// makeItemIndexVisible has its own visibility guard and is a no-op when the note is
|
||||
// already visible — this covers arrow-key and click navigation without double-scrolling.
|
||||
makeItemIndexVisible(targetIndex);
|
||||
scrollPendingRef.current = false;
|
||||
}, [selectedNoteId, selectedFolderId, targetIndex, makeItemIndexVisible]);
|
||||
};
|
||||
|
||||
export default useAutoScroll;
|
||||
@@ -0,0 +1,41 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import useRefocusOnDeletion from './useRefocusOnDeletion';
|
||||
|
||||
describe('useRefocusOnDeletion', () => {
|
||||
it('should refocus when a note is deleted in the same folder', () => {
|
||||
const focusNote = jest.fn();
|
||||
const { rerender } = renderHook(
|
||||
({ noteCount }: { noteCount: number }) =>
|
||||
useRefocusOnDeletion(noteCount, ['note-1'], '', 'folder-1', focusNote),
|
||||
{ initialProps: { noteCount: 3 } },
|
||||
);
|
||||
rerender({ noteCount: 2 });
|
||||
expect(focusNote).toHaveBeenCalledWith('note-1');
|
||||
});
|
||||
|
||||
test.each([
|
||||
['note count increases', 2, 3, '', ['note-1']],
|
||||
['another field has focus', 3, 2, 'editor', ['note-1']],
|
||||
['multiple notes are selected', 3, 2, '', ['note-1', 'note-2']],
|
||||
])('should not refocus when %s', (_label, initialCount, newCount, focusedField, noteIds) => {
|
||||
const focusNote = jest.fn();
|
||||
const { rerender } = renderHook(
|
||||
({ noteCount }: { noteCount: number }) =>
|
||||
useRefocusOnDeletion(noteCount, noteIds, focusedField, 'folder-1', focusNote),
|
||||
{ initialProps: { noteCount: initialCount } },
|
||||
);
|
||||
rerender({ noteCount: newCount });
|
||||
expect(focusNote).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not refocus when switching to a folder with fewer notes', () => {
|
||||
const focusNote = jest.fn();
|
||||
const { rerender } = renderHook(
|
||||
({ noteCount, folderId }: { noteCount: number; folderId: string }) =>
|
||||
useRefocusOnDeletion(noteCount, ['note-1'], '', folderId, focusNote),
|
||||
{ initialProps: { noteCount: 3, folderId: 'folder-1' } },
|
||||
);
|
||||
rerender({ noteCount: 2, folderId: 'folder-2' });
|
||||
expect(focusNote).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useEffect } from 'react';
|
||||
import usePrevious from '@joplin/lib/hooks/usePrevious';
|
||||
const useRefocusOnDeletion = (
|
||||
noteCount: number,
|
||||
selectedNoteIds: string[],
|
||||
focusedField: string,
|
||||
selectedFolderId: string,
|
||||
focusNote: (noteId: string)=> void,
|
||||
) => {
|
||||
const previousNoteCount = usePrevious(noteCount, 0);
|
||||
const previousFolderId = usePrevious(selectedFolderId, '');
|
||||
useEffect(() => {
|
||||
const noteWasRemoved = noteCount < previousNoteCount;
|
||||
const folderDidNotChange = selectedFolderId === previousFolderId;
|
||||
if (noteWasRemoved && folderDidNotChange && selectedNoteIds.length === 1 && !focusedField) {
|
||||
focusNote(selectedNoteIds[0]);
|
||||
}
|
||||
}, [noteCount, previousNoteCount, selectedNoteIds, focusedField, selectedFolderId, previousFolderId, focusNote]);
|
||||
};
|
||||
export default useRefocusOnDeletion;
|
||||
@@ -6,7 +6,7 @@ import Button, { ButtonLevel, ButtonSize, buttonSizePx } from '../Button/Button'
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import { runtime as focusSearchRuntime } from './commands/focusSearch';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import { notesSortOrderNextField } from '../../services/sortOrder/notesSortOrderUtils';
|
||||
import { notesSortOrderNextField } from '@joplin/lib/services/sortOrder/notesSortOrderUtils';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { connect } from 'react-redux';
|
||||
import styled from 'styled-components';
|
||||
@@ -284,9 +284,11 @@ interface ConnectProps {
|
||||
const mapStateToProps = (state: AppState, ownProps: ConnectProps) => {
|
||||
const whenClauseContext = stateToWhenClauseContext(state, { windowId: ownProps.windowId });
|
||||
const windowState = stateUtils.windowStateById(state, ownProps.windowId);
|
||||
const hasFolderForNewNotes = whenClauseContext.selectedFolderIsValid
|
||||
&& windowState.selectedFolderId !== getTrashFolderId();
|
||||
|
||||
return {
|
||||
showNewNoteButtons: windowState.selectedFolderId !== getTrashFolderId(),
|
||||
showNewNoteButtons: hasFolderForNewNotes,
|
||||
newNoteButtonEnabled: CommandService.instance().isEnabled('newNote', whenClauseContext),
|
||||
newTodoButtonEnabled: CommandService.instance().isEnabled('newTodo', whenClauseContext),
|
||||
sortOrderButtonsVisible: state.settings['notes.sortOrder.buttonsVisible'],
|
||||
|
||||
@@ -4,8 +4,7 @@ import { ItemFlow, ListRenderer, NoteListColumns, OnChangeEvent, OnChangeHandler
|
||||
import { Size } from '@joplin/utils/types';
|
||||
import useRootElement from './utils/useRootElement';
|
||||
import useItemElement from './utils/useItemElement';
|
||||
import useItemEventHandlers from './utils/useItemEventHandlers';
|
||||
import { OnInputChange } from './utils/types';
|
||||
import { ItemEventHandlers, OnInputChange } from './utils/types';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import { NoteEntity } from '@joplin/lib/services/database/types';
|
||||
import useRenderedNote from './utils/useRenderedNote';
|
||||
@@ -72,7 +71,9 @@ const NoteListItem = (props: NoteItemProps, ref: LegacyRef<HTMLDivElement>) => {
|
||||
|
||||
const renderedNote = useRenderedNote(props.note, props.isSelected, props.isWatched, props.listRenderer, props.highlightedWords, props.index, props.columns);
|
||||
|
||||
const itemElement = useItemElement(
|
||||
const itemEventHandlers = useMemo((): ItemEventHandlers => ({ onInputChange, onClick: null }), [onInputChange]);
|
||||
|
||||
useItemElement(
|
||||
rootElement,
|
||||
noteId,
|
||||
renderedNote ? renderedNote.html : '',
|
||||
@@ -82,9 +83,9 @@ const NoteListItem = (props: NoteItemProps, ref: LegacyRef<HTMLDivElement>) => {
|
||||
props.onClick,
|
||||
props.onDoubleClick,
|
||||
props.flow,
|
||||
itemEventHandlers,
|
||||
);
|
||||
|
||||
useItemEventHandlers(rootElement, itemElement, onInputChange, null);
|
||||
|
||||
const className = useMemo(() => {
|
||||
return [
|
||||
|
||||
@@ -2,3 +2,8 @@ import * as React from 'react';
|
||||
|
||||
export type OnInputChange = (event: React.ChangeEvent<HTMLInputElement>)=> void;
|
||||
export type OnClick = (event: React.MouseEvent<HTMLElement>)=> void;
|
||||
|
||||
export type ItemEventHandlers = {
|
||||
onInputChange: OnInputChange;
|
||||
onClick: OnClick | null;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { ItemFlow } from '@joplin/lib/services/plugins/api/noteListType';
|
||||
import useItemElement from './useItemElement';
|
||||
import * as React from 'react';
|
||||
|
||||
const defaultProps = {
|
||||
noteId: 'note-1',
|
||||
noteHtml: '<span>Test content</span>',
|
||||
focusVisible: false,
|
||||
style: { height: '24px' } as React.CSSProperties,
|
||||
itemSize: { width: 200, height: 24 },
|
||||
onClick: jest.fn(),
|
||||
onDoubleClick: jest.fn(),
|
||||
flow: ItemFlow.TopToBottom,
|
||||
};
|
||||
|
||||
const defaultItemEventHandlers = { onInputChange: jest.fn(), onClick: null as import('./types').OnClick | null };
|
||||
|
||||
describe('useItemElement', () => {
|
||||
let rootElement: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
rootElement = document.createElement('div');
|
||||
document.body.appendChild(rootElement);
|
||||
defaultProps.onClick.mockClear();
|
||||
defaultProps.onDoubleClick.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rootElement.remove();
|
||||
});
|
||||
|
||||
test('returns a ref (no setState, avoids "Maximum update depth exceeded")', () => {
|
||||
const { result } = renderHook(
|
||||
() => useItemElement(rootElement, defaultProps.noteId, defaultProps.noteHtml, defaultProps.focusVisible, defaultProps.style, defaultProps.itemSize, defaultProps.onClick, defaultProps.onDoubleClick, defaultProps.flow, defaultItemEventHandlers),
|
||||
);
|
||||
|
||||
expect(result.current).toHaveProperty('current');
|
||||
});
|
||||
|
||||
test('assigns created element to ref.current and cleanup nulls ref and removes element', () => {
|
||||
const { result, unmount } = renderHook(
|
||||
() => useItemElement(rootElement, defaultProps.noteId, defaultProps.noteHtml, false, defaultProps.style, defaultProps.itemSize, defaultProps.onClick, defaultProps.onDoubleClick, defaultProps.flow, defaultItemEventHandlers),
|
||||
);
|
||||
|
||||
act(() => {});
|
||||
|
||||
const el = result.current.current;
|
||||
expect(el).toBeInstanceOf(HTMLDivElement);
|
||||
expect(rootElement.contains(el)).toBe(true);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(result.current.current).toBeNull();
|
||||
expect(rootElement.contains(el as Node)).toBe(false);
|
||||
});
|
||||
|
||||
test('does nothing when rootElement is null', () => {
|
||||
const { result } = renderHook(
|
||||
() => useItemElement(null, defaultProps.noteId, defaultProps.noteHtml, false, defaultProps.style, defaultProps.itemSize, defaultProps.onClick, defaultProps.onDoubleClick, defaultProps.flow, defaultItemEventHandlers),
|
||||
);
|
||||
|
||||
act(() => {});
|
||||
|
||||
expect(result.current.current).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,65 @@
|
||||
import * as React from 'react';
|
||||
import { Size } from '@joplin/utils/types';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { ItemFlow } from '@joplin/lib/services/plugins/api/noteListType';
|
||||
import { ItemEventHandlers } from './types';
|
||||
|
||||
const addItemEventListeners = (
|
||||
element: HTMLElement,
|
||||
listeners: ItemEventHandlers,
|
||||
onClick: React.MouseEventHandler<HTMLDivElement>,
|
||||
onDoubleClick: React.MouseEventHandler<HTMLDivElement>,
|
||||
): { cleanup: ()=> void } => {
|
||||
const processedInputs: HTMLInputElement[] = [];
|
||||
const processedButtons: HTMLButtonElement[] = [];
|
||||
|
||||
const inputs = element.getElementsByTagName('input');
|
||||
for (const input of inputs) {
|
||||
if (input.type === 'checkbox' || input.type === 'text') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- we're mixing React synthetic events with DOM events which ideally should not be done but it is fine in this particular case
|
||||
input.addEventListener('change', listeners.onInputChange as any);
|
||||
processedInputs.push(input);
|
||||
}
|
||||
}
|
||||
|
||||
const buttons = element.getElementsByTagName('button');
|
||||
if (listeners.onClick) {
|
||||
for (const button of buttons) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- we're mixing React synthetic events with DOM events which ideally should not be done but it is fine in this particular case
|
||||
button.addEventListener('click', listeners.onClick as any);
|
||||
processedButtons.push(button);
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- we're mixing React synthetic events with DOM events which ideally should not be done but it is fine in this particular case
|
||||
const clickHandler = (e: MouseEvent) => onClick(e as any);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- we're mixing React synthetic events with DOM events which ideally should not be done but it is fine in this particular case
|
||||
const dblclickHandler = (e: MouseEvent) => onDoubleClick(e as any);
|
||||
element.addEventListener('click', clickHandler);
|
||||
element.addEventListener('dblclick', dblclickHandler);
|
||||
|
||||
return {
|
||||
cleanup: () => {
|
||||
for (const input of processedInputs) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- we're mixing React synthetic events with DOM events which ideally should not be done but it is fine in this particular case
|
||||
input.removeEventListener('change', listeners.onInputChange as any);
|
||||
}
|
||||
if (listeners.onClick) {
|
||||
for (const button of processedButtons) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- we're mixing React synthetic events with DOM events which ideally should not be done but it is fine in this particular case
|
||||
button.removeEventListener('click', listeners.onClick as any);
|
||||
}
|
||||
}
|
||||
element.removeEventListener('click', clickHandler);
|
||||
element.removeEventListener('dblclick', dblclickHandler);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const useItemElement = (
|
||||
rootElement: HTMLDivElement, noteId: string, noteHtml: string, focusVisible: boolean, style: React.CSSProperties, itemSize: Size, onClick: React.MouseEventHandler<HTMLDivElement>, onDoubleClick: React.MouseEventHandler<HTMLDivElement>, flow: ItemFlow,
|
||||
rootElement: HTMLDivElement | null, noteId: string, noteHtml: string, focusVisible: boolean, style: React.CSSProperties, itemSize: Size, onClick: React.MouseEventHandler<HTMLDivElement>, onDoubleClick: React.MouseEventHandler<HTMLDivElement>, flow: ItemFlow, itemEventHandlers: ItemEventHandlers,
|
||||
) => {
|
||||
const [itemElement, setItemElement] = useState<HTMLDivElement>(null);
|
||||
const itemElement = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!rootElement) return () => {};
|
||||
@@ -21,29 +74,28 @@ const useItemElement = (
|
||||
if (flow === ItemFlow.LeftToRight) element.style.width = `${itemSize.width}px`;
|
||||
element.style.height = `${itemSize.height}px`;
|
||||
element.innerHTML = noteHtml;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- we're mixing React synthetic events with DOM events which ideally should not be done but it is fine in this particular case
|
||||
element.addEventListener('click', onClick as any);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- we're mixing React synthetic events with DOM events which ideally should not be done but it is fine in this particular case
|
||||
element.addEventListener('dblclick', onDoubleClick as any);
|
||||
|
||||
const { cleanup } = addItemEventListeners(element, itemEventHandlers, onClick, onDoubleClick);
|
||||
|
||||
rootElement.appendChild(element);
|
||||
|
||||
setItemElement(element);
|
||||
itemElement.current = element;
|
||||
|
||||
return () => {
|
||||
cleanup();
|
||||
itemElement.current = null;
|
||||
element.remove();
|
||||
};
|
||||
}, [rootElement, itemSize, noteHtml, noteId, style, onClick, onDoubleClick, flow]);
|
||||
}, [rootElement, itemSize, noteHtml, noteId, flow, style, onClick, onDoubleClick, itemEventHandlers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!itemElement) return;
|
||||
|
||||
const element = itemElement.current;
|
||||
if (!element) return;
|
||||
if (focusVisible) {
|
||||
itemElement.classList.add('-focus-visible');
|
||||
element.classList.add('-focus-visible');
|
||||
} else {
|
||||
itemElement.classList.remove('-focus-visible');
|
||||
element.classList.remove('-focus-visible');
|
||||
}
|
||||
}, [focusVisible, itemElement]);
|
||||
}, [focusVisible]);
|
||||
|
||||
return itemElement;
|
||||
};
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import { OnClick, OnInputChange } from './types';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const useItemEventHandlers = (rootElement: HTMLDivElement, itemElement: HTMLDivElement, onInputChange: OnInputChange, onClick: OnClick) => {
|
||||
useEffect(() => {
|
||||
if (!itemElement) return () => {};
|
||||
|
||||
const inputs = itemElement.getElementsByTagName('input');
|
||||
|
||||
const processedCheckboxes: HTMLInputElement[] = [];
|
||||
const processedTextInputs: HTMLInputElement[] = [];
|
||||
|
||||
for (const input of inputs) {
|
||||
if (input.type === 'checkbox') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
input.addEventListener('change', onInputChange as any);
|
||||
processedCheckboxes.push(input);
|
||||
}
|
||||
|
||||
if (input.type === 'text') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
input.addEventListener('change', onInputChange as any);
|
||||
processedTextInputs.push(input);
|
||||
}
|
||||
}
|
||||
|
||||
const buttons = itemElement.getElementsByTagName('button');
|
||||
const processedButtons: HTMLButtonElement[] = [];
|
||||
|
||||
if (onClick) {
|
||||
for (const button of buttons) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
button.addEventListener('click', onClick as any);
|
||||
processedButtons.push(button);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const input of processedCheckboxes) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
input.removeEventListener('change', onInputChange as any);
|
||||
}
|
||||
|
||||
for (const input of processedTextInputs) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
input.removeEventListener('change', onInputChange as any);
|
||||
}
|
||||
|
||||
for (const button of processedButtons) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
button.removeEventListener('click', onClick as any);
|
||||
}
|
||||
};
|
||||
}, [itemElement, rootElement, onInputChange, onClick]);
|
||||
};
|
||||
|
||||
export default useItemEventHandlers;
|
||||
@@ -60,7 +60,7 @@ const useNoteListControlsBreakpoints = (width: number, newNoteButtonElement: Ele
|
||||
const [dynamicBreakpoints, setDynamicBreakpoints] = useState<Breakpoints>({ Sm: BaseBreakpoint.Sm, Md: BaseBreakpoint.Md, Lg: BaseBreakpoint.Lg, Xl: BaseBreakpoint.Xl });
|
||||
const previousWidth = usePrevious(width);
|
||||
const widthHasChanged = width !== previousWidth;
|
||||
const showNewNoteButton = selectedFolderId !== getTrashFolderId();
|
||||
const showNewNoteButton = !!selectedFolderId && selectedFolderId !== getTrashFolderId();
|
||||
|
||||
// Initialize language-specific breakpoints
|
||||
useEffect(() => {
|
||||
|
||||
@@ -35,6 +35,7 @@ interface Props {
|
||||
customCss: string;
|
||||
scrollbarSize: ScrollbarSize;
|
||||
fontFamily: string;
|
||||
showNoteLinkIcon: boolean;
|
||||
}
|
||||
|
||||
const useNoteContent = (
|
||||
@@ -45,6 +46,7 @@ const useNoteContent = (
|
||||
customCss: string,
|
||||
scrollbarSize: ScrollbarSize,
|
||||
fontFamily: string,
|
||||
showNoteLinkIcon: boolean,
|
||||
) => {
|
||||
const [note, setNote] = useState<NoteEntity>(null);
|
||||
|
||||
@@ -75,17 +77,18 @@ const useNoteContent = (
|
||||
resources: await shared.attachedResources(noteBody),
|
||||
whiteBackgroundNoteRendering: markupLanguage === MarkupLanguage.Html,
|
||||
globalSettings: getGlobalSettings(Setting),
|
||||
showNoteLinkIcon,
|
||||
});
|
||||
|
||||
viewerRef.current.setHtml(result.html, {
|
||||
pluginAssets: result.pluginAssets,
|
||||
});
|
||||
}, [note, viewerRef]);
|
||||
}, [note, viewerRef, markupToHtml, showNoteLinkIcon]);
|
||||
|
||||
return note;
|
||||
};
|
||||
|
||||
const NoteRevisionViewerComponent: React.FC<Props> = ({ themeId, noteId, onBack, customCss, scrollbarSize, fontFamily }) => {
|
||||
const NoteRevisionViewerComponent: React.FC<Props> = ({ themeId, noteId, onBack, customCss, scrollbarSize, fontFamily, showNoteLinkIcon }) => {
|
||||
const helpButton_onClick = useCallback(() => {}, []);
|
||||
const viewerRef = useRef<NoteViewerControl|null>(null);
|
||||
const revisionListRef = useRef<HTMLSelectElement|null>(null);
|
||||
@@ -96,7 +99,7 @@ const NoteRevisionViewerComponent: React.FC<Props> = ({ themeId, noteId, onBack,
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const note = useNoteContent(
|
||||
viewerRef, currentRevId, revisions, themeId, customCss, scrollbarSize, fontFamily,
|
||||
viewerRef, currentRevId, revisions, themeId, customCss, scrollbarSize, fontFamily, showNoteLinkIcon,
|
||||
);
|
||||
|
||||
const viewer_domReady = useCallback(async () => {
|
||||
@@ -154,7 +157,10 @@ const NoteRevisionViewerComponent: React.FC<Props> = ({ themeId, noteId, onBack,
|
||||
// if (msg !== 'percentScroll') console.info(`Got ipc-message: ${msg}`, args);
|
||||
|
||||
try {
|
||||
if (msg.indexOf('joplin://') === 0) {
|
||||
if (msg.indexOf('checkboxclick:') === 0) {
|
||||
// Revision previews are read-only. Ignore checkbox toggle IPC messages so they
|
||||
// don't fall through to URL handling (`checkboxclick:` looks like a protocol).
|
||||
} else if (msg.indexOf('joplin://') === 0) {
|
||||
throw new Error(_('Unsupported link or message: %s', msg));
|
||||
} else if (urlUtils.urlProtocol(msg)) {
|
||||
await bridge().openExternal(msg);
|
||||
@@ -229,6 +235,7 @@ const mapStateToProps = (state: AppState) => {
|
||||
themeId: state.settings.theme,
|
||||
scrollbarSize: state.settings['style.scrollbarSize'],
|
||||
fontFamily: state.settings['style.viewer.fontFamily'],
|
||||
showNoteLinkIcon: state.settings['notes.showNoteLinkIcon'],
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ interface Props {
|
||||
dispatch?: Function;
|
||||
selectedNoteId: string;
|
||||
isFocused?: boolean;
|
||||
globalQuery?: string;
|
||||
}
|
||||
|
||||
function SearchBar(props: Props) {
|
||||
@@ -163,6 +164,22 @@ function SearchBar(props: Props) {
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, []);
|
||||
|
||||
// if the globalQuery is not undefined and is not equal to the current value of query changes the query to global query. Else the current query remains the same.
|
||||
// used setQuery((previousQuery)=>{}) to prevent linter error asking to have [query] in the dependency array, since this useEffect would then run every time the query is changed
|
||||
useEffect(() => {
|
||||
if (props.globalQuery !== undefined) {
|
||||
setQuery((previousQuery) => {
|
||||
if (props.globalQuery !== previousQuery) {
|
||||
if (props.globalQuery.length > 0) {
|
||||
setSearchStarted(true);
|
||||
}
|
||||
return props.globalQuery;
|
||||
}
|
||||
return previousQuery;
|
||||
});
|
||||
}
|
||||
}, [props.globalQuery]);
|
||||
|
||||
return (
|
||||
<Root className="search-bar">
|
||||
<SearchInput
|
||||
@@ -186,10 +203,20 @@ interface OwnProps {
|
||||
|
||||
const mapStateToProps = (state: AppState, ownProps: OwnProps) => {
|
||||
const windowState = stateUtils.windowStateById(state, ownProps.windowId);
|
||||
|
||||
let globalQuery = '';
|
||||
if (windowState.notesParentType === 'Search' && windowState.selectedSearchId) {
|
||||
const activeSearch = state.searches.find((s: { id: string; query_pattern: string }) => s.id === windowState.selectedSearchId);
|
||||
if (activeSearch && activeSearch.query_pattern) {
|
||||
globalQuery = activeSearch.query_pattern;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
notesParentType: windowState.notesParentType,
|
||||
selectedNoteId: stateUtils.selectedNoteId(windowState),
|
||||
isFocused: state.focusedField === 'globalSearch',
|
||||
globalQuery: globalQuery,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ const FolderAndTagList: React.FC<Props> = props => {
|
||||
selectedIndex,
|
||||
selectedIndexes,
|
||||
listItems,
|
||||
allFoldersCollapsed,
|
||||
containerRef: listContainerRef,
|
||||
});
|
||||
|
||||
@@ -76,7 +77,7 @@ const FolderAndTagList: React.FC<Props> = props => {
|
||||
const listHeight = useElementHeight(itemListContainer);
|
||||
const listStyle = useMemo(() => ({ height: listHeight }), [listHeight]);
|
||||
|
||||
const onRenderContentWrapper = useOnRenderListWrapper({ allFoldersCollapsed, selectedIndex, onKeyDown: onKeyEventHandler });
|
||||
const onRenderContentWrapper = useOnRenderListWrapper({ selectedIndex, onKeyDown: onKeyEventHandler });
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { StyledSyncReportText, StyledSyncReport, StyledSynchronizeButton, StyledRoot } from './styles';
|
||||
import { ButtonLevel } from '../Button/Button';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
@@ -10,6 +11,8 @@ import { connect } from 'react-redux';
|
||||
import { themeStyle } from '@joplin/lib/theme';
|
||||
import { Dispatch } from 'redux';
|
||||
import FolderAndTagList from './FolderAndTagList';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import time from '@joplin/lib/time';
|
||||
|
||||
|
||||
interface Props {
|
||||
@@ -20,6 +23,7 @@ interface Props {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
syncReport: any;
|
||||
syncStarted: boolean;
|
||||
syncReportLogExpanded: boolean;
|
||||
}
|
||||
|
||||
const SidebarComponent = (props: Props) => {
|
||||
@@ -52,30 +56,55 @@ const SidebarComponent = (props: Props) => {
|
||||
resourceFetcherText = _('Fetching resources: %d/%d', props.resourceFetcher.fetchingCount, props.resourceFetcher.toFetchCount);
|
||||
}
|
||||
|
||||
const syncReportExpanded = props.syncReportLogExpanded;
|
||||
|
||||
const toggleSyncReport = useCallback(() => {
|
||||
Setting.setValue('syncReportLogExpanded', !syncReportExpanded);
|
||||
}, [syncReportExpanded]);
|
||||
|
||||
const lines = Synchronizer.reportToLines(props.syncReport);
|
||||
if (resourceFetcherText) lines.push(resourceFetcherText);
|
||||
if (decryptionReportText) lines.push(decryptionReportText);
|
||||
const syncReportText = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
syncReportText.push(
|
||||
<StyledSyncReportText key={i}>
|
||||
{lines[i]}
|
||||
</StyledSyncReportText>,
|
||||
);
|
||||
}
|
||||
|
||||
const completedTime = props.syncReport && props.syncReport.completedTime
|
||||
? time.formatMsToLocal(props.syncReport.completedTime)
|
||||
: null;
|
||||
|
||||
const syncButton = renderSynchronizeButton(props.syncStarted ? 'cancel' : 'sync');
|
||||
|
||||
const syncReportComp = !syncReportText.length ? null : (
|
||||
// Show toggle when there are log lines or a completed timestamp
|
||||
const hasContent = lines.length > 0 || completedTime;
|
||||
|
||||
// Toggle to show/hide sync log output
|
||||
const toggleButton = hasContent ? (
|
||||
<button
|
||||
className="sidebar-sync-toggle"
|
||||
onClick={toggleSyncReport}
|
||||
aria-expanded={syncReportExpanded}
|
||||
aria-label={syncReportExpanded ? _('Hide sync log') : _('Show sync log')}
|
||||
title={syncReportExpanded ? _('Hide sync log') : _('Show sync log')}
|
||||
>
|
||||
<i className={`fas fa-caret-${syncReportExpanded ? 'down' : 'up'}`} />
|
||||
{!syncReportExpanded && completedTime ? <span className="timestamp">{_('Last sync: %s', completedTime)}</span> : ''}
|
||||
</button>
|
||||
) : null;
|
||||
|
||||
// Sync log output, only visible when expanded
|
||||
const syncReportComp = (syncReportExpanded && lines.length > 0) ? (
|
||||
<StyledSyncReport key="sync_report">
|
||||
{syncReportText}
|
||||
{lines.map((line, i) => (
|
||||
<StyledSyncReportText key={i}>
|
||||
{line}
|
||||
</StyledSyncReportText>
|
||||
))}
|
||||
</StyledSyncReport>
|
||||
);
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<StyledRoot className='sidebar _scrollbar2' role='navigation' aria-label={_('Sidebar')}>
|
||||
<div style={{ flex: 1 }}><FolderAndTagList/></div>
|
||||
<div style={{ flex: 1 }}><FolderAndTagList /></div>
|
||||
<div style={{ flex: 0, padding: theme.mainPadding }}>
|
||||
{toggleButton}
|
||||
{syncReportComp}
|
||||
{syncButton}
|
||||
</div>
|
||||
@@ -95,6 +124,7 @@ const mapStateToProps = (state: AppState) => {
|
||||
collapsedFolderIds: state.collapsedFolderIds,
|
||||
decryptionWorker: state.decryptionWorker,
|
||||
resourceFetcher: state.resourceFetcher,
|
||||
syncReportLogExpanded: state.settings.syncReportLogExpanded,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -28,12 +28,19 @@ const useScrollToSelectionHandler = (
|
||||
return lastSelectedItemKey.current;
|
||||
}
|
||||
}, [listItems, selectedIndex]);
|
||||
lastSelectedItemKey.current = selectedItemKey;
|
||||
|
||||
const selectedIndexRef = useRef(selectedIndex);
|
||||
selectedIndexRef.current = selectedIndex;
|
||||
|
||||
useEffect(() => {
|
||||
// Skip scrolling if the selected item hasn't actually changed. When a folder is
|
||||
// expanded or collapsed the selected item's index may shift, but its key stays
|
||||
// the same — in that case we don't want to scroll the view.
|
||||
if (selectedItemKey === lastSelectedItemKey.current) {
|
||||
return;
|
||||
}
|
||||
lastSelectedItemKey.current = selectedItemKey;
|
||||
|
||||
if (!itemListRef.current || !selectedItemKey) return;
|
||||
|
||||
const hasFocus = !!itemListRef.current.container.contains(document.activeElement);
|
||||
|
||||
@@ -18,7 +18,7 @@ import { FolderEntity } from '@joplin/lib/services/database/types';
|
||||
import InteropService from '@joplin/lib/services/interop/InteropService';
|
||||
import InteropServiceHelper from '../../../InteropServiceHelper';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import PerFolderSortOrderService from '../../../services/sortOrder/PerFolderSortOrderService';
|
||||
import PerFolderSortOrderService from '@joplin/lib/services/sortOrder/PerFolderSortOrderService';
|
||||
import { getFolderCallbackUrl, getTagCallbackUrl } from '@joplin/lib/callbackUrlUtils';
|
||||
import { PluginStates, utils as pluginUtils } from '@joplin/lib/services/plugins/reducer';
|
||||
import { MenuItemLocation } from '@joplin/lib/services/plugins/api/types';
|
||||
@@ -48,18 +48,49 @@ interface Props {
|
||||
selectedIndex: number;
|
||||
selectedIndexes: number[];
|
||||
listItems: ListItem[];
|
||||
allFoldersCollapsed: boolean;
|
||||
}
|
||||
|
||||
type ItemContextMenuListener = MouseEventHandler<HTMLElement>;
|
||||
|
||||
const menuUtils = new MenuUtils(CommandService.instance());
|
||||
|
||||
// Checks whether an element is at least partially visible within a scrollable
|
||||
// container by comparing their bounding rectangles.
|
||||
const isElementVisibleInContainer = (element: HTMLElement, container: HTMLElement) => {
|
||||
const elementRect = element.getBoundingClientRect();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
|
||||
return elementRect.bottom > containerRect.top && elementRect.top < containerRect.bottom;
|
||||
};
|
||||
|
||||
const focusListItem = (item: HTMLElement|null) => {
|
||||
if (item) {
|
||||
// Avoid scrolling to the selected item when refocusing the note list. Such a refocus
|
||||
// can happen if the note list rerenders and the selection is scrolled out of view and
|
||||
// can cause scroll to change unexpectedly.
|
||||
focus('useOnRenderItem', item, { preventScroll: true });
|
||||
const activeElement = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
const itemList = item.closest('.item-list');
|
||||
const activeTreeItem = activeElement?.closest('[role="treeitem"]');
|
||||
const focusWasLost = activeElement === document.body;
|
||||
|
||||
// If the currently focused element is a tree item inside the same list,
|
||||
// the user is navigating with the keyboard — always allow focus to move
|
||||
// to the newly selected item so arrow-key scrolling is not interrupted.
|
||||
const isKeyboardNavigating = !!activeTreeItem && itemList?.contains(activeTreeItem);
|
||||
|
||||
// Avoid disturbing scroll while user is manually scrolling through the list.
|
||||
// However, if focus was lost (activeElement -> <body>), or the user is
|
||||
// navigating with the keyboard, allow re-focusing even when the selected
|
||||
// item is currently out of view.
|
||||
if (itemList instanceof HTMLElement && !isElementVisibleInContainer(item, itemList) && !focusWasLost && !isKeyboardNavigating) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Move focus only if needed: either focus was lost, or selection changed
|
||||
// to a different tree item.
|
||||
if (focusWasLost || activeTreeItem !== item) {
|
||||
// preventScroll: true avoids a secondary scroll caused by the focus() call
|
||||
// itself when the item is near the edge of the visible area.
|
||||
focus('useOnRenderItem', item, { preventScroll: true });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -77,6 +108,8 @@ const useOnRenderItem = (props: Props) => {
|
||||
const foldersRef = useRef<FolderEntity[]>(null);
|
||||
foldersRef.current = props.folders;
|
||||
|
||||
const allFoldersCollapsed = props.allFoldersCollapsed;
|
||||
|
||||
const onTagDrop_: DragEventHandler<HTMLElement> = useCallback(async event => {
|
||||
const tagId = event.currentTarget.getAttribute('data-tag-id');
|
||||
const dt = event.dataTransfer;
|
||||
@@ -363,7 +396,12 @@ const useOnRenderItem = (props: Props) => {
|
||||
multipleItemsSelected: props.selectedIndexes.length > 1,
|
||||
};
|
||||
|
||||
const focusInList = document.hasFocus() && props.containerRef.current?.contains(document.activeElement);
|
||||
const sidebarContainsFocus = props.containerRef.current?.contains(document.activeElement);
|
||||
// Focus moves to <body> when the previously-focused element is removed
|
||||
// from the DOM (e.g. scrolled out of the virtualized list). We still
|
||||
// want to restore focus to the newly-selected item in that case.
|
||||
const focusLostFromDom = document.activeElement === document.body;
|
||||
const focusInList = document.hasFocus() && (sidebarContainsFocus || focusLostFromDom);
|
||||
const anchorRef = (focusInList && primarySelected) ? focusListItem : noFocusListItem;
|
||||
|
||||
if (item.kind === ListItemType.Tag) {
|
||||
@@ -429,6 +467,7 @@ const useOnRenderItem = (props: Props) => {
|
||||
item={item}
|
||||
selectionState={selectionState}
|
||||
onDrop={item.supportsFolderDrop ? onFolderDrop_ : null}
|
||||
allFoldersCollapsed={allFoldersCollapsed}
|
||||
index={index}
|
||||
itemCount={itemCount}
|
||||
/>;
|
||||
@@ -474,6 +513,7 @@ const useOnRenderItem = (props: Props) => {
|
||||
props.selectedIndex,
|
||||
props.selectedIndexes,
|
||||
props.containerRef,
|
||||
allFoldersCollapsed,
|
||||
itemCount,
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -1,62 +1,16 @@
|
||||
import * as React from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
|
||||
interface Props {
|
||||
selectedIndex: number;
|
||||
onKeyDown: React.KeyboardEventHandler;
|
||||
allFoldersCollapsed: boolean;
|
||||
}
|
||||
|
||||
const onAddFolderButtonClick = () => {
|
||||
void CommandService.instance().execute('newFolder');
|
||||
};
|
||||
|
||||
const onToggleAllFolders = (allFoldersCollapsed: boolean) => {
|
||||
void CommandService.instance().execute('toggleAllFolders', !allFoldersCollapsed);
|
||||
};
|
||||
|
||||
interface CollapseExpandAllButtonProps {
|
||||
allFoldersCollapsed: boolean;
|
||||
}
|
||||
|
||||
const CollapseExpandAllButton = (props: CollapseExpandAllButtonProps) => {
|
||||
// To allow it to be accessed by accessibility tools, the toggle button
|
||||
// is not included in the portion of the list with role='tree'.
|
||||
const icon = props.allFoldersCollapsed ? 'far fa-caret-square-right' : 'far fa-caret-square-down';
|
||||
const label = props.allFoldersCollapsed ? _('Expand all notebooks') : _('Collapse all notebooks');
|
||||
|
||||
return <button onClick={() => onToggleAllFolders(props.allFoldersCollapsed)} className='sidebar-header-button -collapseall' title={label}>
|
||||
<i
|
||||
aria-label={label}
|
||||
role='img'
|
||||
className={icon}
|
||||
/>
|
||||
</button>;
|
||||
};
|
||||
|
||||
const NewFolderButton = () => {
|
||||
// To allow it to be accessed by accessibility tools, the new folder button
|
||||
// is not included in the portion of the list with role='tree'.
|
||||
const label = _('New notebook');
|
||||
|
||||
return <button onClick={onAddFolderButtonClick} className='sidebar-header-button -newfolder' title={label}>
|
||||
<i
|
||||
aria-label={label}
|
||||
role='img'
|
||||
className='fas fa-plus'
|
||||
/>
|
||||
</button>;
|
||||
};
|
||||
|
||||
const useOnRenderListWrapper = (props: Props) => {
|
||||
return useCallback((listItems: React.ReactNode[]) => {
|
||||
const listHasValidSelection = props.selectedIndex >= 0;
|
||||
const allowContainerFocus = !listHasValidSelection;
|
||||
return <>
|
||||
<CollapseExpandAllButton allFoldersCollapsed={props.allFoldersCollapsed}/>
|
||||
<NewFolderButton/>
|
||||
<div
|
||||
role='tree'
|
||||
className='sidebar-list-items-wrapper'
|
||||
@@ -66,7 +20,7 @@ const useOnRenderListWrapper = (props: Props) => {
|
||||
{...listItems}
|
||||
</div>
|
||||
</>;
|
||||
}, [props.selectedIndex, props.onKeyDown, props.allFoldersCollapsed]);
|
||||
}, [props.selectedIndex, props.onKeyDown]);
|
||||
};
|
||||
|
||||
export default useOnRenderListWrapper;
|
||||
|
||||
@@ -6,7 +6,7 @@ import bridge from '../../../services/bridge';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import MenuUtils from '@joplin/lib/services/commands/MenuUtils';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import PerFolderSortOrderService from '../../../services/sortOrder/PerFolderSortOrderService';
|
||||
import PerFolderSortOrderService from '@joplin/lib/services/sortOrder/PerFolderSortOrderService';
|
||||
import { connect } from 'react-redux';
|
||||
import EmptyExpandLink from './EmptyExpandLink';
|
||||
import ListItemWrapper, { ItemSelectionState, ListItemRef } from './ListItemWrapper';
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import * as React from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { StyledHeader, StyledHeaderIcon, StyledHeaderLabel } from '../styles';
|
||||
import { StyledHeader, StyledHeaderButtons, StyledHeaderIcon, StyledHeaderLabel } from '../styles';
|
||||
import { HeaderId, HeaderListItem } from '../types';
|
||||
import bridge from '../../../services/bridge';
|
||||
import MenuUtils from '@joplin/lib/services/commands/MenuUtils';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import ListItemWrapper, { ItemSelectionState, ListItemRef } from './ListItemWrapper';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
|
||||
const Menu = bridge().Menu;
|
||||
const MenuItem = bridge().MenuItem;
|
||||
@@ -19,6 +20,7 @@ interface Props {
|
||||
onDrop: React.DragEventHandler|null;
|
||||
index: number;
|
||||
itemCount: number;
|
||||
allFoldersCollapsed?: boolean;
|
||||
}
|
||||
|
||||
const HeaderItem: React.FC<Props> = props => {
|
||||
@@ -32,6 +34,16 @@ const HeaderItem: React.FC<Props> = props => {
|
||||
}
|
||||
}, [onItemClick, itemId]);
|
||||
|
||||
const onAddFolderButtonClick: React.MouseEventHandler<HTMLElement> = useCallback(event => {
|
||||
event.stopPropagation();
|
||||
void CommandService.instance().execute('newFolder');
|
||||
}, []);
|
||||
|
||||
const onToggleAllFoldersClick: React.MouseEventHandler<HTMLElement> = useCallback(event => {
|
||||
event.stopPropagation();
|
||||
void CommandService.instance().execute('toggleAllFolders', !(props.allFoldersCollapsed ?? false));
|
||||
}, [props.allFoldersCollapsed]);
|
||||
|
||||
const onContextMenu = useCallback(async () => {
|
||||
if (itemId === HeaderId.FolderHeader) {
|
||||
const menu = new Menu();
|
||||
@@ -44,6 +56,24 @@ const HeaderItem: React.FC<Props> = props => {
|
||||
}
|
||||
}, [itemId]);
|
||||
|
||||
const collapseIcon = (props.allFoldersCollapsed ?? false) ? 'far fa-caret-square-right' : 'far fa-caret-square-down';
|
||||
const collapseLabel = (props.allFoldersCollapsed ?? false) ? _('Expand all notebooks') : _('Collapse all notebooks');
|
||||
const newFolderLabel = _('New notebook');
|
||||
|
||||
const renderFolderHeaderButtons = () => {
|
||||
if (itemId !== HeaderId.FolderHeader) return null;
|
||||
return (
|
||||
<StyledHeaderButtons className='sidebar-header-actions'>
|
||||
<button onClick={onToggleAllFoldersClick} className='sidebar-header-button -collapseall' title={collapseLabel}>
|
||||
<i aria-label={collapseLabel} role='img' className={collapseIcon}/>
|
||||
</button>
|
||||
<button onClick={onAddFolderButtonClick} className='sidebar-header-button -newfolder' title={newFolderLabel}>
|
||||
<i aria-label={newFolderLabel} role='img' className='fas fa-plus'/>
|
||||
</button>
|
||||
</StyledHeaderButtons>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ListItemWrapper
|
||||
containerRef={props.anchorRef}
|
||||
@@ -63,6 +93,7 @@ const HeaderItem: React.FC<Props> = props => {
|
||||
>
|
||||
<StyledHeaderIcon aria-hidden='true' role='img' className={item.iconName}/>
|
||||
<StyledHeaderLabel>{item.label}</StyledHeaderLabel>
|
||||
{renderFolderHeaderButtons()}
|
||||
</StyledHeader>
|
||||
</ListItemWrapper>
|
||||
);
|
||||
|
||||
@@ -6,4 +6,5 @@
|
||||
@use 'styles/sidebar-header-container.scss';
|
||||
@use 'styles/sidebar-spacer-item.scss';
|
||||
@use 'styles/sidebar-header-button.scss';
|
||||
@use 'styles/sidebar-sync-button.scss';
|
||||
@use 'styles/sidebar-sync-button.scss';
|
||||
@use 'styles/sidebar-sync-toggle.scss';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user