You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2026-03-12 10:00:05 +02:00
Compare commits
76 Commits
android-v3
...
fix_contex
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68d1601847 | ||
|
|
2132c2cdf4 | ||
|
|
67aff20e39 | ||
|
|
3719e1eee0 | ||
|
|
4abe83fdb6 | ||
|
|
6ba912e5aa | ||
|
|
8533083730 | ||
|
|
754ff28b36 | ||
|
|
b663c64def | ||
|
|
998b26d9a4 | ||
|
|
b097cf9a6a | ||
|
|
e22c367566 | ||
|
|
71a2e98155 | ||
|
|
714bbd6d23 | ||
|
|
eda03333a6 | ||
|
|
93f17a87fa | ||
|
|
c765306e6f | ||
|
|
f05fe5754d | ||
|
|
d046bfa14b | ||
|
|
2a681008dd | ||
|
|
7214823c74 | ||
|
|
ed5b92a91e | ||
|
|
2c8a9eee61 | ||
|
|
6451305c89 | ||
|
|
5fd0dc23da | ||
|
|
fd3b133b16 | ||
|
|
118bc3edf1 | ||
|
|
d90836bc50 | ||
|
|
9a477dbeb9 | ||
|
|
5271081b3a | ||
|
|
b26370fc5a | ||
|
|
737c7dcdb4 | ||
|
|
04babe0261 | ||
|
|
85e5bbd246 | ||
|
|
f819e1c88b | ||
|
|
79c153c498 | ||
|
|
1db9903926 | ||
|
|
e736e05d1c | ||
|
|
5ef10676d8 | ||
|
|
b38613ca22 | ||
|
|
ea486fbe13 | ||
|
|
d2784aff54 | ||
|
|
7308d9541e | ||
|
|
d6ac709e5f | ||
|
|
b290046e66 | ||
|
|
c2321a04ae | ||
|
|
3df77a4395 | ||
|
|
38fd790719 | ||
|
|
40bfa9dd3d | ||
|
|
8d08e5df60 | ||
|
|
4121c47e18 | ||
|
|
d30e6ad0da | ||
|
|
be712df89d | ||
|
|
f7762c403e | ||
|
|
b89d37de84 | ||
|
|
a7b9af61c0 | ||
|
|
a3186cdfe1 | ||
|
|
0a580493a2 | ||
|
|
7a7bf72aa8 | ||
|
|
a20a584273 | ||
|
|
ae30e8cf00 | ||
|
|
1a7bb9131a | ||
|
|
81ed35b117 | ||
|
|
2704495ac6 | ||
|
|
a96f7c6ee7 | ||
|
|
af706ac1b3 | ||
|
|
766ef933b9 | ||
|
|
35de2aca18 | ||
|
|
c1827e1b9e | ||
|
|
89e3544a0c | ||
|
|
7f40e9e661 | ||
|
|
20405ea95f | ||
|
|
2574e18c2f | ||
|
|
36b25a9517 | ||
|
|
b3e0575361 | ||
|
|
f9f40b3c9b |
@@ -107,3 +107,4 @@ knowledge_base:
|
||||
filePatterns:
|
||||
- "readme/dev/coding_style.md"
|
||||
- "readme/dev/index.md"
|
||||
- "CLAUDE.md"
|
||||
|
||||
@@ -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
|
||||
|
||||
4
.github/workflows/build-android.yml
vendored
4
.github/workflows/build-android.yml
vendored
@@ -4,6 +4,10 @@
|
||||
name: react-native-android-build-apk
|
||||
on: [push, pull_request]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.ref == 'refs/heads/dev' && github.run_id || format('{0}-{1}', github.workflow, github.ref) }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
AssembleRelease:
|
||||
if: github.repository == 'laurent22/joplin'
|
||||
|
||||
5
.github/workflows/build-macos-m1.yml
vendored
5
.github/workflows/build-macos-m1.yml
vendored
@@ -1,5 +1,10 @@
|
||||
name: Build macOS M1
|
||||
on: [push, pull_request]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.ref == 'refs/heads/dev' && github.run_id || format('{0}-{1}', github.workflow, github.ref) }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
Main:
|
||||
# We always process desktop release tags, because they also publish the release
|
||||
|
||||
5
.github/workflows/github-actions-main.yml
vendored
5
.github/workflows/github-actions-main.yml
vendored
@@ -1,5 +1,10 @@
|
||||
name: Joplin Continuous Integration
|
||||
on: [push, pull_request]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.ref == 'refs/heads/dev' && github.run_id || format('{0}-{1}', github.workflow, github.ref) }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
Main:
|
||||
# We always process server or desktop release tags, because they also publish the release
|
||||
|
||||
5
.github/workflows/ui-tests.yml
vendored
5
.github/workflows/ui-tests.yml
vendored
@@ -1,5 +1,10 @@
|
||||
name: Joplin UI tests
|
||||
on: [push, pull_request]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.ref == 'refs/heads/dev' && github.run_id || format('{0}-{1}', github.workflow, github.ref) }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
|
||||
15
.gitignore
vendored
15
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"version": "latest",
|
||||
"excluded_platforms": ["aarch64-darwin", "x86_64-darwin"],
|
||||
},
|
||||
"git": "2.50.1",
|
||||
"git": "2.51.0",
|
||||
},
|
||||
"shell": {
|
||||
"init_hook": [
|
||||
|
||||
@@ -31,9 +31,14 @@ cliUtils.printArray = function(logFunction, rows) {
|
||||
const line = [];
|
||||
for (let col = 0; col < colWidths.length; col++) {
|
||||
const item = rows[row][col];
|
||||
const width = colWidths[col];
|
||||
const dir = colAligns[col] === ALIGN_LEFT ? stringPadding.RIGHT : stringPadding.LEFT;
|
||||
line.push(stringPadding(item, width, ' ', dir));
|
||||
const isLastCol = col === colWidths.length - 1;
|
||||
if (isLastCol) {
|
||||
line.push(item ? item.toString() : '');
|
||||
} else {
|
||||
const width = colWidths[col];
|
||||
const dir = colAligns[col] === ALIGN_LEFT ? stringPadding.RIGHT : stringPadding.LEFT;
|
||||
line.push(stringPadding(item, width, ' ', dir));
|
||||
}
|
||||
}
|
||||
logFunction(line.join(' '));
|
||||
}
|
||||
|
||||
@@ -45,6 +45,10 @@ describe('HtmlToMd', () => {
|
||||
htmlToMdOptions.preserveColorStyles = true;
|
||||
}
|
||||
|
||||
if (htmlFilename.indexOf('table_with') === 0 || htmlFilename.indexOf('table_default') === 0) {
|
||||
htmlToMdOptions.preserveTableStyles = true;
|
||||
}
|
||||
|
||||
const html = await readFile(htmlPath, 'utf8');
|
||||
let expectedMd = await readFile(mdPath, 'utf8');
|
||||
|
||||
@@ -96,4 +100,34 @@ describe('HtmlToMd', () => {
|
||||
expect(htmlToMd.parse('> 1 _2_ 3.pdf', { disableEscapeContent: false })).toBe('\\> 1 \\_2_ 3.pdf');
|
||||
});
|
||||
|
||||
it('should support tightLists option', async () => {
|
||||
const htmlToMd = new HtmlToMd();
|
||||
const html = '<ul><li><p><strong>Item 1</strong></p></li><li><p><strong>Item 2</strong></p></li><li><p><strong>Item 3</strong></p></li></ul>';
|
||||
|
||||
// Without tightLists, paragraphs inside list items produce extra blank lines
|
||||
const looseResult = htmlToMd.parse(html, { tightLists: false });
|
||||
expect(looseResult).toContain('\n \n');
|
||||
|
||||
// With tightLists, list items are compact without blank lines
|
||||
const tightResult = htmlToMd.parse(html, { tightLists: true });
|
||||
expect(tightResult).toBe('- **Item 1**\n- **Item 2**\n- **Item 3**');
|
||||
});
|
||||
|
||||
it('should support collapseMultipleBlankLines option', async () => {
|
||||
const htmlToMd = new HtmlToMd();
|
||||
const html = '<p>First</p><br><br><br><p>Second</p>';
|
||||
|
||||
// Without collapseMultipleBlankLines, multiple blank lines are preserved
|
||||
const looseResult = htmlToMd.parse(html, { collapseMultipleBlankLines: false });
|
||||
expect(looseResult).toContain('\n\n \n');
|
||||
|
||||
// With collapseMultipleBlankLines, multiple blank lines are collapsed into one
|
||||
const collapsedResult = htmlToMd.parse(html, { collapseMultipleBlankLines: true });
|
||||
expect(collapsedResult).not.toContain('\n\n\n');
|
||||
expect(collapsedResult).not.toContain('\n\n \n');
|
||||
|
||||
// Verify that a single blank line is preserved (not fully removed)
|
||||
expect(collapsedResult).toContain('\n\n');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
7
packages/app-cli/tests/enex_to_md/list_with_br.html
Normal file
7
packages/app-cli/tests/enex_to_md/list_with_br.html
Normal file
@@ -0,0 +1,7 @@
|
||||
<ul>
|
||||
<li>First line<br/>Second line</li>
|
||||
<li>Normal item</li>
|
||||
<li>With sub-list<ul>
|
||||
<li>Sub-list<br/>Paragraph<br/>Also another line</li>
|
||||
</ul></li>
|
||||
</ul>
|
||||
8
packages/app-cli/tests/enex_to_md/list_with_br.md
Normal file
8
packages/app-cli/tests/enex_to_md/list_with_br.md
Normal file
@@ -0,0 +1,8 @@
|
||||
- First line
|
||||
Second line
|
||||
|
||||
- Normal item
|
||||
- With sub-list
|
||||
- Sub-list
|
||||
Paragraph
|
||||
Also another line
|
||||
@@ -0,0 +1,18 @@
|
||||
<table style="border-collapse: collapse; width: 100%;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 50%;">Name</th>
|
||||
<th style="width: 50%;">Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width: 50%;">Cell A</td>
|
||||
<td style="width: 50%;">Cell B</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="width: 50%;">Cell C</td>
|
||||
<td style="width: 50%;">Cell D</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -0,0 +1,4 @@
|
||||
| Name | Value |
|
||||
| --- | --- |
|
||||
| Cell A | Cell B |
|
||||
| Cell C | Cell D |
|
||||
@@ -0,0 +1,18 @@
|
||||
<table bgcolor="#f0f0f0" cellpadding="8">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Cell A</td>
|
||||
<td>Cell B</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Cell C</td>
|
||||
<td>Cell D</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -0,0 +1 @@
|
||||
<div class="joplin-table-wrapper"><table bgcolor="#f0f0f0" cellpadding="8"><thead><tr><th>Name</th><th>Value</th></tr></thead><tbody><tr><td>Cell A</td><td>Cell B</td></tr><tr><td>Cell C</td><td>Cell D</td></tr></tbody></table></div>
|
||||
@@ -0,0 +1,18 @@
|
||||
<table style="border-collapse: collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="background-color: #e03e2d">Red cell</td>
|
||||
<td style="padding: 10px 15px">Padded cell</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="border-color: #2dc26b; border-style: solid">Green border</td>
|
||||
<td>Normal cell</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -0,0 +1 @@
|
||||
<div class="joplin-table-wrapper"><table style="border-collapse: collapse"><thead><tr><th>Name</th><th>Value</th></tr></thead><tbody><tr><td style="background-color: #e03e2d">Red cell</td><td style="padding: 10px 15px">Padded cell</td></tr><tr><td style="border-color: #2dc26b; border-style: solid">Green border</td><td>Normal cell</td></tr></tbody></table></div>
|
||||
@@ -10,6 +10,7 @@ import Folder from '@joplin/lib/models/Folder';
|
||||
import { expectNotThrow, setupDatabaseAndSynchronizer, switchClient, expectThrow, createTempDir, supportDir, mockMobilePlatform } from '@joplin/lib/testing/test-utils';
|
||||
import { newPluginScript } from '../../testUtils';
|
||||
import { join } from 'path';
|
||||
import { PluginManifest } from '@joplin/lib/services/plugins/utils/types';
|
||||
|
||||
const testPluginDir = `${supportDir}/plugins`;
|
||||
|
||||
@@ -472,4 +473,18 @@ describe('services_PluginService', () => {
|
||||
await fs.remove(testDir);
|
||||
}
|
||||
});
|
||||
|
||||
it('should report a missing app_min_version field specifically', () => {
|
||||
const service = newPluginService();
|
||||
const manifest = {
|
||||
manifest_version: 1,
|
||||
id: 'test.plugin',
|
||||
name: 'Test Plugin',
|
||||
version: '1.0.0',
|
||||
// Missing app_min_version
|
||||
};
|
||||
|
||||
const error = service.describeIncompatibility(manifest as unknown as PluginManifest);
|
||||
expect(error).toContain('Invalid plugin manifest: Missing required field: app_min_version');
|
||||
});
|
||||
});
|
||||
|
||||
BIN
packages/app-cli/tests/support/onenote/truncated.zip
Normal file
BIN
packages/app-cli/tests/support/onenote/truncated.zip
Normal file
Binary file not shown.
@@ -6,7 +6,7 @@ const shim: typeof ShimType = require('@joplin/lib/shim').default;
|
||||
import { isCallbackUrl } from '@joplin/lib/callbackUrlUtils';
|
||||
import { FileLocker } from '@joplin/utils/fs';
|
||||
import { IpcMessageHandler, IpcServer, Message, newHttpError, sendMessage, SendMessageOptions, startServer, stopServer } from '@joplin/utils/ipc';
|
||||
import { BrowserWindow, Tray, WebContents, screen, App, nativeTheme } from 'electron';
|
||||
import { BrowserWindow, Tray, WebContents, screen, App, nativeTheme, powerMonitor } from 'electron';
|
||||
import bridge from './bridge';
|
||||
import * as url from 'url';
|
||||
const path = require('path');
|
||||
@@ -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) {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -709,6 +709,7 @@ function useMenu(props: Props) {
|
||||
menuItemDic.textCut,
|
||||
menuItemDic.textPaste,
|
||||
menuItemDic.pasteAsText,
|
||||
menuItemDic.pasteAsMarkdown,
|
||||
menuItemDic.textSelectAll,
|
||||
separator(),
|
||||
menuItemDic.globalUndo,
|
||||
|
||||
@@ -6,12 +6,18 @@ describe('useContextMenu', () => {
|
||||
|
||||
it('should return type=image when cursor is inside markdown image', () => {
|
||||
const line = ``;
|
||||
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 = ` [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', () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -221,7 +221,14 @@ const translateLE_ = (codeMirror: any, percent: number, l2e: boolean) => {
|
||||
linInterp = percent * lineCount - lineU;
|
||||
result = ePercentU + (ePercentL - ePercentU) * linInterp;
|
||||
} else {
|
||||
linInterp = Math.max(0, Math.min(1, (percent - ePercentU) / (ePercentL - ePercentU))) || 0;
|
||||
const rawLinInterp = (percent - ePercentU) / (ePercentL - ePercentU);
|
||||
if (ePercentL === ePercentU) {
|
||||
// Prevents the Viewer from jumping to the bottom of
|
||||
// the document when there is division by zero.
|
||||
linInterp = percent;
|
||||
} else {
|
||||
linInterp = Math.max(0, Math.min(1, rawLinInterp)) || 0;
|
||||
}
|
||||
result = (lineU + linInterp) / lineCount;
|
||||
}
|
||||
return Math.max(0, Math.min(1, result));
|
||||
|
||||
@@ -338,7 +338,7 @@ function CodeMirror(props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
|
||||
}, [editorPasteText, onEditorPaste]);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const loadScript = async (script: any) => {
|
||||
const loadScript = async (script: any, document: Document) => {
|
||||
return new Promise((resolve) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
let element: any = document.createElement('script');
|
||||
@@ -367,6 +367,7 @@ function CodeMirror(props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!editorRoot) return () => { };
|
||||
let cancelled = false;
|
||||
|
||||
async function loadScripts() {
|
||||
@@ -393,13 +394,14 @@ function CodeMirror(props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
|
||||
});
|
||||
}
|
||||
|
||||
const ownerDoc = editorRoot.ownerDocument;
|
||||
for (const s of scriptsToLoad) {
|
||||
if (document.getElementById(s.id)) {
|
||||
if (ownerDoc.getElementById(s.id)) {
|
||||
s.loaded = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
await loadScript(s);
|
||||
await loadScript(s, ownerDoc);
|
||||
if (cancelled) return;
|
||||
|
||||
s.loaded = true;
|
||||
@@ -411,7 +413,7 @@ function CodeMirror(props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [styles.editor.codeMirrorTheme]);
|
||||
}, [styles.editor.codeMirrorTheme, editorRoot]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editorRoot) return () => {};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { EditorKeymap, EditorLanguageType, EditorSettings, EditorTheme } from '@joplin/editor/types';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import { MarkupLanguage, MarkupToHtml } from '@joplin/renderer';
|
||||
import { useMemo } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { AppState } from '../../../../../../app.reducer';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
|
||||
interface EditorSettingsProps {
|
||||
contentMarkupLanguage: MarkupLanguage;
|
||||
keyboardMode: string;
|
||||
disabled: boolean;
|
||||
tabMovesFocus: boolean;
|
||||
baseTheme: EditorTheme;
|
||||
}
|
||||
|
||||
const useEditorSettings = (props: EditorSettingsProps) => {
|
||||
const stateToSettings = (state: AppState) => ({
|
||||
markdownMark: state.settings['markdown.plugin.mark'],
|
||||
markdownInsert: state.settings['markdown.plugin.insert'],
|
||||
katex: state.settings['markdown.plugin.katex'],
|
||||
inlineRendering: state.settings['editor.inlineRendering'],
|
||||
imageRendering: state.settings['editor.imageRendering'],
|
||||
highlightActiveLine: state.settings['editor.highlightActiveLine'],
|
||||
monospaceFont: state.settings['style.editor.monospaceFontFamily'],
|
||||
automatchBraces: state.settings['editor.autoMatchingBraces'],
|
||||
autocompleteMarkup: state.settings['editor.autocompleteMarkup'],
|
||||
spellcheckEnabled: state.settings['editor.spellcheckBeta'],
|
||||
});
|
||||
type SelectedSettings = ReturnType<typeof stateToSettings>;
|
||||
const settings = useSelector<AppState, SelectedSettings>(stateToSettings, isDeepStrictEqual);
|
||||
|
||||
return useMemo((): EditorSettings => {
|
||||
const isHTMLNote = props.contentMarkupLanguage === MarkupToHtml.MARKUP_LANGUAGE_HTML;
|
||||
|
||||
let keyboardMode = EditorKeymap.Default;
|
||||
if (props.keyboardMode === 'vim') {
|
||||
keyboardMode = EditorKeymap.Vim;
|
||||
} else if (props.keyboardMode === 'emacs') {
|
||||
keyboardMode = EditorKeymap.Emacs;
|
||||
}
|
||||
|
||||
return {
|
||||
language: isHTMLNote ? EditorLanguageType.Html : EditorLanguageType.Markdown,
|
||||
readOnly: props.disabled,
|
||||
markdownMarkEnabled: settings.markdownMark,
|
||||
markdownInsertEnabled: settings.markdownInsert,
|
||||
katexEnabled: settings.katex,
|
||||
inlineRenderingEnabled: settings.inlineRendering,
|
||||
imageRenderingEnabled: settings.imageRendering,
|
||||
highlightActiveLine: settings.highlightActiveLine,
|
||||
themeData: {
|
||||
...props.baseTheme,
|
||||
marginLeft: 0,
|
||||
marginRight: 0,
|
||||
monospaceFont: settings.monospaceFont,
|
||||
},
|
||||
automatchBraces: settings.automatchBraces,
|
||||
autocompleteMarkup: settings.autocompleteMarkup,
|
||||
useExternalSearch: false,
|
||||
ignoreModifiers: true,
|
||||
spellcheckEnabled: settings.spellcheckEnabled,
|
||||
keymap: keyboardMode,
|
||||
preferMacShortcuts: shim.isMac(),
|
||||
indentWithTabs: true,
|
||||
tabMovesFocus: props.tabMovesFocus,
|
||||
editorLabel: _('Markdown editor'),
|
||||
};
|
||||
}, [
|
||||
props.contentMarkupLanguage, props.disabled, props.keyboardMode, props.baseTheme,
|
||||
props.tabMovesFocus, settings,
|
||||
]);
|
||||
};
|
||||
|
||||
export default useEditorSettings;
|
||||
@@ -705,6 +705,15 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const containerWindow = editorContainerDom.defaultView as any;
|
||||
const isDefaultEnglishLocale = ['en_US', 'en_GB'].includes(language);
|
||||
|
||||
if (!isDefaultEnglishLocale) {
|
||||
await loadScript({
|
||||
id: `tinyMceLang_${language}`,
|
||||
src: `${bridge().vendorDir()}/lib/tinymce/langs/${language}.js`,
|
||||
}, editorContainerDom);
|
||||
}
|
||||
|
||||
const editors = await containerWindow.tinymce.init({
|
||||
selector: `#${editorContainer.id}`,
|
||||
|
||||
@@ -735,7 +744,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
|
||||
// Handle the first table row as table header.
|
||||
// https://www.tiny.cloud/docs/plugins/table/#table_header_type
|
||||
table_header_type: 'sectionCells',
|
||||
language_url: ['en_US', 'en_GB'].includes(language) ? undefined : `${bridge().vendorDir()}/lib/tinymce/langs/${language}`,
|
||||
language: isDefaultEnglishLocale ? undefined : language,
|
||||
toolbar: toolbar.join(' '),
|
||||
localization_function: _,
|
||||
// See https://www.tiny.cloud/docs/tinymce/latest/tinymce-and-csp/#content_security_policy
|
||||
@@ -887,6 +896,30 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
|
||||
editor.addShortcut('Meta+Shift+8', '', () => editor.execCommand('InsertUnorderedList'));
|
||||
editor.addShortcut('Meta+Shift+9', '', () => editor.execCommand('InsertJoplinChecklist'));
|
||||
|
||||
// Override ScrollIntoView to scroll to the cursor's character position
|
||||
// instead of the start of the paragraph.
|
||||
// See: https://github.com/laurent22/joplin/issues/14143
|
||||
editor.on('ScrollIntoView', (event) => {
|
||||
const sel = editor.getDoc().getSelection();
|
||||
if (!sel || sel.rangeCount === 0) return;
|
||||
|
||||
const rect = sel.getRangeAt(0).getBoundingClientRect();
|
||||
const win = editor.getWin();
|
||||
const viewHeight = win.innerHeight;
|
||||
|
||||
if (rect.top < 0) {
|
||||
win.scrollBy(0, rect.top);
|
||||
} else if (rect.bottom > viewHeight) {
|
||||
win.scrollBy(0, rect.bottom - viewHeight);
|
||||
} else if (rect.top === 0 && rect.height === 0) {
|
||||
// Handles edge case where rect is not rendered
|
||||
// See: https://stackoverflow.com/a/14384220/5757550
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
return;
|
||||
});
|
||||
|
||||
// TODO: remove event on unmount?
|
||||
editor.on('drop', (event) => {
|
||||
// Prevent the message "Dropped file type is not supported" from showing up.
|
||||
@@ -1326,13 +1359,35 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const onSetAttrib = (event: EditorEvent<any>) => {
|
||||
// Dispatch onChange when a link is edited
|
||||
// Dispatch onChange when a link or table-related formatting is edited
|
||||
const target = Array.isArray(event.attrElm) ? event.attrElm[0] : event.attrElm;
|
||||
if (!target) return;
|
||||
|
||||
if (target.nodeName === 'A') {
|
||||
if (event.attrName === 'title' || event.attrName === 'href' || event.attrName === 'rel') {
|
||||
onChangeHandler();
|
||||
}
|
||||
}
|
||||
|
||||
if (['TABLE', 'TR', 'TD', 'TH'].includes(target.nodeName)) {
|
||||
const attributeName = (event.attrName ?? '').toLowerCase();
|
||||
if (
|
||||
attributeName === 'style' ||
|
||||
attributeName === 'class' ||
|
||||
attributeName === 'bgcolor' ||
|
||||
attributeName === 'bordercolor' ||
|
||||
attributeName === 'background' ||
|
||||
attributeName === 'cellpadding' ||
|
||||
attributeName === 'cellspacing'
|
||||
) {
|
||||
onChangeHandler();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Table plugin fires this on structure/style changes from dialogs.
|
||||
const onTableModified = () => {
|
||||
onChangeHandler();
|
||||
};
|
||||
|
||||
// Keypress means that a printable key (letter, digit, etc.) has been
|
||||
@@ -1481,6 +1536,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
|
||||
editor.on(TinyMceEditorEvents.Redo, onChangeHandler);
|
||||
editor.on(TinyMceEditorEvents.ExecCommand, onExecCommand);
|
||||
editor.on(TinyMceEditorEvents.SetAttrib, onSetAttrib);
|
||||
editor.on('TableModified', onTableModified);
|
||||
|
||||
return () => {
|
||||
try {
|
||||
@@ -1497,6 +1553,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
|
||||
editor.off(TinyMceEditorEvents.Redo, onChangeHandler);
|
||||
editor.off(TinyMceEditorEvents.ExecCommand, onExecCommand);
|
||||
editor.off(TinyMceEditorEvents.SetAttrib, onSetAttrib);
|
||||
editor.off('TableModified', onTableModified);
|
||||
} catch (error) {
|
||||
console.warn('Error removing events', error);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as focusElementNoteBody from './focusElementNoteBody';
|
||||
import * as focusElementNoteTitle from './focusElementNoteTitle';
|
||||
import * as focusElementNoteViewer from './focusElementNoteViewer';
|
||||
import * as focusElementToolbar from './focusElementToolbar';
|
||||
import * as pasteAsMarkdown from './pasteAsMarkdown';
|
||||
import * as pasteAsText from './pasteAsText';
|
||||
import * as showLocalSearch from './showLocalSearch';
|
||||
import * as showRevisions from './showRevisions';
|
||||
@@ -12,6 +13,7 @@ const index: any[] = [
|
||||
focusElementNoteTitle,
|
||||
focusElementNoteViewer,
|
||||
focusElementToolbar,
|
||||
pasteAsMarkdown,
|
||||
pasteAsText,
|
||||
showLocalSearch,
|
||||
showRevisions,
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { CommandRuntime, CommandDeclaration } from '@joplin/lib/services/CommandService';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import HtmlToMd from '@joplin/lib/HtmlToMd';
|
||||
import { processImagesInPastedHtml } from '../utils/resourceHandling';
|
||||
|
||||
const { clipboard } = require('electron');
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'pasteAsMarkdown',
|
||||
label: () => _('Paste as Markdown'),
|
||||
};
|
||||
|
||||
let htmlToMd_: HtmlToMd | null = null;
|
||||
|
||||
const htmlToMd = () => {
|
||||
if (!htmlToMd_) {
|
||||
htmlToMd_ = new HtmlToMd();
|
||||
}
|
||||
return htmlToMd_;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Props passed from NoteEditor component
|
||||
export const runtime = (comp: any): CommandRuntime => {
|
||||
return {
|
||||
execute: async () => {
|
||||
let html = clipboard.readHTML();
|
||||
if (html) {
|
||||
// Download images and convert them to Joplin resources
|
||||
html = await processImagesInPastedHtml(html, { useInternalUrls: true });
|
||||
const markdown = htmlToMd().parse(html, { tightLists: true, collapseMultipleBlankLines: true });
|
||||
comp.editorRef.current.execCommand({ name: 'insertText', value: markdown });
|
||||
} else {
|
||||
// Fall back to plain text if no HTML is available
|
||||
const text = clipboard.readText();
|
||||
if (text) {
|
||||
comp.editorRef.current.execCommand({ name: 'insertText', value: text });
|
||||
}
|
||||
}
|
||||
},
|
||||
enabledCondition: 'oneNoteSelected && markdownEditorVisible',
|
||||
};
|
||||
};
|
||||
@@ -103,10 +103,17 @@ export function menuItems(dispatch: Function): ContextMenuItems {
|
||||
}
|
||||
},
|
||||
isActive: (itemType: ContextMenuItemType, options: ContextMenuOptions) => (
|
||||
(!options.textToCopy && (itemType === ContextMenuItemType.Image || itemType === ContextMenuItemType.Resource))
|
||||
(!options.textToCopy && (itemType === ContextMenuItemType.Image || itemType === ContextMenuItemType.Resource || itemType === ContextMenuItemType.NoteLink))
|
||||
|| (!!options.linkToOpen && itemType === ContextMenuItemType.Link)
|
||||
),
|
||||
},
|
||||
openNoteInNewWindow: {
|
||||
label: _('Open in new window'),
|
||||
onAction: async (options: ContextMenuOptions) => {
|
||||
await CommandService.instance().execute('openNoteInNewWindow', options.resourceId);
|
||||
},
|
||||
isActive: (itemType: ContextMenuItemType) => itemType === ContextMenuItemType.NoteLink,
|
||||
},
|
||||
saveAs: {
|
||||
label: _('Save as...'),
|
||||
onAction: async (options: ContextMenuOptions) => {
|
||||
|
||||
@@ -6,6 +6,8 @@ import { ContextMenuItemType, EditContextMenuFilterObject } from '@joplin/lib/se
|
||||
import eventManager from '@joplin/lib/eventManager';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import { type MenuItem as MenuItemType } from 'electron';
|
||||
import BaseItem from '@joplin/lib/models/BaseItem';
|
||||
import { ModelType } from '@joplin/lib/BaseModel';
|
||||
|
||||
const MenuItem = bridge().MenuItem;
|
||||
const logger = Logger.create('contextMenuUtils');
|
||||
@@ -13,6 +15,19 @@ const logger = Logger.create('contextMenuUtils');
|
||||
// Re-export for backward compatibility
|
||||
export { ContextMenuItemType };
|
||||
|
||||
// Resolves whether a resource-type item is actually a note link.
|
||||
// Falls back to Resource on error or if the item is not found.
|
||||
export const resolveContextMenuItemType = async (itemType: ContextMenuItemType, resourceId: string): Promise<ContextMenuItemType> => {
|
||||
if (itemType !== ContextMenuItemType.Resource || !resourceId) return itemType;
|
||||
try {
|
||||
const item = await BaseItem.loadItemById(resourceId);
|
||||
if (item?.type_ === ModelType.Note) return ContextMenuItemType.NoteLink;
|
||||
} catch (error) {
|
||||
logger.warn('resolveContextMenuItemType: failed to load item, defaulting to Resource', error);
|
||||
}
|
||||
return ContextMenuItemType.Resource;
|
||||
};
|
||||
|
||||
export interface ContextMenuOptions {
|
||||
itemType: ContextMenuItemType;
|
||||
resourceId: string;
|
||||
@@ -182,39 +197,48 @@ export const handleEditorContextMenuFilter = async (context?: EditorContextMenuF
|
||||
return output;
|
||||
};
|
||||
|
||||
export const buildMenuItems = async (items: ContextMenuItems, options: ContextMenuOptions) => {
|
||||
export interface BuildMenuItemsOptions {
|
||||
excludeEditItems?: boolean;
|
||||
excludePluginItems?: boolean;
|
||||
}
|
||||
|
||||
export const buildMenuItems = async (items: ContextMenuItems, options: ContextMenuOptions, buildOptions?: BuildMenuItemsOptions) => {
|
||||
const editItemKeys = ['cut', 'copy', 'paste', 'pasteAsText', 'separator4'];
|
||||
const activeItems: ContextMenuItem[] = [];
|
||||
for (const itemKey in items) {
|
||||
if (buildOptions?.excludeEditItems && editItemKeys.includes(itemKey)) continue;
|
||||
const item = items[itemKey];
|
||||
if (item.isActive(options.itemType, options)) {
|
||||
activeItems.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
const extraItems = await handleEditorContextMenuFilter({
|
||||
resourceId: options.resourceId,
|
||||
itemType: options.itemType,
|
||||
textToCopy: options.textToCopy,
|
||||
});
|
||||
|
||||
if (extraItems.length) {
|
||||
activeItems.push({
|
||||
isActive: () => true,
|
||||
label: '',
|
||||
onAction: () => {},
|
||||
isSeparator: true,
|
||||
if (!buildOptions?.excludePluginItems) {
|
||||
const extraItems = await handleEditorContextMenuFilter({
|
||||
resourceId: options.resourceId,
|
||||
itemType: options.itemType,
|
||||
textToCopy: options.textToCopy,
|
||||
});
|
||||
}
|
||||
|
||||
for (const [, extraItem] of extraItems.entries()) {
|
||||
activeItems.push({
|
||||
isActive: () => true,
|
||||
label: extraItem.label,
|
||||
onAction: () => {
|
||||
extraItem.click();
|
||||
},
|
||||
isSeparator: extraItem.type === 'separator',
|
||||
});
|
||||
if (extraItems.length) {
|
||||
activeItems.push({
|
||||
isActive: () => true,
|
||||
label: '',
|
||||
onAction: () => {},
|
||||
isSeparator: true,
|
||||
});
|
||||
}
|
||||
|
||||
for (const [, extraItem] of extraItems.entries()) {
|
||||
activeItems.push({
|
||||
isActive: () => true,
|
||||
label: extraItem.label,
|
||||
onAction: () => {
|
||||
extraItem.click();
|
||||
},
|
||||
isSeparator: extraItem.type === 'separator',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const filteredItems = filterSeparators(activeItems, item => item.isSeparator);
|
||||
|
||||
@@ -13,6 +13,7 @@ export async function htmlToMarkdown(markupLanguage: number, html: string, origi
|
||||
newBody = htmlToMd.parse(html, {
|
||||
preserveImageTagsWithSize: true,
|
||||
preserveNestedTables: true,
|
||||
preserveTableStyles: true,
|
||||
preserveColorStyles: true,
|
||||
...parseOptions,
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { processPastedHtml } from './resourceHandling';
|
||||
import { processImagesInPastedHtml, processPastedHtml } from './resourceHandling';
|
||||
import markupLanguageUtils from '@joplin/lib/markupLanguageUtils';
|
||||
import HtmlToMd from '@joplin/lib/HtmlToMd';
|
||||
import { HtmlToMarkdownHandler, MarkupToHtmlHandler } from './types';
|
||||
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
|
||||
|
||||
const createTestMarkupConverters = () => {
|
||||
const markupToHtml: MarkupToHtmlHandler = async (markupLanguage, markup, options) => {
|
||||
@@ -63,4 +64,69 @@ describe('resourceHandling', () => {
|
||||
const html = `<img src="file://${encodeURI(Setting.value('resourceDir'))}/resource.png" alt="test"/>`;
|
||||
expect(await processPastedHtml(html, htmlToMd, markupToHtml)).toBe(html);
|
||||
});
|
||||
|
||||
it('should normalize HTML-encoded newlines in image alt attributes', async () => {
|
||||
// Word encodes newlines in alt text as HTML entities. These must be
|
||||
// normalized to spaces before Turndown processes the HTML, otherwise
|
||||
// node.outerHTML (returned verbatim for images with width/height) embeds
|
||||
// literal newlines that break Markdown raw HTML block parsing.
|
||||
const resourceSrc = `file://${encodeURI(Setting.value('resourceDir'))}/resource.png`;
|
||||
const testCases: [string, string][] = [
|
||||
// HTML entity newlines (Word clipboard format: = LF)
|
||||
[
|
||||
`<img src="${resourceSrc}" alt="A screenshot AI-generated content."/>`,
|
||||
`<img src="${resourceSrc}" alt="A screenshot AI-generated content."/>`,
|
||||
],
|
||||
// Literal newlines in the raw HTML attribute value
|
||||
[
|
||||
`<img src="${resourceSrc}" alt="hello\nworld"/>`,
|
||||
`<img src="${resourceSrc}" alt="hello world"/>`,
|
||||
],
|
||||
];
|
||||
|
||||
for (const [html, expected] of testCases) {
|
||||
expect(await processPastedHtml(html, null, null)).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it('should render Word-pasted images with newlines in alt as img elements, not broken text', async () => {
|
||||
// When Word pastes an image with width/height attributes and in the alt,
|
||||
// Turndown returns node.outerHTML verbatim (preserveImageTagsWithSize=true).
|
||||
// Without normalization, literal newlines inside the Markdown raw HTML block
|
||||
// would terminate the block early, causing the <img> to render as plain text.
|
||||
const { markupToHtml, htmlToMd } = createTestMarkupConverters();
|
||||
const resourceSrc = `file://${encodeURI(Setting.value('resourceDir'))}/resource.png`;
|
||||
|
||||
const testCases = [
|
||||
// Word-style: width/height present, alt has entities
|
||||
`<img width="625" height="284" src="${resourceSrc}" alt="A screenshot AI-generated content."/>`,
|
||||
// Multiple consecutive newline entities collapsed to single space
|
||||
`<img width="100" height="100" src="${resourceSrc}" alt="line1 line2"/>`,
|
||||
];
|
||||
|
||||
for (const html of testCases) {
|
||||
const result = await processPastedHtml(html, htmlToMd, markupToHtml);
|
||||
// The image must be rendered as an <img> element, not as escaped/broken text
|
||||
expect(result).toContain('<img');
|
||||
// The alt text after normalization must not contain literal newlines
|
||||
expect(result).not.toMatch(/alt="[^"]*\n/);
|
||||
}
|
||||
});
|
||||
|
||||
// Regression test: base64 branch was hardcoding file:// and ignoring useInternalUrls
|
||||
// 1x1 transparent PNG — smallest valid base64-encoded image for testing
|
||||
const minimalPng = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
|
||||
|
||||
test.each([
|
||||
{ useInternalUrls: true, expectMatch: /src=":\/[a-f0-9]+"/, expectAbsent: 'file://' },
|
||||
{ useInternalUrls: false, expectMatch: /src="file:\/\//, expectAbsent: 'data:' },
|
||||
])('should convert base64 image using resourceUrl (useInternalUrls=$useInternalUrls)', async ({ useInternalUrls, expectMatch, expectAbsent }) => {
|
||||
await setupDatabaseAndSynchronizer(1);
|
||||
await switchClient(1);
|
||||
const html = `<img src="data:image/png;base64,${minimalPng}"/>`;
|
||||
const result = await processImagesInPastedHtml(html, { useInternalUrls });
|
||||
expect(result).toMatch(expectMatch);
|
||||
expect(result).not.toContain(expectAbsent);
|
||||
expect(result).not.toContain('data:');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import shim from '@joplin/lib/shim';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import Resource from '@joplin/lib/models/Resource';
|
||||
import { ResourceEntity } from '@joplin/lib/services/database/types';
|
||||
import ResourceFetcher from '@joplin/lib/services/ResourceFetcher';
|
||||
import htmlUtils from '@joplin/lib/htmlUtils';
|
||||
import rendererHtmlUtils, { extractHtmlBody, removeWrappingParagraphAndTrailingEmptyElements } from '@joplin/renderer/htmlUtils';
|
||||
@@ -120,10 +121,21 @@ export async function getResourcesFromPasteEvent(event: any) {
|
||||
}
|
||||
|
||||
|
||||
const processImagesInPastedHtml = async (html: string) => {
|
||||
export interface ProcessImagesOptions {
|
||||
// When true, returns Joplin internal URLs (:/resourceId) instead of file:// URLs
|
||||
useInternalUrls?: boolean;
|
||||
}
|
||||
|
||||
export const processImagesInPastedHtml = async (html: string, options: ProcessImagesOptions = {}) => {
|
||||
const allImageUrls: string[] = [];
|
||||
const mappedResources: Record<string, string> = {};
|
||||
|
||||
const resourceUrl = (resource: ResourceEntity) => {
|
||||
return options.useInternalUrls
|
||||
? Resource.internalUrl(resource)
|
||||
: `file://${encodeURI(Resource.fullPath(resource))}`;
|
||||
};
|
||||
|
||||
htmlUtils.replaceImageUrls(html, (src: string) => {
|
||||
allImageUrls.push(src);
|
||||
});
|
||||
@@ -138,7 +150,7 @@ const processImagesInPastedHtml = async (html: string) => {
|
||||
await shim.fetchBlob(imageSrc, { path: filePath });
|
||||
const createdResource = await shim.createResourceFromPath(filePath);
|
||||
await shim.fsDriver().remove(filePath);
|
||||
mappedResources[imageSrc] = `file://${encodeURI(Resource.fullPath(createdResource))}`;
|
||||
mappedResources[imageSrc] = resourceUrl(createdResource);
|
||||
} catch (error) {
|
||||
logger.warn(`Error creating a resource for ${imageSrc}.`, error);
|
||||
mappedResources[imageSrc] = imageSrc;
|
||||
@@ -155,14 +167,49 @@ const processImagesInPastedHtml = async (html: string) => {
|
||||
const imageFilePath = path.normalize(fileUriToPath(imageSrc));
|
||||
const resourceDirPath = path.normalize(Setting.value('resourceDir'));
|
||||
|
||||
if (imageFilePath.startsWith(resourceDirPath)) {
|
||||
mappedResources[imageSrc] = imageSrc;
|
||||
// Use path.relative for robust containment check - startsWith can falsely match sibling paths
|
||||
const rel = path.relative(resourceDirPath, imageFilePath);
|
||||
const isInsideResourceDir = rel && !rel.startsWith('..') && !path.isAbsolute(rel);
|
||||
if (isInsideResourceDir) {
|
||||
if (options.useInternalUrls) {
|
||||
const resourceId = Resource.pathToId(imageFilePath);
|
||||
mappedResources[imageSrc] = `:/${resourceId}`;
|
||||
} else {
|
||||
mappedResources[imageSrc] = imageSrc;
|
||||
}
|
||||
} else {
|
||||
const createdResource = await shim.createResourceFromPath(imageFilePath);
|
||||
mappedResources[imageSrc] = `file://${encodeURI(Resource.fullPath(createdResource))}`;
|
||||
mappedResources[imageSrc] = resourceUrl(createdResource);
|
||||
}
|
||||
} else if (imageSrc.startsWith('data:')) {
|
||||
mappedResources[imageSrc] = imageSrc;
|
||||
// Word encodes base64 with MIME line breaks every ~76 chars.
|
||||
// Strip whitespace before decoding, then save as a Joplin resource
|
||||
// so Turndown's outerHTML (used for images with width/height) gets
|
||||
// a short URL instead of 200KB of base64.
|
||||
const cleanSrc = imageSrc.replace(/\s/g, '');
|
||||
const dataUrlMatch = cleanSrc.match(/^data:([^;]+);base64,(.+)$/);
|
||||
if (dataUrlMatch) {
|
||||
const mimeType = dataUrlMatch[1];
|
||||
const base64Data = dataUrlMatch[2];
|
||||
const fileExt = mimeUtils.toFileExtension(mimeType) || 'bin';
|
||||
const filePath = `${Setting.value('tempDir')}/${md5(Date.now() + Math.random())}.${fileExt}`;
|
||||
try {
|
||||
await shim.fsDriver().writeFile(filePath, base64Data, 'base64');
|
||||
const createdResource = await shim.createResourceFromPath(filePath);
|
||||
mappedResources[imageSrc] = resourceUrl(createdResource);
|
||||
} catch (writeError) {
|
||||
writeError.message = `processPastedHtml: Failed to write or create resource from pasted image: ${writeError.message}`;
|
||||
throw writeError;
|
||||
} finally {
|
||||
try {
|
||||
await shim.fsDriver().remove(filePath);
|
||||
} catch (cleanupError) {
|
||||
logger.warn('processPastedHtml: Error removing temporary file.', cleanupError);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
mappedResources[imageSrc] = imageSrc;
|
||||
}
|
||||
} else {
|
||||
downloadImages.push(downloadImage(imageSrc));
|
||||
}
|
||||
@@ -188,6 +235,27 @@ export async function processPastedHtml(html: string, htmlToMd: HtmlToMarkdownHa
|
||||
|
||||
html = await processImagesInPastedHtml(html);
|
||||
|
||||
// Word encodes newlines in alt attributes as HTML entities ( 
 etc.).
|
||||
// These get decoded to literal newline characters by JSDOM when Turndown processes
|
||||
// the HTML. With preserveImageTagsWithSize=true, Turndown returns node.outerHTML
|
||||
// verbatim — embedding literal newlines inside an HTML attribute value, which
|
||||
// breaks the Markdown raw HTML block (a blank line ends the block, making the
|
||||
// parser treat the <img> as plain text). Normalize them to spaces here.
|
||||
html = html.replace(
|
||||
/(\balt\s*=\s*)(["'])([\s\S]*?)\2/gi,
|
||||
(_m, prefix, quote, altText) => {
|
||||
// Replace HTML-encoded newlines/control chars and literal ones with a space
|
||||
const normalized = altText
|
||||
.replace(/&#(?:10|13);|&#x(?:0*[aAdD]);/gi, ' ')
|
||||
// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional sanitisation of control chars
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/[\r\n\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ' ')
|
||||
.replace(/ {2,}/g, ' ')
|
||||
.trim();
|
||||
return `${prefix}${quote}${normalized}${quote}`;
|
||||
},
|
||||
);
|
||||
|
||||
// TinyMCE can accept any type of HTML, including HTML that may not be preserved once saved as
|
||||
// Markdown. For example the content may have a dark background which would be supported by
|
||||
// TinyMCE, but lost once the note is saved. So here we convert the HTML to Markdown then back
|
||||
|
||||
@@ -6,6 +6,7 @@ import PostMessageService from '@joplin/lib/services/PostMessageService';
|
||||
import ResourceFetcher from '@joplin/lib/services/ResourceFetcher';
|
||||
import { reg } from '@joplin/lib/registry';
|
||||
import bridge from '../../../services/bridge';
|
||||
import { resolveContextMenuItemType } from './contextMenuUtils';
|
||||
|
||||
export default function useMessageHandler(
|
||||
scrollWhenReadyRef: RefObject<ScrollOptions|null>,
|
||||
@@ -46,9 +47,11 @@ export default function useMessageHandler(
|
||||
if (s.length < 2) throw new Error(`Invalid message: ${msg}`);
|
||||
void ResourceFetcher.instance().markForDownload(s[1]);
|
||||
} else if (msg === 'contextMenu') {
|
||||
const resourceId = arg0.resourceId;
|
||||
const itemType = await resolveContextMenuItemType(arg0 && arg0.type, resourceId);
|
||||
const menu = await contextMenu({
|
||||
itemType: arg0 && arg0.type,
|
||||
resourceId: arg0.resourceId,
|
||||
itemType,
|
||||
resourceId: resourceId,
|
||||
filename: arg0.filename,
|
||||
mime: arg0.mime,
|
||||
linkToOpen: null,
|
||||
|
||||
@@ -13,6 +13,7 @@ const commandsWithDependencies = [
|
||||
require('../commands/focusElementNoteViewer'),
|
||||
require('../commands/focusElementToolbar'),
|
||||
require('../commands/pasteAsText'),
|
||||
require('../commands/pasteAsMarkdown'),
|
||||
];
|
||||
|
||||
type OnBodyChange = (event: OnChangeEvent)=> void;
|
||||
|
||||
@@ -30,6 +30,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,
|
||||
|
||||
106
packages/app-desktop/gui/NoteList/utils/UseAutoScroll.test.ts
Normal file
106
packages/app-desktop/gui/NoteList/utils/UseAutoScroll.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import useAutoScroll from './useAutoScroll';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
type Props = {
|
||||
selectedNoteId: string;
|
||||
selectedFolderId: string;
|
||||
targetIndex: number;
|
||||
makeItemIndexVisible: (index: number)=> void;
|
||||
};
|
||||
|
||||
describe('useAutoScroll', () => {
|
||||
|
||||
test('scrolls to the note when a new note is selected', () => {
|
||||
const makeItemIndexVisible = jest.fn();
|
||||
|
||||
renderHook(() => useAutoScroll('note-1', 'folder-1', 5, makeItemIndexVisible));
|
||||
|
||||
expect(makeItemIndexVisible).toHaveBeenCalledTimes(1);
|
||||
expect(makeItemIndexVisible).toHaveBeenCalledWith(5);
|
||||
});
|
||||
|
||||
test('does not scroll when the same note is already selected', () => {
|
||||
const makeItemIndexVisible = jest.fn();
|
||||
|
||||
const { rerender } = renderHook(() =>
|
||||
useAutoScroll('note-1', 'folder-1', 5, makeItemIndexVisible),
|
||||
);
|
||||
|
||||
makeItemIndexVisible.mockClear();
|
||||
rerender();
|
||||
|
||||
expect(makeItemIndexVisible).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('does not scroll for multi-selection or no selection', () => {
|
||||
const makeItemIndexVisible = jest.fn();
|
||||
|
||||
renderHook(() => useAutoScroll('', 'folder-1', -1, makeItemIndexVisible));
|
||||
|
||||
expect(makeItemIndexVisible).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('defers scroll until notes load after folder change', () => {
|
||||
const makeItemIndexVisible = jest.fn();
|
||||
|
||||
const { rerender } = renderHook(
|
||||
(props: Props) => useAutoScroll(
|
||||
props.selectedNoteId,
|
||||
props.selectedFolderId,
|
||||
props.targetIndex,
|
||||
props.makeItemIndexVisible,
|
||||
),
|
||||
{ initialProps: { selectedNoteId: 'note-1', selectedFolderId: 'folder-2', targetIndex: -1, makeItemIndexVisible } },
|
||||
);
|
||||
|
||||
expect(makeItemIndexVisible).not.toHaveBeenCalled();
|
||||
|
||||
rerender({ selectedNoteId: 'note-1', selectedFolderId: 'folder-2', targetIndex: 3, makeItemIndexVisible });
|
||||
|
||||
expect(makeItemIndexVisible).toHaveBeenCalledTimes(1);
|
||||
expect(makeItemIndexVisible).toHaveBeenCalledWith(3);
|
||||
});
|
||||
|
||||
test('scrolls again when the folder changes even if note ID is the same', () => {
|
||||
const makeItemIndexVisible = jest.fn();
|
||||
|
||||
const { rerender } = renderHook(
|
||||
(props: Props) => useAutoScroll(
|
||||
props.selectedNoteId,
|
||||
props.selectedFolderId,
|
||||
props.targetIndex,
|
||||
props.makeItemIndexVisible,
|
||||
),
|
||||
{ initialProps: { selectedNoteId: 'note-1', selectedFolderId: 'folder-1', targetIndex: 2, makeItemIndexVisible } },
|
||||
);
|
||||
|
||||
expect(makeItemIndexVisible).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender({ selectedNoteId: 'note-1', selectedFolderId: 'folder-2', targetIndex: 2, makeItemIndexVisible });
|
||||
|
||||
expect(makeItemIndexVisible).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('does not scroll again when targetIndex changes after the pending flag is cleared', () => {
|
||||
// Covers the case where a sort or filter changes targetIndex without a new selection.
|
||||
// Without this guard, arrow-key navigation would trigger a spurious second scroll.
|
||||
const makeItemIndexVisible = jest.fn();
|
||||
|
||||
const { rerender } = renderHook(
|
||||
(props: Props) => useAutoScroll(
|
||||
props.selectedNoteId,
|
||||
props.selectedFolderId,
|
||||
props.targetIndex,
|
||||
props.makeItemIndexVisible,
|
||||
),
|
||||
{ initialProps: { selectedNoteId: 'note-1', selectedFolderId: 'folder-1', targetIndex: 5, makeItemIndexVisible } },
|
||||
);
|
||||
|
||||
expect(makeItemIndexVisible).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender({ selectedNoteId: 'note-1', selectedFolderId: 'folder-1', targetIndex: 7, makeItemIndexVisible });
|
||||
|
||||
expect(makeItemIndexVisible).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
});
|
||||
43
packages/app-desktop/gui/NoteList/utils/useAutoScroll.ts
Normal file
43
packages/app-desktop/gui/NoteList/utils/useAutoScroll.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useRef, useEffect } from 'react';
|
||||
|
||||
// Auto-scrolls the note list to the selected note when selection changes. Uses a pending flag
|
||||
// to handle cross-folder navigation where notes may not be loaded on the first render.
|
||||
const useAutoScroll = (
|
||||
selectedNoteId: string,
|
||||
selectedFolderId: string,
|
||||
targetIndex: number,
|
||||
makeItemIndexVisible: (index: number)=> void,
|
||||
) => {
|
||||
const lastNoteIdRef = useRef('');
|
||||
const lastFolderIdRef = useRef('');
|
||||
const scrollPendingRef = useRef(false); // true when scroll requested but notes not yet loaded
|
||||
|
||||
useEffect(() => {
|
||||
// No selection or multi-selection — reset tracking state.
|
||||
if (!selectedNoteId) {
|
||||
lastNoteIdRef.current = '';
|
||||
lastFolderIdRef.current = selectedFolderId;
|
||||
scrollPendingRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const isNewNote = selectedNoteId !== lastNoteIdRef.current;
|
||||
const isFolderChange = selectedFolderId !== lastFolderIdRef.current;
|
||||
|
||||
if (isNewNote || isFolderChange) {
|
||||
lastNoteIdRef.current = selectedNoteId;
|
||||
lastFolderIdRef.current = selectedFolderId;
|
||||
scrollPendingRef.current = true;
|
||||
}
|
||||
|
||||
// targetIndex is -1 until the new folder's notes load — re-runs automatically when they do.
|
||||
if (!scrollPendingRef.current || targetIndex === -1) return;
|
||||
|
||||
// makeItemIndexVisible has its own visibility guard and is a no-op when the note is
|
||||
// already visible — this covers arrow-key and click navigation without double-scrolling.
|
||||
makeItemIndexVisible(targetIndex);
|
||||
scrollPendingRef.current = false;
|
||||
}, [selectedNoteId, selectedFolderId, targetIndex, makeItemIndexVisible]);
|
||||
};
|
||||
|
||||
export default useAutoScroll;
|
||||
@@ -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'],
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -81,6 +81,7 @@ export default function() {
|
||||
'switchProfile2',
|
||||
'switchProfile3',
|
||||
'pasteAsText',
|
||||
'pasteAsMarkdown',
|
||||
'showNoteProperties',
|
||||
'convertNoteToMarkdown',
|
||||
'toggleEditors',
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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()');
|
||||
@@ -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),
|
||||
];
|
||||
};
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.';
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -253,6 +253,7 @@ const NoteRevisionViewer: React.FC<Props> = props => {
|
||||
multiline={multiline}
|
||||
text={note?.title ?? ''}
|
||||
updateState={textWrapCalculator_updateState}
|
||||
readOnly={true}
|
||||
/>
|
||||
{
|
||||
multiline ?
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ const convertHtmlToMarkdown = (html: string|HTMLElement) => {
|
||||
codeBlockStyle: 'fenced',
|
||||
preserveImageTagsWithSize: true,
|
||||
preserveNestedTables: true,
|
||||
preserveTableStyles: true,
|
||||
preserveColorStyles: true,
|
||||
bulletListMarker: '-',
|
||||
emDelimiter: '*',
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 : [],
|
||||
],
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user