1
0
mirror of https://github.com/laurent22/joplin.git synced 2026-03-12 10:00:05 +02:00

Compare commits

..

76 Commits

Author SHA1 Message Date
Laurent Cozic
68d1601847 update 2026-03-11 22:31:39 +00:00
Laurent Cozic
2132c2cdf4 Revert "Desktop: Fix context menu missing cut/copy when selecting resource links in markdown editor" (#14710) 2026-03-11 21:19:55 +00:00
Sergio
67aff20e39 Desktop: Fixes #14676: Toggling checkboxes in the note history viewer opens an open with prompt on Windows (#14679) 2026-03-11 16:19:46 +00:00
Gnana Pragadeesh K
3719e1eee0 Mobile: Fixes #14152: Fix font-size inconsistency of code block and inline code (#14463) 2026-03-11 16:19:08 +00:00
Laurent Cozic
4abe83fdb6 Doc: Fix broken language selector on website 2026-03-10 18:49:04 +00:00
Laurent Cozic
6ba912e5aa Chore: Fixed website localisation issue 2026-03-10 15:32:29 +00:00
Surendra Manjhi
8533083730 Desktop: Fixes #14627: use resourceUrl() for base64 images in pasteAsMarkdown (#14632) 2026-03-10 12:38:00 +00:00
Yugal Kaushik
754ff28b36 Desktop: Fixes #101111: ENEX import no longer breaks bullet items with a line break into separate paragraphs (#14642) 2026-03-10 12:36:48 +00:00
Ahmed Idani
b663c64def All: Fixes #14412: Skip share consistency check when not using Joplin Server/Cloud (#14649) 2026-03-10 12:35:55 +00:00
Laurent Cozic
998b26d9a4 Doc: Update CLAUDE.md to specify whitespace rules
Clarified guidelines on whitespace changes in code.
2026-03-10 12:31:00 +00:00
Veivel P
b097cf9a6a Desktop: Resolves #14143: Fixed scrolling behaviour in long lines for TinyMCE and CodeMirror (#14669) 2026-03-10 12:26:03 +00:00
Vinayreddy765
e22c367566 Desktop: Fixes #14637: Fix context menu missing cut/copy when selecting resource links in markdown editor (#14638)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-03-10 12:20:40 +00:00
Justin Charles
71a2e98155 Desktop: Fixes #14661: hide new note/todo buttons when no notebook exists (#14674)
Signed-off-by: justin212407 <charlesjustin2124@gmail.com>
2026-03-10 12:15:34 +00:00
Davideb18
714bbd6d23 Desktop: Fixes #11823: Fixed cancel behavior labels when switching config screens (#14677) 2026-03-10 12:14:04 +00:00
Akshaj Rawat
eda03333a6 Desktop: Fixes #12394: Fix search bar remaining empty when navigating back (#14488) 2026-03-10 12:01:16 +00:00
divyanshkhurana06
93f17a87fa Desktop: Fixes #14142: Fix search highlights breaking mermaid diagram rendering (#14516) 2026-03-10 11:57:15 +00:00
Dipanshu Rawat
c765306e6f Chore: ArrayUtils optimize unique and removeElement functions, improve type handling (#14552)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-03-10 11:38:28 +00:00
Justin Charles
f05fe5754d Desktop: Fixes #14542: Fix Prevent unclosed frontmatter from breaking Markdown rendering (#14563)
Signed-off-by: justin212407 <charlesjustin2124@gmail.com>
2026-03-10 11:31:38 +00:00
Keshav
d046bfa14b Desktop: Resolves #10562: Preserve table customization made on RTE (#14572) 2026-03-10 11:29:59 +00:00
Yousef Genedy
2a681008dd Mobile, Desktop: Resolves #9481: Start sync when app opens or resumes (#14574) 2026-03-10 11:27:46 +00:00
Ashutosh Singh
7214823c74 Chore: Resolves #12037: Remove JSDOM from Turndown package (#14653) 2026-03-09 14:09:08 +00:00
renovate[bot]
ed5b92a91e Update dependency ldapts to v8.0.18 (#14655)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-09 10:27:43 +00:00
Aayushi Rajesh
2c8a9eee61 Doc: Add Plugins link to website navigation (#14645) 2026-03-09 09:10:33 +00:00
renovate[bot]
6451305c89 Update dependency esbuild to v0.26.0 (#14654)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-09 09:05:27 +00:00
Henry Heino
5fd0dc23da Desktop: Fixes #14584: Fix changes to editor settings not applied until editor reloads (#14586) 2026-03-08 21:11:54 +00:00
renovate[bot]
fd3b133b16 Update dependency dompurify to v3.3.1 (#14648)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-08 17:12:46 +00:00
renovate[bot]
118bc3edf1 Update dependency git to v2.51.0 (#14646)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-08 14:39:59 +00:00
renovate[bot]
d90836bc50 Update dependency ldapts to v8.0.17 (#14641)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-08 13:45:27 +00:00
Dipanshu Rawat
9a477dbeb9 Chore: Fix typo for enum for Right value (#14575)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-03-08 12:01:31 +00:00
Justin Charles
5271081b3a Docs: Add plugin website link in README (#14626)
Signed-off-by: justin212407 <charlesjustin2124@gmail.com>
2026-03-08 11:58:25 +00:00
bwat47
b26370fc5a Desktop, Mobile: Fixes #14630: underline disappearing from ++insert++ syntax when cursor is on that line (#14631) 2026-03-08 11:57:51 +00:00
Laurent Cozic
737c7dcdb4 CI: Do not cancel CI execution on dev branch 2026-03-08 11:17:08 +00:00
Joplin Bot
04babe0261 Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-03-07 18:44:23 +00:00
Laurent Cozic
85e5bbd246 Desktop release v3.6.4 2026-03-07 16:17:05 +00:00
bwat47
f819e1c88b Desktop, Mobile: Fixes #14564: Implement cursor-aware markup rendering and hide bulletpoints on task lists (#14573) 2026-03-07 16:15:48 +00:00
Ashutosh Singh
79c153c498 Desktop, Mobile: Fixes #12793: Prevent a failing plugin from blocking other plugins (#14577) 2026-03-07 16:14:05 +00:00
Harsh Gupta
1db9903926 Desktop: Fixes #12355: Auto-scroll to selected note from 'Go to Anything' search results (#14591) 2026-03-07 16:12:31 +00:00
Surendra Manjhi
e736e05d1c Desktop: Fixes #13140: Normalize img alt line breaks and convert data: URLs when pasting from Word (#14518) 2026-03-07 15:44:35 +00:00
Surendra Manjhi
5ef10676d8 Desktop: Fixes #14525: Show only relevant options in context menu when right-clicking a note link (#14528) 2026-03-07 15:42:55 +00:00
yentropysack
b38613ca22 Mobile: Fixes #9938: Fix in-page links don't work if clicked in succession (#14538)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-03-07 15:42:08 +00:00
Justin Charles
ea486fbe13 All: Fixes #14543: Fix ++insert++ syntax rendering fix in markdown (#14547)
Signed-off-by: justin212407 <charlesjustin2124@gmail.com>
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-03-07 15:39:53 +00:00
Laurent Cozic
d2784aff54 Plugins: Add support for joplin.fs.archiveExtract plugin method (#14625) 2026-03-07 15:33:27 +00:00
renovate[bot]
7308d9541e Update dependency ldapts to v8.0.14 (#14614)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-07 00:57:38 +00:00
renovate[bot]
d6ac709e5f Update dependency ldapts to v8.0.13 (#14592)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-05 22:46:54 +00:00
Sriram Varun Kumar
b290046e66 Mobile: Fixes #14555: Fix tapping rendered image scrolling to cursor position (#14580) 2026-03-05 13:20:23 +00:00
Henry Heino
c2321a04ae Chore: Importing from OneNote: Add test to verify that errors are reported to JavaScript (#14550) 2026-03-05 09:13:18 +00:00
mrjo118
3df77a4395 Desktop, Mobile: Fix issue where the revision service does not start on the first launch of the app (#14554) 2026-03-05 09:06:04 +00:00
mrjo118
38fd790719 Mobile: Add ability to set per notebook sorting on mobile (#14562) 2026-03-05 09:04:26 +00:00
Vinayreddy765
40bfa9dd3d Desktop: Show feedback message when master passwords do not match (#14566) 2026-03-05 09:01:03 +00:00
Henry Heino
8d08e5df60 Desktop: Importing from OneNote: Fix importing cross-page links (#14567) 2026-03-05 09:00:16 +00:00
Ash092016
4121c47e18 CI: Add concurrency block to cancel outdated workflow runs (#14570) 2026-03-05 09:00:02 +00:00
Yugal Kaushik
d30e6ad0da Desktop: Fixes #13178: Invisible cursor in legacy editor when using dark theme in separate window (#14557)
Co-authored-by: Henry Heino <46334387+personalizedrefrigerator@users.noreply.github.com>
2026-03-05 08:32:09 +00:00
Yugal Kaushik
be712df89d Mobile: Fixes #14534: Call unmount() in Note.test.tsx tests to suppress act() warnings (#14535) 2026-03-05 08:31:44 +00:00
Sriram Varun Kumar
f7762c403e Mobile: Rich Text Editor: Fix extra blank line above nested lists (#14504) 2026-03-05 08:31:22 +00:00
renovate[bot]
b89d37de84 Update dependency @types/serviceworker to v0.0.168 (#14578)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-05 08:26:56 +00:00
Ashutosh Singh
a7b9af61c0 Desktop: Fixes #14500: Fixes zh_TW locale detection on first start (#14527) 2026-03-04 20:00:12 +00:00
Laurent Cozic
a3186cdfe1 Doc: Add CLAUDE.md rule regarding duplicate tests 2026-03-04 18:48:05 +00:00
Laurent Cozic
0a580493a2 Doc: Added YouTube link to main website page and removed Lemmy link 2026-03-04 16:07:39 +00:00
Laurent Cozic
7a7bf72aa8 Chore: Minor fix to Paste as Markdown feature 2026-03-04 16:06:27 +00:00
Laurent Cozic
a20a584273 Desktop: Add "Paste as Markdown" command for Markdown editor (#14556) 2026-03-04 14:31:54 +00:00
Sriram Varun Kumar
ae30e8cf00 CLI: Fix trailing spaces in ls -l output (#14559) 2026-03-04 10:16:22 +00:00
Joplin Bot
1a7bb9131a Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-03-04 02:14:13 +00:00
Harsh Gupta
81ed35b117 Desktop: Resolves #12210: Translate Find and Replace dialog in Rich Text editor (#14529) 2026-03-03 16:48:40 +00:00
Sriram Varun Kumar
2704495ac6 Desktop: Fixes #14196: Fix file:// links with backslashes for Windows UNC paths (#14541) 2026-03-03 16:38:08 +00:00
Parth Thirwani
a96f7c6ee7 Desktop: Fixes #13883: Secondary windows no longer follow primary selection after moving notes (#14498)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2026-03-03 15:32:04 +00:00
Henry Heino
af706ac1b3 Web: Add welcome notes specific to the web app (#14499) 2026-03-03 15:30:03 +00:00
Henry Heino
766ef933b9 Web: Link to the official web app when attempting to sync with Joplin Cloud (#14523) 2026-03-03 15:09:14 +00:00
Surendra Manjhi
35de2aca18 Desktop: Fixes #12313: Prevent All Notes sort order from overwriting shared notebook sort on relaunch (#14524) 2026-03-03 15:08:35 +00:00
Ahmed Idani
c1827e1b9e Desktop: Fixes #14522: App fails to restart on Linux AppImage (#14530) 2026-03-03 14:59:53 +00:00
Henry Heino
89e3544a0c Chore: Desktop: Fix automated tests fail when the system locale is not English (#14531) 2026-03-03 14:58:47 +00:00
mrjo118
7f40e9e661 Mobile: Prevent focus issues and keyboard opening when opening a note in view mode (#14533) 2026-03-03 14:58:10 +00:00
Akshaj Rawat
20405ea95f Desktop: Resolves #12326: Add keyboard shortcuts to toolbar buttons (#14408) 2026-03-03 13:39:57 +00:00
Akshaj Rawat
2574e18c2f Desktop: Fixes #14271: Error message is incorrect when plugin manifest is invalid (#14374) 2026-03-03 13:36:08 +00:00
Joplin Bot
36b25a9517 Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-03-03 02:18:36 +00:00
Laurent Cozic
b3e0575361 iOS 13.6.2 2026-03-02 22:16:27 +00:00
Joplin Bot
f9f40b3c9b Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-03-02 18:59:44 +00:00
166 changed files with 2932 additions and 721 deletions

View File

@@ -107,3 +107,4 @@ knowledge_base:
filePatterns:
- "readme/dev/coding_style.md"
- "readme/dev/index.md"
- "CLAUDE.md"

View File

@@ -271,6 +271,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
@@ -303,6 +304,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
@@ -340,9 +342,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
@@ -608,10 +612,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
@@ -902,6 +902,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
@@ -1520,6 +1521,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
@@ -1575,6 +1577,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
@@ -1673,6 +1676,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

View File

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

View File

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

View File

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

View File

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

15
.gitignore vendored
View File

@@ -244,6 +244,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
@@ -276,6 +277,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
@@ -313,9 +315,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
@@ -581,10 +585,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
@@ -875,6 +875,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
@@ -1493,6 +1494,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
@@ -1548,6 +1550,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
@@ -1646,6 +1649,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

View File

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

View File

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

View File

@@ -14,6 +14,7 @@
<div class="col-9 text-right d-none d-md-block">
{{> twitterLink}}
<a href="{{baseUrl}}/news/" class="fw500">News</a>
<a href="{{baseUrl}}/plugins/" class="fw500">Plugins</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>
@@ -59,6 +60,7 @@
<div class="text-center menu-mobile-top">
<a href="{{baseUrl}}/news/" class="fw500 mobile-menu-link">News</a>
<a href="{{baseUrl}}/plugins/" class="fw500 mobile-menu-link">Plugins</a>
<a href="{{baseUrl}}/help/" class="fw500 mobile-menu-link">Help</a>
<a href="{{forumUrl}}" class="fw500 mobile-menu-link">Forum</a>
</div>
@@ -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>

View File

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

View File

@@ -8,6 +8,9 @@
- 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

View File

@@ -17,7 +17,7 @@
"version": "latest",
"excluded_platforms": ["aarch64-darwin", "x86_64-darwin"],
},
"git": "2.50.1",
"git": "2.51.0",
},
"shell": {
"init_hook": [

View File

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

View File

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

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

View File

@@ -0,0 +1,8 @@
- First line
Second line
- Normal item
- With sub-list
- Sub-list
Paragraph
Also another line

View File

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

View File

@@ -0,0 +1,4 @@
| Name | Value |
| --- | --- |
| Cell A | Cell B |
| Cell C | Cell D |

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

View File

@@ -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');
@@ -401,6 +401,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 +901,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) {

View File

@@ -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';
@@ -640,16 +640,19 @@ class Application extends BaseApplication {
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();
});
RevisionService.instance().runInBackground();
});
}, 1000);
}
this.startRotatingLogMaintenance(Setting.value('profileDir'));
@@ -730,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());

View File

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

View File

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

View File

@@ -138,6 +138,7 @@ export default function(props: Props) {
}, [currentPassword]);
function renderPasswordForm() {
const passwordsMatch = password1 === password2;
const renderCurrentPassword = () => {
if (!showCurrentPassword) return null;
@@ -176,12 +177,22 @@ 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>

View File

@@ -709,6 +709,7 @@ function useMenu(props: Props) {
menuItemDic.textCut,
menuItemDic.textPaste,
menuItemDic.pasteAsText,
menuItemDic.pasteAsMarkdown,
menuItemDic.textSelectAll,
separator(),
menuItemDic.globalUndo,

View File

@@ -6,12 +6,18 @@ describe('useContextMenu', () => {
it('should return type=image when cursor is inside markdown image', () => {
const line = `![alt text](:/${resourceId})`;
expect(getResourceIdFromMarkup(line, 15)).toEqual({ resourceId, type: 'image' });
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 type=file when cursor is inside markdown link', () => {
const line = `[document.pdf](:/${resourceId})`;
expect(getResourceIdFromMarkup(line, 15)).toEqual({ resourceId, type: 'file' });
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', () => {
@@ -22,8 +28,13 @@ describe('useContextMenu', () => {
it('should correctly distinguish between image and file on same line', () => {
const line = `![image](:/${resourceId}) [file](:/${resourceId2})`;
expect(getResourceIdFromMarkup(line, 10)).toEqual({ resourceId, type: 'image' });
expect(getResourceIdFromMarkup(line, 48)).toEqual({ resourceId: resourceId2, type: 'file' });
const imageResult = getResourceIdFromMarkup(line, 10);
expect(imageResult.resourceId).toBe(resourceId);
expect(imageResult.type).toBe('image');
const fileResult = getResourceIdFromMarkup(line, 48);
expect(fileResult.resourceId).toBe(resourceId2);
expect(fileResult.type).toBe('file');
});
it('should return null for empty line', () => {

View File

@@ -11,7 +11,7 @@ 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';
@@ -22,6 +22,8 @@ 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.
@@ -74,7 +76,7 @@ export const getResourceIdFromMarkup = (lineContent: string, cursorPosInLine: nu
}
if (markupEnd !== -1 && cursorPosInLine >= markupStart && cursorPosInLine <= markupEnd) {
return { resourceId: resourceInfo.itemId, type: markupType };
return { resourceId: resourceInfo.itemId, type: markupType, markupStart, markupEnd };
}
}
}
@@ -161,30 +163,24 @@ const useContextMenu = (props: ContextMenuProps) => {
return clickedElement?.closest(`.${imageClassName}`) as HTMLElement | null;
};
// Get resource info from markup at click position (not cursor position)
const getResourceInfoAtClickPos = (params: ContextMenuParams): ResourceMarkupInfo | 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 targetWindow = bridge().windowById(windowId);
const appendEditMenuItems = (menu: typeof Menu.prototype) => {
const hasSelectedText = editorRef.current && !!editorRef.current.getSelection();
menu.append(new MenuItem({ label: _('Cut'), enabled: hasSelectedText, click: () => props.editorCutText() }));
menu.append(new MenuItem({ label: _('Copy'), enabled: hasSelectedText, click: () => props.editorCopyText() }));
menu.append(new MenuItem({ label: _('Paste'), enabled: true, click: () => props.editorPaste() }));
menu.append(new MenuItem({ label: _('Paste as Markdown'), enabled: true, click: () => CommandService.instance().execute('pasteAsMarkdown') }));
};
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 contextMenuOptions: ContextMenuOptions = {
itemType: type === 'image' ? ContextMenuItemType.Image : ContextMenuItemType.Resource,
itemType,
resourceId,
filename: null,
mime: null,
@@ -192,18 +188,34 @@ const useContextMenu = (props: ContextMenuProps) => {
linkToOpen: null,
textToCopy: null,
htmlToCopy: null,
insertContent: () => {},
isReadOnly: true,
insertContent: () => { editorRef.current?.insertText(''); },
isReadOnly: false,
fireEditorEvent: () => {},
htmlToMd: null,
mdToHtml: null,
};
const resourceMenuItems = await buildMenuItems(menuItems(props.dispatch), contextMenuOptions);
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 });
};
@@ -225,7 +237,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)) {
@@ -233,19 +263,40 @@ const useContextMenu = (props: ContextMenuProps) => {
if (imgElement) {
const resourceId = pathToId(imgElement.src);
if (resourceId) {
event.preventDefault();
moveCursorToImageLine(imageContainer);
await showResourceContextMenu(resourceId, 'image');
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 resource markup text (images or file attachments)
const markupResourceInfo = getResourceInfoAtClickPos(params);
if (markupResourceInfo && pointerInsideEditor(params)) {
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 showResourceContextMenu(markupResourceInfo.resourceId, markupResourceInfo.type);
await showResourceContextMenu(resourceInfo.resourceId, resourceInfo.type);
return;
}
@@ -256,38 +307,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);

View File

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

View File

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

View File

@@ -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);
@@ -338,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;
@@ -390,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'>
@@ -399,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}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 &#10; 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: &#10; = LF)
[
`<img src="${resourceSrc}" alt="A screenshot&#10;&#10;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 &#10; 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 &#10; entities
`<img width="625" height="284" src="${resourceSrc}" alt="A screenshot&#10;&#10;AI-generated content."/>`,
// Multiple consecutive newline entities collapsed to single space
`<img width="100" height="100" src="${resourceSrc}" alt="line1&#10;&#13;&#10;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:');
});
});

View File

@@ -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:([^;]+);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 (&#10; &#13; &#xA; 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

View File

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

View File

@@ -13,6 +13,7 @@ const commandsWithDependencies = [
require('../commands/focusElementNoteViewer'),
require('../commands/focusElementToolbar'),
require('../commands/pasteAsText'),
require('../commands/pasteAsMarkdown'),
];
type OnBodyChange = (event: OnChangeEvent)=> void;

View File

@@ -30,6 +30,7 @@ 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';
const commands = {
focusElementNoteList,
@@ -131,6 +132,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,

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ import { _ } from '@joplin/lib/locale';
import Note from '@joplin/lib/models/Note';
import Folder from '@joplin/lib/models/Folder';
export const newNoteEnabledConditions = 'oneFolderSelected && !inConflictFolder && !folderIsReadOnly && !folderIsTrash';
export const newNoteEnabledConditions = 'oneFolderSelected && selectedFolderIsValid && !inConflictFolder && !folderIsReadOnly && !folderIsTrash';
export const declaration: CommandDeclaration = {
name: 'newNote',

View File

@@ -30,19 +30,27 @@ export const runtime = (): CommandRuntime => {
} else {
void bridge().openExternal(link);
}
} else if (urlProtocol(link)) {
if (link.indexOf('file://') === 0) {
// When using the file:// protocol, openPath doesn't work (does
// nothing) with URL-encoded paths.
//
// shell.openPath seems to work with file:// urls on Windows,
// but doesn't on macOS, so we need to convert it to a path
// before passing it to openPath.
const decodedPath = fileUriToPath(urlDecode(link), shim.platformName());
void bridge().openItem(decodedPath);
} else {
void bridge().openExternal(link);
} else if (link.indexOf('file://') === 0) {
// When using the file:// protocol, openPath doesn't work (does
// nothing) with URL-encoded paths.
//
// shell.openPath seems to work with file:// urls on Windows,
// but doesn't on macOS, so we need to convert it to a path
// before passing it to openPath.
let decoded = urlDecode(link);
// On Windows, UNC paths like file://\\server\share have backslashes
// right after file:// which makes the URL invalid. Convert them
// to forward slashes so fileUriToPath can handle them correctly.
// https://github.com/laurent22/joplin/issues/14196
if (decoded.startsWith('file://\\')) {
decoded = `file://${decoded.substring(7).replace(/\\/g, '/')}`;
}
const decodedPath = fileUriToPath(decoded, shim.platformName());
void bridge().openItem(decodedPath);
} else if (urlProtocol(link)) {
void bridge().openExternal(link);
} else {
bridge().showErrorMessageBox(_('Unsupported link or message: %s', link));
}

View File

@@ -1,5 +1,5 @@
import { CommandContext, CommandDeclaration, CommandRuntime } from '@joplin/lib/services/CommandService';
import { setNotesSortOrder } from '../../../services/sortOrder/notesSortOrderUtils';
import { setNotesSortOrder } from '@joplin/lib/services/sortOrder/notesSortOrderUtils';
import { _ } from '@joplin/lib/locale';
export const declaration: CommandDeclaration = {

View File

@@ -1,7 +1,7 @@
import { CommandContext, CommandDeclaration, CommandRuntime } from '@joplin/lib/services/CommandService';
import Setting from '@joplin/lib/models/Setting';
import { _ } from '@joplin/lib/locale';
import { setNotesSortOrder } from '../../../services/sortOrder/notesSortOrderUtils';
import { setNotesSortOrder } from '@joplin/lib/services/sortOrder/notesSortOrderUtils';
export const declaration: CommandDeclaration = {
name: 'toggleNotesSortOrderReverse',

View File

@@ -1,6 +1,6 @@
import { CommandContext, CommandDeclaration, CommandRuntime } from '@joplin/lib/services/CommandService';
import { _ } from '@joplin/lib/locale';
import PerFolderSortOrderService from '../../../services/sortOrder/PerFolderSortOrderService';
import PerFolderSortOrderService from '@joplin/lib/services/sortOrder/PerFolderSortOrderService';
export const declaration: CommandDeclaration = {
name: 'togglePerFolderSortOrder',

View File

@@ -81,6 +81,7 @@ export default function() {
'switchProfile2',
'switchProfile3',
'pasteAsText',
'pasteAsMarkdown',
'showNoteProperties',
'convertNoteToMarkdown',
'toggleEditors',

View File

@@ -34,13 +34,14 @@ export default class MainScreen {
}
public async waitFor() {
await this.newNoteButton.waitFor();
await this.noteList.waitFor();
}
// Follows the steps a user would use to create a new note.
public async createNewNote(title: string) {
await this.waitFor();
// The new note button is only visible when a folder is selected -- wait for it explicitly.
await this.newNoteButton.waitFor();
// Create the new note. Retry this -- creating new notes can sometimes fail if done just after
// application startup.

View File

@@ -206,5 +206,26 @@ test.describe('pluginApi', () => {
await expect(panelLocator).not.toBeVisible();
});
// Regression test for https://github.com/laurent22/joplin/issues/12793
test('a plugin that crashes before register() should not block other plugins', async ({ startAppWithPlugins }) => {
// Load a crashing plugin alongside a working plugin. If the fix works,
// startAppWithPlugins completes because startup-plugins-loaded fires.
// Without the fix, this test would timeout because allPluginsStarted
// never becomes true.
const { app, mainWindow } = await startAppWithPlugins([
'resources/test-plugins/crashBeforeRegister.js',
'resources/test-plugins/execCommand.js',
]);
const mainScreen = await new MainScreen(mainWindow).setup();
await mainScreen.createNewNote('Test note');
// Verify the working plugin is functional
const editor = mainScreen.noteEditor;
await editor.focusCodeMirrorEditor();
await mainWindow.keyboard.type('Should be overwritten.');
await mainScreen.goToAnything.runCommand(app, 'testUpdateEditorText');
await editor.expectToHaveText('PASS');
});
});

View File

@@ -0,0 +1,22 @@
// Allows referencing the Joplin global:
/* eslint-disable no-undef */
// Allows the `joplin-manifest` block comment:
/* eslint-disable multiline-comment-style */
/* joplin-manifest:
{
"id": "org.joplinapp.plugins.example.crashBeforeRegister",
"manifest_version": 1,
"app_min_version": "3.1",
"name": "Crash Before Register",
"description": "Plugin that crashes before calling register()",
"version": "1.0.0",
"author": "",
"homepage_url": "https://joplinapp.org"
}
*/
// Crash before calling joplin.plugins.register()
// This tests the fix for https://github.com/laurent22/joplin/issues/12793
throw new Error('Simulated plugin crash before register()');

View File

@@ -7,7 +7,7 @@ const createStartupArgs = (profileDirectory: string) => {
// We need to run with --env dev to disable the single instance check.
return [
mainPath, '--env', 'dev', '--log-level', 'debug', '--no-welcome', '--running-tests', '--profile', resolve(profileDirectory),
mainPath, '--env', 'dev', '--lang=en-GB', '--log-level', 'debug', '--no-welcome', '--running-tests', '--profile', resolve(profileDirectory),
];
};

View File

@@ -1,6 +1,6 @@
{
"name": "@joplin/app-desktop",
"version": "3.6.3",
"version": "3.6.4",
"description": "Joplin for Desktop",
"main": "main.bundle.js",
"private": true,
@@ -172,7 +172,7 @@
"electron-builder": "24.13.3",
"electron-updater": "6.6.8",
"electron-window-state": "5.0.3",
"esbuild": "^0.25.3",
"esbuild": "^0.26.0",
"formatcoords": "1.1.3",
"glob": "11.0.3",
"gulp": "4.0.2",

View File

@@ -168,6 +168,16 @@ export default class PluginRunner extends BasePluginRunner {
promise.resolve(message.result);
}
} else {
// Handle notification that a plugin failed to start before
// calling register(). Emit 'started' so allPluginsStarted
// is not blocked. See: https://github.com/laurent22/joplin/issues/12793
if (message.path === '__pluginFailedToStart__') {
logger.error(`Plugin "${plugin.id}" failed to start:`, message.args?.[0]);
plugin.running = false;
plugin.emit('started');
return;
}
const mappedArgs = mapEventIdsToHandlers(plugin.id, message.args);
const fullPath = `joplin.${message.path}`;

View File

@@ -130,10 +130,38 @@
console.warn('Unhandled plugin message:', message);
});
// Track whether the plugin has called joplin.plugins.register().
// If the plugin script throws before register() is called, the 'started'
// event will never fire, which blocks other plugins from working.
// See: https://github.com/laurent22/joplin/issues/12793
let registerCalled = false;
const originalTarget = target;
const wrappedTarget = (path, args) => {
if (path === 'plugins.register') {
registerCalled = true;
}
return originalTarget(path, args);
};
// Catch unhandled errors from the plugin script. If the plugin throws
// before calling register(), notify the host so it can unblock startup.
window.onerror = (message) => {
if (!registerCalled) {
console.error(`Plugin "${pluginId}" threw an error before registering:`, message);
ipcRendererSend('pluginMessage', {
target: 'mainWindow',
pluginId: pluginId,
path: '__pluginFailedToStart__',
args: [String(message)],
});
}
};
const pluginScriptPath = urlParams.get('pluginScript');
const script = document.createElement('script');
script.src = pluginScriptPath;
document.head.appendChild(script);
globalObject.joplin = sandboxProxy(target);
globalObject.joplin = sandboxProxy(wrappedTarget);
})(window);

View File

@@ -279,6 +279,7 @@ const useEditorSettings = (props: Props) => {
const editorSettings: EditorSettings = useMemo(() => ({
themeData: editorTheme(props.themeId),
markdownMarkEnabled: Setting.value('markdown.plugin.mark'),
markdownInsertEnabled: Setting.value('markdown.plugin.insert'),
katexEnabled: Setting.value('markdown.plugin.katex'),
spellcheckEnabled: Setting.value('editor.mobile.spellcheckEnabled'),
inlineRenderingEnabled,

View File

@@ -460,6 +460,24 @@ describe('RichTextEditor', () => {
});
});
it.each(['-', '1.'])('should not add extra blank lines around nested lists (marker: %j)', async (marker) => {
const nested = marker === '1.' ? '1.' : '-';
let body = `${marker} a\n${marker} b\n ${nested} c\n ${nested} d\n${marker} e`;
render(<WrappedEditor
noteBody={body}
onBodyChange={newBody => { body = newBody; }}
/>);
const window = await getEditorWindow();
mockTyping(window, ' testing');
await waitFor(async () => {
// Nested lists should not have extra blank lines above or below
expect(body).not.toMatch(/\n\n\s*[-\d]/);
});
});
it('should preserve table of contents blocks on edit', async () => {
let body = '# Heading\n\n# Heading 2\n\n[toc]\n\nTest.';

View File

@@ -27,6 +27,7 @@ function useCss(themeId: number, editorCss: string): string {
:root {
background-color: ${theme.backgroundColor};
font-size: 13pt;
}
body {
@@ -41,7 +42,6 @@ function useCss(themeId: number, editorCss: string): string {
padding-bottom: 1px;
padding-top: 10px;
font-size: 13pt;
font-family: ${JSON.stringify(theme.fontFamily)}, sans-serif;
}

View File

@@ -11,6 +11,7 @@ import NavService from '@joplin/lib/services/NavService';
import { Platform, StyleSheet, View } from 'react-native';
import CardButton from '../buttons/CardButton';
import Setting from '@joplin/lib/models/Setting';
import shim from '@joplin/lib/shim';
interface Props {
dispatch: Dispatch;
@@ -47,12 +48,14 @@ const styles = StyleSheet.create({
},
});
const isAppJoplinCloud = () => {
return Platform.OS === 'web' && location.origin === 'https://app.joplincloud.com';
};
const useShouldShowOtherButton = () => {
// Always show "other" on non-web platforms
if (Platform.OS !== 'web') return true;
// Don't show "other" when hosted on Joplin Cloud (other sync
// targets can still be selected from settings).
return location.origin !== 'https://app.joplincloud.com';
return !isAppJoplinCloud();
};
interface SyncProviderProps {
@@ -102,7 +105,15 @@ const SyncWizard: React.FC<Props> = ({ themeId, visible, dispatch }) => {
const onSelectJoplinCloud = useCallback(async () => {
onDismiss();
await NavService.go('JoplinCloudLogin');
if (Platform.OS === 'web' && !isAppJoplinCloud()) {
if (await shim.showConfirmationDialog(
_('Self-hosted instances of the Joplin web app cannot sync with Joplin Cloud. Open the official web app?'),
)) {
await shim.openUrl('https://app.joplincloud.com/');
}
} else {
await NavService.go('JoplinCloudLogin');
}
}, [onDismiss]);
const onSelectOtherTarget = useCallback(async () => {

View File

@@ -378,14 +378,16 @@ describe('screens/Note', () => {
[['viewer']],
[['editor']],
])('should initialize in the correct mode when noteVisiblePanes is %j', async (panes) => {
await setupNoteWithPanes(panes);
const { unmount } = await setupNoteWithPanes(panes);
await expectToBeEditing(panes.includes('editor'));
unmount();
});
it('should show toggle button', async () => {
await setupNoteWithPanes(['viewer']);
const { unmount } = await setupNoteWithPanes(['viewer']);
const toggleButton = await screen.findByLabelText('Toggle view/edit');
expect(toggleButton).toBeVisible();
unmount();
});
it.each([
@@ -394,11 +396,12 @@ describe('screens/Note', () => {
])('should switch modes when toggle button is pressed', async (panes) => {
const initialEditing = panes.includes('editor');
const expectedEditing = !initialEditing;
await setupNoteWithPanes(panes);
const { unmount } = await setupNoteWithPanes(panes);
await expectToBeEditing(initialEditing);
const toggleButton = await screen.findByLabelText('Toggle view/edit');
fireEvent.press(toggleButton);
await expectToBeEditing(expectedEditing);
unmount();
});
it('should always start in edit mode for provisional notes regardless of noteVisiblePanes', async () => {
@@ -414,10 +417,11 @@ describe('screens/Note', () => {
note: note,
provisional: true,
});
render(<WrappedNoteScreen />);
const { unmount } = render(<WrappedNoteScreen />);
const titleInput = await screen.findByDisplayValue('Provisional note');
expect(titleInput).toBeVisible();
await expectToBeEditing(true);
unmount();
});
it.each([
@@ -441,12 +445,13 @@ describe('screens/Note', () => {
await act(async () => {
await openExistingNote(noteId);
});
render(<WrappedNoteScreen />);
const { unmount } = render(<WrappedNoteScreen />);
const titleInput = await screen.findByDisplayValue('Test note');
expect(titleInput).toBeVisible();
// Should still be in the same mode
await expectToBeEditing(panes.includes('editor'));
expect(store.getState().noteVisiblePanes).toEqual(panes);
unmount();
});
it.each([
@@ -476,12 +481,13 @@ describe('screens/Note', () => {
await act(async () => {
await openExistingNote(note2Id);
});
render(<WrappedNoteScreen />);
const { unmount } = render(<WrappedNoteScreen />);
const titleInput2 = await screen.findByDisplayValue('Note 2');
expect(titleInput2).toBeVisible();
// Note 2 should be in the same mode
await expectToBeEditing(panes.includes('editor'));
expect(store.getState().noteVisiblePanes).toEqual(panes);
unmount();
});
it('should set the initial editor cursor location to the specified hash', async () => {

View File

@@ -1810,6 +1810,7 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
multiline={this.state.multiline}
text={note.title}
updateState={textWrapCalculator_updateState}
readOnly={false}
/>
{isTodo && <Checkbox style={this.styles().checkbox} checked={!!Number(note.todo_completed)} onChange={this.todoCheckbox_change} />}
<TextInput

View File

@@ -253,6 +253,7 @@ const NoteRevisionViewer: React.FC<Props> = props => {
multiline={multiline}
text={note?.title ?? ''}
updateState={textWrapCalculator_updateState}
readOnly={true}
/>
{
multiline ?

View File

@@ -13,13 +13,15 @@ import { _ } from '@joplin/lib/locale';
import { BaseScreenComponent } from '../../base-screen';
import { AppState } from '../../../utils/types';
import { FolderEntity, NoteEntity, TagEntity } from '@joplin/lib/services/database/types';
import { itemIsInTrash } from '@joplin/lib/services/trash';
import { getTrashFolderId, itemIsInTrash } from '@joplin/lib/services/trash';
import AccessibleView from '../../accessibility/AccessibleView';
import { Dispatch } from 'redux';
import { DialogContext, DialogControl } from '../../DialogManager';
import { useContext } from 'react';
import { MenuChoice } from '../../DialogManager/types';
import NewNoteButton from './NewNoteButton';
import PerFolderSortOrderService from '@joplin/lib/services/sortOrder/PerFolderSortOrderService';
const { ALL_NOTES_FILTER_ID } = require('@joplin/lib/reserved-ids');
interface Props {
dispatch: Dispatch;
@@ -102,12 +104,57 @@ class NotesScreenComponent extends BaseScreenComponent<ComponentProps, State> {
id: { name: 'showCompletedTodos', value: !Setting.value('showCompletedTodos') },
});
const showPerFolderToggle = this.shouldShowPerFolderSortToggle();
const currentFolderId = this.getCurrentFolderIdForSort();
if (showPerFolderToggle) {
const isSet = PerFolderSortOrderService.isSet(currentFolderId);
buttons.push({
text: `[ ${_('Use own sort order')} ]`,
checked: isSet,
id: { name: 'perFolderSortOrder', value: !isSet },
});
}
const r = await this.props.dialogManager.showMenu(Setting.settingMetadata('notes.sortOrder.field').label(), buttons);
if (!r) return;
Setting.setValue(r.name, r.value);
if (r.name === 'perFolderSortOrder') {
PerFolderSortOrderService.set(currentFolderId, r.value as boolean);
} else if (r.name === 'notes.sortOrder.field' || r.name === 'notes.sortOrder.reverse') {
Setting.setValue(r.name, r.value);
// Update the appropriate sort order storage based on whether per-folder sort is enabled
PerFolderSortOrderService.onSortOrderChange(currentFolderId);
} else {
Setting.setValue(r.name, r.value);
}
};
// Show "use own sort order" toggle for folders and the All Notes smart filter,
// but not for tags, conflicts folder, or trash folder.
private shouldShowPerFolderSortToggle(): boolean {
const { notesParentType, selectedFolderId, selectedSmartFilterId } = this.props;
if (notesParentType === 'Folder') {
return selectedFolderId !== Folder.conflictFolderId() && selectedFolderId !== getTrashFolderId();
}
if (notesParentType === 'SmartFilter') {
return selectedSmartFilterId === ALL_NOTES_FILTER_ID;
}
return false;
}
private getCurrentFolderIdForSort(): string {
if (this.props.notesParentType === 'Folder') {
return this.props.selectedFolderId;
} else if (this.props.notesParentType === 'SmartFilter') {
return this.props.selectedSmartFilterId;
}
return '';
}
public styles() {
if (!this.styles_) this.styles_ = {};
const themeId = this.props.themeId;

View File

@@ -8,6 +8,7 @@ interface Props {
multiline: boolean;
text: string;
updateState: (showMultilineToggle: boolean, multiline: boolean)=> void;
readOnly: boolean;
}
// This component can be used to estimate when text wrapping is required for a TextInput or Text element, to conditionally display a button to enable / disable
@@ -17,6 +18,11 @@ interface Props {
// Even if already using a Text element, a separate hidden Text element must be used for text wrapping estimation, because if onTextLayout is used on a visible
// component, it prevents text highlighting from working.
const TextWrapCalculator: React.FC<Props> = props => {
// Text values which are initially 0 or 1 characters in length do not trigger onTextLayout a second time after textCompContainerWidth has been set,
// which may result in a component remount after entering the first character on the TextInput this is linked to, which will cause change or loss of focus.
// Set the text to a dummy value of at least 2 characters when the titleContainerWidth is not yet measured, to ensure onTextLayout will fire a second time
const text = props.textCompContainerWidth !== 0 ? props.text : 'abc';
return Platform.OS === 'web' ? null : <Text
pointerEvents='none'
style={[
@@ -33,8 +39,9 @@ const TextWrapCalculator: React.FC<Props> = props => {
const showToggle = numberOfLines > 1;
let enableMultiline;
if (props.showMultilineToggle === null) {
// Upon opening the screen, multiline should be enabled when not wrapped, or disabled when wrapped (so that the element starts collapsed)
enableMultiline = !showToggle;
// Upon opening the screen, multiline should always be disabled, so long titles start collapsed and it does not open the keyboard automatically.
// If the field is readonly, the keyboard opening is a non issue, and multiline should be enabled when not expandable to make text selectable
enableMultiline = props.readOnly ? !showToggle : false;
} else {
// In every other case, retain the value of multiline so that it does not change while the user is typing, but only showMultilineToggle changes
enableMultiline = props.multiline;
@@ -45,7 +52,7 @@ const TextWrapCalculator: React.FC<Props> = props => {
}
}}
>
{props.text}
{text}
</Text>;
};

View File

@@ -0,0 +1,68 @@
/** @jest-environment jsdom */
import { handleAnchorClick } from './index';
describe('index.handleAnchorClick', () => {
let document: Document;
let scrollIntoViewMock: jest.Mock;
beforeEach(() => {
document = window.document;
document.body.innerHTML = `
<h2 id="section-a">Section A</h2>
<h2 id="section-b">Section B</h2>
<a id="link-a" href="#section-a">Go to A</a>
<a id="link-b" href="#section-b">Go to B</a>
<a id="link-ext" href="https://example.com/page#section">External</a>
`;
scrollIntoViewMock = jest.fn();
Element.prototype.scrollIntoView = scrollIntoViewMock;
document.addEventListener('click', handleAnchorClick, true);
});
afterEach(() => {
document.removeEventListener('click', handleAnchorClick, true);
jest.clearAllMocks();
});
test('scrollIntoView is called even when the same link is clicked twice', () => {
const linkA = document.getElementById('link-a')!;
linkA.click();
expect(scrollIntoViewMock).toHaveBeenCalledTimes(1);
linkA.click();
expect(scrollIntoViewMock).toHaveBeenCalledTimes(2);
});
test('scrollIntoView is called for each click when different links are clicked alternately', () => {
document.getElementById('link-a')!.click();
document.getElementById('link-b')!.click();
document.getElementById('link-a')!.click();
expect(scrollIntoViewMock).toHaveBeenCalledTimes(3);
});
test('does not intercept external links (http://...#hash)', () => {
const linkExt = document.getElementById('link-ext')!;
linkExt.click();
expect(scrollIntoViewMock).not.toHaveBeenCalled();
});
test('works with URL-encoded Japanese anchors', () => {
document.body.innerHTML += `
<h2 id="セクション">日本語セクション</h2>
<a id="link-ja" href="#%E3%82%BB%E3%82%AF%E3%82%B7%E3%83%A7%E3%83%B3">日本語リンク</a>
`;
document.getElementById('link-ja')!.click();
expect(scrollIntoViewMock).toHaveBeenCalledTimes(1);
});
test('does not throw when clicking a link to a missing anchor', () => {
document.body.innerHTML += '<a id="link-dead" href="#missing-section">dead link</a>';
expect(() => {
document.getElementById('link-dead')!.click();
}).not.toThrow();
});
});

View File

@@ -55,7 +55,23 @@ const initializeMessenger = (options: RendererWebViewOptions) => {
return { messenger };
};
export const handleAnchorClick = (event: MouseEvent) => {
if (!(event.target instanceof Element)) return;
const anchor = event.target.closest('a');
if (anchor && anchor.getAttribute('href')?.startsWith('#') && anchor.hash) {
let targetId = anchor.hash.slice(1);
try {
targetId = decodeURIComponent(targetId);
} catch {
// Keep raw hash if decoding fails.
}
const targetElement = document.getElementById(targetId);
if (targetElement) {
event.preventDefault();
targetElement.scrollIntoView();
}
}
};
// eslint-disable-next-line import/prefer-default-export -- This is a bundle entrypoint
export const initialize = (options: RendererWebViewOptions) => {
const { messenger } = initializeMessenger(options);
@@ -78,5 +94,7 @@ export const initialize = (options: RendererWebViewOptions) => {
// the listener is added to window with window.addEventListener('scroll', ...).
document.scrollingElement?.addEventListener('scroll', onMainContentScroll);
window.addEventListener('scroll', onMainContentScroll);
document.addEventListener('click', handleAnchorClick, true);
};

View File

@@ -10,6 +10,7 @@ const convertHtmlToMarkdown = (html: string|HTMLElement) => {
codeBlockStyle: 'fenced',
preserveImageTagsWithSize: true,
preserveNestedTables: true,
preserveTableStyles: true,
preserveColorStyles: true,
bulletListMarker: '-',
emDelimiter: '*',

View File

@@ -520,7 +520,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
CURRENT_PROJECT_VERSION = 150;
CURRENT_PROJECT_VERSION = 151;
DEVELOPMENT_TEAM = A9BXAFS6CT;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Joplin/Info.plist;
@@ -529,7 +529,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 13.6.1;
MARKETING_VERSION = 13.6.2;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@@ -555,7 +555,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
CURRENT_PROJECT_VERSION = 150;
CURRENT_PROJECT_VERSION = 151;
DEVELOPMENT_TEAM = A9BXAFS6CT;
INFOPLIST_FILE = Joplin/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
@@ -563,7 +563,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 13.6.1;
MARKETING_VERSION = 13.6.2;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@@ -758,7 +758,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 150;
CURRENT_PROJECT_VERSION = 151;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = A9BXAFS6CT;
GCC_C_LANGUAGE_STANDARD = gnu11;
@@ -769,7 +769,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 13.6.1;
MARKETING_VERSION = 13.6.2;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
OTHER_LDFLAGS = (
@@ -801,7 +801,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 150;
CURRENT_PROJECT_VERSION = 151;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = A9BXAFS6CT;
GCC_C_LANGUAGE_STANDARD = gnu11;
@@ -812,7 +812,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 13.6.1;
MARKETING_VERSION = 13.6.2;
MTL_FAST_MATH = YES;
OTHER_LDFLAGS = (
"$(inherited)",

View File

@@ -116,13 +116,13 @@
"@types/node": "18.19.130",
"@types/react": "19.1.10",
"@types/react-redux": "7.1.33",
"@types/serviceworker": "0.0.167",
"@types/serviceworker": "0.0.168",
"@types/tar-stream": "3.1.4",
"babel-jest": "29.7.0",
"babel-loader": "9.1.3",
"babel-plugin-module-resolver": "4.1.0",
"babel-plugin-react-native-web": "0.21.2",
"esbuild": "0.25.12",
"esbuild": "0.26.0",
"fast-deep-equal": "3.1.3",
"fs-extra": "11.3.2",
"gulp": "4.0.2",

View File

@@ -19,7 +19,7 @@ import SyncTargetJoplinServer from '@joplin/lib/SyncTargetJoplinServer';
import SyncTargetJoplinCloud from '@joplin/lib/SyncTargetJoplinCloud';
import SyncTargetOneDrive from '@joplin/lib/SyncTargetOneDrive';
import { Keyboard, BackHandler, Animated, StatusBar, Platform, Dimensions } from 'react-native';
import { AppState as RNAppState, EmitterSubscription, View, Text, Linking, NativeEventSubscription, Appearance, ActivityIndicator } from 'react-native';
import { AppState as RNAppState, AppStateStatus, EmitterSubscription, View, Text, Linking, NativeEventSubscription, Appearance, ActivityIndicator } from 'react-native';
import getResponsiveValue from './components/getResponsiveValue';
import NetInfo, { NetInfoSubscription } from '@react-native-community/netinfo';
const DropdownAlert = require('react-native-dropdownalert').default;
@@ -295,7 +295,8 @@ class AppComponent extends React.Component<AppComponentProps, AppComponentState>
private unsubscribeScreenWidthChangeHandler_: EmitterSubscription|undefined;
private unsubscribeNetInfoHandler_: NetInfoSubscription|undefined;
private unsubscribeNewShareListener_: UnsubscribeShareListener|undefined;
private onAppStateChange_: ()=> void;
private onAppStateChange_: (nextAppState: AppStateStatus)=> void;
private lastResumeSyncTime_ = 0;
private backButtonHandler_: BackButtonHandler;
private handleNewShare_: ()=> void;
private handleOpenURL_: (event: unknown)=> void;
@@ -315,8 +316,24 @@ class AppComponent extends React.Component<AppComponentProps, AppComponentState>
return this.backButtonHandler();
};
this.onAppStateChange_ = () => {
this.onAppStateChange_ = (nextAppState: AppStateStatus) => {
PoorManIntervals.update();
// Trigger sync immediately when the app becomes active (resume from background/lock screen).
// Only run when the app becomes active, with a 30-second minimum interval
// prevent sync spam on rapid lock/unlock cycles.
const minResumeSyncIntervalMs = 30_000;
if (nextAppState === 'active') {
const elapsed = Date.now() - this.lastResumeSyncTime_;
if (elapsed >= minResumeSyncIntervalMs) {
logger.info(`onAppStateChange_: App became active - scheduling immediate sync (elapsed since last resume sync: ${elapsed}ms)`);
this.lastResumeSyncTime_ = Date.now();
void reg.scheduleSync(0, null, true);
} else {
logger.info(`onAppStateChange_: App became active but skipping sync - minimum interval not reached (${elapsed}ms < ${minResumeSyncIntervalMs}ms)`);
}
}
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied

View File

@@ -92,6 +92,7 @@ import shim from '@joplin/lib/shim';
import { Platform } from 'react-native';
import VoiceTyping from '../services/voiceTyping/VoiceTyping';
import whisper from '../services/voiceTyping/whisper';
import PerFolderSortOrderService from '@joplin/lib/services/sortOrder/PerFolderSortOrderService';
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
@@ -389,6 +390,9 @@ const buildStartupTasks = (
});
});
addTask('buildStartupTasks/clear shared files cache', clearSharedFilesCache);
addTask('buildStartupTasks/initialize PerFolderSortOrderService', async () => {
PerFolderSortOrderService.initialize();
});
addTask('buildStartupTasks/go: initial route', async () => {
const folder = await getInitialActiveFolder();
@@ -436,18 +440,21 @@ const buildStartupTasks = (
// start almost immediately to get the latest data.
// doWifiConnectionCheck set to true so initial sync
// doesn't happen on mobile data
// eslint-disable-next-line promise/prefer-await-to-then -- Old code before rule was applied
void reg.scheduleSync(100, null, true).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, null, true).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();
// Collect revisions more frequently on mobile because it doesn't auto-save
// and it cannot collect anything when the app is not active.
RevisionService.instance().runInBackground(1000 * 30);
});
// Collect revisions more frequently on mobile because it doesn't auto-save
// and it cannot collect anything when the app is not active.
RevisionService.instance().runInBackground(1000 * 30);
});
}, 100);
});
addTask('buildStartupTasks/set up welcome utils', async () => {
await WelcomeUtils.install(Setting.value('locale'), dispatch);

View File

@@ -207,6 +207,12 @@ const config = {
label: 'News',
position: 'right',
},
{
to: process.env.WEBSITE_BASE_URL + '/plugins',
label: 'Plugins',
position: 'right',
target: '_self',
},
{
type: 'docSidebar',
sidebarId: 'helpSidebar',

View File

@@ -210,4 +210,47 @@ describe('CodeMirror5Emulation', () => {
{ line: 0, ch: 5 },
)).toThrow(/is not an integer/i);
});
it('heightAtLine for a line past the document should be greater than for the last line', () => {
const codeMirror = makeCodeMirrorEmulation('line1\nline2\nline3');
// Mock lineBlockAt to return a block with non-zero height so that the
// distinction between top and top+height is observable in the test.
const mockLineBlock = { top: 900, bottom: 1000, height: 100, from: 0, to: 0 };
jest.spyOn(codeMirror.editor, 'lineBlockAt').mockReturnValue(mockLineBlock as never);
const lineCount = codeMirror.lineCount();
const heightAtLastLine = codeMirror.heightAtLine(lineCount - 1, 'local');
const heightPastEnd = codeMirror.heightAtLine(lineCount, 'local');
expect(heightPastEnd).toBeGreaterThan(heightAtLastLine);
});
it('heightAtLine for a line past the document should be greater than for the last line, given one long line', () => {
const singleLine = 'Very long line of text. '.repeat(400);
const codeMirror = makeCodeMirrorEmulation(`${singleLine}`);
// Mock lineBlockAt to return a block with non-zero height so that the
// distinction between top and top+height is observable in the test.
const mockLineBlock = { top: 900, bottom: 1000, height: 100, from: 0, to: 0 };
jest.spyOn(codeMirror.editor, 'lineBlockAt').mockReturnValue(mockLineBlock as never);
const lineCount = codeMirror.lineCount();
const heightAtLastLine = codeMirror.heightAtLine(lineCount - 1, 'local');
const heightPastEnd = codeMirror.heightAtLine(lineCount, 'local');
expect(heightPastEnd).toBeGreaterThan(heightAtLastLine);
});
it('heightAtLine should return a non-negative value for valid line numbers', () => {
const codeMirror = makeCodeMirrorEmulation('first\nsecond\nthird');
const lineCount = codeMirror.lineCount();
// Test all lines from top to bottom of document
for (let i = 0; i <= lineCount; i++) {
expect(codeMirror.heightAtLine(i, 'local')).toBeGreaterThanOrEqual(0);
}
});
});

View File

@@ -268,7 +268,15 @@ export default class CodeMirror5Emulation extends BaseCodeMirror5Emulation {
const lineInfo = doc.line(Math.min(lineNumber + 1, doc.lines));
const lineBlock = this.editor.lineBlockAt(lineInfo.from);
const height = lineBlock.top;
let height;
if (lineNumber >= doc.lines) {
// Handle case when lineNumber is at or below the last line
// This ensures ePercentL != ePercentU in translateLE_, which may cause linInterp to be infinity.
// See: https://github.com/laurent22/joplin/issues/14143#issuecomment-3767793559
height = lineBlock.top + lineBlock.height;
} else {
height = lineBlock.top;
}
if (mode === 'local') {
const editorTop = this.editor.lineBlockAt(0).top;
return height - editorTop;

View File

@@ -6,7 +6,7 @@ import { EditorState, Prec, StateField } from '@codemirror/state';
import { deleteMarkupBackward, markdown, markdownLanguage } from '@codemirror/lang-markdown';
import { GFM as GitHubFlavoredMarkdownExtension } from '@lezer/markdown';
import markdownMathExtension from './extensions/markdownMathExtension';
import markdownHighlightExtension from './extensions/markdownHighlightExtension';
import markdownHighlightExtension, { markdownInsertExtension } from './extensions/markdownHighlightExtension';
import markdownFrontMatterExtension from './extensions/markdownFrontMatterExtension';
import lookUpLanguage from './utils/markdown/codeBlockLanguages/lookUpLanguage';
import { html } from '@codemirror/lang-html';
@@ -44,7 +44,7 @@ const configFromSettings = (settings: EditorSettings, context: RenderedContentCo
markdownFrontMatterExtension,
settings.markdownMarkEnabled ? markdownHighlightExtension : [],
settings.markdownInsertEnabled ? markdownInsertExtension : [],
// Don't highlight KaTeX if the user disabled it
settings.katexEnabled ? markdownMathExtension : [],
],

View File

@@ -27,4 +27,21 @@ left | right
expect(codeBlock.textContent).toBe('`foo`');
expect(codeBlock.parentElement.classList.contains('.cm-tableRow'));
});
test.each([
0,
'before ++'.length + 1,
])('should decorate ++insert++ spans when the caret is at %i', async cursorPos => {
const editorText = 'before ++inserted++ after';
const editor = await createTestEditor(
editorText,
EditorSelection.cursor(cursorPos),
['Insert'],
[decoratorExtension],
);
const insertSpan = editor.contentDOM.querySelector('.cm-insert');
expect(insertSpan).not.toBeNull();
expect(insertSpan?.textContent).toContain('inserted');
});
});

View File

@@ -116,6 +116,10 @@ const strikethroughDecoration = Decoration.mark({
attributes: { class: 'cm-strike' },
});
const insertDecoration = Decoration.mark({
attributes: { class: 'cm-insert' },
});
const nodeNameToLineDecoration: Record<string, Decoration> = {
'FencedCode': codeBlockDecoration,
'CodeBlock': codeBlockDecoration,
@@ -150,6 +154,7 @@ const nodeNameToMarkDecoration: Record<string, Decoration> = {
'HorizontalRule': horizontalRuleDecoration,
'TaskMarker': taskMarkerDecoration,
'Strikethrough': strikethroughDecoration,
'Insert': insertDecoration,
'Highlight': markDecoration,
};

View File

@@ -54,6 +54,17 @@ describe('MarkdownFrontMatterExtension', () => {
expect(frontMatterNodes.length).toBe(0);
});
it('should treat the entire document as frontmatter when closing delimiter is missing (issue #14542)', async () => {
const documentText = '---\nsome: frontmatter\n--\n\n# Hey';
const editor = await createEditorState(documentText, [frontMatterTagName]);
const frontMatterNodes = findNodesWithName(editor, frontMatterTagName);
// Frontmatter block must be recognised and span the entire document
expect(frontMatterNodes.length).toBe(1);
expect(frontMatterNodes[0].from).toBe(0);
expect(frontMatterNodes[0].to).toBe(documentText.length);
});
it('should handle empty FrontMatter block', async () => {
const documentText = '---\n---\n\n# Heading';
const editor = await createEditorState(documentText, [frontMatterTagName, 'ATXHeading1']);

View File

@@ -66,14 +66,15 @@ const frontMatterConfig: MarkdownConfig = {
return false;
}
// Store the opening delimiter position
// If the document starts with --- always claim it as a frontmatter block,
// even when the closing delimiter is absent.
const openingMarkerStart = cx.lineStart;
const openingMarkerEnd = cx.lineStart + line.text.length;
const contentStart = openingMarkerEnd + 1;
const contentStart = openingMarkerEnd + 1; // Start after the opening --- and newline
let foundEnd = false;
// Consume lines until we find the closing ---
// Consume lines until we find the closing --- or reach end of document.
while (cx.nextLine()) {
if (frontMatterDelimiterRegex.test(line.text)) {
foundEnd = true;
@@ -81,37 +82,34 @@ const frontMatterConfig: MarkdownConfig = {
}
}
if (!foundEnd) {
// No closing delimiter found - not a valid FrontMatter block
return false;
}
// cx.lineStart now points to the closing --- (if found) or end of document (if not).
const contentEnd = cx.lineStart;
// The content is between the two --- delimiters
const contentEnd = cx.lineStart; // Start of the closing --- line
// Closing delimiter positions
const closingMarkerStart = cx.lineStart;
const closingMarkerEnd = cx.lineStart + line.text.length;
// Create marker elements for the --- delimiters
const openingMarkerElem = cx.elt(frontMatterMarkerTagName, openingMarkerStart, openingMarkerEnd);
const closingMarkerElem = cx.elt(frontMatterMarkerTagName, closingMarkerStart, closingMarkerEnd);
// Create the content element (the YAML content between delimiters)
const contentElem = cx.elt(frontMatterContentTagName, contentStart, contentEnd);
// Create the container element spanning from start of first --- to end of last ---
const containerElement = cx.elt(
frontMatterTagName,
0, // Start at document beginning
closingMarkerEnd, // End after closing ---
[openingMarkerElem, contentElem, closingMarkerElem],
);
if (foundEnd) {
const closingMarkerStart = cx.lineStart;
const closingMarkerEnd = cx.lineStart + line.text.length;
const closingMarkerElem = cx.elt(frontMatterMarkerTagName, closingMarkerStart, closingMarkerEnd);
cx.addElement(containerElement);
// Move past the closing delimiter
cx.nextLine();
const containerElement = cx.elt(
frontMatterTagName,
0,
closingMarkerEnd,
[openingMarkerElem, contentElem, closingMarkerElem],
);
cx.addElement(containerElement);
cx.nextLine();
} else {
const containerElement = cx.elt(
frontMatterTagName,
0,
contentEnd,
[openingMarkerElem, contentElem],
);
cx.addElement(containerElement);
}
return true;
},

View File

@@ -2,7 +2,7 @@ import { EditorSelection, EditorState } from '@codemirror/state';
import createTestEditor from '../testing/createTestEditor';
import findNodesWithName from '../testing/findNodesWithName';
import { highlightMarkerTagName, highlightTagName } from './markdownHighlightExtension';
import { highlightMarkerTagName, highlightTagName, insertMarkerTagName, insertTagName } from './markdownHighlightExtension';
const createEditorState = async (initialText: string, expectedTags: string[]): Promise<EditorState> => {
return (await createTestEditor(initialText, EditorSelection.cursor(0), expectedTags)).state;
@@ -70,4 +70,37 @@ describe('MarkdownHighlightExtension', () => {
expect(markerNodes).toMatchObject(expectedMarkerRanges);
}
});
it.each([
{ // Should support single-word insert
text: '++insert++',
expectedInsertRanges: [{ from: 0, to: '++insert++'.length }],
expectedMarkerRanges: [
{ from: 0, to: 2 },
{ from: '++insert'.length, to: '++insert++'.length },
],
},
{ // Should not parse if only one +
text: 'test++ing+',
expectedInsertRanges: [],
},
])('should parse inline insert (case %#: %j)', async ({ text, expectedInsertRanges, expectedMarkerRanges }) => {
const expectedNodes: string[] = [];
if (expectedInsertRanges.length) {
expectedNodes.push(insertTagName);
}
if (expectedMarkerRanges?.length) {
expectedNodes.push(insertMarkerTagName);
}
const editor = await createEditorState(text, expectedNodes);
const insertNodes = findNodesWithName(editor, insertTagName);
expect(insertNodes).toMatchObject(expectedInsertRanges);
if (expectedMarkerRanges) {
const markerNodes = findNodesWithName(editor, insertMarkerTagName);
expect(markerNodes).toMatchObject(expectedMarkerRanges);
}
});
});

View File

@@ -2,38 +2,50 @@ import { tags, Tag } from '@lezer/highlight';
import { MarkdownConfig, InlineContext, MarkdownExtension } from '@lezer/markdown';
const equalsSignCharcode = 61;
const plusSignCharcode = 43;
export const highlightTagName = 'Highlight';
export const highlightMarkerTagName = 'HighlightMarker';
export const insertTagName = 'Insert';
export const insertMarkerTagName = 'InsertMarker';
export const highlightTag = Tag.define();
export const highlightMarkerTag = Tag.define(tags.meta);
export const insertTag = Tag.define();
export const insertMarkerTag = Tag.define(tags.meta);
const HighlightDelimiter = { resolve: highlightTagName, mark: highlightMarkerTagName };
const InsertDelimiter = { resolve: insertTagName, mark: insertMarkerTagName };
const isSpaceOrEmpty = (text: string) => text.match(/^\s*$/);
// Markdown extension for recognizing highlighting. This is similar to the upstream
// extension for strikethrough:
// https://github.com/lezer-parser/markdown/blob/d6f0aa095722329a0188b9c7afe207dab4835e55/src/extension.ts#L10
const highlightConfig: MarkdownConfig = {
const createDoubleCharInlineConfig = (
charCode: number,
tagName: string,
delimiter: { resolve: string; mark: string },
): MarkdownConfig => ({
defineNodes: [
{
name: highlightTagName,
style: highlightTag,
name: delimiter.resolve,
style: tagName === highlightTagName ? highlightTag : insertTag,
},
{
name: highlightMarkerTagName,
style: highlightMarkerTag,
name: delimiter.mark,
style: tagName === highlightTagName ? highlightMarkerTag : insertMarkerTag,
},
],
parseInline: [{
name: highlightTagName,
name: tagName,
parse(cx: InlineContext, current: number, pos: number): number {
const nextCharCode = cx.char(pos + 1);
const nextNextCharCode = cx.char(pos + 2);
if (current !== equalsSignCharcode || nextCharCode !== equalsSignCharcode || nextNextCharCode === equalsSignCharcode) {
if (current !== charCode || nextCharCode !== charCode || nextNextCharCode === charCode) {
return -1;
}
@@ -50,16 +62,24 @@ const highlightConfig: MarkdownConfig = {
}
return cx.addDelimiter(
HighlightDelimiter,
delimiter,
pos, pos + 2,
canStart,
canEnd,
);
},
}],
};
});
const highlightConfig = createDoubleCharInlineConfig(equalsSignCharcode, highlightTagName, HighlightDelimiter);
const insertConfig = createDoubleCharInlineConfig(plusSignCharcode, insertTagName, InsertDelimiter);
const markdownHighlightExtension: MarkdownExtension = [
highlightConfig,
];
export const markdownInsertExtension: MarkdownExtension = [
insertConfig,
];
export default markdownHighlightExtension;

View File

@@ -5,6 +5,8 @@ const linkClassName = 'cm-ext-unfocused-link';
const urlMarkDecoration = Decoration.mark({ class: linkClassName });
const strikethroughClassName = 'cm-ext-strikethrough';
const strikethroughMarkDecoration = Decoration.mark({ class: strikethroughClassName });
const insertClassName = 'cm-ext-insert';
const insertMarkDecoration = Decoration.mark({ class: insertClassName });
const addFormattingClasses = [
EditorView.theme({
@@ -14,8 +16,19 @@ const addFormattingClasses = [
[`& .${strikethroughClassName}, & .${strikethroughClassName} span`]: {
textDecoration: 'line-through',
},
[`& .${insertClassName}, & .${insertClassName} span`]: {
textDecoration: 'underline',
},
}),
makeInlineReplaceExtension({
getRevealStrategy: (node) => {
// Links: use 'select' because the Link's parent is Paragraph,
// which spans the whole line - 'active' would hide all link decorations on the line
if (node.name === 'URL' || node.name === 'Link') {
return 'select';
}
return 'active';
},
createDecoration: (node) => {
if (node.name === 'URL' || node.name === 'Link') {
return urlMarkDecoration;
@@ -23,6 +36,9 @@ const addFormattingClasses = [
if (node.name === 'Strikethrough') {
return strikethroughMarkDecoration;
}
if (node.name === 'Insert') {
return insertMarkDecoration;
}
return null;
},
}),

View File

@@ -101,7 +101,7 @@ class ImageWidget extends WidgetType {
return true;
}
public toDOM(_view: EditorView) {
public toDOM(view: EditorView) {
const container = document.createElement('div');
container.classList.add(imageClassName);
container.dataset.sourceFrom = String(this.sourceFrom_);
@@ -112,6 +112,16 @@ class ImageWidget extends WidgetType {
container.appendChild(image);
this.updateDOM(container);
container.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
e.preventDefault();
const pos = Math.min(view.posAtDOM(container), view.state.doc.length);
view.dispatch({
selection: { anchor: view.state.doc.lineAt(pos).from },
scrollIntoView: false,
});
});
return container;
}
@@ -122,6 +132,10 @@ class ImageWidget extends WidgetType {
public get estimatedHeight() {
return imageHeightCache.get(this.cacheKey) ?? -1;
}
public ignoreEvent() {
return true;
}
}
const getImageSrc = (node: SyntaxNodeRef, state: EditorState) => {

View File

@@ -3,31 +3,20 @@ import { SyntaxNodeRef } from '@lezer/common';
import makeReplaceExtension from './utils/makeInlineReplaceExtension';
import toggleCheckboxAt from '../../utils/markdown/toggleCheckboxAt';
const checkboxContainerClassName = 'cm-ext-checkbox-toggle';
const checkboxClassName = 'cm-ext-checkbox';
const checkboxClassName = 'cm-ext-checkbox-toggle';
class CheckboxWidget extends WidgetType {
public constructor(
private checked: boolean,
private depth: number,
private label: string,
private markup: string,
) {
public constructor(private checked: boolean, private depth: number, private label: string) {
super();
}
public eq(other: CheckboxWidget) {
return other.checked === this.checked
&& other.depth === this.depth
&& other.label === this.label
&& other.markup === this.markup;
return other.checked === this.checked && other.depth === this.depth && other.label === this.label;
}
private applyContainerClasses(container: HTMLElement) {
container.classList.add(checkboxContainerClassName);
// For sizing: Should have the same font/styles as non-rendered checkboxes:
container.classList.add('cm-taskMarker');
container.classList.add(checkboxClassName);
for (const className of [...container.classList]) {
if (className.startsWith('-depth-')) {
@@ -41,22 +30,12 @@ class CheckboxWidget extends WidgetType {
public toDOM(view: EditorView) {
const container = document.createElement('span');
const sizingNode = document.createElement('span');
sizingNode.classList.add('sizing');
sizingNode.textContent = this.markup;
container.appendChild(sizingNode);
const checkboxWrapper = document.createElement('span');
checkboxWrapper.classList.add('content');
container.appendChild(checkboxWrapper);
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = this.checked;
checkbox.ariaLabel = this.label;
checkbox.title = this.label;
checkbox.classList.add(checkboxClassName);
checkboxWrapper.appendChild(checkbox);
container.appendChild(checkbox);
checkbox.oninput = () => {
toggleCheckboxAt(view.posAtDOM(container))(view);
@@ -87,32 +66,16 @@ const completedListItemDecoration = Decoration.line({ class: completedTaskClassN
const replaceCheckboxes = [
EditorView.theme({
[`& .${checkboxContainerClassName}`]: {
position: 'relative',
'& > .sizing': {
visibility: 'hidden',
},
'& > .content': {
position: 'absolute',
left: '0',
right: '0',
top: '0',
bottom: '0',
textAlign: 'center',
},
},
[`& .${checkboxClassName}`]: {
verticalAlign: 'middle',
// Ensure that the checkbox grows as the font size increases:
width: '100%',
minHeight: '70%',
// Shift the checkbox slightly so that it's aligned with the list item bullet point
margin: '0',
marginBottom: '3px',
'& > input': {
width: '1.1em',
height: '1.1em',
margin: '4px',
verticalAlign: 'middle',
},
'&:not(.-depth-1) > input': {
marginInlineStart: 0,
},
},
[`& .${completedTaskClassName}`]: {
opacity: 0.69,
@@ -121,7 +84,7 @@ const replaceCheckboxes = [
EditorView.domEventHandlers({
mousedown: (event) => {
const target = event.target as Element;
if (target.nodeName === 'INPUT' && target.classList?.contains(checkboxClassName)) {
if (target.nodeName === 'INPUT' && target.parentElement?.classList?.contains(checkboxClassName)) {
// Let the checkbox handle the event
return true;
}
@@ -129,6 +92,25 @@ const replaceCheckboxes = [
},
}),
makeReplaceExtension({
getRevealStrategy: (node, state) => {
if (node.name === 'TaskMarker') {
const container = node.node.parent?.parent;
const listMarker = container?.getChild('ListMark');
// Intersection check logic similar to nodeIntersectsSelection but with custom range
const selection = state.selection.main;
const rangeFrom = listMarker ? listMarker.from : node.from;
const rangeTo = node.to;
const rangeContains = (point: number) => point >= rangeFrom && point <= rangeTo;
const selectionContains = (point: number) => point >= selection.from && point <= selection.to;
// Reveal if cursor touches the checkbox or the list bullet point
return rangeContains(selection.from) || rangeContains(selection.to)
|| selectionContains(rangeFrom) || selectionContains(rangeTo);
}
return 'line';
},
createDecoration: (node, state, parentTags) => {
const markerIsChecked = (marker: SyntaxNodeRef) => {
const content = state.doc.sliceString(marker.from, marker.to);
@@ -138,14 +120,8 @@ const replaceCheckboxes = [
if (node.name === 'TaskMarker') {
const containerLine = state.doc.lineAt(node.from);
const labelText = state.doc.sliceString(node.to, containerLine.to);
const markerText = state.doc.sliceString(node.from, node.to);
return new CheckboxWidget(
markerIsChecked(node),
parentTags.get('ListItem') ?? 0,
labelText,
markerText,
);
return new CheckboxWidget(markerIsChecked(node), parentTags.get('ListItem') ?? 0, labelText);
} else if (node.name === 'Task') {
const marker = node.node.getChild('TaskMarker');
if (marker && markerIsChecked(marker)) {
@@ -162,7 +138,7 @@ const replaceCheckboxes = [
return null;
}
return [node.from, node.to];
return [listMarker.from, node.to];
} else if (node.name === 'Task') {
const taskLine = state.doc.lineAt(node.from);
return [taskLine.from];

View File

@@ -8,7 +8,7 @@ const shouldFullReplace = (node: SyntaxNodeRef, state: EditorState) => {
const getParentName = () => node.node.parent?.name;
const getNodeStartLine = () => state.doc.lineAt(node.from);
if (['HeaderMark', 'CodeMark', 'EmphasisMark', 'StrikethroughMark', 'HighlightMarker'].includes(node.name)) {
if (['HeaderMark', 'CodeMark', 'EmphasisMark', 'StrikethroughMark', 'HighlightMarker', 'InsertMarker'].includes(node.name)) {
return true;
}
@@ -49,6 +49,18 @@ const replaceFormatCharacters = [
referenceLinkStateField,
makeInlineReplaceExtension({
getRevealStrategy: (node) => {
if (node.name === 'QuoteMark') {
return 'line';
}
if (node.name === 'CodeMark') {
if (node.node.parent?.name === 'FencedCode') {
return 'line';
}
}
return 'active';
},
createDecoration: (node, state) => {
if (shouldFullReplace(node, state)) {
return hideDecoration;

View File

@@ -3,16 +3,20 @@ import createTestEditor from '../../testing/createTestEditor';
import replaceInlineHtml from './replaceInlineHtml';
import waitFor from '@joplin/lib/testing/waitFor';
const createEditor = async (initialMarkdown: string, expectedTags: string[] = ['HTMLTag']) => {
const createEditorWithCursor = async (initialMarkdown: string, cursorIndex: number, expectedTags: string[] = ['HTMLTag']) => {
const editor = await createTestEditor(
initialMarkdown,
EditorSelection.cursor(0),
EditorSelection.cursor(cursorIndex),
expectedTags,
[replaceInlineHtml],
);
return editor;
};
const createEditor = async (initialMarkdown: string, expectedTags: string[] = ['HTMLTag']) => {
return createEditorWithCursor(initialMarkdown, 0, expectedTags);
};
describe('replaceInlineHtml', () => {
jest.retryTimes(2);
@@ -35,4 +39,42 @@ describe('replaceInlineHtml', () => {
expect(editor.contentDOM.querySelector(expectedTagsQuery)).toBeTruthy();
});
});
test('should keep other inline HTML rendered when cursor is on same line, but not touching tags', async () => {
const markdown = 'A <sub>one</sub> B <sub>two</sub>';
const editor = await createEditorWithCursor(markdown, markdown.indexOf('A'));
await waitFor(() => {
expect(editor.contentDOM.querySelectorAll('sub')).toHaveLength(2);
});
});
test('should reveal only the inline HTML touched by the cursor', async () => {
const markdown = 'A <sub>one</sub> B <sub>two</sub>';
const cursorAtFirstSubContent = markdown.indexOf('one') + 1;
const editor = await createEditorWithCursor(markdown, cursorAtFirstSubContent);
await waitFor(() => {
expect(editor.contentDOM.querySelectorAll('sub')).toHaveLength(1);
});
});
test('should not hide incomplete inline HTML tags', async () => {
const markdown = '<sup>x';
const editor = await createEditorWithCursor(markdown, markdown.length);
await waitFor(() => {
expect(editor.contentDOM.textContent).toContain('<sup>x');
});
});
test('should not style incomplete inline HTML tags', async () => {
const markdown = '<strike>';
const editor = await createEditorWithCursor(markdown, markdown.length, []);
await waitFor(() => {
expect(editor.contentDOM.querySelector('strike')).toBeFalsy();
expect(editor.contentDOM.textContent).toContain('<strike>');
});
});
});

Some files were not shown because too many files have changed in this diff Show More