1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-08-27 20:29:45 +02:00

Compare commits

..

106 Commits

Author SHA1 Message Date
Laurent Cozic
f0496bd348 Android 3.1.3 2024-09-02 13:18:23 +01:00
Laurent Cozic
a746c02d4a Desktop release v3.1.6 2024-09-02 13:05:22 +01:00
Laurent Cozic
02def5900e lock file 2024-09-02 13:04:32 +01:00
Henry Heino
fd06c18cf0 Desktop: Windows portable: Fix keychain-backed storage incorrectly enabled (#10942) 2024-09-02 12:26:43 +01:00
Alice
6163364b26 Desktop: Seamless-Updates - added tests for autoUpdaterService (#10935)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2024-09-02 12:24:19 +01:00
renovate[bot]
7e9c7d7d23 Update dependency @types/node to v18.19.34 (#10966) 2024-09-02 02:37:00 +00:00
renovate[bot]
88d02b04dd Update dependency ldapts to v7.0.12 (#10951) 2024-09-01 20:33:07 +00:00
renovate[bot]
42e39b7789 Update dependency re-resizable to v6.9.17 (#10953) 2024-09-01 20:33:05 +00:00
renovate[bot]
9b3ce555cb Update dependency react-native-webview to v13.8.7 (#10956) 2024-09-01 20:33:04 +00:00
renovate[bot]
bae311b2a9 Update dependency sharp to v0.33.4 (#10958) 2024-09-01 20:32:56 +00:00
Laurent Cozic
f8a09e5540 Update renovate.json5 2024-09-01 21:14:35 +01:00
renovate[bot]
461ba1762a Update dependency sass to v1.76.0 (#10962)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-09-01 21:11:34 +01:00
renovate[bot]
82a4b8915b Update dependency async-mutex to v0.5.0 (#10961)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-09-01 21:11:21 +01:00
renovate[bot]
e8593e7ae5 Update dependency @react-native-clipboard/clipboard to v1.14.1 (#10960)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-09-01 21:11:13 +01:00
renovate[bot]
8f1c7bf61c Update dependency @playwright/test to v1.43.1 (#10959)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-09-01 21:11:04 +01:00
renovate[bot]
23c08f77e0 Update dependency react-native-safe-area-context to v4.10.3 (#10955)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-09-01 21:10:28 +01:00
renovate[bot]
e1fa7d0e83 Update dependency react-native-device-info to v10.13.2 (#10954)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-09-01 21:10:15 +01:00
renovate[bot]
fc3c59bad5 Update dependency lint-staged to v15.2.5 (#10952)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-09-01 21:10:01 +01:00
renovate[bot]
8588e36373 Update dependency koa to v2.15.3 (#10950)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-09-01 21:09:44 +01:00
renovate[bot]
1f43b199d5 Update dependency glob to v10.3.16 (#10949)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-09-01 21:09:37 +01:00
Joplin Bot
e2fd48eecd Doc: Auto-update documentation
Auto-updated using release-website.sh
2024-09-01 00:53:18 +00:00
renovate[bot]
d0e6368867 Update dependency @types/react to v18.3.3 (#10964) 2024-08-31 18:04:01 +00:00
renovate[bot]
4f98aad24f Update dependency dayjs to v1.11.11 (#10948)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-08-31 17:59:17 +00:00
renovate[bot]
fae313785d Update dependency react-native-zip-archive to v6.1.2 (#10957) 2024-08-31 15:29:19 +00:00
renovate[bot]
db5bbeecd4 Update react monorepo (#10963) 2024-08-31 15:28:44 +00:00
Henry Heino
d2b7d64f4f Desktop: Accessibility: Improve note list keyboard and screen reader accessibility (#10940) 2024-08-31 16:05:01 +01:00
Henry Heino
4f2d0c8e5d Desktop: Add left/right arrow keys as expand/collapse shortcuts for notebooks (#10944) 2024-08-31 15:44:12 +01:00
Alice
3bca12fd4a Desktop: Seamless-Updates: added latest-mac-arm64.yml (#10941) 2024-08-31 15:42:07 +01:00
Henry Heino
4ccc46e4db Docs: Fix typo: Change "Thee" to "The" (#10945) 2024-08-31 15:40:28 +01:00
renovate[bot]
5062cd1616 Update dependency @bam.tech/react-native-image-resizer to v3.0.10 (#10938) 2024-08-28 20:52:24 +00:00
renovate[bot]
1710155a6f Update dependency @types/jsdom to v21.1.7 (#10939) 2024-08-28 20:52:17 +00:00
Joplin Bot
4cb693b4d6 Doc: Auto-update documentation
Auto-updated using release-website.sh
2024-08-28 00:45:16 +00:00
Joplin Bot
e607a7376f Doc: Auto-update documentation
Auto-updated using release-website.sh
2024-08-27 18:20:33 +00:00
Laurent Cozic
d1403ba2cb Desktop release v3.1.4 2024-08-27 18:06:16 +01:00
Henry Heino
74be949d33 Desktop: Accessibility: Improve note title focus handling (#10932) 2024-08-27 18:05:48 +01:00
Alice
2afc2ca369 Desktop: Seamless-Updates: implemented flow for prereleases (#10892) 2024-08-27 18:04:18 +01:00
Laurent Cozic
b617a84696 All: Resolves #10407: Added feature flag to disable sync lock support (#10925) 2024-08-23 12:16:19 +01:00
Henry Heino
c691fedc12 Mac: Fixes #10921: Markdown editor: Fix custom arrow-key shortcuts fail to register (#10922) 2024-08-23 11:49:30 +01:00
Henry Heino
12a26023dd Chore: CI: Fix .eslintignore and .gitignore CI check passes when it should fail (#10923) 2024-08-23 11:49:17 +01:00
Laurent Cozic
700ffa2529 Desktop, Mobile, Cli: Make feature flags advanced settings by default 2024-08-23 11:39:40 +01:00
Laurent Cozic
5fdd088504 Desktop, Mobile: Fixed italic support in Fountain documents 2024-08-23 10:05:50 +01:00
Laurent Cozic
fe89b1cf21 Merge branch 'release-3.0' into dev 2024-08-23 09:52:04 +01:00
Henry Heino
3d1bbf3c4d Chore: Web: Fix build fails on Windows (#10919) 2024-08-22 21:53:52 +01:00
Henry Heino
580d2914e7 Mobile,Desktop: Fixes #10914: Fix BMP image rendering in the Markdown viewer (#10915) 2024-08-22 21:53:44 +01:00
Henry Heino
33599324d6 Desktop: Fixes #10737: Fix Fix editing notes in "Conflicts" causes them to temporarily vanish (#10913) 2024-08-22 21:53:39 +01:00
Henry Heino
02bdb7a79c Docs: Include dependency overview (#10911)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2024-08-22 21:53:27 +01:00
Henry Heino
a1c64eeefc Web: Fix renderer plugin assets fail to load on secondary profiles (#10903) 2024-08-22 21:52:42 +01:00
Henry Heino
78015028ec Desktop, Mobile: Fixes #10891: Markdown editor: Fix toggling bulleted lists when items start with asterisks (#10902) 2024-08-22 21:52:36 +01:00
Henry Heino
beff45e195 Desktop: Fix viewer and panel plugins that check for the presence of exports fail to load (#10900) 2024-08-22 21:52:29 +01:00
Henry Heino
6a0dd4e20d Desktop: Fixes #10895: Fix alt+up/alt+down fails to re-order multiple lines (#10899) 2024-08-22 21:52:22 +01:00
Henry Heino
ea420967c4 Chore: Remove recently-added .gitignored files (#10898) 2024-08-22 21:52:17 +01:00
Henry Heino
c19d0773b3 Mobile,Desktop,Cli: Fixes #10896: Fix "Enable auto-updates" enabled by default and visible on unsupported platforms (#10897) 2024-08-22 21:51:57 +01:00
Henry Heino
59a8c2115c Chore: Fix CI: Add Famegear to cspell dictionary (#10912) 2024-08-21 20:57:11 +01:00
Joplin Bot
88cae93fc4 Doc: Auto-update documentation
Auto-updated using release-website.sh
2024-08-21 18:20:16 +00:00
Laurent Cozic
4eb0fb542e Doc: Update sponsors 2024-08-21 18:26:58 +01:00
Henry Heino
60a1617b22 Chore: Fix compilation errors due to test resources with .ts extensions (#10909) 2024-08-21 18:24:33 +01:00
Henry Heino
e05d7197bd Docs: Improve explanation for using custom OCR language data (#10910) 2024-08-21 18:24:05 +01:00
Joplin Bot
9dc29d034b Doc: Auto-update documentation
Auto-updated using release-website.sh
2024-08-21 12:23:24 +00:00
renovate[bot]
32fe122e60 Update dependency @fortawesome/react-fontawesome to v0.2.2 (#10905) 2024-08-21 08:45:31 +00:00
renovate[bot]
8fcb377162 Update dependency @rollup/plugin-commonjs to v25.0.8 (#10906) 2024-08-21 08:45:24 +00:00
Laurent Cozic
598677b207 Desktop release v3.0.15 2024-08-21 09:36:44 +01:00
renovate[bot]
b3437fa4cd Update dependency @js-draw/material-icons to v1.20.2 (#10894) 2024-08-19 20:52:32 +00:00
Joplin Bot
0d82584777 Doc: Auto-update documentation
Auto-updated using release-website.sh
2024-08-18 12:23:44 +00:00
Laurent Cozic
e627fb7fad Doc: Update sponsors 2024-08-18 10:41:09 +01:00
Joplin Bot
b60acf038b Doc: Auto-update documentation
Auto-updated using release-website.sh
2024-08-17 18:18:50 +00:00
renovate[bot]
111820c0cf Update dependency @js-draw/material-icons to v1.20.1 (#10890) 2024-08-17 11:38:01 +00:00
Laurent Cozic
263a7ef076 Desktop release v3.1.3 2024-08-17 12:23:14 +01:00
Henry Heino
255d3e737d Chore: Windows: Fix building the desktop app clears the list of supported Rich Text Editor locales (#10885) 2024-08-17 12:22:52 +01:00
Henry Heino
9cb13af7b0 Chore: Windows: Fix mobile plugin assets change when rebuilding the mobile app (#10886) 2024-08-17 12:22:35 +01:00
Henry Heino
7bc81880e1 Windows: Fix handleCustomProtocols test (#10884) 2024-08-17 12:22:13 +01:00
Henry Heino
eb53c7e3b9 Windows: Fix PDF, video, and audio rendering (#10881) 2024-08-17 12:22:03 +01:00
Henry Heino
b94cf5a107 Desktop: Resolves #5762: Rich Text Editor: Add eight spaces when pressing tab (#10880) 2024-08-17 12:21:43 +01:00
Alice
72163018b4 Desktop: Seamless-Updates - added flow for automatic updates for releases (#10857)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2024-08-17 12:19:05 +01:00
github-actions[bot]
0cbf8d8ebc @maresmar has signed the CLA in laurent22/joplin#10882 2024-08-16 18:34:22 +00:00
Joplin Bot
bde14f9ddc Doc: Auto-update documentation
Auto-updated using release-website.sh
2024-08-16 12:23:54 +00:00
Henry Heino
b220413a9b Desktop: Security: Fix HTML parsing bug (#10876) 2024-08-15 19:37:58 +01:00
Henry Heino
72472623cc Merge remote-tracking branch 'origin/release-3.0' into dev 2024-08-15 08:34:56 -07:00
Laurent Cozic
7e599c3a3e Desktop release v3.1.2 2024-08-15 16:09:57 +01:00
Henry Heino
74ba386ae3 Docs: Mobile plugins: Document using the web app to run mobile plugins (#10831) 2024-08-15 16:09:11 +01:00
Henry Heino
06f05d3823 Docs: Add example transformers.js-based plugin (#10840) 2024-08-15 16:08:57 +01:00
Henry Heino
6ada0a0103 Docs: Explain purpose of main Cloud Teams account (#10841)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2024-08-15 16:08:43 +01:00
Henry Heino
39bd7d3aef Mobile: Fixes #10867: Fix toolbar overflow menu is invisible (#10871) 2024-08-15 16:02:05 +01:00
Henry Heino
b5313067cd Desktop: Allow searching when only the note viewer is visible and sync search with editor (#10866) 2024-08-15 16:01:52 +01:00
Henry Heino
1edef99811 Desktop: Upgrade electron to v29.4.5 (#10847)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2024-08-15 16:00:46 +01:00
Alice
6518c17679 Desktop: Seamless-Updates: generate latest-mac.yml file (#10869) 2024-08-15 15:59:18 +01:00
Laurent Cozic
3ef07ac39a Clipper release v3.1.1 2024-08-15 10:51:18 +01:00
Laurent Cozic
bfcf3815a4 Chore: Fixing clipper dependencies 2024-08-15 10:50:14 +01:00
renovate[bot]
292479ac28 Update dependency @fortawesome/react-fontawesome to v0.2.1 (#10875) 2024-08-15 05:35:10 +00:00
Laurent Cozic
786e58ed54 Chore: Remove jcenter() dependency, now discontinued (#10868) 2024-08-13 14:40:45 +01:00
Naoki Kawamukai
b88630855f All: Translation: Update ja_JP.po (#10864) 2024-08-12 16:40:07 -04:00
cedecode
b956a21dae All: Translation: Update de_DE.po (#10863) 2024-08-12 16:39:11 -04:00
Henry Heino
c4ebb64fb2 Chore: Mobile,Testing: Fix keychain warning (#10861) 2024-08-12 15:34:36 +01:00
renovate[bot]
fb181cf935 Update dependency @react-native-community/datetimepicker to v8.0.1 (#10862) 2024-08-12 02:56:05 +00:00
Joplin Bot
f6fdc9fe66 Doc: Auto-update documentation
Auto-updated using release-website.sh
2024-08-10 18:18:17 +00:00
Laurent Cozic
23f521de74 iOS 13.1.3 2024-08-10 14:08:45 +01:00
Joplin Bot
88ed7c934f Doc: Auto-update documentation
Auto-updated using release-website.sh
2024-08-10 12:21:37 +00:00
renovate[bot]
4c7a1f0f88 Update dependency @react-native-community/netinfo to v11.3.2 (#10855) 2024-08-10 12:05:01 +00:00
Laurent Cozic
98b7354373 iOS 13.1.2 2024-08-10 12:51:29 +01:00
Laurent Cozic
8d8cca06eb Android 3.1.2 2024-08-10 12:50:34 +01:00
Henry Heino
45b61e1f49 iOS: Fix crash on opening the sidebar (#10852) 2024-08-09 20:49:10 +01:00
Henry Heino
f58d29a6a6 Mobile: Fixes #10848: Fix WebDAV sync on mobile (#10849) 2024-08-09 20:49:01 +01:00
Joplin Bot
4caca4934e Doc: Auto-update documentation
Auto-updated using release-website.sh
2024-08-09 12:23:13 +00:00
Laurent Cozic
a8613d1444 lock file 2024-08-09 12:16:58 +01:00
Laurent Cozic
c6377eafbf iOS 13.1.1 2024-08-09 12:15:35 +01:00
pedr
a6cf0a3a81 Desktop: Fixes #10740: Improve the reliability of fetching resources (#10826) 2024-08-05 19:36:54 +01:00
Henry Heino
60e347a782 Android: Fix crash on opening settings on old versions of Android (#10793) 2024-07-29 22:18:16 +01:00
214 changed files with 79195 additions and 1744 deletions

View File

@@ -297,8 +297,10 @@ packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/shouldPasteResources.
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/shouldPasteResources.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/types.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useContextMenu.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useKeyboardRefocusHandler.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useLinkTooltips.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useScroll.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useTabIndenter.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useWebViewApi.js
packages/app-desktop/gui/NoteEditor/NoteEditor.js
packages/app-desktop/gui/NoteEditor/NoteTitle/NoteTitleBar.js
@@ -340,8 +342,10 @@ packages/app-desktop/gui/NoteList/commands/focusElementNoteList.js
packages/app-desktop/gui/NoteList/commands/index.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/useDragAndDrop.js
packages/app-desktop/gui/NoteList/utils/useFocusNote.js
packages/app-desktop/gui/NoteList/utils/useFocusVisible.js
packages/app-desktop/gui/NoteList/utils/useItemCss.js
packages/app-desktop/gui/NoteList/utils/useMoveNote.js
packages/app-desktop/gui/NoteList/utils/useOnKeyDown.js
@@ -362,6 +366,7 @@ packages/app-desktop/gui/NoteListHeader/utils/useContextMenu.js
packages/app-desktop/gui/NoteListHeader/utils/validateColumns.test.js
packages/app-desktop/gui/NoteListHeader/utils/validateColumns.js
packages/app-desktop/gui/NoteListItem/NoteListItem.js
packages/app-desktop/gui/NoteListItem/utils/getNoteElementIdFromJoplinId.js
packages/app-desktop/gui/NoteListItem/utils/getNoteTitleHtml.js
packages/app-desktop/gui/NoteListItem/utils/prepareViewProps.test.js
packages/app-desktop/gui/NoteListItem/utils/prepareViewProps.js
@@ -456,6 +461,7 @@ packages/app-desktop/gui/style/StyledLink.js
packages/app-desktop/gui/style/StyledMessage.js
packages/app-desktop/gui/style/StyledTextInput.js
packages/app-desktop/gui/utils/NoteListUtils.js
packages/app-desktop/gui/utils/announceForAccessibility.js
packages/app-desktop/gui/utils/convertToScreenCoordinates.js
packages/app-desktop/gui/utils/dragAndDrop.js
packages/app-desktop/gui/utils/loadScript.js
@@ -466,6 +472,7 @@ packages/app-desktop/integration-tests/markdownEditor.spec.js
packages/app-desktop/integration-tests/models/GoToAnything.js
packages/app-desktop/integration-tests/models/MainScreen.js
packages/app-desktop/integration-tests/models/NoteEditorScreen.js
packages/app-desktop/integration-tests/models/NoteList.js
packages/app-desktop/integration-tests/models/SettingsScreen.js
packages/app-desktop/integration-tests/models/Sidebar.js
packages/app-desktop/integration-tests/noteList.spec.js
@@ -475,6 +482,7 @@ packages/app-desktop/integration-tests/sidebar.spec.js
packages/app-desktop/integration-tests/simpleBackup.spec.js
packages/app-desktop/integration-tests/util/activateMainMenuItem.js
packages/app-desktop/integration-tests/util/createStartupArgs.js
packages/app-desktop/integration-tests/util/extendedExpect.js
packages/app-desktop/integration-tests/util/firstNonDevToolsWindow.js
packages/app-desktop/integration-tests/util/getImageSourceSize.js
packages/app-desktop/integration-tests/util/setFilePickerResponse.js
@@ -483,6 +491,7 @@ packages/app-desktop/integration-tests/util/test.js
packages/app-desktop/integration-tests/util/waitForNextOpenPath.js
packages/app-desktop/playwright.config.js
packages/app-desktop/plugins/GotoAnything.js
packages/app-desktop/services/autoUpdater/AutoUpdaterService.test.js
packages/app-desktop/services/autoUpdater/AutoUpdaterService.js
packages/app-desktop/services/bridge.js
packages/app-desktop/services/commands/stateToWhenClauseContext.js
@@ -796,8 +805,6 @@ packages/editor/CodeMirror/editorCommands/insertLineAfter.js
packages/editor/CodeMirror/editorCommands/sortSelectedLines.test.js
packages/editor/CodeMirror/editorCommands/sortSelectedLines.js
packages/editor/CodeMirror/editorCommands/supportsCommand.js
packages/editor/CodeMirror/editorCommands/swapLine.test.js
packages/editor/CodeMirror/editorCommands/swapLine.js
packages/editor/CodeMirror/getScrollFraction.js
packages/editor/CodeMirror/markdown/codeBlockLanguages/allLanguages.js
packages/editor/CodeMirror/markdown/codeBlockLanguages/defaultLanguage.js
@@ -841,9 +848,12 @@ packages/editor/CodeMirror/utils/formatting/toggleRegionFormatGlobally.test.js
packages/editor/CodeMirror/utils/formatting/toggleRegionFormatGlobally.js
packages/editor/CodeMirror/utils/formatting/toggleSelectedLinesStartWith.js
packages/editor/CodeMirror/utils/formatting/types.js
packages/editor/CodeMirror/utils/getSearchState.js
packages/editor/CodeMirror/utils/growSelectionToNode.js
packages/editor/CodeMirror/utils/handlePasteEvent.js
packages/editor/CodeMirror/utils/isCursorAtBeginning.js
packages/editor/CodeMirror/utils/isInSyntaxNode.js
packages/editor/CodeMirror/utils/searchExtension.js
packages/editor/CodeMirror/utils/setupVim.js
packages/editor/SelectionFormatting.js
packages/editor/events.js
@@ -1425,7 +1435,23 @@ packages/tools/generate-database-types.js
packages/tools/generate-images.js
packages/tools/git-changelog.test.js
packages/tools/git-changelog.js
packages/tools/licenseChecker.js
packages/tools/licenses/getLicenses.js
packages/tools/licenses/licenseChecker.js
packages/tools/licenses/licenseOverrides/fontAwesomeOverride/index.js
packages/tools/licenses/licenseOverrides/fontIsoOverride/index.js
packages/tools/licenses/licenseOverrides/index.js
packages/tools/licenses/licenseOverrides/materialCommunityIconsOverride/index.js
packages/tools/licenses/licenseOverrides/materialIconsOverride/index.js
packages/tools/licenses/licenseOverrides/types.js
packages/tools/licenses/licenseStatementBuilder.js
packages/tools/licenses/licenseText/agplv3.js
packages/tools/licenses/licenseText/apache2.js
packages/tools/licenses/licenseText/mit.js
packages/tools/licenses/utils/equalIgnoringSpacing.js
packages/tools/licenses/utils/matchApache2.test.js
packages/tools/licenses/utils/matchApache2.js
packages/tools/licenses/utils/matchMit.test.js
packages/tools/licenses/utils/matchMit.js
packages/tools/packageJsonLint.js
packages/tools/postPreReleasesToForum.js
packages/tools/release-android.js

View File

@@ -180,6 +180,10 @@ fi
if [ "$IS_LINUX" == "1" ]; then
echo "Step: Checking for files that should have been ignored..."
# .gitignore and .eslintignore can be modified during yarn install. Reset them
# so that checkIgnoredFiles works.
git restore .gitignore .eslintignore
node packages/tools/checkIgnoredFiles.js
testResult=$?
if [ $testResult -ne 0 ]; then

View File

@@ -62,8 +62,10 @@ jobs:
yarn install
cd packages/app-desktop
npm pkg set 'build.mac.artifactName'='${productName}-${version}-${arch}.${ext}'
npm pkg set 'build.mac.target.target'='dmg'
npm pkg set 'build.mac.target.arch[0]'='arm64'
npm pkg set 'build.mac.target[0].target'='dmg'
npm pkg set 'build.mac.target[0].arch[0]'='arm64'
npm pkg set 'build.mac.target[1].target'='zip'
npm pkg set 'build.mac.target[1].arch[0]'='arm64'
if [[ $GIT_TAG_NAME = v* ]]; then
echo "Building and publishing desktop application..."
@@ -81,4 +83,4 @@ jobs:
npm pkg set 'build.mac.identity'=null --json
PYTHON_PATH=$(which python) USE_HARD_LINKS=false yarn dist --mac --arm64 --publish=never
fi
fi

32
.gitignore vendored
View File

@@ -274,8 +274,10 @@ packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/shouldPasteResources.
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/shouldPasteResources.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/types.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useContextMenu.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useKeyboardRefocusHandler.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useLinkTooltips.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useScroll.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useTabIndenter.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useWebViewApi.js
packages/app-desktop/gui/NoteEditor/NoteEditor.js
packages/app-desktop/gui/NoteEditor/NoteTitle/NoteTitleBar.js
@@ -317,8 +319,10 @@ packages/app-desktop/gui/NoteList/commands/focusElementNoteList.js
packages/app-desktop/gui/NoteList/commands/index.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/useDragAndDrop.js
packages/app-desktop/gui/NoteList/utils/useFocusNote.js
packages/app-desktop/gui/NoteList/utils/useFocusVisible.js
packages/app-desktop/gui/NoteList/utils/useItemCss.js
packages/app-desktop/gui/NoteList/utils/useMoveNote.js
packages/app-desktop/gui/NoteList/utils/useOnKeyDown.js
@@ -339,6 +343,7 @@ packages/app-desktop/gui/NoteListHeader/utils/useContextMenu.js
packages/app-desktop/gui/NoteListHeader/utils/validateColumns.test.js
packages/app-desktop/gui/NoteListHeader/utils/validateColumns.js
packages/app-desktop/gui/NoteListItem/NoteListItem.js
packages/app-desktop/gui/NoteListItem/utils/getNoteElementIdFromJoplinId.js
packages/app-desktop/gui/NoteListItem/utils/getNoteTitleHtml.js
packages/app-desktop/gui/NoteListItem/utils/prepareViewProps.test.js
packages/app-desktop/gui/NoteListItem/utils/prepareViewProps.js
@@ -433,6 +438,7 @@ packages/app-desktop/gui/style/StyledLink.js
packages/app-desktop/gui/style/StyledMessage.js
packages/app-desktop/gui/style/StyledTextInput.js
packages/app-desktop/gui/utils/NoteListUtils.js
packages/app-desktop/gui/utils/announceForAccessibility.js
packages/app-desktop/gui/utils/convertToScreenCoordinates.js
packages/app-desktop/gui/utils/dragAndDrop.js
packages/app-desktop/gui/utils/loadScript.js
@@ -443,6 +449,7 @@ packages/app-desktop/integration-tests/markdownEditor.spec.js
packages/app-desktop/integration-tests/models/GoToAnything.js
packages/app-desktop/integration-tests/models/MainScreen.js
packages/app-desktop/integration-tests/models/NoteEditorScreen.js
packages/app-desktop/integration-tests/models/NoteList.js
packages/app-desktop/integration-tests/models/SettingsScreen.js
packages/app-desktop/integration-tests/models/Sidebar.js
packages/app-desktop/integration-tests/noteList.spec.js
@@ -452,6 +459,7 @@ packages/app-desktop/integration-tests/sidebar.spec.js
packages/app-desktop/integration-tests/simpleBackup.spec.js
packages/app-desktop/integration-tests/util/activateMainMenuItem.js
packages/app-desktop/integration-tests/util/createStartupArgs.js
packages/app-desktop/integration-tests/util/extendedExpect.js
packages/app-desktop/integration-tests/util/firstNonDevToolsWindow.js
packages/app-desktop/integration-tests/util/getImageSourceSize.js
packages/app-desktop/integration-tests/util/setFilePickerResponse.js
@@ -460,6 +468,7 @@ packages/app-desktop/integration-tests/util/test.js
packages/app-desktop/integration-tests/util/waitForNextOpenPath.js
packages/app-desktop/playwright.config.js
packages/app-desktop/plugins/GotoAnything.js
packages/app-desktop/services/autoUpdater/AutoUpdaterService.test.js
packages/app-desktop/services/autoUpdater/AutoUpdaterService.js
packages/app-desktop/services/bridge.js
packages/app-desktop/services/commands/stateToWhenClauseContext.js
@@ -773,8 +782,6 @@ packages/editor/CodeMirror/editorCommands/insertLineAfter.js
packages/editor/CodeMirror/editorCommands/sortSelectedLines.test.js
packages/editor/CodeMirror/editorCommands/sortSelectedLines.js
packages/editor/CodeMirror/editorCommands/supportsCommand.js
packages/editor/CodeMirror/editorCommands/swapLine.test.js
packages/editor/CodeMirror/editorCommands/swapLine.js
packages/editor/CodeMirror/getScrollFraction.js
packages/editor/CodeMirror/markdown/codeBlockLanguages/allLanguages.js
packages/editor/CodeMirror/markdown/codeBlockLanguages/defaultLanguage.js
@@ -818,9 +825,12 @@ packages/editor/CodeMirror/utils/formatting/toggleRegionFormatGlobally.test.js
packages/editor/CodeMirror/utils/formatting/toggleRegionFormatGlobally.js
packages/editor/CodeMirror/utils/formatting/toggleSelectedLinesStartWith.js
packages/editor/CodeMirror/utils/formatting/types.js
packages/editor/CodeMirror/utils/getSearchState.js
packages/editor/CodeMirror/utils/growSelectionToNode.js
packages/editor/CodeMirror/utils/handlePasteEvent.js
packages/editor/CodeMirror/utils/isCursorAtBeginning.js
packages/editor/CodeMirror/utils/isInSyntaxNode.js
packages/editor/CodeMirror/utils/searchExtension.js
packages/editor/CodeMirror/utils/setupVim.js
packages/editor/SelectionFormatting.js
packages/editor/events.js
@@ -1402,7 +1412,23 @@ packages/tools/generate-database-types.js
packages/tools/generate-images.js
packages/tools/git-changelog.test.js
packages/tools/git-changelog.js
packages/tools/licenseChecker.js
packages/tools/licenses/getLicenses.js
packages/tools/licenses/licenseChecker.js
packages/tools/licenses/licenseOverrides/fontAwesomeOverride/index.js
packages/tools/licenses/licenseOverrides/fontIsoOverride/index.js
packages/tools/licenses/licenseOverrides/index.js
packages/tools/licenses/licenseOverrides/materialCommunityIconsOverride/index.js
packages/tools/licenses/licenseOverrides/materialIconsOverride/index.js
packages/tools/licenses/licenseOverrides/types.js
packages/tools/licenses/licenseStatementBuilder.js
packages/tools/licenses/licenseText/agplv3.js
packages/tools/licenses/licenseText/apache2.js
packages/tools/licenses/licenseText/mit.js
packages/tools/licenses/utils/equalIgnoringSpacing.js
packages/tools/licenses/utils/matchApache2.test.js
packages/tools/licenses/utils/matchApache2.js
packages/tools/licenses/utils/matchMit.test.js
packages/tools/licenses/utils/matchMit.js
packages/tools/packageJsonLint.js
packages/tools/postPreReleasesToForum.js
packages/tools/release-android.js

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -31,7 +31,7 @@ Please see the [donation page](https://github.com/laurent22/joplin/blob/dev/read
# Sponsors
<!-- SPONSORS-ORG -->
<a href="https://seirei.ne.jp"><img title="Serei Network" width="256" src="https://joplinapp.org/images/sponsors/SeireiNetwork.png"/></a> <a href="https://www.hosting.de/nextcloud/?mtm_campaign=managed-nextcloud&amp;mtm_kwd=joplinapp&amp;mtm_source=joplinapp-webseite&amp;mtm_medium=banner"><img title="Hosting.de" width="256" src="https://joplinapp.org/images/sponsors/HostingDe.png"/></a> <a href="https://grundstueckspreise.info/"><img title="SP Software GmbH" width="256" src="https://joplinapp.org/images/sponsors/Grundstueckspreise.png"/></a> <a href="https://citricsheep.com"><img title="Citric Sheep" width="256" src="https://joplinapp.org/images/sponsors/CitricSheep.png"/></a> <a href="https://sorted.travel/?utm_source=joplinapp"><img title="Sorted Travel" width="256" src="https://joplinapp.org/images/sponsors/SortedTravel.png"/></a> <a href="https://celebian.com"><img title="Celebian" width="256" src="https://joplinapp.org/images/sponsors/Celebian.png"/></a> <a href="https://bestkru.com"><img title="BestKru" width="256" src="https://joplinapp.org/images/sponsors/BestKru.png"/></a> <a href="https://www.socialfollowers.uk/buy-tiktok-followers/"><img title="Social Followers" width="256" src="https://joplinapp.org/images/sponsors/SocialFollowers.png"/></a> <a href="https://stormlikes.com/"><img title="Stormlikes" width="256" src="https://joplinapp.org/images/sponsors/Stormlikes.png"/></a> <a href="https://route4me.com"><img title="Route4Me" width="256" src="https://joplinapp.org/images/sponsors/Route4Me.png"/></a> <a href="https://buyyoutubviews.com"><img title="BYTV" width="256" src="https://joplinapp.org/images/sponsors/BYTV.png"/></a>
<a href="https://seirei.ne.jp"><img title="Serei Network" width="256" src="https://joplinapp.org/images/sponsors/SeireiNetwork.png"/></a> <a href="https://www.hosting.de/nextcloud/?mtm_campaign=managed-nextcloud&amp;mtm_kwd=joplinapp&amp;mtm_source=joplinapp-webseite&amp;mtm_medium=banner"><img title="Hosting.de" width="256" src="https://joplinapp.org/images/sponsors/HostingDe.png"/></a> <a href="https://citricsheep.com"><img title="Citric Sheep" width="256" src="https://joplinapp.org/images/sponsors/CitricSheep.png"/></a> <a href="https://sorted.travel/?utm_source=joplinapp"><img title="Sorted Travel" width="256" src="https://joplinapp.org/images/sponsors/SortedTravel.png"/></a> <a href="https://celebian.com"><img title="Celebian" width="256" src="https://joplinapp.org/images/sponsors/Celebian.png"/></a> <a href="https://bestkru.com"><img title="BestKru" width="256" src="https://joplinapp.org/images/sponsors/BestKru.png"/></a> <a href="https://www.socialfollowers.uk/buy-tiktok-followers/"><img title="Social Followers" width="256" src="https://joplinapp.org/images/sponsors/SocialFollowers.png"/></a> <a href="https://stormlikes.com/"><img title="Stormlikes" width="256" src="https://joplinapp.org/images/sponsors/Stormlikes.png"/></a> <a href="https://route4me.com"><img title="Route4Me" width="256" src="https://joplinapp.org/images/sponsors/Route4Me.png"/></a> <a href="https://buyyoutubviews.com"><img title="BYTV" width="256" src="https://joplinapp.org/images/sponsors/BYTV.png"/></a> <a href="https://www.famegear.com"><img title="Famegear" width="256" src="https://joplinapp.org/images/sponsors/Famegear.png"/></a>
<!-- SPONSORS-ORG -->
* * *

View File

@@ -61,6 +61,7 @@
"/packages/turndown/config",
"/readme/_i18n",
"/readme/about/changelog/desktop.md",
"/readme/licenses.md",
"/readme/i18n",
"cspell.json",
"node_modules"

View File

@@ -83,11 +83,11 @@
"eslint-plugin-react": "7.33.2",
"execa": "5.1.1",
"fs-extra": "11.2.0",
"glob": "10.3.12",
"glob": "10.3.16",
"gulp": "4.0.2",
"husky": "3.1.0",
"lerna": "3.22.1",
"lint-staged": "15.2.2",
"lint-staged": "15.2.5",
"madge": "6.1.0",
"npm-package-json-lint": "7.1.0",
"typescript": "5.2.2"

View File

@@ -57,7 +57,7 @@
"proper-lockfile": "4.1.2",
"read-chunk": "2.1.0",
"server-destroy": "1.0.1",
"sharp": "0.33.3",
"sharp": "0.33.4",
"sprintf-js": "1.1.3",
"sqlite3": "5.1.6",
"string-padding": "1.0.2",
@@ -73,7 +73,7 @@
"@joplin/tools": "~3.1",
"@types/fs-extra": "11.0.4",
"@types/jest": "29.5.8",
"@types/node": "18.19.33",
"@types/node": "18.19.34",
"@types/proper-lockfile": "^4.1.2",
"gulp": "4.0.2",
"jest": "29.7.0",

View File

@@ -0,0 +1,2 @@
<div class="jop-noMdConv">
&lt;.a

View File

@@ -0,0 +1,2 @@
<div>
<.a<iframe src="http://example.com/" >

View File

@@ -0,0 +1,3 @@
dist/
node_modules/
publish/

View File

@@ -0,0 +1,8 @@
*.md
!README.md
/*.jpl
/api
/src
/dist
tsconfig.json
webpack.config.js

View File

@@ -0,0 +1,78 @@
# Plugin development
This documentation describes how to create a plugin, and how to work with the plugin builder framework and API.
## Installation
First, install [Yeoman](http://yeoman.io) and generator-joplin using [npm](https://www.npmjs.com/) (we assume you have pre-installed [node.js](https://nodejs.org/)).
```bash
npm install -g yo
npm install -g generator-joplin
```
Then generate your new project:
```bash
yo --node-package-manager npm joplin
```
## Structure
The main two files you will want to look at are:
- `/src/index.ts`, which contains the entry point for the plugin source code.
- `/src/manifest.json`, which is the plugin manifest. It contains information such as the plugin a name, version, etc.
The file `/plugin.config.json` could also be useful if you intend to use [external scripts](#external-script-files), such as content scripts or webview scripts.
## Building the plugin
The plugin is built using Webpack, which creates the compiled code in `/dist`. A JPL archive will also be created at the root, which can use to distribute the plugin.
To build the plugin, simply run `npm run dist`.
The project is setup to use TypeScript, although you can change the configuration to use plain JavaScript.
## Updating the manifest version number
You can run `npm run updateVersion` to bump the patch part of the version number, so for example 1.0.3 will become 1.0.4. This script will update both the package.json and manifest.json version numbers so as to keep them in sync.
## Publishing the plugin
To publish the plugin, add it to npmjs.com by running `npm publish`. Later on, a script will pick up your plugin and add it automatically to the Joplin plugin repository as long as the package satisfies these conditions:
- In `package.json`, the name starts with "joplin-plugin-". For example, "joplin-plugin-toc".
- In `package.json`, the keywords include "joplin-plugin".
- In the `publish/` directory, there should be a .jpl and .json file (which are built by `npm run dist`)
In general all this is done automatically by the plugin generator, which will set the name and keywords of package.json, and will put the right files in the "publish" directory. But if something doesn't work and your plugin doesn't appear in the repository, double-check the above conditions.
## Updating the plugin framework
To update the plugin framework, run `npm run update`.
In general this command tries to do the right thing - in particular it's going to merge the changes in package.json and .gitignore instead of overwriting. It will also leave "/src" as well as README.md untouched.
The file that may cause problem is "webpack.config.js" because it's going to be overwritten. For that reason, if you want to change it, consider creating a separate JavaScript file and include it in webpack.config.js. That way, when you update, you only have to restore the line that include your file.
## External script files
By default, the compiler (webpack) is going to compile `src/index.ts` only (as well as any file it imports), and any other file will simply be copied to the plugin package. In some cases this is sufficient, however if you have [content scripts](https://joplinapp.org/api/references/plugin_api/classes/joplincontentscripts.html) or [webview scripts](https://joplinapp.org/api/references/plugin_api/classes/joplinviewspanels.html#addscript) you might want to compile them too, in particular in these two cases:
- The script is a TypeScript file - in which case it has to be compiled to JavaScript.
- The script requires modules you've added to package.json. In that case, the script, whether JS or TS, must be compiled so that the dependencies are bundled with the JPL file.
To get such an external script file to compile, you need to add it to the `extraScripts` array in `plugin.config.json`. The path you add should be relative to /src. For example, if you have a file in "/src/webviews/index.ts", the path should be set to "webviews/index.ts". Once compiled, the file will always be named with a .js extension. So you will get "webviews/index.js" in the plugin package, and that's the path you should use to reference the file.
## More information
- [Joplin Plugin API](https://joplinapp.org/api/references/plugin_api/classes/joplin.html)
- [Joplin Data API](https://joplinapp.org/help/api/references/rest_api)
- [Joplin Plugin Manifest](https://joplinapp.org/api/references/plugin_manifest/)
- Ask for help on the [forum](https://discourse.joplinapp.org/) or our [Discord channel](https://discord.gg/VSj7AFHvpq)
## License
MIT © Laurent Cozic

View File

@@ -0,0 +1,10 @@
# Worker example
This plugin demonstrates how to create a `Worker` and, within it, use `transformers.js` for text classification.
At present, this plugin only works on desktop. However, it should also be possible to load the `Worker` from a plugin panel or dialog on mobile (but not web).
**Notes**:
- `plugin.config.json` was updated to target **web**. This allows the plugin to load `transformers.js` from the worker without depending on `node-loader`.
- This simplifies the build process and should make it easier to add mobile and web support to this plugin.
- WASM files and other assets are copied by `tools/copyAssets.js`.

View File

@@ -0,0 +1,14 @@
import Plugin from '../Plugin';
import Joplin from './Joplin';
/**
* @ignore
*/
/**
* @ignore
*/
export default class Global {
private joplin_;
constructor(implementation: any, plugin: Plugin, store: any);
get joplin(): Joplin;
get process(): any;
}

View File

@@ -0,0 +1,74 @@
import Plugin from '../Plugin';
import JoplinData from './JoplinData';
import JoplinPlugins from './JoplinPlugins';
import JoplinWorkspace from './JoplinWorkspace';
import JoplinFilters from './JoplinFilters';
import JoplinCommands from './JoplinCommands';
import JoplinViews from './JoplinViews';
import JoplinInterop from './JoplinInterop';
import JoplinSettings from './JoplinSettings';
import JoplinContentScripts from './JoplinContentScripts';
import JoplinClipboard from './JoplinClipboard';
import JoplinWindow from './JoplinWindow';
import BasePlatformImplementation from '../BasePlatformImplementation';
import JoplinImaging from './JoplinImaging';
/**
* This is the main entry point to the Joplin API. You can access various services using the provided accessors.
*
* The API is now relatively stable and in general maintaining backward compatibility is a top priority, so you shouldn't except much breakages.
*
* If a breaking change ever becomes needed, best effort will be done to:
*
* - Deprecate features instead of removing them, so as to give you time to fix the issue;
* - Document breaking changes in the changelog;
*
* So if you are developing a plugin, please keep an eye on the changelog as everything will be in there with information about how to update your code.
*/
export default class Joplin {
private data_;
private plugins_;
private imaging_;
private workspace_;
private filters_;
private commands_;
private views_;
private interop_;
private settings_;
private contentScripts_;
private clipboard_;
private window_;
private implementation_;
constructor(implementation: BasePlatformImplementation, plugin: Plugin, store: any);
get data(): JoplinData;
get clipboard(): JoplinClipboard;
get imaging(): JoplinImaging;
get window(): JoplinWindow;
get plugins(): JoplinPlugins;
get workspace(): JoplinWorkspace;
get contentScripts(): JoplinContentScripts;
/**
* @ignore
*
* Not sure if it's the best way to hook into the app
* so for now disable filters.
*/
get filters(): JoplinFilters;
get commands(): JoplinCommands;
get views(): JoplinViews;
get interop(): JoplinInterop;
get settings(): JoplinSettings;
/**
* It is not possible to bundle native packages with a plugin, because they
* need to work cross-platforms. Instead access to certain useful native
* packages is provided using this function.
*
* Currently these packages are available:
*
* - [sqlite3](https://www.npmjs.com/package/sqlite3)
* - [fs-extra](https://www.npmjs.com/package/fs-extra)
*
* [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/nativeModule)
*/
require(_path: string): any;
versionInfo(): Promise<import("./types").VersionInfo>;
}

View File

@@ -0,0 +1,23 @@
export default class JoplinClipboard {
private electronClipboard_;
private electronNativeImage_;
constructor(electronClipboard: any, electronNativeImage: any);
readText(): Promise<string>;
writeText(text: string): Promise<void>;
readHtml(): Promise<string>;
writeHtml(html: string): Promise<void>;
/**
* Returns the image in [data URL](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs) format.
*/
readImage(): Promise<string>;
/**
* Takes an image in [data URL](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs) format.
*/
writeImage(dataUrl: string): Promise<void>;
/**
* Returns the list available formats (mime types).
*
* For example [ 'text/plain', 'text/html' ]
*/
availableFormats(): Promise<string[]>;
}

View File

@@ -0,0 +1,89 @@
import { Command } from './types';
/**
* This class allows executing or registering new Joplin commands. Commands
* can be executed or associated with
* {@link JoplinViewsToolbarButtons | toolbar buttons} or
* {@link JoplinViewsMenuItems | menu items}.
*
* [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/register_command)
*
* ## Executing Joplin's internal commands
*
* It is also possible to execute internal Joplin's commands which, as of
* now, are not well documented. You can find the list directly on GitHub
* though at the following locations:
*
* * [Main screen commands](https://github.com/laurent22/joplin/tree/dev/packages/app-desktop/gui/MainScreen/commands)
* * [Global commands](https://github.com/laurent22/joplin/tree/dev/packages/app-desktop/commands)
* * [Editor commands](https://github.com/laurent22/joplin/tree/dev/packages/app-desktop/gui/NoteEditor/editorCommandDeclarations.ts)
*
* To view what arguments are supported, you can open any of these files
* and look at the `execute()` command.
*
* ## Executing editor commands
*
* There might be a situation where you want to invoke editor commands
* without using a {@link JoplinContentScripts | contentScript}. For this
* reason Joplin provides the built in `editor.execCommand` command.
*
* `editor.execCommand` should work with any core command in both the
* [CodeMirror](https://codemirror.net/doc/manual.html#execCommand) and
* [TinyMCE](https://www.tiny.cloud/docs/api/tinymce/tinymce.editorcommands/#execcommand) editors,
* as well as most functions calls directly on a CodeMirror editor object (extensions).
*
* * [CodeMirror commands](https://codemirror.net/doc/manual.html#commands)
* * [TinyMCE core editor commands](https://www.tiny.cloud/docs/advanced/editor-command-identifiers/#coreeditorcommands)
*
* `editor.execCommand` supports adding arguments for the commands.
*
* ```typescript
* await joplin.commands.execute('editor.execCommand', {
* name: 'madeUpCommand', // CodeMirror and TinyMCE
* args: [], // CodeMirror and TinyMCE
* ui: false, // TinyMCE only
* value: '', // TinyMCE only
* });
* ```
*
* [View the example using the CodeMirror editor](https://github.com/laurent22/joplin/blob/dev/packages/app-cli/tests/support/plugins/codemirror_content_script/src/index.ts)
*
*/
export default class JoplinCommands {
/**
* <span class="platform-desktop">desktop</span> Executes the given
* command.
*
* The command can take any number of arguments, and the supported
* arguments will vary based on the command. For custom commands, this
* is the `args` passed to the `execute()` function. For built-in
* commands, you can find the supported arguments by checking the links
* above.
*
* ```typescript
* // Create a new note in the current notebook:
* await joplin.commands.execute('newNote');
*
* // Create a new sub-notebook under the provided notebook
* // Note: internally, notebooks are called "folders".
* await joplin.commands.execute('newFolder', "SOME_FOLDER_ID");
* ```
*/
execute(commandName: string, ...args: any[]): Promise<any | void>;
/**
* <span class="platform-desktop">desktop</span> Registers a new command.
*
* ```typescript
* // Register a new commmand called "testCommand1"
*
* await joplin.commands.register({
* name: 'testCommand1',
* label: 'My Test Command 1',
* iconName: 'fas fa-music',
* execute: () => {
* alert('Testing plugin command 1');
* },
* });
* ```
*/
register(command: Command): Promise<void>;
}

View File

@@ -0,0 +1,40 @@
import Plugin from '../Plugin';
import { ContentScriptType } from './types';
export default class JoplinContentScripts {
private plugin;
constructor(plugin: Plugin);
/**
* Registers a new content script. Unlike regular plugin code, which runs in
* a separate process, content scripts run within the main process code and
* thus allow improved performances and more customisations in specific
* cases. It can be used for example to load a Markdown or editor plugin.
*
* Note that registering a content script in itself will do nothing - it
* will only be loaded in specific cases by the relevant app modules (eg.
* the Markdown renderer or the code editor). So it is not a way to inject
* and run arbitrary code in the app, which for safety and performance
* reasons is not supported.
*
* The plugin generator provides a way to build any content script you might
* want to package as well as its dependencies. See the [Plugin Generator
* doc](https://github.com/laurent22/joplin/blob/dev/packages/generator-joplin/README.md)
* for more information.
*
* * [View the renderer demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/content_script)
* * [View the editor demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/codemirror_content_script)
*
* See also the [postMessage demo](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/post_messages)
*
* @param type Defines how the script will be used. See the type definition for more information about each supported type.
* @param id A unique ID for the content script.
* @param scriptPath Must be a path relative to the plugin main script. For example, if your file content_script.js is next to your index.ts file, you would set `scriptPath` to `"./content_script.js`.
*/
register(type: ContentScriptType, id: string, scriptPath: string): Promise<void>;
/**
* Listens to a messages sent from the content script using postMessage().
* See {@link ContentScriptType} for more information as well as the
* [postMessage
* demo](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/post_messages)
*/
onMessage(contentScriptId: string, callback: any): Promise<void>;
}

View File

@@ -0,0 +1,73 @@
import { ModelType } from '../../../BaseModel';
import Plugin from '../Plugin';
import { Path } from './types';
/**
* This module provides access to the Joplin data API: https://joplinapp.org/help/api/references/rest_api
* This is the main way to retrieve data, such as notes, notebooks, tags, etc.
* or to update them or delete them.
*
* This is also what you would use to search notes, via the `search` endpoint.
*
* [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/simple)
*
* In general you would use the methods in this class as if you were using a REST API. There are four methods that map to GET, POST, PUT and DELETE calls.
* And each method takes these parameters:
*
* * `path`: This is an array that represents the path to the resource in the form `["resouceName", "resourceId", "resourceLink"]` (eg. ["tags", ":id", "notes"]). The "resources" segment is the name of the resources you want to access (eg. "notes", "folders", etc.). If not followed by anything, it will refer to all the resources in that collection. The optional "resourceId" points to a particular resources within the collection. Finally, an optional "link" can be present, which links the resource to a collection of resources. This can be used in the API for example to retrieve all the notes associated with a tag.
* * `query`: (Optional) The query parameters. In a URL, this is the part after the question mark "?". In this case, it should be an object with key/value pairs.
* * `data`: (Optional) Applies to PUT and POST calls only. The request body contains the data you want to create or modify, for example the content of a note or folder.
* * `files`: (Optional) Used to create new resources and associate them with files.
*
* Please refer to the [Joplin API documentation](https://joplinapp.org/help/api/references/rest_api) for complete details about each call. As the plugin runs within the Joplin application **you do not need an authorisation token** to use this API.
*
* For example:
*
* ```typescript
* // Get a note ID, title and body
* const noteId = 'some_note_id';
* const note = await joplin.data.get(['notes', noteId], { fields: ['id', 'title', 'body'] });
*
* // Get all folders
* const folders = await joplin.data.get(['folders']);
*
* // Set the note body
* await joplin.data.put(['notes', noteId], null, { body: "New note body" });
*
* // Create a new note under one of the folders
* await joplin.data.post(['notes'], null, { body: "my new note", title: "some title", parent_id: folders[0].id });
* ```
*/
export default class JoplinData {
private api_;
private pathSegmentRegex_;
private plugin;
constructor(plugin: Plugin);
private serializeApiBody;
private pathToString;
get(path: Path, query?: any): Promise<any>;
post(path: Path, query?: any, body?: any, files?: any[]): Promise<any>;
put(path: Path, query?: any, body?: any, files?: any[]): Promise<any>;
delete(path: Path, query?: any): Promise<any>;
itemType(itemId: string): Promise<ModelType>;
resourcePath(resourceId: string): Promise<string>;
/**
* Gets an item user data. User data are key/value pairs. The `key` can be any
* arbitrary string, while the `value` can be of any type supported by
* [JSON.stringify](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#description)
*
* User data is synchronised across devices, and each value wil be merged based on their timestamp:
*
* - If value is modified by client 1, then modified by client 2, it will take the value from client 2
* - If value is modified by client 1, then deleted by client 2, the value will be deleted after merge
* - If value is deleted by client 1, then updated by client 2, the value will be restored and set to the value from client 2 after merge
*/
userDataGet<T>(itemType: ModelType, itemId: string, key: string): Promise<T>;
/**
* Sets a note user data. See {@link JoplinData.userDataGet} for more details.
*/
userDataSet<T>(itemType: ModelType, itemId: string, key: string, value: T): Promise<void>;
/**
* Deletes a note user data. See {@link JoplinData.userDataGet} for more details.
*/
userDataDelete(itemType: ModelType, itemId: string, key: string): Promise<void>;
}

View File

@@ -0,0 +1,10 @@
/**
* @ignore
*
* Not sure if it's the best way to hook into the app
* so for now disable filters.
*/
export default class JoplinFilters {
on(name: string, callback: Function): Promise<void>;
off(name: string, callback: Function): Promise<void>;
}

View File

@@ -0,0 +1,61 @@
import { Rectangle } from './types';
export interface Implementation {
nativeImage: any;
}
export interface CreateFromBufferOptions {
width?: number;
height?: number;
scaleFactor?: number;
}
export interface ResizeOptions {
width?: number;
height?: number;
quality?: 'good' | 'better' | 'best';
}
export type Handle = string;
/**
* Provides imaging functions to resize or process images. You create an image
* using one of the `createFrom` functions, then use the other functions to
* process the image.
*
* Images are associated with a handle which is what will be available to the
* plugin. Once you are done with an image, free it using the `free()` function.
*
* [View the
* example](https://github.com/laurent22/joplin/blob/dev/packages/app-cli/tests/support/plugins/imaging/src/index.ts)
*
*/
export default class JoplinImaging {
private implementation_;
private images_;
constructor(implementation: Implementation);
private createImageHandle;
private imageByHandle;
private cacheImage;
createFromPath(filePath: string): Promise<Handle>;
createFromResource(resourceId: string): Promise<Handle>;
getSize(handle: Handle): Promise<any>;
resize(handle: Handle, options?: ResizeOptions): Promise<string>;
crop(handle: Handle, rectange: Rectangle): Promise<string>;
toPngFile(handle: Handle, filePath: string): Promise<void>;
/**
* Quality is between 0 and 100
*/
toJpgFile(handle: Handle, filePath: string, quality?: number): Promise<void>;
private tempFilePath;
/**
* Creates a new Joplin resource from the image data. The image will be
* first converted to a JPEG.
*/
toJpgResource(handle: Handle, resourceProps: any, quality?: number): Promise<import("../../database/types").ResourceEntity>;
/**
* Creates a new Joplin resource from the image data. The image will be
* first converted to a PNG.
*/
toPngResource(handle: Handle, resourceProps: any): Promise<import("../../database/types").ResourceEntity>;
/**
* Image data is not automatically deleted by Joplin so make sure you call
* this method on the handle once you are done.
*/
free(handle: Handle): Promise<void>;
}

View File

@@ -0,0 +1,17 @@
import { ExportModule, ImportModule } from './types';
/**
* Provides a way to create modules to import external data into Joplin or to export notes into any arbitrary format.
*
* [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/json_export)
*
* To implement an import or export module, you would simply define an object with various event handlers that are called
* by the application during the import/export process.
*
* See the documentation of the [[ExportModule]] and [[ImportModule]] for more information.
*
* You may also want to refer to the Joplin API documentation to see the list of properties for each item (note, notebook, etc.) - https://joplinapp.org/help/api/references/rest_api
*/
export default class JoplinInterop {
registerExportModule(module: ExportModule): Promise<void>;
registerImportModule(module: ImportModule): Promise<void>;
}

View File

@@ -0,0 +1,43 @@
import Plugin from '../Plugin';
import { ContentScriptType, Script } from './types';
/**
* This class provides access to plugin-related features.
*/
export default class JoplinPlugins {
private plugin;
constructor(plugin: Plugin);
/**
* Registers a new plugin. This is the entry point when creating a plugin. You should pass a simple object with an `onStart` method to it.
* That `onStart` method will be executed as soon as the plugin is loaded.
*
* ```typescript
* joplin.plugins.register({
* onStart: async function() {
* // Run your plugin code here
* }
* });
* ```
*/
register(script: Script): Promise<void>;
/**
* @deprecated Use joplin.contentScripts.register()
*/
registerContentScript(type: ContentScriptType, id: string, scriptPath: string): Promise<void>;
/**
* Gets the plugin own data directory path. Use this to store any
* plugin-related data. Unlike [[installationDir]], any data stored here
* will be persisted.
*/
dataDir(): Promise<string>;
/**
* Gets the plugin installation directory. This can be used to access any
* asset that was packaged with the plugin. This directory should be
* considered read-only because any data you store here might be deleted or
* re-created at any time. To store new persistent data, use [[dataDir]].
*/
installationDir(): Promise<string>;
/**
* @deprecated Use joplin.require()
*/
require(_path: string): any;
}

View File

@@ -0,0 +1,63 @@
import Plugin from '../Plugin';
import { SettingItem, SettingSection } from './types';
export interface ChangeEvent {
/**
* Setting keys that have been changed
*/
keys: string[];
}
export type ChangeHandler = (event: ChangeEvent) => void;
export declare const namespacedKey: (pluginId: string, key: string) => string;
/**
* This API allows registering new settings and setting sections, as well as getting and setting settings. Once a setting has been registered it will appear in the config screen and be editable by the user.
*
* Settings are essentially key/value pairs.
*
* Note: Currently this API does **not** provide access to Joplin's built-in settings. This is by design as plugins that modify user settings could give unexpected results
*
* [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/settings)
*/
export default class JoplinSettings {
private plugin_;
constructor(plugin: Plugin);
/**
* Registers new settings.
* Note that registering a setting item is dynamic and will be gone next time Joplin starts.
* What it means is that you need to register the setting every time the plugin starts (for example in the onStart event).
* The setting value however will be preserved from one launch to the next so there is no risk that it will be lost even if for some
* reason the plugin fails to start at some point.
*/
registerSettings(settings: Record<string, SettingItem>): Promise<void>;
/**
* @deprecated Use joplin.settings.registerSettings()
*
* Registers a new setting.
*/
registerSetting(key: string, settingItem: SettingItem): Promise<void>;
/**
* Registers a new setting section. Like for registerSetting, it is dynamic and needs to be done every time the plugin starts.
*/
registerSection(name: string, section: SettingSection): Promise<void>;
/**
* Gets a setting value (only applies to setting you registered from your plugin)
*/
value(key: string): Promise<any>;
/**
* Sets a setting value (only applies to setting you registered from your plugin)
*/
setValue(key: string, value: any): Promise<void>;
/**
* Gets a global setting value, including app-specific settings and those set by other plugins.
*
* The list of available settings is not documented yet, but can be found by looking at the source code:
*
* https://github.com/laurent22/joplin/blob/dev/packages/lib/models/Setting.ts#L142
*/
globalValue(key: string): Promise<any>;
/**
* Called when one or multiple settings of your plugin have been changed.
* - For performance reasons, this event is triggered with a delay.
* - You will only get events for your own plugin settings.
*/
onChange(handler: ChangeHandler): Promise<void>;
}

View File

@@ -0,0 +1,31 @@
import Plugin from '../Plugin';
import JoplinViewsDialogs from './JoplinViewsDialogs';
import JoplinViewsMenuItems from './JoplinViewsMenuItems';
import JoplinViewsMenus from './JoplinViewsMenus';
import JoplinViewsToolbarButtons from './JoplinViewsToolbarButtons';
import JoplinViewsPanels from './JoplinViewsPanels';
import JoplinViewsNoteList from './JoplinViewsNoteList';
/**
* This namespace provides access to view-related services.
*
* All view services provide a `create()` method which you would use to create the view object, whether it's a dialog, a toolbar button or a menu item.
* In some cases, the `create()` method will return a [[ViewHandle]], which you would use to act on the view, for example to set certain properties or call some methods.
*/
export default class JoplinViews {
private store;
private plugin;
private panels_;
private menuItems_;
private menus_;
private toolbarButtons_;
private dialogs_;
private noteList_;
private implementation_;
constructor(implementation: any, plugin: Plugin, store: any);
get dialogs(): JoplinViewsDialogs;
get panels(): JoplinViewsPanels;
get menuItems(): JoplinViewsMenuItems;
get menus(): JoplinViewsMenus;
get toolbarButtons(): JoplinViewsToolbarButtons;
get noteList(): JoplinViewsNoteList;
}

View File

@@ -0,0 +1,74 @@
import Plugin from '../Plugin';
import { ButtonSpec, ViewHandle, DialogResult } from './types';
/**
* Allows creating and managing dialogs. A dialog is modal window that
* contains a webview and a row of buttons. You can update the
* webview using the `setHtml` method. Dialogs are hidden by default and
* you need to call `open()` to open them. Once the user clicks on a
* button, the `open` call will return an object indicating what button was
* clicked on.
*
* ## Retrieving form values
*
* If your HTML content included one or more forms, a `formData` object
* will also be included with the key/value for each form.
*
* ## Special button IDs
*
* The following buttons IDs have a special meaning:
*
* - `ok`, `yes`, `submit`, `confirm`: They are considered "submit" buttons
* - `cancel`, `no`, `reject`: They are considered "dismiss" buttons
*
* This information is used by the application to determine what action
* should be done when the user presses "Enter" or "Escape" within the
* dialog. If they press "Enter", the first "submit" button will be
* automatically clicked. If they press "Escape" the first "dismiss" button
* will be automatically clicked.
*
* [View the demo
* plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/dialog)
*/
export default class JoplinViewsDialogs {
private store;
private plugin;
private implementation_;
constructor(implementation: any, plugin: Plugin, store: any);
private controller;
/**
* Creates a new dialog
*/
create(id: string): Promise<ViewHandle>;
/**
* Displays a message box with OK/Cancel buttons. Returns the button index that was clicked - "0" for OK and "1" for "Cancel"
*/
showMessageBox(message: string): Promise<number>;
/**
* Displays a dialog to select a file or a directory. Same options and
* output as
* https://www.electronjs.org/docs/latest/api/dialog#dialogshowopendialogbrowserwindow-options
*/
showOpenDialog(options: any): Promise<any>;
/**
* Sets the dialog HTML content
*/
setHtml(handle: ViewHandle, html: string): Promise<string>;
/**
* Adds and loads a new JS or CSS files into the dialog.
*/
addScript(handle: ViewHandle, scriptPath: string): Promise<void>;
/**
* Sets the dialog buttons.
*/
setButtons(handle: ViewHandle, buttons: ButtonSpec[]): Promise<ButtonSpec[]>;
/**
* Opens the dialog
*/
open(handle: ViewHandle): Promise<DialogResult>;
/**
* Toggle on whether to fit the dialog size to the content or not.
* When set to false, the dialog is set to 90vw and 80vh
* @default true
*/
setFitToContent(handle: ViewHandle, status: boolean): Promise<boolean>;
}

View File

@@ -0,0 +1,16 @@
import { CreateMenuItemOptions, MenuItemLocation } from './types';
import Plugin from '../Plugin';
/**
* Allows creating and managing menu items.
*
* [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/register_command)
*/
export default class JoplinViewsMenuItems {
private store;
private plugin;
constructor(plugin: Plugin, store: any);
/**
* Creates a new menu item and associate it with the given command. You can specify under which menu the item should appear using the `location` parameter.
*/
create(id: string, commandName: string, location?: MenuItemLocation, options?: CreateMenuItemOptions): Promise<void>;
}

View File

@@ -0,0 +1,18 @@
import { MenuItem, MenuItemLocation } from './types';
import Plugin from '../Plugin';
/**
* Allows creating menus.
*
* [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/menu)
*/
export default class JoplinViewsMenus {
private store;
private plugin;
constructor(plugin: Plugin, store: any);
private registerCommandAccelerators;
/**
* Creates a new menu from the provided menu items and place it at the given location. As of now, it is only possible to place the
* menu as a sub-menu of the application build-in menus.
*/
create(id: string, label: string, menuItems: MenuItem[], location?: MenuItemLocation): Promise<void>;
}

View File

@@ -0,0 +1,24 @@
import { Store } from 'redux';
import Plugin from '../Plugin';
import { ListRenderer } from './noteListType';
/**
* This API allows you to customise how each note in the note list is rendered.
* The renderer you implement follows a unidirectional data flow.
*
* The app provides the required dependencies whenever a note is updated - you
* process these dependencies, and return some props, which are then passed to
* your template and rendered. See [[[ListRenderer]]] for a detailed description
* of each property of the renderer.
*
* [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/note_list_renderer)
*
* The default list renderer is implemented using the same API, so it worth checking it too:
*
* [Default list renderer](https://github.com/laurent22/joplin/tree/dev/packages/lib/services/noteList/defaultListRenderer.ts)
*/
export default class JoplinViewsNoteList {
private plugin_;
private store_;
constructor(plugin: Plugin, store: Store);
registerRenderer(renderer: ListRenderer): Promise<void>;
}

View File

@@ -0,0 +1,78 @@
import Plugin from '../Plugin';
import { ViewHandle } from './types';
/**
* Allows creating and managing view panels. View panels currently are
* displayed at the right of the sidebar and allows displaying any HTML
* content (within a webview) and update it in real-time. For example it
* could be used to display a table of content for the active note, or
* display various metadata or graph.
*
* [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/toc)
*/
export default class JoplinViewsPanels {
private store;
private plugin;
constructor(plugin: Plugin, store: any);
private controller;
/**
* Creates a new panel
*/
create(id: string): Promise<ViewHandle>;
/**
* Sets the panel webview HTML
*/
setHtml(handle: ViewHandle, html: string): Promise<string>;
/**
* Adds and loads a new JS or CSS files into the panel.
*/
addScript(handle: ViewHandle, scriptPath: string): Promise<void>;
/**
* Called when a message is sent from the webview (using postMessage).
*
* To post a message from the webview to the plugin use:
*
* ```javascript
* const response = await webviewApi.postMessage(message);
* ```
*
* - `message` can be any JavaScript object, string or number
* - `response` is whatever was returned by the `onMessage` handler
*
* Using this mechanism, you can have two-way communication between the
* plugin and webview.
*
* See the [postMessage
* demo](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/post_messages) for more details.
*
*/
onMessage(handle: ViewHandle, callback: Function): Promise<void>;
/**
* Sends a message to the webview.
*
* The webview must have registered a message handler prior, otherwise the message is ignored. Use;
*
* ```javascript
* webviewApi.onMessage((message) => { ... });
* ```
*
* - `message` can be any JavaScript object, string or number
*
* The view API may have only one onMessage handler defined.
* This method is fire and forget so no response is returned.
*
* It is particularly useful when the webview needs to react to events emitted by the plugin or the joplin api.
*/
postMessage(handle: ViewHandle, message: any): void;
/**
* Shows the panel
*/
show(handle: ViewHandle, show?: boolean): Promise<void>;
/**
* Hides the panel
*/
hide(handle: ViewHandle): Promise<void>;
/**
* Tells whether the panel is visible or not
*/
visible(handle: ViewHandle): Promise<boolean>;
}

View File

@@ -0,0 +1,16 @@
import { ToolbarButtonLocation } from './types';
import Plugin from '../Plugin';
/**
* Allows creating and managing toolbar buttons.
*
* [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/register_command)
*/
export default class JoplinViewsToolbarButtons {
private store;
private plugin;
constructor(plugin: Plugin, store: any);
/**
* Creates a new toolbar button and associate it with the given command.
*/
create(id: string, commandName: string, location: ToolbarButtonLocation): Promise<void>;
}

View File

@@ -0,0 +1,24 @@
import Plugin from '../Plugin';
export interface Implementation {
injectCustomStyles(elementId: string, cssFilePath: string): Promise<void>;
}
export default class JoplinWindow {
private plugin_;
private store_;
private implementation_;
constructor(implementation: Implementation, plugin: Plugin, store: any);
/**
* Loads a chrome CSS file. It will apply to the window UI elements, except
* for the note viewer. It is the same as the "Custom stylesheet for
* Joplin-wide app styles" setting. See the [Load CSS Demo](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/load_css)
* for an example.
*/
loadChromeCssFile(filePath: string): Promise<void>;
/**
* Loads a note CSS file. It will apply to the note viewer, as well as any
* exported or printed note. It is the same as the "Custom stylesheet for
* rendered Markdown" setting. See the [Load CSS Demo](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/load_css)
* for an example.
*/
loadNoteCssFile(filePath: string): Promise<void>;
}

View File

@@ -0,0 +1,87 @@
import { FolderEntity } from '../../database/types';
import { Disposable, MenuItem } from './types';
export interface EditContextMenuFilterObject {
items: MenuItem[];
}
type FilterHandler<T> = (object: T) => Promise<void>;
declare enum ItemChangeEventType {
Create = 1,
Update = 2,
Delete = 3
}
interface ItemChangeEvent {
id: string;
event: ItemChangeEventType;
}
interface SyncStartEvent {
withErrors: boolean;
}
interface ResourceChangeEvent {
id: string;
}
type ItemChangeHandler = (event: ItemChangeEvent) => void;
type SyncStartHandler = (event: SyncStartEvent) => void;
type ResourceChangeHandler = (event: ResourceChangeEvent) => void;
/**
* The workspace service provides access to all the parts of Joplin that
* are being worked on - i.e. the currently selected notes or notebooks as
* well as various related events, such as when a new note is selected, or
* when the note content changes.
*
* [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins)
*/
export default class JoplinWorkspace {
private store;
constructor(store: any);
/**
* Called when a new note or notes are selected.
*/
onNoteSelectionChange(callback: Function): Promise<Disposable>;
/**
* Called when the content of a note changes.
* @deprecated Use `onNoteChange()` instead, which is reliably triggered whenever the note content, or any note property changes.
*/
onNoteContentChange(callback: Function): Promise<void>;
/**
* Called when the content of the current note changes.
*/
onNoteChange(handler: ItemChangeHandler): Promise<Disposable>;
/**
* Called when a resource is changed. Currently this handled will not be
* called when a resource is added or deleted.
*/
onResourceChange(handler: ResourceChangeHandler): Promise<void>;
/**
* Called when an alarm associated with a to-do is triggered.
*/
onNoteAlarmTrigger(handler: Function): Promise<Disposable>;
/**
* Called when the synchronisation process is starting.
*/
onSyncStart(handler: SyncStartHandler): Promise<Disposable>;
/**
* Called when the synchronisation process has finished.
*/
onSyncComplete(callback: Function): Promise<Disposable>;
/**
* Called just before the editor context menu is about to open. Allows
* adding items to it.
*/
filterEditorContextMenu(handler: FilterHandler<EditContextMenuFilterObject>): void;
/**
* Gets the currently selected note
*/
selectedNote(): Promise<any>;
/**
* Gets the currently selected folder. In some cases, for example during
* search or when viewing a tag, no folder is actually selected in the user
* interface. In that case, that function would return the last selected
* folder.
*/
selectedFolder(): Promise<FolderEntity>;
/**
* Gets the IDs of the selected notes (can be zero, one, or many). Use the data API to retrieve information about these notes.
*/
selectedNoteIds(): Promise<string[]>;
}
export {};

View File

@@ -0,0 +1,5 @@
import type Joplin from './Joplin';
declare const joplin: Joplin;
export default joplin;

View File

@@ -0,0 +1,156 @@
import { Size } from './types';
type ListRendererDatabaseDependency = 'folder.created_time' | 'folder.encryption_applied' | 'folder.encryption_cipher_text' | 'folder.icon' | 'folder.id' | 'folder.is_shared' | 'folder.master_key_id' | 'folder.parent_id' | 'folder.share_id' | 'folder.title' | 'folder.updated_time' | 'folder.user_created_time' | 'folder.user_data' | 'folder.user_updated_time' | 'folder.type_' | 'note.altitude' | 'note.application_data' | 'note.author' | 'note.body' | 'note.conflict_original_id' | 'note.created_time' | 'note.encryption_applied' | 'note.encryption_cipher_text' | 'note.id' | 'note.is_conflict' | 'note.is_shared' | 'note.is_todo' | 'note.latitude' | 'note.longitude' | 'note.markup_language' | 'note.master_key_id' | 'note.order' | 'note.parent_id' | 'note.share_id' | 'note.source' | 'note.source_application' | 'note.source_url' | 'note.title' | 'note.todo_completed' | 'note.todo_due' | 'note.updated_time' | 'note.user_created_time' | 'note.user_data' | 'note.user_updated_time' | 'note.type_';
export declare enum ItemFlow {
TopToBottom = "topToBottom",
LeftToRight = "leftToRight"
}
export type RenderNoteView = Record<string, any>;
export interface OnChangeEvent {
elementId: string;
value: any;
noteId: string;
}
export type OnRenderNoteHandler = (props: any) => Promise<RenderNoteView>;
export type OnChangeHandler = (event: OnChangeEvent) => Promise<void>;
/**
* Most of these are the built-in note properties, such as `note.title`,
* `note.todo_completed`, etc.
*
* Additionally, the `item.*` properties are specific to the rendered item. The
* most important being `item.selected`, which you can use to display the
* selected note in a different way.
*
* Finally some special properties are provided to make it easier to render
* notes. In particular, if possible prefer `note.titleHtml` to `note.title`
* since some important processing has already been done on the string, such as
* handling the search highlighter and escaping. Since it's HTML and already
* escaped you would insert it using `{{{titleHtml}}}` (triple-mustache syntax,
* which disables escaping).
*
* `notes.tag` gives you the list of tags associated with the note.
*
* `note.isWatched` tells you if the note is currently opened in an external
* editor. In which case you would generally display some indicator.
*/
export type ListRendererDepependency = ListRendererDatabaseDependency | 'item.size.width' | 'item.size.height' | 'item.selected' | 'note.titleHtml' | 'note.isWatched' | 'note.tags';
export interface ListRenderer {
/**
* It must be unique to your plugin.
*/
id: string;
/**
* Can be top to bottom or left to right. Left to right gives you more
* option to set the size of the items since you set both its width and
* height.
*/
flow: ItemFlow;
/**
* The size of each item must be specified in advance for performance
* reasons, and cannot be changed afterwards. If the item flow is top to
* bottom, you only need to specificy the item height (the width will be
* ignored).
*/
itemSize: Size;
/**
* The CSS is relative to the list item container. What will appear in the
* page is essentially `.note-list-item { YOUR_CSS; }`. It means you can use
* child combinator with guarantee it will only apply to your own items. In
* this example, the styling will apply to `.note-list-item > .content`:
*
* ```css
* > .content {
* padding: 10px;
* }
* ```
*
* In order to get syntax highlighting working here, it's recommended
* installing an editor extension such as [es6-string-html VSCode
* extension](https://marketplace.visualstudio.com/items?itemName=Tobermory.es6-string-html)
*/
itemCss?: string;
/**
* List the dependencies that your plugin needs to render the note list
* items. Only these will be passed to your `onRenderNote` handler. Ensure
* that you do not add more than what you need since there is a performance
* penalty for each property.
*/
dependencies: ListRendererDepependency[];
/**
* This is the HTML template that will be used to render the note list item.
* This is a [Mustache template](https://github.com/janl/mustache.js) and it
* will receive the variable you return from `onRenderNote` as tags. For
* example, if you return a property named `formattedDate` from
* `onRenderNote`, you can insert it in the template using `Created date:
* {{formattedDate}}`.
*
* In order to get syntax highlighting working here, it's recommended
* installing an editor extension such as [es6-string-html VSCode
* extension](https://marketplace.visualstudio.com/items?itemName=Tobermory.es6-string-html)
*/
itemTemplate: string;
/**
* This user-facing text is used for example in the View menu, so that your
* renderer can be selected.
*/
label: () => Promise<string>;
/**
* This is where most of the real-time processing will happen. When a note
* is rendered for the first time and every time it changes, this handler
* receives the properties specified in the `dependencies` property. You can
* then process them, load any additional data you need, and once done you
* need to return the properties that are needed in the `itemTemplate` HTML.
* Again, to use the formatted date example, you could have such a renderer:
*
* ```typescript
* dependencies: [
* 'note.title',
* 'note.created_time',
* ],
*
* itemTemplate: // html
* `
* <div>
* Title: {{note.title}}<br/>
* Date: {{formattedDate}}
* </div>
* `,
*
* onRenderNote: async (props: any) => {
* const formattedDate = dayjs(props.note.created_time).format();
* return {
* // Also return the props, so that note.title is available from the
* // template
* ...props,
* formattedDate,
* }
* },
* ```
*/
onRenderNote: OnRenderNoteHandler;
/**
* This handler allows adding some interacivity to the note renderer -
* whenever an input element within the item is changed (for example, when a
* checkbox is clicked, or a text input is changed), this `onChange` handler
* is going to be called.
*
* You can inspect `event.elementId` to know which element had some changes,
* and `event.value` to know the new value. `event.noteId` also tells you
* what note is affected, so that you can potentially apply changes to it.
*
* You specify the element ID, by setting a `data-id` attribute on the
* input.
*
* For example, if you have such a template:
*
* ```html
* <div>
* <input type="text" value="{{note.title}}" data-id="noteTitleInput"/>
* </div>
* ```
*
* The event handler will receive an event with `elementId` set to
* `noteTitleInput`.
*/
onChange?: OnChangeHandler;
}
export {};

View File

@@ -0,0 +1,181 @@
/* eslint-disable multiline-comment-style */
import { Size } from './types';
// AUTO-GENERATED by generate-database-type
type ListRendererDatabaseDependency = 'folder.created_time' | 'folder.encryption_applied' | 'folder.encryption_cipher_text' | 'folder.icon' | 'folder.id' | 'folder.is_shared' | 'folder.master_key_id' | 'folder.parent_id' | 'folder.share_id' | 'folder.title' | 'folder.updated_time' | 'folder.user_created_time' | 'folder.user_data' | 'folder.user_updated_time' | 'folder.type_' | 'note.altitude' | 'note.application_data' | 'note.author' | 'note.body' | 'note.conflict_original_id' | 'note.created_time' | 'note.encryption_applied' | 'note.encryption_cipher_text' | 'note.id' | 'note.is_conflict' | 'note.is_shared' | 'note.is_todo' | 'note.latitude' | 'note.longitude' | 'note.markup_language' | 'note.master_key_id' | 'note.order' | 'note.parent_id' | 'note.share_id' | 'note.source' | 'note.source_application' | 'note.source_url' | 'note.title' | 'note.todo_completed' | 'note.todo_due' | 'note.updated_time' | 'note.user_created_time' | 'note.user_data' | 'note.user_updated_time' | 'note.type_';
// AUTO-GENERATED by generate-database-type
export enum ItemFlow {
TopToBottom = 'topToBottom',
LeftToRight = 'leftToRight',
}
export type RenderNoteView = Record<string, any>;
export interface OnChangeEvent {
elementId: string;
value: any;
noteId: string;
}
export type OnRenderNoteHandler = (props: any)=> Promise<RenderNoteView>;
export type OnChangeHandler = (event: OnChangeEvent)=> Promise<void>;
/**
* Most of these are the built-in note properties, such as `note.title`,
* `note.todo_completed`, etc.
*
* Additionally, the `item.*` properties are specific to the rendered item. The
* most important being `item.selected`, which you can use to display the
* selected note in a different way.
*
* Finally some special properties are provided to make it easier to render
* notes. In particular, if possible prefer `note.titleHtml` to `note.title`
* since some important processing has already been done on the string, such as
* handling the search highlighter and escaping. Since it's HTML and already
* escaped you would insert it using `{{{titleHtml}}}` (triple-mustache syntax,
* which disables escaping).
*
* `notes.tag` gives you the list of tags associated with the note.
*
* `note.isWatched` tells you if the note is currently opened in an external
* editor. In which case you would generally display some indicator.
*/
export type ListRendererDepependency =
ListRendererDatabaseDependency |
'item.size.width' |
'item.size.height' |
'item.selected' |
'note.titleHtml' |
'note.isWatched' |
'note.tags';
export interface ListRenderer {
/**
* It must be unique to your plugin.
*/
id: string;
/**
* Can be top to bottom or left to right. Left to right gives you more
* option to set the size of the items since you set both its width and
* height.
*/
flow: ItemFlow;
/**
* The size of each item must be specified in advance for performance
* reasons, and cannot be changed afterwards. If the item flow is top to
* bottom, you only need to specificy the item height (the width will be
* ignored).
*/
itemSize: Size;
/**
* The CSS is relative to the list item container. What will appear in the
* page is essentially `.note-list-item { YOUR_CSS; }`. It means you can use
* child combinator with guarantee it will only apply to your own items. In
* this example, the styling will apply to `.note-list-item > .content`:
*
* ```css
* > .content {
* padding: 10px;
* }
* ```
*
* In order to get syntax highlighting working here, it's recommended
* installing an editor extension such as [es6-string-html VSCode
* extension](https://marketplace.visualstudio.com/items?itemName=Tobermory.es6-string-html)
*/
itemCss?: string;
/**
* List the dependencies that your plugin needs to render the note list
* items. Only these will be passed to your `onRenderNote` handler. Ensure
* that you do not add more than what you need since there is a performance
* penalty for each property.
*/
dependencies: ListRendererDepependency[];
/**
* This is the HTML template that will be used to render the note list item.
* This is a [Mustache template](https://github.com/janl/mustache.js) and it
* will receive the variable you return from `onRenderNote` as tags. For
* example, if you return a property named `formattedDate` from
* `onRenderNote`, you can insert it in the template using `Created date:
* {{formattedDate}}`.
*
* In order to get syntax highlighting working here, it's recommended
* installing an editor extension such as [es6-string-html VSCode
* extension](https://marketplace.visualstudio.com/items?itemName=Tobermory.es6-string-html)
*/
itemTemplate: string;
/**
* This user-facing text is used for example in the View menu, so that your
* renderer can be selected.
*/
label: ()=> Promise<string>;
/**
* This is where most of the real-time processing will happen. When a note
* is rendered for the first time and every time it changes, this handler
* receives the properties specified in the `dependencies` property. You can
* then process them, load any additional data you need, and once done you
* need to return the properties that are needed in the `itemTemplate` HTML.
* Again, to use the formatted date example, you could have such a renderer:
*
* ```typescript
* dependencies: [
* 'note.title',
* 'note.created_time',
* ],
*
* itemTemplate: // html
* `
* <div>
* Title: {{note.title}}<br/>
* Date: {{formattedDate}}
* </div>
* `,
*
* onRenderNote: async (props: any) => {
* const formattedDate = dayjs(props.note.created_time).format();
* return {
* // Also return the props, so that note.title is available from the
* // template
* ...props,
* formattedDate,
* }
* },
* ```
*/
onRenderNote: OnRenderNoteHandler;
/**
* This handler allows adding some interacivity to the note renderer -
* whenever an input element within the item is changed (for example, when a
* checkbox is clicked, or a text input is changed), this `onChange` handler
* is going to be called.
*
* You can inspect `event.elementId` to know which element had some changes,
* and `event.value` to know the new value. `event.noteId` also tells you
* what note is affected, so that you can potentially apply changes to it.
*
* You specify the element ID, by setting a `data-id` attribute on the
* input.
*
* For example, if you have such a template:
*
* ```html
* <div>
* <input type="text" value="{{note.title}}" data-id="noteTitleInput"/>
* </div>
* ```
*
* The event handler will receive an event with `elementId` set to
* `noteTitleInput`.
*/
onChange?: OnChangeHandler;
}

View File

@@ -0,0 +1,748 @@
/* eslint-disable multiline-comment-style */
// =================================================================
// Command API types
// =================================================================
export interface Command {
/**
* Name of command - must be globally unique
*/
name: string;
/**
* Label to be displayed on menu items or keyboard shortcut editor for example.
* If it is missing, it's assumed it's a private command, to be called programmatically only.
* In that case the command will not appear in the shortcut editor or command panel, and logically
* should not be used as a menu item.
*/
label?: string;
/**
* Icon to be used on toolbar buttons for example
*/
iconName?: string;
/**
* Code to be ran when the command is executed. It may return a result.
*/
execute(...args: any[]): Promise<any | void>;
/**
* Defines whether the command should be enabled or disabled, which in turns
* affects the enabled state of any associated button or menu item.
*
* The condition should be expressed as a "when-clause" (as in Visual Studio
* Code). It's a simple boolean expression that evaluates to `true` or
* `false`. It supports the following operators:
*
* Operator | Symbol | Example
* -- | -- | --
* Equality | == | "editorType == markdown"
* Inequality | != | "currentScreen != config"
* Or | \|\| | "noteIsTodo \|\| noteTodoCompleted"
* And | && | "oneNoteSelected && !inConflictFolder"
*
* Joplin, unlike VSCode, also supports parentheses, which allows creating
* more complex expressions such as `cond1 || (cond2 && cond3)`. Only one
* level of parentheses is possible (nested ones aren't supported).
*
* Currently the supported context variables aren't documented, but you can
* find the list below:
*
* - [Global When Clauses](https://github.com/laurent22/joplin/blob/dev/packages/lib/services/commands/stateToWhenClauseContext.ts)
* - [Desktop app When Clauses](https://github.com/laurent22/joplin/blob/dev/packages/app-desktop/services/commands/stateToWhenClauseContext.ts)
*
* Note: Commands are enabled by default unless you use this property.
*/
enabledCondition?: string;
}
// =================================================================
// Interop API types
// =================================================================
export enum FileSystemItem {
File = 'file',
Directory = 'directory',
}
export enum ImportModuleOutputFormat {
Markdown = 'md',
Html = 'html',
}
/**
* Used to implement a module to export data from Joplin. [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/json_export) for an example.
*
* In general, all the event handlers you'll need to implement take a `context` object as a first argument. This object will contain the export or import path as well as various optional properties, such as which notes or notebooks need to be exported.
*
* To get a better sense of what it will contain it can be useful to print it using `console.info(context)`.
*/
export interface ExportModule {
/**
* The format to be exported, eg "enex", "jex", "json", etc.
*/
format: string;
/**
* The description that will appear in the UI, for example in the menu item.
*/
description: string;
/**
* Whether the module will export a single file or multiple files in a directory. It affects the open dialog that will be presented to the user when using your exporter.
*/
target: FileSystemItem;
/**
* Only applies to single file exporters or importers
* It tells whether the format can package multiple notes into one file.
* For example JEX or ENEX can, but HTML cannot.
*/
isNoteArchive: boolean;
/**
* The extensions of the files exported by your module. For example, it is `["htm", "html"]` for the HTML module, and just `["jex"]` for the JEX module.
*/
fileExtensions?: string[];
/**
* Called when the export process starts.
*/
onInit(context: ExportContext): Promise<void>;
/**
* Called when an item needs to be processed. An "item" can be any Joplin object, such as a note, a folder, a notebook, etc.
*/
onProcessItem(context: ExportContext, itemType: number, item: any): Promise<void>;
/**
* Called when a resource file needs to be exported.
*/
onProcessResource(context: ExportContext, resource: any, filePath: string): Promise<void>;
/**
* Called when the export process is done.
*/
onClose(context: ExportContext): Promise<void>;
}
export interface ImportModule {
/**
* The format to be exported, eg "enex", "jex", "json", etc.
*/
format: string;
/**
* The description that will appear in the UI, for example in the menu item.
*/
description: string;
/**
* Only applies to single file exporters or importers
* It tells whether the format can package multiple notes into one file.
* For example JEX or ENEX can, but HTML cannot.
*/
isNoteArchive: boolean;
/**
* The type of sources that are supported by the module. Tells whether the module can import files or directories or both.
*/
sources: FileSystemItem[];
/**
* Tells the file extensions of the exported files.
*/
fileExtensions?: string[];
/**
* Tells the type of notes that will be generated, either HTML or Markdown (default).
*/
outputFormat?: ImportModuleOutputFormat;
/**
* Called when the import process starts. There is only one event handler within which you should import the complete data.
*/
onExec(context: ImportContext): Promise<void>;
}
export interface ExportOptions {
format?: string;
path?: string;
sourceFolderIds?: string[];
sourceNoteIds?: string[];
// modulePath?: string;
target?: FileSystemItem;
}
export interface ExportContext {
destPath: string;
options: ExportOptions;
/**
* You can attach your own custom data using this propery - it will then be passed to each event handler, allowing you to keep state from one event to the next.
*/
userData?: any;
}
export interface ImportContext {
sourcePath: string;
options: any;
warnings: string[];
}
// =================================================================
// Misc types
// =================================================================
export interface Script {
onStart?(event: any): Promise<void>;
}
export interface Disposable {
// dispose():void;
}
export enum ModelType {
Note = 1,
Folder = 2,
Setting = 3,
Resource = 4,
Tag = 5,
NoteTag = 6,
Search = 7,
Alarm = 8,
MasterKey = 9,
ItemChange = 10,
NoteResource = 11,
ResourceLocalState = 12,
Revision = 13,
Migration = 14,
SmartFilter = 15,
Command = 16,
}
export interface VersionInfo {
version: string;
profileVersion: number;
syncVersion: number;
}
// =================================================================
// Menu types
// =================================================================
export interface CreateMenuItemOptions {
accelerator: string;
}
export enum MenuItemLocation {
File = 'file',
Edit = 'edit',
View = 'view',
Note = 'note',
Tools = 'tools',
Help = 'help',
/**
* @deprecated Do not use - same as NoteListContextMenu
*/
Context = 'context',
// If adding an item here, don't forget to update isContextMenuItemLocation()
/**
* When a command is called from the note list context menu, the
* command will receive the following arguments:
*
* - `noteIds:string[]`: IDs of the notes that were right-clicked on.
*/
NoteListContextMenu = 'noteListContextMenu',
EditorContextMenu = 'editorContextMenu',
/**
* When a command is called from a folder context menu, the
* command will receive the following arguments:
*
* - `folderId:string`: ID of the folder that was right-clicked on
*/
FolderContextMenu = 'folderContextMenu',
/**
* When a command is called from a tag context menu, the
* command will receive the following arguments:
*
* - `tagId:string`: ID of the tag that was right-clicked on
*/
TagContextMenu = 'tagContextMenu',
}
export function isContextMenuItemLocation(location: MenuItemLocation): boolean {
return [
MenuItemLocation.Context,
MenuItemLocation.NoteListContextMenu,
MenuItemLocation.EditorContextMenu,
MenuItemLocation.FolderContextMenu,
MenuItemLocation.TagContextMenu,
].includes(location);
}
export interface MenuItem {
/**
* Command that should be associated with the menu item. All menu item should
* have a command associated with them unless they are a sub-menu.
*/
commandName?: string;
/**
* Arguments that should be passed to the command. They will be as rest
* parameters.
*/
commandArgs?: any[];
/**
* Set to "separator" to create a divider line
*/
type?: ('normal' | 'separator' | 'submenu' | 'checkbox' | 'radio');
/**
* Accelerator associated with the menu item
*/
accelerator?: string;
/**
* Menu items that should appear below this menu item. Allows creating a menu tree.
*/
submenu?: MenuItem[];
/**
* Menu item label. If not specified, the command label will be used instead.
*/
label?: string;
}
// =================================================================
// View API types
// =================================================================
export interface ButtonSpec {
id: ButtonId;
title?: string;
onClick?(): void;
}
export type ButtonId = string;
export enum ToolbarButtonLocation {
/**
* This toolbar in the top right corner of the application. It applies to the note as a whole, including its metadata.
*/
NoteToolbar = 'noteToolbar',
/**
* This toolbar is right above the text editor. It applies to the note body only.
*/
EditorToolbar = 'editorToolbar',
}
export type ViewHandle = string;
export interface EditorCommand {
name: string;
value?: any;
}
export interface DialogResult {
id: ButtonId;
formData?: any;
}
export interface Size {
width?: number;
height?: number;
}
export interface Rectangle {
x?: number;
y?: number;
width?: number;
height?: number;
}
// =================================================================
// Settings types
// =================================================================
export enum SettingItemType {
Int = 1,
String = 2,
Bool = 3,
Array = 4,
Object = 5,
Button = 6,
}
export enum SettingItemSubType {
FilePathAndArgs = 'file_path_and_args',
FilePath = 'file_path', // Not supported on mobile!
DirectoryPath = 'directory_path', // Not supported on mobile!
}
export enum AppType {
Desktop = 'desktop',
Mobile = 'mobile',
Cli = 'cli',
}
export enum SettingStorage {
Database = 1,
File = 2,
}
// Redefine a simplified interface to mask internal details
// and to remove function calls as they would have to be async.
export interface SettingItem {
value: any;
type: SettingItemType;
/**
* Currently only used to display a file or directory selector. Always set
* `type` to `SettingItemType.String` when using this property.
*/
subType?: SettingItemSubType;
label: string;
description?: string;
/**
* A public setting will appear in the Configuration screen and will be
* modifiable by the user. A private setting however will not appear there,
* and can only be changed programmatically. You may use this to store some
* values that you do not want to directly expose.
*/
public: boolean;
/**
* You would usually set this to a section you would have created
* specifically for the plugin.
*/
section?: string;
/**
* To create a setting with multiple options, set this property to `true`.
* That setting will render as a dropdown list in the configuration screen.
*/
isEnum?: boolean;
/**
* This property is required when `isEnum` is `true`. In which case, it
* should contain a map of value => label.
*/
options?: Record<any, any>;
/**
* Reserved property. Not used at the moment.
*/
appTypes?: AppType[];
/**
* Set this to `true` to store secure data, such as passwords. Any such
* setting will be stored in the system keychain if one is available.
*/
secure?: boolean;
/**
* An advanced setting will be moved under the "Advanced" button in the
* config screen.
*/
advanced?: boolean;
/**
* Set the min, max and step values if you want to restrict an int setting
* to a particular range.
*/
minimum?: number;
maximum?: number;
step?: number;
/**
* Either store the setting in the database or in settings.json. Defaults to database.
*/
storage?: SettingStorage;
}
export interface SettingSection {
label: string;
iconName?: string;
description?: string;
name?: string;
}
// =================================================================
// Data API types
// =================================================================
/**
* An array of at least one element and at most three elements.
*
* - **[0]**: Resource name (eg. "notes", "folders", "tags", etc.)
* - **[1]**: (Optional) Resource ID.
* - **[2]**: (Optional) Resource link.
*/
export type Path = string[];
// =================================================================
// Content Script types
// =================================================================
export type PostMessageHandler = (message: any)=> Promise<any>;
/**
* When a content script is initialised, it receives a `context` object.
*/
export interface ContentScriptContext {
/**
* The plugin ID that registered this content script
*/
pluginId: string;
/**
* The content script ID, which may be necessary to post messages
*/
contentScriptId: string;
/**
* Can be used by CodeMirror content scripts to post a message to the plugin
*/
postMessage: PostMessageHandler;
}
export interface ContentScriptModuleLoadedEvent {
userData?: any;
}
export interface ContentScriptModule {
onLoaded?: (event: ContentScriptModuleLoadedEvent)=> void;
plugin: ()=> any;
assets?: ()=> void;
}
export interface MarkdownItContentScriptModule extends Omit<ContentScriptModule, 'plugin'> {
plugin: (markdownIt: any, options: any)=> any;
}
type EditorCommandCallback = (...args: any[])=> any;
export interface CodeMirrorControl {
/** Points to a CodeMirror 6 EditorView instance. */
editor: any;
cm6: any;
/** `extension` should be a [CodeMirror 6 extension](https://codemirror.net/docs/ref/#state.Extension). */
addExtension(extension: any|any[]): void;
supportsCommand(name: string): boolean;
execCommand(name: string, ...args: any[]): any;
registerCommand(name: string, callback: EditorCommandCallback): void;
joplinExtensions: {
/**
* Returns a [CodeMirror 6 extension](https://codemirror.net/docs/ref/#state.Extension) that
* registers the given [CompletionSource](https://codemirror.net/docs/ref/#autocomplete.CompletionSource).
*
* Use this extension rather than the built-in CodeMirror [`autocompletion`](https://codemirror.net/docs/ref/#autocomplete.autocompletion)
* if you don't want to use [langaugeData-based autocompletion](https://codemirror.net/docs/ref/#autocomplete.autocompletion^config.override).
*
* Using `autocompletion({ override: [ ... ]})` causes errors when done by multiple plugins.
*/
completionSource(completionSource: any): any;
/**
* Creates an extension that enables or disables [`languageData`-based autocompletion](https://codemirror.net/docs/ref/#autocomplete.autocompletion^config.override).
*/
enableLanguageDataAutocomplete: { of: (enabled: boolean)=> any };
};
}
export interface MarkdownEditorContentScriptModule extends Omit<ContentScriptModule, 'plugin'> {
plugin: (editorControl: CodeMirrorControl)=> void;
}
export enum ContentScriptType {
/**
* Registers a new Markdown-It plugin, which should follow the template
* below.
*
* ```javascript
* module.exports = {
* default: function(context) {
* return {
* plugin: function(markdownIt, pluginOptions) {
* // ...
* },
* assets: {
* // ...
* },
* }
* }
* }
* ```
*
* See [the
* demo](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/content_script)
* for a simple Markdown-it plugin example.
*
* ## Exported members
*
* - The `context` argument is currently unused but could be used later on
* to provide access to your own plugin so that the content script and
* plugin can communicate.
*
* - The **required** `plugin` key is the actual Markdown-It plugin - check
* the [official doc](https://github.com/markdown-it/markdown-it) for more
* information.
*
* - Using the **optional** `assets` key you may specify assets such as JS
* or CSS that should be loaded in the rendered HTML document. Check for
* example the Joplin [Mermaid
* plugin](https://github.com/laurent22/joplin/blob/dev/packages/renderer/MdToHtml/rules/mermaid.ts)
* to see how the data should be structured.
*
* ## Getting the settings from the renderer
*
* You can access your plugin settings from the renderer by calling
* `pluginOptions.settingValue("your-setting-key')`.
*
* ## Posting messages from the content script to your plugin
*
* The application provides the following function to allow executing
* commands from the rendered HTML code:
*
* ```javascript
* const response = await webviewApi.postMessage(contentScriptId, message);
* ```
*
* - `contentScriptId` is the ID you've defined when you registered the
* content script. You can retrieve it from the
* {@link ContentScriptContext | context}.
* - `message` can be any basic JavaScript type (number, string, plain
* object), but it cannot be a function or class instance.
*
* When you post a message, the plugin can send back a `response` thus
* allowing two-way communication:
*
* ```javascript
* await joplin.contentScripts.onMessage(contentScriptId, (message) => {
* // Process message
* return response; // Can be any object, string or number
* });
* ```
*
* See {@link JoplinContentScripts.onMessage} for more details, as well as
* the [postMessage
* demo](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/post_messages).
*
* ## Registering an existing Markdown-it plugin
*
* To include a regular Markdown-It plugin, that doesn't make use of any
* Joplin-specific features, you would simply create a file such as this:
*
* ```javascript
* module.exports = {
* default: function(context) {
* return {
* plugin: require('markdown-it-toc-done-right');
* }
* }
* }
* ```
*/
MarkdownItPlugin = 'markdownItPlugin',
/**
* Registers a new CodeMirror plugin, which should follow the template
* below.
*
* ```javascript
* module.exports = {
* default: function(context) {
* return {
* plugin: function(CodeMirror) {
* // ...
* },
* codeMirrorResources: [],
* codeMirrorOptions: {
* // ...
* },
* assets: {
* // ...
* },
* }
* }
* }
* ```
*
* - The `context` argument is currently unused but could be used later on
* to provide access to your own plugin so that the content script and
* plugin can communicate.
*
* - The `plugin` key is your CodeMirror plugin. This is where you can
* register new commands with CodeMirror or interact with the CodeMirror
* instance as needed.
*
* - The `codeMirrorResources` key is an array of CodeMirror resources that
* will be loaded and attached to the CodeMirror module. These are made up
* of addons, keymaps, and modes. For example, for a plugin that want's to
* enable clojure highlighting in code blocks. `codeMirrorResources` would
* be set to `['mode/clojure/clojure']`.
*
* - The `codeMirrorOptions` key contains all the
* [CodeMirror](https://codemirror.net/doc/manual.html#config) options
* that will be set or changed by this plugin. New options can alse be
* declared via
* [`CodeMirror.defineOption`](https://codemirror.net/doc/manual.html#defineOption),
* and then have their value set here. For example, a plugin that enables
* line numbers would set `codeMirrorOptions` to `{'lineNumbers': true}`.
*
* - Using the **optional** `assets` key you may specify **only** CSS assets
* that should be loaded in the rendered HTML document. Check for example
* the Joplin [Mermaid
* plugin](https://github.com/laurent22/joplin/blob/dev/packages/renderer/MdToHtml/rules/mermaid.ts)
* to see how the data should be structured.
*
* One of the `plugin`, `codeMirrorResources`, or `codeMirrorOptions` keys
* must be provided for the plugin to be valid. Having multiple or all
* provided is also okay.
*
* See also the [demo
* plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/codemirror_content_script)
* for an example of all these keys being used in one plugin.
*
* ## Posting messages from the content script to your plugin
*
* In order to post messages to the plugin, you can use the postMessage
* function passed to the {@link ContentScriptContext | context}.
*
* ```javascript
* const response = await context.postMessage('messageFromCodeMirrorContentScript');
* ```
*
* When you post a message, the plugin can send back a `response` thus
* allowing two-way communication:
*
* ```javascript
* await joplin.contentScripts.onMessage(contentScriptId, (message) => {
* // Process message
* return response; // Can be any object, string or number
* });
* ```
*
* See {@link JoplinContentScripts.onMessage} for more details, as well as
* the [postMessage
* demo](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/post_messages).
*
*/
CodeMirrorPlugin = 'codeMirrorPlugin',
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,38 @@
{
"name": "joplin-plugin-worker-example",
"version": "1.0.0",
"scripts": {
"dist": "webpack --env joplin-plugin-config=buildMain && webpack --env joplin-plugin-config=buildExtraScripts && npm run copyAssets && webpack --env joplin-plugin-config=createArchive",
"copyAssets": "node tools/copyAssets.js",
"prepare": "npm run dist",
"updateVersion": "webpack --env joplin-plugin-config=updateVersion",
"update": "npm install -g generator-joplin && yo joplin --node-package-manager npm --update --force"
},
"license": "MIT",
"keywords": [
"joplin-plugin"
],
"files": [
"publish"
],
"devDependencies": {
"@types/node": "^18.7.13",
"chalk": "^4.1.0",
"copy-webpack-plugin": "^11.0.0",
"fs-extra": "^10.1.0",
"glob": "^8.0.3",
"tar": "^6.1.11",
"ts-loader": "^9.3.1",
"typescript": "^4.8.2",
"webpack": "^5.74.0",
"webpack-cli": "^4.10.0"
},
"overrides": {
"@xenova/transformers": {
"sharp": "0.33.4"
}
},
"dependencies": {
"@xenova/transformers": "^2.17.2"
}
}

View File

@@ -0,0 +1,6 @@
{
"extraScripts": [ "worker.ts" ],
"webpackOverrides": {
"target": "web"
}
}

View File

@@ -0,0 +1,39 @@
import joplin from 'api';
import { ToolbarButtonLocation } from 'api/types';
joplin.plugins.register({
onStart: async function() {
const worker = new Worker(`${await joplin.plugins.installationDir()}/worker.js`);
worker.onmessage = event => {
if ('response' in event.data) {
alert(
JSON.stringify(event.data.response)
);
}
};
joplin.commands.register({
name: 'classifySelection',
label: 'Run sentiment analysis on selected text',
iconName: 'fas fa-robot',
execute: async () => {
const selectedText = await joplin.commands.execute('selectedText');
if (!selectedText) {
alert('No text selected!');
return;
}
worker.postMessage({
type: 'classify',
text: selectedText,
});
},
});
await joplin.views.toolbarButtons.create(
'classifySelectionButton',
'classifySelection',
ToolbarButtonLocation.EditorToolbar,
);
},
});

View File

@@ -0,0 +1,15 @@
{
"manifest_version": 1,
"id": "com.example.worker",
"app_min_version": "3.0",
"version": "1.0.0",
"name": "Web Worker",
"description": "Shows how to load a web worker from a plugin.",
"author": "",
"homepage_url": "",
"repository_url": "",
"keywords": [],
"categories": [],
"screenshots": [],
"icons": {}
}

View File

@@ -0,0 +1,30 @@
// @ts-ignore -- no types available for transformers.js
import { pipeline, env } from '@xenova/transformers';
// Use code similar to the following to use local models
// See https://github.com/xenova/transformers.js?tab=readme-ov-file#settings
// env.localModelPath = './models/';
// env.allowRemoteModels = false;
// Don't fetch ONNX WASM from the internet (see tools/copyModels.js).
env.backends.onnx.wasm.wasmPaths = './onnx-dist/';
let pipe: (text: string)=>Promise<unknown>;
const classify = async (text: string) => {
pipe ??= await pipeline('sentiment-analysis');
const classification = await pipe(text);
console.debug('classified', text, 'as', classification);
return classification;
};
self.addEventListener('message', async (event) => {
console.debug('got message', event.data);
if (event.data.type === 'classify') {
postMessage({
response: await classify(event.data.text),
});
}
});

View File

@@ -0,0 +1,12 @@
const fs = require('fs-extra');
const path = require('path');
// Copy WASM files
// See https://github.com/xenova/transformers.js/issues/367#issuecomment-1777743129
const onnxDistFiles = path.dirname(require.resolve('onnxruntime-web'));
const baseDir = path.dirname(__dirname);
fs.copy(onnxDistFiles, path.join(baseDir, 'dist', 'onnx-dist'));
// Predownload model files -- TODO.
// See https://huggingface.co/docs/transformers.js/custom_usage#use-custom-models

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"outDir": "./dist/",
"module": "commonjs",
"target": "es2015",
"jsx": "react",
"allowJs": true,
"baseUrl": "."
}
}

View File

@@ -0,0 +1,403 @@
// -----------------------------------------------------------------------------
// This file is used to build the plugin file (.jpl) and plugin info (.json). It
// is recommended not to edit this file as it would be overwritten when updating
// the plugin framework. If you do make some changes, consider using an external
// JS file and requiring it here to minimize the changes. That way when you
// update, you can easily restore the functionality you've added.
// -----------------------------------------------------------------------------
/* eslint-disable no-console */
const path = require('path');
const crypto = require('crypto');
const fs = require('fs-extra');
const chalk = require('chalk');
const CopyPlugin = require('copy-webpack-plugin');
const tar = require('tar');
const glob = require('glob');
const execSync = require('child_process').execSync;
// AUTO-GENERATED by updateCategories
const allPossibleCategories = [{ 'name': 'appearance' }, { 'name': 'developer tools' }, { 'name': 'productivity' }, { 'name': 'themes' }, { 'name': 'integrations' }, { 'name': 'viewer' }, { 'name': 'search' }, { 'name': 'tags' }, { 'name': 'editor' }, { 'name': 'files' }, { 'name': 'personal knowledge management' }];
// AUTO-GENERATED by updateCategories
const rootDir = path.resolve(__dirname);
const userConfigFilename = './plugin.config.json';
const userConfigPath = path.resolve(rootDir, userConfigFilename);
const distDir = path.resolve(rootDir, 'dist');
const srcDir = path.resolve(rootDir, 'src');
const publishDir = path.resolve(rootDir, 'publish');
const userConfig = { extraScripts: [], ...(fs.pathExistsSync(userConfigPath) ? require(userConfigFilename) : {}) };
const manifestPath = `${srcDir}/manifest.json`;
const packageJsonPath = `${rootDir}/package.json`;
const allPossibleScreenshotsType = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
const manifest = readManifest(manifestPath);
const pluginArchiveFilePath = path.resolve(publishDir, `${manifest.id}.jpl`);
const pluginInfoFilePath = path.resolve(publishDir, `${manifest.id}.json`);
const { builtinModules } = require('node:module');
// Webpack5 doesn't polyfill by default and displays a warning when attempting to require() built-in
// node modules. Set these to false to prevent Webpack from warning about not polyfilling these modules.
// We don't need to polyfill because the plugins run in Electron's Node environment.
const moduleFallback = {};
for (const moduleName of builtinModules) {
moduleFallback[moduleName] = false;
}
const getPackageJson = () => {
return JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
};
function validatePackageJson() {
const content = getPackageJson();
if (!content.name || content.name.indexOf('joplin-plugin-') !== 0) {
console.warn(chalk.yellow(`WARNING: To publish the plugin, the package name should start with "joplin-plugin-" (found "${content.name}") in ${packageJsonPath}`));
}
if (!content.keywords || content.keywords.indexOf('joplin-plugin') < 0) {
console.warn(chalk.yellow(`WARNING: To publish the plugin, the package keywords should include "joplin-plugin" (found "${JSON.stringify(content.keywords)}") in ${packageJsonPath}`));
}
if (content.scripts && content.scripts.postinstall) {
console.warn(chalk.yellow(`WARNING: package.json contains a "postinstall" script. It is recommended to use a "prepare" script instead so that it is executed before publish. In ${packageJsonPath}`));
}
}
function fileSha256(filePath) {
const content = fs.readFileSync(filePath);
return crypto.createHash('sha256').update(content).digest('hex');
}
function currentGitInfo() {
try {
let branch = execSync('git rev-parse --abbrev-ref HEAD', { stdio: 'pipe' }).toString().trim();
const commit = execSync('git rev-parse HEAD', { stdio: 'pipe' }).toString().trim();
if (branch === 'HEAD') branch = 'master';
return `${branch}:${commit}`;
} catch (error) {
const messages = error.message ? error.message.split('\n') : [''];
console.info(chalk.cyan('Could not get git commit (not a git repo?):', messages[0].trim()));
console.info(chalk.cyan('Git information will not be stored in plugin info file'));
return '';
}
}
function validateCategories(categories) {
if (!categories) return null;
if ((categories.length !== new Set(categories).size)) throw new Error('Repeated categories are not allowed');
// eslint-disable-next-line github/array-foreach -- Old code before rule was applied
categories.forEach(category => {
if (!allPossibleCategories.map(category => { return category.name; }).includes(category)) throw new Error(`${category} is not a valid category. Please make sure that the category name is lowercase. Valid categories are: \n${allPossibleCategories.map(category => { return category.name; })}\n`);
});
}
function validateScreenshots(screenshots) {
if (!screenshots) return null;
for (const screenshot of screenshots) {
if (!screenshot.src) throw new Error('You must specify a src for each screenshot');
// Avoid attempting to download and verify URL screenshots.
if (screenshot.src.startsWith('https://') || screenshot.src.startsWith('http://')) {
continue;
}
const screenshotType = screenshot.src.split('.').pop();
if (!allPossibleScreenshotsType.includes(screenshotType)) throw new Error(`${screenshotType} is not a valid screenshot type. Valid types are: \n${allPossibleScreenshotsType}\n`);
const screenshotPath = path.resolve(rootDir, screenshot.src);
// Max file size is 1MB
const fileMaxSize = 1024;
const fileSize = fs.statSync(screenshotPath).size / 1024;
if (fileSize > fileMaxSize) throw new Error(`Max screenshot file size is ${fileMaxSize}KB. ${screenshotPath} is ${fileSize}KB`);
}
}
function readManifest(manifestPath) {
const content = fs.readFileSync(manifestPath, 'utf8');
const output = JSON.parse(content);
if (!output.id) throw new Error(`Manifest plugin ID is not set in ${manifestPath}`);
validateCategories(output.categories);
validateScreenshots(output.screenshots);
return output;
}
function createPluginArchive(sourceDir, destPath) {
const distFiles = glob.sync(`${sourceDir}/**/*`, { nodir: true, windowsPathsNoEscape: true })
.map(f => f.substr(sourceDir.length + 1));
if (!distFiles.length) throw new Error('Plugin archive was not created because the "dist" directory is empty');
fs.removeSync(destPath);
tar.create(
{
strict: true,
portable: true,
file: destPath,
cwd: sourceDir,
sync: true,
},
distFiles,
);
console.info(chalk.cyan(`Plugin archive has been created in ${destPath}`));
}
const writeManifest = (manifestPath, content) => {
fs.writeFileSync(manifestPath, JSON.stringify(content, null, '\t'), 'utf8');
};
function createPluginInfo(manifestPath, destPath, jplFilePath) {
const contentText = fs.readFileSync(manifestPath, 'utf8');
const content = JSON.parse(contentText);
content._publish_hash = `sha256:${fileSha256(jplFilePath)}`;
content._publish_commit = currentGitInfo();
writeManifest(destPath, content);
}
function onBuildCompleted() {
try {
fs.removeSync(path.resolve(publishDir, 'index.js'));
createPluginArchive(distDir, pluginArchiveFilePath);
createPluginInfo(manifestPath, pluginInfoFilePath, pluginArchiveFilePath);
validatePackageJson();
} catch (error) {
console.error(chalk.red(error.message));
}
}
const baseConfig = {
mode: 'production',
target: 'node',
stats: 'errors-only',
module: {
rules: [
{
test: /\.tsx?$/,
use: 'ts-loader',
exclude: /node_modules/,
},
],
},
...userConfig.webpackOverrides,
};
const pluginConfig = { ...baseConfig, entry: './src/index.ts',
resolve: {
alias: {
api: path.resolve(__dirname, 'api'),
},
fallback: moduleFallback,
// JSON files can also be required from scripts so we include this.
// https://github.com/joplin/plugin-bibtex/pull/2
extensions: ['.js', '.tsx', '.ts', '.json'],
},
output: {
filename: 'index.js',
path: distDir,
},
plugins: [
new CopyPlugin({
patterns: [
{
from: '**/*',
context: path.resolve(__dirname, 'src'),
to: path.resolve(__dirname, 'dist'),
globOptions: {
ignore: [
// All TypeScript files are compiled to JS and
// already copied into /dist so we don't copy them.
'**/*.ts',
'**/*.tsx',
],
},
},
],
}),
] };
// These libraries can be included with require(...) or
// joplin.require(...) from content scripts.
const externalContentScriptLibraries = [
'@codemirror/view',
'@codemirror/state',
'@codemirror/search',
'@codemirror/language',
'@codemirror/autocomplete',
'@codemirror/commands',
'@codemirror/highlight',
'@codemirror/lint',
'@codemirror/lang-html',
'@codemirror/lang-markdown',
'@codemirror/language-data',
'@lezer/common',
'@lezer/markdown',
'@lezer/highlight',
];
const extraScriptExternals = {};
for (const library of externalContentScriptLibraries) {
extraScriptExternals[library] = { commonjs: library };
}
const extraScriptConfig = {
...baseConfig,
resolve: {
alias: {
api: path.resolve(__dirname, 'api'),
},
fallback: moduleFallback,
extensions: ['.js', '.tsx', '.ts', '.json'],
},
// We support requiring @codemirror/... libraries through require('@codemirror/...')
externalsType: 'commonjs',
externals: extraScriptExternals,
};
const createArchiveConfig = {
stats: 'errors-only',
entry: './dist/index.js',
resolve: {
fallback: moduleFallback,
},
output: {
filename: 'index.js',
path: publishDir,
},
plugins: [{
apply(compiler) {
compiler.hooks.done.tap('archiveOnBuildListener', onBuildCompleted);
},
}],
};
function resolveExtraScriptPath(name) {
const relativePath = `./src/${name}`;
const fullPath = path.resolve(`${rootDir}/${relativePath}`);
if (!fs.pathExistsSync(fullPath)) throw new Error(`Could not find extra script: "${name}" at "${fullPath}"`);
const s = name.split('.');
s.pop();
const nameNoExt = s.join('.');
return {
entry: relativePath,
output: {
filename: `${nameNoExt}.js`,
path: distDir,
library: 'default',
libraryTarget: 'commonjs',
libraryExport: 'default',
},
};
}
function buildExtraScriptConfigs(userConfig) {
if (!userConfig.extraScripts.length) return [];
const output = [];
for (const scriptName of userConfig.extraScripts) {
const scriptPaths = resolveExtraScriptPath(scriptName);
output.push({ ...extraScriptConfig, entry: scriptPaths.entry,
output: scriptPaths.output });
}
return output;
}
const increaseVersion = version => {
try {
const s = version.split('.');
const d = Number(s[s.length - 1]) + 1;
s[s.length - 1] = `${d}`;
return s.join('.');
} catch (error) {
error.message = `Could not parse version number: ${version}: ${error.message}`;
throw error;
}
};
const updateVersion = () => {
const packageJson = getPackageJson();
packageJson.version = increaseVersion(packageJson.version);
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`, 'utf8');
const manifest = readManifest(manifestPath);
manifest.version = increaseVersion(manifest.version);
writeManifest(manifestPath, manifest);
if (packageJson.version !== manifest.version) {
console.warn(chalk.yellow(`Version numbers have been updated but they do not match: package.json (${packageJson.version}), manifest.json (${manifest.version}). Set them to the required values to get them in sync.`));
}
};
function main(environ) {
const configName = environ['joplin-plugin-config'];
if (!configName) throw new Error('A config file must be specified via the --joplin-plugin-config flag');
// Webpack configurations run in parallel, while we need them to run in
// sequence, and to do that it seems the only way is to run webpack multiple
// times, with different config each time.
const configs = {
// Builds the main src/index.ts and copy the extra content from /src to
// /dist including scripts, CSS and any other asset.
buildMain: [pluginConfig],
// Builds the extra scripts as defined in plugin.config.json. When doing
// so, some JavaScript files that were copied in the previous might be
// overwritten here by the compiled version. This is by design. The
// result is that JS files that don't need compilation, are simply
// copied to /dist, while those that do need it are correctly compiled.
buildExtraScripts: buildExtraScriptConfigs(userConfig),
// Ths config is for creating the .jpl, which is done via the plugin, so
// it doesn't actually need an entry and output, however webpack won't
// run without this. So we give it an entry that we know is going to
// exist and output in the publish dir. Then the plugin will delete this
// temporary file before packaging the plugin.
createArchive: [createArchiveConfig],
};
// If we are running the first config step, we clean up and create the build
// directories.
if (configName === 'buildMain') {
fs.removeSync(distDir);
fs.removeSync(publishDir);
fs.mkdirpSync(publishDir);
}
if (configName === 'updateVersion') {
updateVersion();
return [];
}
return configs[configName];
}
module.exports = (env) => {
let exportedConfigs = [];
try {
exportedConfigs = main(env);
} catch (error) {
console.error(error.message);
process.exit(1);
}
if (!exportedConfigs.length) {
// Nothing to do - for example where there are no external scripts to
// compile.
process.exit(0);
}
return exportedConfigs;
};

View File

@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "Joplin Web Clipper [DEV]",
"version": "3.1.0",
"version": "3.1.1",
"description": "Capture and save web pages and screenshots from your browser to Joplin.",
"homepage_url": "https://joplinapp.org",
"content_security_policy": {

View File

@@ -18,6 +18,7 @@
"@babel/preset-react": "7.23.3",
"babel-loader": "9.1.3",
"css-loader": "6.8.1",
"fs-extra": "11.2.0",
"html-webpack-plugin": "5.6.0",
"react-redux": "9.0.4",
"redux": "5.0.1",
@@ -2980,6 +2981,20 @@
"flat": "cli.js"
}
},
"node_modules/fs-extra": {
"version": "11.2.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz",
"integrity": "sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==",
"dev": true,
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=14.14"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -3279,6 +3294,18 @@
"node": ">=6"
}
},
"node_modules/jsonfile": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz",
"integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==",
"dev": true,
"dependencies": {
"universalify": "^2.0.0"
},
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/kind-of": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
@@ -4217,6 +4244,15 @@
"node": ">=4"
}
},
"node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"dev": true,
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/update-browserslist-db": {
"version": "1.0.13",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz",
@@ -6579,6 +6615,17 @@
"integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==",
"dev": true
},
"fs-extra": {
"version": "11.2.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz",
"integrity": "sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==",
"dev": true,
"requires": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
}
},
"function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -6780,6 +6827,16 @@
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
"dev": true
},
"jsonfile": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz",
"integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==",
"dev": true,
"requires": {
"graceful-fs": "^4.1.6",
"universalify": "^2.0.0"
}
},
"kind-of": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
@@ -7456,6 +7513,12 @@
"integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==",
"dev": true
},
"universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"dev": true
},
"update-browserslist-db": {
"version": "1.0.13",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz",

View File

@@ -18,6 +18,7 @@
"@babel/preset-react": "7.23.3",
"babel-loader": "9.1.3",
"css-loader": "6.8.1",
"fs-extra": "11.2.0",
"html-webpack-plugin": "5.6.0",
"react-redux": "9.0.4",
"redux": "5.0.1",

View File

@@ -1,7 +1,8 @@
import Logger, { LoggerWrapper } from '@joplin/utils/Logger';
import { PluginMessage } from './services/plugins/PluginRunner';
// import AutoUpdaterService from './services/autoUpdater/AutoUpdaterService';
import shim from '@joplin/lib/shim';
import AutoUpdaterService, { defaultUpdateInterval, initialUpdateStartup } from './services/autoUpdater/AutoUpdaterService';
import type ShimType from '@joplin/lib/shim';
const shim: typeof ShimType = require('@joplin/lib/shim').default;
import { isCallbackUrl } from '@joplin/lib/callbackUrlUtils';
import { BrowserWindow, Tray, screen } from 'electron';
@@ -42,8 +43,9 @@ export default class ElectronAppWrapper {
private rendererProcessQuitReply_: RendererProcessQuitReply = null;
private pluginWindows_: PluginWindows = {};
private initialCallbackUrl_: string = null;
// private updaterService_: AutoUpdaterService = null;
private updaterService_: AutoUpdaterService = null;
private customProtocolHandler_: CustomProtocolHandler = null;
private updatePollInterval_: ReturnType<typeof setTimeout>|null = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
public constructor(electronApp: any, env: string, profilePath: string|null, isDebugMode: boolean, initialCallbackUrl: string) {
@@ -326,6 +328,10 @@ export default class ElectronAppWrapper {
}
});
ipcMain.on('apply-update-now', () => {
this.updaterService_.updateApp();
});
// Let us register listeners on the window, so we can update the state
// automatically (the listeners will be removed when the window is closed)
// and restore the maximized or full screen state
@@ -358,6 +364,7 @@ export default class ElectronAppWrapper {
}
public quit() {
this.stopPeriodicUpdateCheck();
this.electronApp_.quit();
}
@@ -462,6 +469,31 @@ export default class ElectronAppWrapper {
this.customProtocolHandler_ ??= handleCustomProtocols(logger);
}
// Electron's autoUpdater has to be init from the main process
public async initializeAutoUpdaterService(logger: LoggerWrapper, devMode: boolean, includePreReleases: boolean) {
if (shim.isWindows() || shim.isMac()) {
if (!this.updaterService_) {
this.updaterService_ = new AutoUpdaterService(this.win_, logger, devMode, includePreReleases);
this.startPeriodicUpdateCheck();
}
}
}
private startPeriodicUpdateCheck = (updateInterval: number = defaultUpdateInterval): void => {
this.stopPeriodicUpdateCheck();
this.updatePollInterval_ = setInterval(() => {
void this.updaterService_.checkForUpdates();
}, updateInterval);
setTimeout(this.updaterService_.checkForUpdates, initialUpdateStartup);
};
private stopPeriodicUpdateCheck = (): void => {
if (this.updatePollInterval_) {
clearInterval(this.updatePollInterval_);
this.updatePollInterval_ = null;
}
};
public getCustomProtocolHandler() {
return this.customProtocolHandler_;
}
@@ -476,13 +508,6 @@ export default class ElectronAppWrapper {
this.createWindow();
// TODO: Disabled for now - needs to be behind a feature flag
// if (!shim.isLinux()) {
// this.updaterService_ = new AutoUpdaterService();
// this.updaterService_.startPeriodicUpdateCheck();
// }
this.electronApp_.on('before-quit', () => {
this.willQuitApp_ = true;
});

View File

@@ -2,13 +2,14 @@ const fs = require('fs');
const path = require('path');
const os = require('os');
const sha512 = require('js-sha512');
const distDirName = 'dist';
const distPath = path.join(__dirname, distDirName);
const generateChecksumFile = () => {
if (os.platform() !== 'linux') {
return []; // SHA-512 is only for AppImage
}
const distDirName = 'dist';
const distPath = path.join(__dirname, distDirName);
let appImageName = '';
const files = fs.readdirSync(distPath);
for (const key in files) {
@@ -30,4 +31,8 @@ const generateChecksumFile = () => {
return [sha512FilePath];
};
exports.default = generateChecksumFile;
const mainHook = () => {
generateChecksumFile();
};
exports.default = mainHook;

View File

@@ -5,7 +5,8 @@ import PluginService, { PluginSettings } from '@joplin/lib/services/plugins/Plug
import resourceEditWatcherReducer, { defaultState as resourceEditWatcherDefaultState } from '@joplin/lib/services/ResourceEditWatcher/reducer';
import PluginRunner from './services/plugins/PluginRunner';
import PlatformImplementation from './services/plugins/PlatformImplementation';
import shim from '@joplin/lib/shim';
import type ShimType from '@joplin/lib/shim';
const shim: typeof ShimType = require('@joplin/lib/shim').default;
import AlarmService from '@joplin/lib/services/AlarmService';
import AlarmServiceDriverNode from '@joplin/lib/services/AlarmServiceDriverNode';
import Logger, { TargetType } from '@joplin/utils/Logger';
@@ -403,6 +404,16 @@ class Application extends BaseApplication {
eventManager.on(EventName.ResourceChange, handleResourceChange);
}
private async setupAutoUpdaterService() {
if (Setting.value('featureFlag.autoUpdaterServiceEnabled')) {
await bridge().electronApp().initializeAutoUpdaterService(
Logger.create('AutoUpdaterService'),
Setting.value('env') === 'dev',
Setting.value('autoUpdate.includePreReleases'),
);
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
public async start(argv: string[], startOptions: StartOptions = null): Promise<any> {
// If running inside a package, the command line, instead of being "node.exe <path> <flags>" is "joplin.exe <flags>" so
@@ -569,17 +580,19 @@ class Application extends BaseApplication {
// Note: Auto-update is a misnomer in the code.
// The code below only checks, if a new version is available.
// We only allow Windows and macOS users to automatically check for updates
if (shim.isWindows() || shim.isMac()) {
const runAutoUpdateCheck = () => {
if (Setting.value('autoUpdateEnabled')) {
void checkForUpdates(true, bridge().window(), { includePreReleases: Setting.value('autoUpdate.includePreReleases') });
}
};
if (!Setting.value('featureFlag.autoUpdaterServiceEnabled')) {
if (shim.isWindows() || shim.isMac()) {
const runAutoUpdateCheck = () => {
if (Setting.value('autoUpdateEnabled')) {
void checkForUpdates(true, bridge().window(), { includePreReleases: Setting.value('autoUpdate.includePreReleases') });
}
};
// Initial check on startup
shim.setTimeout(() => { runAutoUpdateCheck(); }, 5000);
// Then every x hours
shim.setInterval(() => { runAutoUpdateCheck(); }, 12 * 60 * 60 * 1000);
// Initial check on startup
shim.setTimeout(() => { runAutoUpdateCheck(); }, 5000);
// Then every x hours
shim.setInterval(() => { runAutoUpdateCheck(); }, 12 * 60 * 60 * 1000);
}
}
initializeUserFetcher();
@@ -685,6 +698,8 @@ class Application extends BaseApplication {
SearchEngine.instance().scheduleSyncTables();
});
await this.setupAutoUpdaterService();
// setTimeout(() => {
// void populateDatabase(reg.db(), {
// clearDatabase: true,

View File

@@ -0,0 +1,25 @@
const fs = require('fs');
const path = require('path');
const os = require('os');
const distDirName = 'dist';
const distPath = path.join(__dirname, distDirName);
const renameLatestYmlFile = () => {
if (os.platform() === 'darwin' && process.arch === 'arm64') {
const latestMacFilePath = path.join(distPath, 'latest-mac.yml');
const renamedMacFilePath = path.join(distPath, 'latest-mac-arm64.yml');
if (fs.existsSync(latestMacFilePath)) {
fs.renameSync(latestMacFilePath, renamedMacFilePath);
return [renamedMacFilePath];
} else {
throw new Error('latest-mac.yml not found!');
}
}
};
const mainHook = () => {
renameLatestYmlFile();
};
exports.default = mainHook;

View File

@@ -4,14 +4,18 @@ export const declaration: CommandDeclaration = {
name: 'focusElement',
};
export interface FocusElementOptions {
moveCursorToStart: boolean;
}
export const runtime = (): CommandRuntime => {
return {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
execute: async (_context: any, target: string) => {
if (target === 'noteBody') return CommandService.instance().execute('focusElementNoteBody');
execute: async (_context: any, target: string, options?: FocusElementOptions) => {
if (target === 'noteBody') return CommandService.instance().execute('focusElementNoteBody', options);
if (target === 'noteList') return CommandService.instance().execute('focusElementNoteList');
if (target === 'sideBar') return CommandService.instance().execute('focusElementSideBar');
if (target === 'noteTitle') return CommandService.instance().execute('focusElementNoteTitle');
if (target === 'noteTitle') return CommandService.instance().execute('focusElementNoteTitle', options);
throw new Error(`Invalid focus target: ${target}`);
},
};

View File

@@ -6,7 +6,7 @@ describe('normalizeAccelerator', () => {
['Z', { v6: 'z', v5: 'Z' }],
['Alt+A', { v6: 'Alt-a', v5: 'Alt-A' }],
['Shift+A', { v6: 'Shift-a', v5: 'Shift-A' }],
['Shift+Up', { v6: 'Shift-Up', v5: 'Shift-Up' }],
['Shift+Up', { v6: 'Shift-ArrowUp', v5: 'Shift-Up' }],
])(
'should convert single-letter key names to lowercase for CM6, keep case unchanged for CM5 (%j)',
(original, expected) => {

View File

@@ -12,11 +12,17 @@ const normalizeAccelerator = (accelerator: string, editorVersion: CodeMirrorVers
const parts = command.split(/-(?!$)/);
let name = parts[parts.length - 1];
// In CodeMirror 6, an uppercase single-letter key name makes the editor
// require the shift key to activate the shortcut. If a key name like `Up`,
// however, `.toLowerCase` breaks the shortcut.
if (editorVersion === CodeMirrorVersion.CodeMirror6 && name.length === 1) {
name = name.toLowerCase();
if (editorVersion === CodeMirrorVersion.CodeMirror6) {
// In CodeMirror 6, an uppercase single-letter key name makes the editor
// require the shift key to activate the shortcut. If a key name like `Up`,
// however, `.toLowerCase` breaks the shortcut.
if (name.length === 1) {
name = name.toLowerCase();
}
if (['Up', 'Down', 'Left', 'Right'].includes(name)) {
name = `Arrow${name}`;
}
}
let alt, ctrl, shift, cmd;

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import shim from '@joplin/lib/shim';
import Logger from '@joplin/utils/Logger';
import CodeMirror5Emulation from '@joplin/editor/CodeMirror/CodeMirror5Emulation/CodeMirror5Emulation';
@@ -20,16 +20,15 @@ export default function useEditorSearchExtension(CodeMirror: CodeMirror5Emulatio
const overlayTimeoutRef = useRef(null);
overlayTimeoutRef.current = overlayTimeout;
function clearMarkers() {
const clearMarkers = useCallback(() => {
for (let i = 0; i < markers.length; i++) {
markers[i].clear();
}
setMarkers([]);
}
}, [markers]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
function clearOverlay(cm: any) {
const clearOverlay = useCallback((cm: CodeMirror5Emulation) => {
if (overlay) cm.removeOverlay(overlay);
if (scrollbarMarks) {
try {
@@ -47,10 +46,10 @@ export default function useEditorSearchExtension(CodeMirror: CodeMirror5Emulatio
setOverlay(null);
setScrollbarMarks(null);
setOverlayTimeout(null);
}
}, [scrollbarMarks, overlay, overlayTimeout]);
// Modified from codemirror/addons/search/search.js
function searchOverlay(query: RegExp) {
const searchOverlay = useCallback((query: RegExp) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
return { token: function(stream: any) {
query.lastIndex = stream.pos;
@@ -65,13 +64,13 @@ export default function useEditorSearchExtension(CodeMirror: CodeMirror5Emulatio
}
return null;
} };
}
}, []);
// Highlights the currently active found work
// It's possible to get tricky with this functions and just use findNext/findPrev
// but this is fast enough and works more naturally with the current search logic
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
function highlightSearch(cm: any, searchTerm: RegExp, index: number, scrollTo: boolean, withSelection: boolean) {
function highlightSearch(cm: CodeMirror5Emulation, searchTerm: RegExp, index: number, scrollTo: boolean, withSelection: boolean) {
const cursor = cm.getSearchCursor(searchTerm);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
@@ -122,6 +121,12 @@ export default function useEditorSearchExtension(CodeMirror: CodeMirror5Emulatio
options = { selectedIndex: 0, searchTimestamp: 0 };
}
if (options.showEditorMarkers === false) {
clearMarkers();
clearOverlay(this);
return;
}
clearMarkers();
// HIGHLIGHT KEYWORDS

View File

@@ -1,6 +1,7 @@
import { RefObject, useEffect } from 'react';
import { RefObject, useEffect, useMemo, useRef } from 'react';
import usePrevious from '../../../../hooks/usePrevious';
import { RenderedBody } from './types';
import { SearchMarkers } from '../../../utils/useSearchMarkers';
const debounce = require('debounce');
interface Props {
@@ -14,14 +15,31 @@ interface Props {
noteContent: string;
renderedBody: RenderedBody;
showEditorMarkers: boolean;
}
const useEditorSearchHandler = (props: Props) => {
const { webviewRef, editorRef, renderedBody, noteContent, searchMarkers } = props;
const {
webviewRef, editorRef, renderedBody, noteContent, searchMarkers, showEditorMarkers,
} = props;
const previousContent = usePrevious(noteContent);
const previousRenderedBody = usePrevious(renderedBody);
const previousSearchMarkers = usePrevious(searchMarkers);
const showEditorMarkersRef = useRef(showEditorMarkers);
showEditorMarkersRef.current = showEditorMarkers;
// Fixes https://github.com/laurent22/joplin/issues/7565
const debouncedMarkers = useMemo(() => debounce((searchMarkers: SearchMarkers) => {
if (!editorRef.current) return;
if (showEditorMarkersRef.current) {
const matches = editorRef.current.setMarkers(searchMarkers.keywords, searchMarkers.options);
props.setLocalSearchResultCount(matches);
} else {
editorRef.current.setMarkers(searchMarkers.keywords, { ...searchMarkers.options, showEditorMarkers: false });
}
}, 50), [editorRef, props.setLocalSearchResultCount]);
useEffect(() => {
if (!searchMarkers) return () => {};
@@ -37,19 +55,7 @@ const useEditorSearchHandler = (props: Props) => {
if (webviewRef.current && (searchMarkers !== previousSearchMarkers || textChanged)) {
webviewRef.current.send('setMarkers', searchMarkers.keywords, searchMarkers.options);
if (editorRef.current) {
// Fixes https://github.com/laurent22/joplin/issues/7565
const debouncedMarkers = debounce(() => {
const matches = editorRef.current.setMarkers(searchMarkers.keywords, searchMarkers.options);
props.setLocalSearchResultCount(matches);
}, 50);
debouncedMarkers();
return () => {
debouncedMarkers.clear();
};
}
debouncedMarkers(searchMarkers);
}
return () => {};
}, [
@@ -62,6 +68,7 @@ const useEditorSearchHandler = (props: Props) => {
previousContent,
previousRenderedBody,
renderedBody,
debouncedMarkers,
]);
};

View File

@@ -686,6 +686,7 @@ function CodeMirror(props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
editorRef,
noteContent: props.content,
renderedBody,
showEditorMarkers: true,
});
const cellEditorStyle = useMemo(() => {

View File

@@ -16,7 +16,7 @@ import { MarkupToHtml } from '@joplin/renderer';
const { clipboard } = require('electron');
import { reg } from '@joplin/lib/registry';
import ErrorBoundary from '../../../../ErrorBoundary';
import { EditorKeymap, EditorLanguageType, EditorSettings, UserEventSource } from '@joplin/editor/types';
import { EditorKeymap, EditorLanguageType, EditorSettings, SearchState, UserEventSource } from '@joplin/editor/types';
import useStyles from '../utils/useStyles';
import { EditorEvent, EditorEventType } from '@joplin/editor/events';
import useScrollHandler from '../utils/useScrollHandler';
@@ -27,6 +27,7 @@ import useContextMenu from '../utils/useContextMenu';
import useWebviewIpcMessage from '../utils/useWebviewIpcMessage';
import Toolbar from '../Toolbar';
import useEditorSearchHandler from '../utils/useEditorSearchHandler';
import CommandService from '@joplin/lib/services/CommandService';
const logger = Logger.create('CodeMirror6');
const logDebug = (message: string) => logger.debug(message);
@@ -175,6 +176,9 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
}
},
supportsCommand: (name: string) => {
if (name === 'search' && !props.visiblePanes.includes('editor')) {
return false;
}
return name in commands || editorRef.current.supportsCommand(name);
},
execCommand: async (cmd: EditorCommand) => {
@@ -197,7 +201,7 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
return commandOutput;
},
};
}, [props.content, commands, resetScroll, setEditorPercentScroll, setViewerPercentScroll]);
}, [props.content, props.visiblePanes, commands, resetScroll, setEditorPercentScroll, setViewerPercentScroll]);
const webview_domReady = useCallback(() => {
setWebviewReady(true);
@@ -321,6 +325,7 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
editorRef,
noteContent: props.content,
renderedBody,
showEditorMarkers: !props.useLocalSearch,
});
useContextMenu({
@@ -330,6 +335,7 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
editorClassName: 'cm-editor',
});
const lastSearchState = useRef<SearchState|null>(null);
const onEditorEvent = useCallback((event: EditorEvent) => {
if (event.kind === EditorEventType.Scroll) {
editor_scroll();
@@ -337,8 +343,21 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
codeMirror_change(event.value);
} else if (event.kind === EditorEventType.SelectionRangeChange) {
setSelectionRange({ from: event.from, to: event.to });
} else if (event.kind === EditorEventType.UpdateSearchDialog) {
if (lastSearchState.current?.searchText !== event.searchState.searchText) {
props.setLocalSearch(event.searchState.searchText);
}
if (lastSearchState.current?.dialogVisible !== event.searchState.dialogVisible) {
props.setShowLocalSearch(event.searchState.dialogVisible);
}
lastSearchState.current = event.searchState;
}
}, [editor_scroll, codeMirror_change]);
}, [editor_scroll, codeMirror_change, props.setLocalSearch, props.setShowLocalSearch]);
const onSelectPastBeginning = useCallback(() => {
void CommandService.instance().execute('focusElement', 'noteTitle');
}, []);
const editorSettings = useMemo((): EditorSettings => {
const isHTMLNote = props.contentMarkupLanguage === MarkupToHtml.MARKUP_LANGUAGE_HTML;
@@ -389,6 +408,9 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
onEvent={onEditorEvent}
onLogMessage={logDebug}
onEditorPaste={onEditorPaste}
onSelectPastBeginning={onSelectPastBeginning}
externalSearch={props.searchMarkers}
useLocalSearch={props.useLocalSearch}
/>
</div>
);

View File

@@ -13,12 +13,15 @@ import { dirname } from 'path';
import useKeymap from './utils/useKeymap';
import useEditorSearch from '../utils/useEditorSearchExtension';
import CommandService from '@joplin/lib/services/CommandService';
import { SearchMarkers } from '../../../utils/useSearchMarkers';
interface Props extends EditorProps {
style: React.CSSProperties;
pluginStates: PluginStates;
onEditorPaste: (event: Event)=> void;
externalSearch: SearchMarkers;
useLocalSearch: boolean;
}
const Editor = (props: Props, ref: ForwardedRef<CodeMirrorControl>) => {
@@ -117,6 +120,25 @@ const Editor = (props: Props, ref: ForwardedRef<CodeMirrorControl>) => {
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Should run just once
}, []);
useEffect(() => {
if (!editor) {
return;
}
const searchState = editor.getSearchState();
const externalSearchText = props.externalSearch.keywords.map(k => k.value).join(' ') || searchState.searchText;
if (externalSearchText === searchState.searchText && searchState.dialogVisible === props.useLocalSearch) {
return;
}
editor.setSearchState({
...searchState,
dialogVisible: props.useLocalSearch,
searchText: externalSearchText,
});
}, [editor, props.externalSearch, props.useLocalSearch]);
const theme = props.settings.themeData;
useEffect(() => {
if (!editor) return () => {};

View File

@@ -9,6 +9,7 @@ import Logger from '@joplin/utils/Logger';
import CodeMirrorControl from '@joplin/editor/CodeMirror/CodeMirrorControl';
import { MarkupLanguage } from '@joplin/renderer';
import { focus } from '@joplin/lib/utils/focusHandler';
import { FocusElementOptions } from '../../../../../commands/focusElement';
const logger = Logger.create('CodeMirror 6 commands');
@@ -103,9 +104,15 @@ const useEditorCommands = (props: Props) => {
logger.warn('CodeMirror execCommand: unsupported command: ', value.name);
}
},
'editor.focus': () => {
'editor.focus': (options?: FocusElementOptions) => {
if (props.visiblePanes.indexOf('editor') >= 0) {
focus('useEditorCommands::editor.focus', editorRef.current.editor);
if (options?.moveCursorToStart) {
editorRef.current.editor.dispatch({
selection: { anchor: 0 },
scrollIntoView: true,
});
}
} else {
// If we just call focus() then the iframe is focused,
// but not its content, such that scrolling up / down

View File

@@ -38,6 +38,8 @@ const md5 = require('md5');
const { clipboard } = require('electron');
const supportedLocales = require('./supportedLocales');
import { hasProtocol } from '@joplin/utils/url';
import useTabIndenter from './utils/useTabIndenter';
import useKeyboardRefocusHandler from './utils/useKeyboardRefocusHandler';
const logger = Logger.create('TinyMCE');
@@ -128,6 +130,8 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {
usePluginServiceRegistration(ref);
useContextMenu(editor, props.plugins, props.dispatch, props.htmlToMarkdown, props.markupToHtml);
useTabIndenter(editor);
useKeyboardRefocusHandler(editor);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const dispatchDidUpdate = (editor: any) => {
@@ -216,6 +220,13 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {
editor.insertContent(result.html);
} else if (cmd.name === 'editor.focus') {
focus('TinyMCE::editor.focus', editor);
if (cmd.value?.moveCursorToStart) {
editor.selection.placeCaretAt(0, 0);
editor.selection.setCursorLocation(
editor.dom.root,
0,
);
}
} else if (cmd.name === 'editor.execCommand') {
if (!('ui' in cmd.value)) cmd.value.ui = false;
if (!('value' in cmd.value)) cmd.value.value = null;
@@ -629,6 +640,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {
icons_url: 'gui/NoteEditor/NoteBody/TinyMCE/icons.js',
plugins: 'noneditable link joplinLists hr searchreplace codesample table',
noneditable_noneditable_class: 'joplin-editable', // Can be a regex too
iframe_aria_text: _('Rich Text editor. Press Escape then Tab to escape focus.'),
// #p: Pad empty paragraphs with &nbsp; to prevent them from being removed.
// *[*]: Allow all elements and attributes -- we already filter in sanitize_html

View File

@@ -0,0 +1,43 @@
import CommandService from '@joplin/lib/services/CommandService';
import { useEffect } from 'react';
import type { Editor, EditorEvent } from 'tinymce';
const useKeyboardRefocusHandler = (editor: Editor) => {
useEffect(() => {
if (!editor) return () => {};
const isAtBeginningOf = (element: Node, parent: HTMLElement) => {
if (!parent.contains(element)) return false;
while (
element &&
element.parentNode !== parent &&
element.parentNode?.firstChild === element
) {
element = element.parentNode;
}
return !!element && element.parentNode?.firstChild === element;
};
const eventHandler = (event: EditorEvent<KeyboardEvent>) => {
if (!event.isDefaultPrevented() && event.key === 'ArrowUp') {
const selection = editor.selection.getRng();
if (selection.startOffset === 0 &&
selection.collapsed &&
isAtBeginningOf(selection.startContainer, editor.dom.getRoot())
) {
event.preventDefault();
void CommandService.instance().execute('focusElement', 'noteTitle');
}
}
};
editor.on('keydown', eventHandler);
return () => {
editor.off('keydown', eventHandler);
};
}, [editor]);
};
export default useKeyboardRefocusHandler;

View File

@@ -0,0 +1,76 @@
import { useEffect } from 'react';
import type { Editor, EditorEvent } from 'tinymce';
const useTabIndenter = (editor: Editor) => {
useEffect(() => {
if (!editor) return () => {};
const canChangeIndentation = () => {
const selectionElement = editor.selection.getNode();
// List items and tables have their own tab key handlers.
return !selectionElement.closest('li, table') && !editor.readonly;
};
const getSpacesBeforeSelectionRange = (maxLength: number) => {
const selectionRange = editor.selection.getRng();
let rangeStart = selectionRange.startOffset;
let outputRange = selectionRange.cloneRange();
while (rangeStart >= 0) {
rangeStart--;
const lastRange = outputRange.cloneRange();
outputRange.setStart(outputRange.startContainer, Math.max(rangeStart, 0));
const rangeContent = outputRange.toString();
const isWhitespace = rangeContent.match(/^\s*$/);
if (!isWhitespace || rangeContent.length > maxLength) {
outputRange = lastRange;
break;
}
}
return outputRange;
};
const indentLengthChars = 8;
let indentHtml = '';
for (let i = 0; i < indentLengthChars; i++) {
indentHtml += '&nbsp;';
}
let lastKeyWasEscape = false;
const eventHandler = (event: EditorEvent<KeyboardEvent>) => {
if (!event.isDefaultPrevented() && event.key === 'Tab' && canChangeIndentation() && !lastKeyWasEscape) {
if (!event.shiftKey) {
editor.execCommand('mceInsertContent', false, indentHtml);
event.preventDefault();
} else {
const selectionRange = editor.selection.getRng();
if (selectionRange.collapsed) {
const spacesRange = getSpacesBeforeSelectionRange(indentLengthChars);
const hasAtLeastOneSpace = spacesRange.toString().match(/^\s+$/);
if (hasAtLeastOneSpace) {
editor.selection.setRng(spacesRange);
editor.execCommand('Delete', false);
event.preventDefault();
}
}
}
} else if (event.key === 'Escape' && !event.shiftKey && !event.altKey && !event.metaKey && !event.ctrlKey) {
// For accessibility, let Escape followed by tab escape the focus trap.
lastKeyWasEscape = true;
} else if (event.key !== 'Shift') { // Allows Esc->Shift+Tab to escape the focus trap.
lastKeyWasEscape = false;
}
};
editor.on('keydown', eventHandler);
return () => {
editor.off('keydown', eventHandler);
};
}, [editor]);
};
export default useTabIndenter;

View File

@@ -411,6 +411,9 @@ function NoteEditor(props: NoteEditorProps) {
noteToolbar: null,
onScroll: onScroll,
setLocalSearchResultCount: setLocalSearchResultCount,
setLocalSearch: localSearch_change,
setShowLocalSearch,
useLocalSearch: showLocalSearch,
searchMarkers: searchMarkers,
visiblePanes: props.noteVisiblePanes || ['editor', 'viewer'],
keyboardMode: Setting.value('editor.keyboardMode'),
@@ -506,6 +509,7 @@ function NoteEditor(props: NoteEditorProps) {
onPrevious={localSearch_previous}
onClose={localSearch_close}
visiblePanes={props.noteVisiblePanes}
editorType={props.bodyEditor}
/>
);
}

View File

@@ -78,18 +78,13 @@ function styles_(props: Props) {
export default function NoteTitleBar(props: Props) {
const styles = styles_(props);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const onTitleKeydown = useCallback((event: any) => {
const keyCode = event.keyCode;
if (keyCode === 9) { // TAB
const onTitleKeydown: React.KeyboardEventHandler<HTMLInputElement> = useCallback((event) => {
const titleElement = event.currentTarget;
const selectionAtEnd = titleElement.selectionEnd === titleElement.value.length;
if ((event.key === 'ArrowDown' && selectionAtEnd) || event.key === 'Enter') {
event.preventDefault();
if (event.shiftKey) {
void CommandService.instance().execute('focusElement', 'noteList');
} else {
void CommandService.instance().execute('focusElement', 'noteBody');
}
const moveCursorToStart = event.key === 'ArrowDown';
void CommandService.instance().execute('focusElement', 'noteBody', { moveCursorToStart });
}
}, []);

View File

@@ -1,5 +1,6 @@
import { CommandRuntime, CommandDeclaration } from '@joplin/lib/services/CommandService';
import { _ } from '@joplin/lib/locale';
import { FocusElementOptions } from '../../../commands/focusElement';
export const declaration: CommandDeclaration = {
name: 'focusElementNoteBody',
@@ -10,8 +11,8 @@ export const declaration: CommandDeclaration = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
export const runtime = (comp: any): CommandRuntime => {
return {
execute: async () => {
comp.editorRef.current.execCommand({ name: 'editor.focus' });
execute: async (_context: unknown, options?: FocusElementOptions) => {
comp.editorRef.current.execCommand({ name: 'editor.focus', value: options });
},
enabledCondition: 'oneNoteSelected',
};

View File

@@ -2,6 +2,7 @@ import { FormNote } from './types';
import HtmlToMd from '@joplin/lib/HtmlToMd';
import Note from '@joplin/lib/models/Note';
import { NoteEntity } from '@joplin/lib/services/database/types';
const { MarkupToHtml } = require('@joplin/renderer');
export async function htmlToMarkdown(markupLanguage: number, html: string, originalCss: string): Promise<string> {
@@ -23,8 +24,7 @@ export async function htmlToMarkdown(markupLanguage: number, html: string, origi
return newBody;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
export async function formNoteToNote(formNote: FormNote): Promise<any> {
export async function formNoteToNote(formNote: FormNote): Promise<NoteEntity> {
return {
id: formNote.id,
// Should also include parent_id and deleted_time so that the reducer
@@ -32,6 +32,7 @@ export async function formNoteToNote(formNote: FormNote): Promise<any> {
// https://discourse.joplinapp.org/t/experimental-wysiwyg-editor-in-joplin/6915/57?u=laurent
parent_id: formNote.parent_id,
deleted_time: formNote.deleted_time,
is_conflict: formNote.is_conflict,
title: formNote.title,
body: formNote.body,
};

View File

@@ -6,6 +6,7 @@ import { RenderResult, RenderResultPluginAsset } from '@joplin/renderer/types';
import { Dispatch } from 'redux';
import { ProcessResultsRow } from '@joplin/lib/services/search/SearchEngine';
import { DropHandler } from './useDropHandler';
import { SearchMarkers } from './useSearchMarkers';
export interface AllAssetsOptions {
contentMaxWidthTarget?: string;
@@ -119,8 +120,11 @@ export interface NoteBodyEditorProps {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
noteToolbar: any;
setLocalSearchResultCount(count: number): void;
setLocalSearch(search: string): void;
setShowLocalSearch(show: boolean): void;
useLocalSearch: boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
searchMarkers: any;
searchMarkers: SearchMarkers;
visiblePanes: string[];
keyboardMode: string;
resourceInfos: ResourceInfos;
@@ -144,6 +148,7 @@ export interface FormNote {
body: string;
parent_id: string;
is_todo: number;
is_conflict?: number;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
bodyEditorContent?: any;
markup_language: number;

View File

@@ -1,9 +1,11 @@
import Note from '@joplin/lib/models/Note';
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
import { setupDatabaseAndSynchronizer, supportDir, switchClient } from '@joplin/lib/testing/test-utils';
import { act, renderHook } from '@testing-library/react-hooks';
import useFormNote, { HookDependencies } from './useFormNote';
import shim from '@joplin/lib/shim';
import Resource from '@joplin/lib/models/Resource';
import { join } from 'path';
import { formNoteToNote } from '.';
const defaultFormNoteProps: HookDependencies = {
syncStarted: false,
@@ -81,6 +83,39 @@ describe('useFormNote', () => {
formNote.unmount();
});
// Lacking is_conflict has previously caused UI issues. See https://github.com/laurent22/joplin/pull/10913
// for details.
it('should preserve value of is_conflict on save', async () => {
const testNote = await Note.save({ title: 'Test Note!', is_conflict: 1 });
const makeFormNoteProps = (): HookDependencies => {
return {
...defaultFormNoteProps,
noteId: testNote.id,
};
};
const formNote = renderHook(props => useFormNote(props), {
initialProps: makeFormNoteProps(),
});
await formNote.waitFor(() => {
expect(formNote.result.current.formNote).toMatchObject({
is_conflict: 1,
title: testNote.title,
});
});
// Should preserve is_conflict after save.
expect(await formNoteToNote(formNote.result.current.formNote)).toMatchObject({
is_conflict: 1,
deleted_time: 0,
title: testNote.title,
});
formNote.unmount();
});
// It seems this test is crashing the worker on CI (out of memory), so disabling it for now.
// it('should reload the note when it is changed outside of the editor', async () => {
@@ -115,7 +150,7 @@ describe('useFormNote', () => {
test('should refresh resource infos when changed outside the editor', async () => {
let note = await Note.save({});
note = await shim.attachFileToNote(note, __filename);
note = await shim.attachFileToNote(note, join(supportDir, 'sample.txt'));
const resourceIds = Note.linkedItemIds(note.body);
const resource = await Resource.load(resourceIds[0]);
@@ -141,12 +176,12 @@ describe('useFormNote', () => {
});
await act(async () => {
await Resource.save({ ...resource, filename: 'test.ts' });
await Resource.save({ ...resource, filename: 'test.txt' });
});
await formNote.waitFor(() => {
const resourceInfo = formNote.result.current.resourceInfos[resource.id];
expect(resourceInfo.item).toMatchObject({
id: resource.id, filename: 'test.ts',
id: resource.id, filename: 'test.txt',
});
});

View File

@@ -101,6 +101,7 @@ export default function useFormNote(dependencies: HookDependencies) {
is_todo: n.is_todo,
parent_id: n.parent_id,
deleted_time: n.deleted_time,
is_conflict: n.is_conflict,
bodyWillChangeId: 0,
bodyChangeId: 0,
markup_language: n.markup_language,

View File

@@ -6,7 +6,8 @@ import shim from '@joplin/lib/shim';
const { themeStyle } = require('@joplin/lib/theme');
import Note from '@joplin/lib/models/Note';
import { MarkupToHtmlOptions } from './types';
import { MarkupToHtmlOptions, ResourceInfos } from './types';
import { resourceFullPath } from '@joplin/lib/models/utils/resourceUtils';
interface HookDependencies {
themeId: number;
@@ -20,12 +21,16 @@ interface HookDependencies {
export default function useMarkupToHtml(deps: HookDependencies) {
const { themeId, customCss, plugins, whiteBackgroundNoteRendering } = deps;
const resourceBaseUrl = useMemo(() => {
return `joplin-content://note-viewer/${Setting.value('resourceDir')}/`;
}, []);
const markupToHtml = useMemo(() => {
return markupLanguageUtils.newMarkupToHtml(plugins, {
resourceBaseUrl: `joplin-content://note-viewer/${Setting.value('resourceDir')}/`,
resourceBaseUrl,
customCss: customCss || '',
});
}, [plugins, customCss]);
}, [plugins, customCss, resourceBaseUrl]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
return useCallback(async (markupLanguage: number, md: string, options: MarkupToHtmlOptions = null): Promise<any> => {
@@ -39,7 +44,7 @@ export default function useMarkupToHtml(deps: HookDependencies) {
md = md || '';
const theme = themeStyle(themeId);
let resources = {};
let resources: ResourceInfos = {};
if (options.replaceResourceInternalToExternalLinks) {
md = await Note.replaceResourceInternalToExternalLinks(md, { useAbsolutePaths: true });
@@ -58,9 +63,16 @@ export default function useMarkupToHtml(deps: HookDependencies) {
codeHighlightCacheKey: 'useMarkupToHtml',
settingValue: deps.settingValue,
whiteBackgroundNoteRendering,
itemIdToUrl: (id: string, urlParameters = '') => {
if (!(id in resources) || !resources[id]) {
return null;
}
return resourceFullPath(resources[id].item, resourceBaseUrl) + urlParameters;
},
...options,
});
return result;
}, [themeId, markupToHtml, whiteBackgroundNoteRendering, deps.settingValue]);
}, [themeId, markupToHtml, whiteBackgroundNoteRendering, resourceBaseUrl, deps.settingValue]);
}

View File

@@ -23,6 +23,10 @@ import useDragAndDrop from './utils/useDragAndDrop';
import { itemIsInTrash } from '@joplin/lib/services/trash';
import getEmptyFolderMessage from '@joplin/lib/components/shared/NoteList/getEmptyFolderMessage';
import Folder from '@joplin/lib/models/Folder';
import { _ } from '@joplin/lib/locale';
import useActiveDescendantId from './utils/useActiveDescendantId';
import getNoteElementIdFromJoplinId from '../NoteListItem/utils/getNoteElementIdFromJoplinId';
import useFocusVisible from './utils/useFocusVisible';
const { connect } = require('react-redux');
const commands = {
@@ -30,7 +34,7 @@ const commands = {
};
const NoteList = (props: Props) => {
const listRef = useRef(null);
const listRef = useRef<HTMLDivElement>(null);
const itemRefs = useRef<Record<string, HTMLDivElement>>({});
const listRenderer = props.listRenderer;
@@ -65,7 +69,8 @@ const NoteList = (props: Props) => {
props.notes.length,
);
const focusNote = useFocusNote(itemRefs, props.notes, makeItemIndexVisible);
const { activeNoteId, setActiveNoteId } = useActiveDescendantId(props.selectedFolderId, props.selectedNoteIds);
const focusNote = useFocusNote(listRef, props.notes, makeItemIndexVisible, setActiveNoteId);
const moveNote = useMoveNote(
props.notesParentType,
@@ -98,6 +103,7 @@ const NoteList = (props: Props) => {
const onNoteClick = useOnNoteClick(props.dispatch, focusNote);
const onKeyDown = useOnKeyDown(
activeNoteId,
props.selectedNoteIds,
moveNote,
makeItemIndexVisible,
@@ -177,6 +183,10 @@ const NoteList = (props: Props) => {
// }
// }, [makeItemIndexVisible, previousSelectedNoteIds, previousNoteCount, previousVisible, props.selectedNoteIds, props.notes, props.focusedField, props.visible]);
const { focusVisible, onFocus, onBlur, onKeyUp } = useFocusVisible(listRef, () => {
focusNote(activeNoteId);
});
const highlightedWords = useMemo(() => {
if (props.notesParentType === 'Search') {
const query = BaseModel.byId(props.searches, props.selectedSearchId);
@@ -197,12 +207,13 @@ const NoteList = (props: Props) => {
};
const renderNotes = () => {
if (!props.notes.length) return null;
if (!props.notes.length) return [];
const output: JSX.Element[] = [];
for (let i = startNoteIndex; i <= endNoteIndex; i++) {
const note = props.notes[i];
const isSelected = props.selectedNoteIds.includes(note.id);
output.push(
<NoteListItem
@@ -222,7 +233,9 @@ const NoteList = (props: Props) => {
isProvisional={props.provisionalNoteIds.includes(note.id)}
flow={listRenderer.flow}
note={note}
isSelected={props.selectedNoteIds.includes(note.id)}
tabIndex={-1}
focusVisible={focusVisible && activeNoteId === note.id}
isSelected={isSelected}
isWatched={props.watchedNoteFiles.includes(note.id)}
listRenderer={listRenderer}
dispatch={props.dispatch}
@@ -264,16 +277,26 @@ const NoteList = (props: Props) => {
return (
<div
role='listbox'
aria-label={_('Notes')}
aria-activedescendant={getNoteElementIdFromJoplinId(activeNoteId)}
aria-multiselectable={true}
tabIndex={0}
onFocus={onFocus}
onBlur={onBlur}
className="note-list"
style={noteListStyle}
ref={listRef}
onScroll={onScroll}
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
onDrop={onDrop}
>
{renderEmptyList()}
{renderFiller('top', topFillerStyle)}
<div className="notes" style={notesStyle}>
<div className='notes' role='presentation' style={notesStyle}>
{renderNotes()}
</div>
{renderFiller('bottom', bottomFillerStyle)}

View File

@@ -18,6 +18,12 @@
background-color: var(--joplin-background-color);
font-family: var(--joplin-font-family);
}
// focus-visible is communicated by displaying the active item in a different style.
// As such, an outline is unnecessary.
&:focus-visible {
outline: unset;
}
}
.note-list-item {

View File

@@ -0,0 +1,38 @@
import { useEffect, useRef, useState } from 'react';
import usePrevious from '@joplin/lib/hooks/usePrevious';
const useActiveDescendantId = (selectedFolderId: string, selectedNoteIds: string[]) => {
const selectedNoteIdsRef = useRef(selectedNoteIds);
selectedNoteIdsRef.current = selectedNoteIds;
const [activeNoteId, setActiveNoteId] = useState('');
useEffect(() => {
setActiveNoteId(selectedNoteIdsRef.current?.[0] ?? '');
}, [selectedFolderId]);
const previousNoteIdsRef = useRef<string[]>();
previousNoteIdsRef.current = usePrevious(selectedNoteIds);
useEffect(() => {
const previousNoteIds = previousNoteIdsRef.current ?? [];
setActiveNoteId(current => {
if (selectedNoteIds.includes(current)) {
return current;
} else {
// Prefer added items
for (const id of selectedNoteIds) {
if (!previousNoteIds.includes(id)) {
return id;
}
}
return selectedNoteIds[0] ?? '';
}
});
}, [selectedNoteIds]);
return { activeNoteId, setActiveNoteId };
};
export default useActiveDescendantId;

View File

@@ -1,44 +1,33 @@
import shim from '@joplin/lib/shim';
import { useRef, useCallback, MutableRefObject } from 'react';
import { useRef, useCallback, RefObject } from 'react';
import { focus } from '@joplin/lib/utils/focusHandler';
import { NoteEntity } from '@joplin/lib/services/database/types';
export type FocusNote = (noteId: string)=> void;
type ItemRefs = MutableRefObject<Record<string, HTMLDivElement>>;
type ContainerRef = RefObject<HTMLElement>;
type OnMakeIndexVisible = (i: number)=> void;
type OnSetActiveId = (id: string)=> void;
const useFocusNote = (itemRefs: ItemRefs, notes: NoteEntity[], makeItemIndexVisible: OnMakeIndexVisible) => {
const focusItemIID = useRef(null);
const useFocusNote = (
containerRef: ContainerRef, notes: NoteEntity[], makeItemIndexVisible: OnMakeIndexVisible, setActiveNoteId: OnSetActiveId,
) => {
const notesRef = useRef(notes);
notesRef.current = notes;
const focusNote: FocusNote = useCallback((noteId: string) => {
// - We need to focus the item manually otherwise focus might be lost when the
// list is scrolled and items within it are being rebuilt.
// - We need to use an interval because when leaving the arrow pressed or scrolling
// offscreen items into view, the rendering of items might lag behind and so the
// ref is not yet available at this point.
if (!itemRefs.current[noteId]) {
const targetIndex = notesRef.current.findIndex(note => note.id === noteId);
if (targetIndex > -1) {
makeItemIndexVisible(targetIndex);
}
if (focusItemIID.current) shim.clearInterval(focusItemIID.current);
focusItemIID.current = shim.setInterval(() => {
if (itemRefs.current[noteId]) {
focus('useFocusNote1', itemRefs.current[noteId]);
shim.clearInterval(focusItemIID.current);
focusItemIID.current = null;
}
}, 10);
} else {
if (focusItemIID.current) shim.clearInterval(focusItemIID.current);
focus('useFocusNote2', itemRefs.current[noteId]);
if (noteId) {
setActiveNoteId(noteId);
}
}, [itemRefs, makeItemIndexVisible]);
// The note list container should have focus even when a note list item is visibly selected.
// The visibly focused item is determined by activeNoteId and is communicated to accessibility
// tools using aria- attributes
focus('useFocusNote', containerRef.current);
const targetIndex = notesRef.current.findIndex(note => note.id === noteId);
if (targetIndex > -1) {
makeItemIndexVisible(targetIndex);
}
}, [containerRef, makeItemIndexVisible, setActiveNoteId]);
return focusNote;
};

View File

@@ -0,0 +1,45 @@
import { useCallback, useState, useRef, RefObject } from 'react';
const useFocusVisible = (containerRef: RefObject<HTMLElement>, onFocusEnter: ()=> void) => {
const [focusVisible, setFocusVisible] = useState(false);
const onFocusEnterRef = useRef(onFocusEnter);
onFocusEnterRef.current = onFocusEnter;
const focusVisibleRef = useRef(focusVisible);
focusVisibleRef.current = focusVisible;
const onFocusVisible = useCallback(() => {
if (!focusVisibleRef.current) {
setFocusVisible(true);
onFocusEnterRef.current();
}
}, []);
const onFocus = useCallback(() => {
if (containerRef.current.matches(':focus-visible')) {
onFocusVisible();
}
}, [containerRef, onFocusVisible]);
const onKeyUp = useCallback(() => {
if (containerRef.current.contains(document.activeElement)) {
onFocusVisible();
}
}, [containerRef, onFocusVisible]);
const onBlur = useCallback(() => setFocusVisible(false), []);
return {
focusVisible,
onFocus,
// When focus becomes visible due to a key press, but the item was already
// focused, no new focus event is emitted and the browser :focus-visible doesn't
// change. As such, we need to handle this case ourselves.
onKeyUp,
onBlur,
};
};
export default useFocusVisible;

View File

@@ -8,8 +8,11 @@ import { Dispatch } from 'redux';
import { FocusNote } from './useFocusNote';
import { ItemFlow } from '@joplin/lib/services/plugins/api/noteListType';
import { KeyboardEventKey } from '@joplin/lib/dom';
import announceForAccessibility from '../../utils/announceForAccessibility';
import { _ } from '@joplin/lib/locale';
const useOnKeyDown = (
activeNoteId: string,
selectedNoteIds: string[],
moveNote: (direction: number, inc: number)=> void,
makeItemIndexVisible: (itemIndex: number)=> void,
@@ -84,18 +87,32 @@ const useOnKeyDown = (
}
}
event.preventDefault();
} else if (noteIds.length > 0 && (key === 'ArrowDown' || key === 'ArrowUp' || key === 'ArrowLeft' || key === 'ArrowRight' || key === 'PageDown' || key === 'PageUp' || key === 'End' || key === 'Home')) {
const noteId = noteIds[0];
} else if (notes.length > 0 && (key === 'ArrowDown' || key === 'ArrowUp' || key === 'ArrowLeft' || key === 'ArrowRight' || key === 'PageDown' || key === 'PageUp' || key === 'End' || key === 'Home')) {
const noteId = activeNoteId ?? notes[0]?.id;
let noteIndex = BaseModel.modelIndexById(notes, noteId);
noteIndex = scrollNoteIndex(visibleItemCount, key, event.ctrlKey, event.metaKey, noteIndex);
const newSelectedNote = notes[noteIndex];
dispatch({
type: 'NOTE_SELECT',
id: newSelectedNote.id,
});
if (event.shiftKey) {
if (selectedNoteIds.includes(newSelectedNote.id)) {
dispatch({
type: 'NOTE_SELECT_REMOVE',
id: noteId,
});
}
dispatch({
type: 'NOTE_SELECT_ADD',
id: newSelectedNote.id,
});
} else {
dispatch({
type: 'NOTE_SELECT',
id: newSelectedNote.id,
});
}
makeItemIndexVisible(noteIndex);
@@ -122,8 +139,7 @@ const useOnKeyDown = (
event.preventDefault();
const selectedNotes = BaseModel.modelsByIds(notes, noteIds);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const todos = selectedNotes.filter((n: any) => !!n.is_todo);
const todos = selectedNotes.filter(n => !!n.is_todo);
if (!todos.length) return;
for (let i = 0; i < todos.length; i++) {
@@ -131,7 +147,10 @@ const useOnKeyDown = (
await Note.save(toggledTodo);
}
dispatch({ type: 'NOTE_SORT' });
focusNote(todos[0].id);
const wasCompleted = !!todos[0].todo_completed;
announceForAccessibility(!wasCompleted ? _('Complete') : _('Incomplete'));
}
if (key === 'Tab') {
@@ -151,7 +170,7 @@ const useOnKeyDown = (
type: 'NOTE_SELECT_ALL',
});
}
}, [moveNote, focusNote, visibleItemCount, scrollNoteIndex, makeItemIndexVisible, notes, selectedNoteIds, dispatch, flow, itemsPerLine]);
}, [moveNote, focusNote, visibleItemCount, scrollNoteIndex, makeItemIndexVisible, notes, selectedNoteIds, activeNoteId, dispatch, flow, itemsPerLine]);
return onKeyDown;

View File

@@ -10,6 +10,7 @@ import Note from '@joplin/lib/models/Note';
import { NoteEntity } from '@joplin/lib/services/database/types';
import useRenderedNote from './utils/useRenderedNote';
import { Dispatch } from 'redux';
import getNoteElementIdFromJoplinId from './utils/getNoteElementIdFromJoplinId';
interface NoteItemProps {
dragIndex: number;
@@ -26,8 +27,12 @@ interface NoteItemProps {
onDragStart: DragEventHandler;
style: CSSProperties;
note: NoteEntity;
isSelected: boolean;
isWatched: boolean;
isSelected: boolean;
tabIndex: number;
focusVisible: boolean;
listRenderer: ListRenderer;
columns: NoteListColumns;
dispatch: Dispatch;
@@ -35,7 +40,7 @@ interface NoteItemProps {
const NoteListItem = (props: NoteItemProps, ref: LegacyRef<HTMLDivElement>) => {
const noteId = props.note.id;
const elementId = `list-note-${noteId}`;
const elementId = getNoteElementIdFromJoplinId(noteId);
const onInputChange: OnInputChange = useCallback(async (event: ChangeEvent<HTMLInputElement>) => {
const getValue = (element: HTMLInputElement) => {
@@ -70,6 +75,7 @@ const NoteListItem = (props: NoteItemProps, ref: LegacyRef<HTMLDivElement>) => {
rootElement,
noteId,
renderedNote ? renderedNote.html : '',
props.focusVisible,
props.style,
props.itemSize,
props.onClick,
@@ -147,13 +153,18 @@ const NoteListItem = (props: NoteItemProps, ref: LegacyRef<HTMLDivElement>) => {
id={elementId}
ref={ref}
draggable={true}
tabIndex={0}
tabIndex={props.tabIndex}
className={className}
data-id={noteId}
style={{ height: props.itemSize.height }}
onContextMenu={props.onContextMenu}
onDragStart={props.onDragStart}
onDragOver={props.onDragOver}
aria-selected={props.isSelected}
aria-posinset={1 + props.index}
aria-setsize={props.noteCount}
role='option'
>
<div className="dragcursor" style={dragCursorStyle}></div>
</div>;

View File

@@ -0,0 +1,4 @@
const getNoteElementIdFromJoplinId = (id: string) => `list-note-${id}`;
export default getNoteElementIdFromJoplinId;

View File

@@ -2,6 +2,7 @@ import { ListRendererDependency } from '@joplin/lib/services/plugins/api/noteLis
import { FolderEntity, NoteEntity, TagEntity } from '@joplin/lib/services/database/types';
import { Size } from '@joplin/utils/types';
import Note from '@joplin/lib/models/Note';
import { _ } from '@joplin/lib/locale';
const prepareViewProps = async (
dependencies: ListRendererDependency[],
@@ -33,6 +34,12 @@ const prepareViewProps = async (
} else if (dep === 'note.folder.title') {
if (!output.note.folder) output.note.folder = {};
output.note.folder[propName] = folder.title;
} else if (dep === 'note.todoStatusText') {
let taskStatus = '';
if (note.is_todo) {
taskStatus = note.todo_completed ? _('Complete to-do') : _('Incomplete to-do');
}
output.note[propName] = taskStatus;
} else {
// The notes in the state only contain the properties defined in
// Note.previewFields(). It means that if a view request a

View File

@@ -3,8 +3,9 @@ import { Size } from '@joplin/utils/types';
import { useEffect, useState } from 'react';
import { ItemFlow } from '@joplin/lib/services/plugins/api/noteListType';
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const useItemElement = (rootElement: HTMLDivElement, noteId: string, noteHtml: string, style: any, itemSize: Size, onClick: React.MouseEventHandler<HTMLDivElement>, flow: ItemFlow) => {
const useItemElement = (
rootElement: HTMLDivElement, noteId: string, noteHtml: string, focusVisible: boolean, style: React.CSSProperties, itemSize: Size, onClick: React.MouseEventHandler<HTMLDivElement>, flow: ItemFlow,
) => {
const [itemElement, setItemElement] = useState<HTMLDivElement>(null);
useEffect(() => {
@@ -32,6 +33,16 @@ const useItemElement = (rootElement: HTMLDivElement, noteId: string, noteHtml: s
};
}, [rootElement, itemSize, noteHtml, noteId, style, onClick, flow]);
useEffect(() => {
if (!itemElement) return;
if (focusVisible) {
itemElement.classList.add('-focus-visible');
} else {
itemElement.classList.remove('-focus-visible');
}
}, [focusVisible, itemElement]);
return itemElement;
};

View File

@@ -18,6 +18,7 @@ interface Props {
resultCount: number;
selectedIndex: number;
visiblePanes: string[];
editorType: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
style: any;
}
@@ -26,6 +27,7 @@ class NoteSearchBar extends React.Component<Props> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
private backgroundColor: any;
private searchInputRef: React.RefObject<HTMLInputElement>;
public constructor(props: Props) {
super(props);
@@ -40,6 +42,7 @@ class NoteSearchBar extends React.Component<Props> {
this.focus = this.focus.bind(this);
this.backgroundColor = undefined;
this.searchInputRef = React.createRef();
}
public style() {
@@ -133,10 +136,8 @@ class NoteSearchBar extends React.Component<Props> {
}
public focus() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
focus('NoteSearchBar::focus', this.refs.searchInput as any);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
(this.refs.searchInput as any).select();
focus('NoteSearchBar::focus', this.searchInputRef.current);
this.searchInputRef.current?.select();
}
public render() {
@@ -173,7 +174,9 @@ class NoteSearchBar extends React.Component<Props> {
</div>
) : null;
const allowScrolling = this.props.visiblePanes.indexOf('editor') >= 0;
const editorVisible = this.props.visiblePanes.includes('editor');
const usesEditorSearch = this.props.editorType === 'CodeMirror6' && editorVisible;
const allowScrolling = editorVisible;
const viewerWarning = (
<div style={textStyle}>
@@ -181,6 +184,8 @@ class NoteSearchBar extends React.Component<Props> {
</div>
);
if (usesEditorSearch) return null;
return (
<div className="note-search-bar" style={this.props.style}>
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}>
@@ -190,7 +195,7 @@ class NoteSearchBar extends React.Component<Props> {
value={query}
onChange={this.searchInput_change}
onKeyDown={this.searchInput_keyDown}
ref="searchInput"
ref={this.searchInputRef}
type="text"
style={{ width: 200, marginRight: 5, backgroundColor: this.backgroundColor, color: theme.color }}
/>

View File

@@ -51,6 +51,7 @@ const FolderAndTagList: React.FC<Props> = props => {
listItems: listItems,
selectedIndex,
updateSelectedIndex,
collapsedFolderIds: props.collapsedFolderIds,
});
const itemListRef = useRef<ItemList<ListItem>>();

View File

@@ -1,28 +1,44 @@
import { Dispatch } from 'redux';
import { ListItem, ListItemType, SetSelectedIndexCallback } from '../types';
import { FolderListItem, ListItem, ListItemType, SetSelectedIndexCallback } from '../types';
import { KeyboardEventHandler, useCallback } from 'react';
import CommandService from '@joplin/lib/services/CommandService';
interface Props {
dispatch: Dispatch;
listItems: ListItem[];
collapsedFolderIds: string[];
selectedIndex: number;
updateSelectedIndex: SetSelectedIndexCallback;
}
const isToggleShortcut = (keyCode: string, selectedItem: FolderListItem, collapsedFolderIds: string[]) => {
if (!['Space', 'ArrowLeft', 'ArrowRight'].includes(keyCode)) {
return false;
}
if (keyCode === 'Space') {
return true;
}
const isCollapsed = collapsedFolderIds.includes(selectedItem.folder.id);
return (keyCode === 'ArrowRight') === isCollapsed;
};
const useOnSidebarKeyDownHandler = (props: Props) => {
const { updateSelectedIndex, listItems, selectedIndex, dispatch } = props;
const { updateSelectedIndex, listItems, selectedIndex, collapsedFolderIds, dispatch } = props;
return useCallback<KeyboardEventHandler<HTMLElement>>((event) => {
const selectedItem = listItems[selectedIndex];
if (selectedItem && selectedItem.kind === ListItemType.Folder && event.code === 'Space') {
if (selectedItem?.kind === ListItemType.Folder && isToggleShortcut(event.code, selectedItem, collapsedFolderIds)) {
event.preventDefault();
dispatch({
type: 'FOLDER_TOGGLE',
id: selectedItem.folder.id,
});
} else if ((event.ctrlKey || event.metaKey) && event.code === 'KeyA') { // ctrl+a or cmd+a
}
if ((event.ctrlKey || event.metaKey) && event.code === 'KeyA') { // ctrl+a or cmd+a
event.preventDefault();
}
@@ -45,7 +61,7 @@ const useOnSidebarKeyDownHandler = (props: Props) => {
event.preventDefault();
updateSelectedIndex(selectedIndex + indexChange);
}
}, [selectedIndex, listItems, updateSelectedIndex, dispatch]);
}, [selectedIndex, collapsedFolderIds, listItems, updateSelectedIndex, dispatch]);
};
export default useOnSidebarKeyDownHandler;

View File

@@ -1,85 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.UpdateNotificationEvents = void 0;
const React = require("react");
const react_1 = require("react");
const theme_1 = require("@joplin/lib/theme");
const NotyfContext_1 = require("../NotyfContext");
const electron_1 = require("electron");
const AutoUpdaterService_1 = require("../../services/autoUpdater/AutoUpdaterService");
const locale_1 = require("@joplin/lib/locale");
const html_1 = require("@joplin/utils/html");
var UpdateNotificationEvents;
(function (UpdateNotificationEvents) {
UpdateNotificationEvents["ApplyUpdate"] = "apply-update";
UpdateNotificationEvents["Dismiss"] = "dismiss-update-notification";
})(UpdateNotificationEvents || (exports.UpdateNotificationEvents = UpdateNotificationEvents = {}));
const changelogLink = 'https://github.com/laurent22/joplin/releases';
window.openChangelogLink = () => {
electron_1.ipcRenderer.send('open-link', changelogLink);
};
const UpdateNotification = ({ themeId }) => {
const notyfContext = (0, react_1.useContext)(NotyfContext_1.default);
const notificationRef = (0, react_1.useRef)(null); // Use ref to hold the current notification
const theme = (0, react_1.useMemo)(() => (0, theme_1.themeStyle)(themeId), [themeId]);
const notyf = (0, react_1.useMemo)(() => {
const output = notyfContext;
output.options.types = notyfContext.options.types.map(type => {
if (type.type === 'success') {
type.background = theme.backgroundColor5;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
type.icon.color = theme.backgroundColor5;
}
return type;
});
return output;
}, [notyfContext, theme]);
const handleDismissNotification = (0, react_1.useCallback)(() => {
notyf.dismiss(notificationRef.current);
notificationRef.current = null;
}, [notyf]);
const handleApplyUpdate = (0, react_1.useCallback)(() => {
electron_1.ipcRenderer.send('apply-update-now');
handleDismissNotification();
}, [handleDismissNotification]);
const handleUpdateDownloaded = (0, react_1.useCallback)((_event, info) => {
if (notificationRef.current)
return;
const updateAvailableHtml = (0, html_1.htmlentities)((0, locale_1._)('A new update (%s) is available', info.version));
const seeChangelogHtml = (0, html_1.htmlentities)((0, locale_1._)('See changelog'));
const restartNowHtml = (0, html_1.htmlentities)((0, locale_1._)('Restart now'));
const updateLaterHtml = (0, html_1.htmlentities)((0, locale_1._)('Update later'));
const messageHtml = `
<div class="update-notification" style="color: ${theme.color2};">
${updateAvailableHtml} <a href="#" onclick="openChangelogLink()" style="color: ${theme.color2};">${seeChangelogHtml}</a>
<div style="display: flex; gap: 10px; margin-top: 8px;">
<button onclick="document.dispatchEvent(new CustomEvent('${UpdateNotificationEvents.ApplyUpdate}'))" class="notyf__button notyf__button--confirm" style="color: ${theme.color2};">${restartNowHtml}</button>
<button onclick="document.dispatchEvent(new CustomEvent('${UpdateNotificationEvents.Dismiss}'))" class="notyf__button notyf__button--dismiss" style="color: ${theme.color2};">${updateLaterHtml}</button>
</div>
</div>
`;
const notification = notyf.open({
type: 'success',
message: messageHtml,
position: {
x: 'right',
y: 'bottom',
},
duration: 0,
});
notificationRef.current = notification;
}, [notyf, theme]);
(0, react_1.useEffect)(() => {
electron_1.ipcRenderer.on(AutoUpdaterService_1.AutoUpdaterEvents.UpdateDownloaded, handleUpdateDownloaded);
document.addEventListener(UpdateNotificationEvents.ApplyUpdate, handleApplyUpdate);
document.addEventListener(UpdateNotificationEvents.Dismiss, handleDismissNotification);
return () => {
electron_1.ipcRenderer.removeListener(AutoUpdaterService_1.AutoUpdaterEvents.UpdateDownloaded, handleUpdateDownloaded);
document.removeEventListener(UpdateNotificationEvents.ApplyUpdate, handleApplyUpdate);
document.removeEventListener(UpdateNotificationEvents.Dismiss, handleDismissNotification);
};
}, [handleApplyUpdate, handleDismissNotification, handleUpdateDownloaded]);
return (React.createElement("div", { style: { display: 'none' } }));
};
exports.default = UpdateNotification;
//# sourceMappingURL=UpdateNotification.js.map

View File

@@ -8,6 +8,7 @@ import { AutoUpdaterEvents } from '../../services/autoUpdater/AutoUpdaterService
import { NotyfNotification } from 'notyf';
import { _ } from '@joplin/lib/locale';
import { htmlentities } from '@joplin/utils/html';
import shim from '@joplin/lib/shim';
interface UpdateNotificationProps {
themeId: number;
@@ -21,7 +22,7 @@ export enum UpdateNotificationEvents {
const changelogLink = 'https://github.com/laurent22/joplin/releases';
window.openChangelogLink = () => {
ipcRenderer.send('open-link', changelogLink);
shim.openUrl(changelogLink);
};
const UpdateNotification = ({ themeId }: UpdateNotificationProps) => {

View File

@@ -46,9 +46,6 @@
<script src="./scrollmap.js"></script>
<script>
// Fixes a warning when loading some TypeScript plugins generated with webpack.
window.exports = {};
// This is function used internally to send message from the webview to
// the host.
const ipcProxySendToHost = (methodName, arg) => {

View File

@@ -0,0 +1,33 @@
import shim from '@joplin/lib/shim';
import Logger from '@joplin/utils/Logger';
const logger = Logger.create('announceForAccessibility');
const announceForAccessibility = (message: string) => {
const id = 'joplin-announce-for-accessibility-live-region';
let announcementArea = document.querySelector(`#${id}`);
if (!announcementArea) {
announcementArea = document.createElement('div');
announcementArea.ariaLive = 'polite';
announcementArea.role = 'status';
announcementArea.id = id;
announcementArea.classList.add('visually-hidden');
document.body.appendChild(announcementArea);
} else {
// Allows messages to be re-announced.
announcementArea.textContent = '';
}
// Defer the announcement. Because `aria-live: polite` causes **changes** in
// content to be announced, a slight delay is needed when there would otherwise
// be no change in content.
const announce = () => {
logger.debug('Announcing:', message);
announcementArea.textContent = message;
};
shim.setTimeout(announce, 100);
};
export default announceForAccessibility;

View File

@@ -24,7 +24,7 @@ test.describe('main', () => {
const editor = await mainScreen.createNewNote('Test note');
// Note list should contain the new note
await expect(mainScreen.noteListContainer.getByText('Test note')).toBeVisible();
await expect(mainScreen.noteList.getNoteItemByTitle('Test note')).toBeVisible();
// Focus the editor
await editor.codeMirrorEditor.click();
@@ -62,12 +62,19 @@ test.describe('main', () => {
'',
'Sum: $\\sum_{x=0}^{100} \\tan x$',
];
let firstLine = true;
for (const line of noteText) {
if (line) {
await mainWindow.keyboard.press('Shift+Tab');
if (!firstLine) {
// Remove any auto-indentation, but avoid pressing shift-tab at
// the beginning of the editor.
await mainWindow.keyboard.press('Shift+Tab');
}
await mainWindow.keyboard.type(line);
}
await mainWindow.keyboard.press('Enter');
firstLine = false;
}
// Should render mermaid

View File

@@ -2,6 +2,7 @@ import { test, expect } from './util/test';
import MainScreen from './models/MainScreen';
import { join } from 'path';
import getImageSourceSize from './util/getImageSourceSize';
import setFilePickerResponse from './util/setFilePickerResponse';
test.describe('markdownEditor', () => {
@@ -14,7 +15,8 @@ test.describe('markdownEditor', () => {
await importedFolder.waitFor();
await importedFolder.click();
const importedHtmlFileItem = mainScreen.noteListContainer.getByText('test-html-file-with-image');
await mainScreen.noteList.focusContent(electronApp);
const importedHtmlFileItem = mainScreen.noteList.getNoteItemByTitle('test-html-file-with-image');
await importedHtmlFileItem.click();
const viewerFrame = mainScreen.noteEditor.getNoteViewerIframe();
@@ -27,6 +29,75 @@ test.describe('markdownEditor', () => {
await expect(await getImageSourceSize(image)).toMatchObject([117, 30]);
});
test('preview pane should render PDFs', async ({ mainWindow, electronApp }) => {
const mainScreen = new MainScreen(mainWindow);
await mainScreen.createNewNote('PDF attachments');
const editor = mainScreen.noteEditor;
await editor.focusCodeMirrorEditor();
await setFilePickerResponse(electronApp, [join(__dirname, 'resources', 'small-pdf.pdf')]);
await editor.attachFileButton.click();
const viewerFrame = mainScreen.noteEditor.getNoteViewerIframe();
const pdfLink = viewerFrame.getByText('small-pdf.pdf');
await expect(pdfLink).toBeVisible();
const expectToBeRendered = async () => {
// PDF preview should render
const pdfViewer = viewerFrame.locator('object[data$=".pdf"]');
// Should create the PDF viewer. Note: This is not sufficient to determine that the PDF viewer
// has rendered.
await expect(pdfViewer).toBeAttached();
// Verify that the PDF viewer has rendered. This relies on how Chrome/Electron loads custom PDFs
// in an object.
// If this breaks due to an Electron upgrade,
// 1. manually verify that the PDF viewer has loaded and
// 2. replace this test with a screenshot comparison (https://playwright.dev/docs/test-snapshots)
await expect.poll(
() => pdfViewer.evaluate((handle) => {
const embed = (handle as HTMLObjectElement).contentDocument.querySelector('embed');
return !!embed;
}),
).toBe(true);
};
await expectToBeRendered();
// Should still render after switching editors
await mainScreen.noteEditor.toggleEditorsButton.click();
await mainScreen.noteEditor.richTextEditor.waitFor();
await mainScreen.noteEditor.toggleEditorsButton.click();
await expectToBeRendered();
});
test('preview pane should render video attachments', async ({ mainWindow, electronApp }) => {
const mainScreen = new MainScreen(mainWindow);
await mainScreen.createNewNote('Media attachments');
const editor = mainScreen.noteEditor;
await editor.focusCodeMirrorEditor();
await setFilePickerResponse(electronApp, [join(__dirname, 'resources', 'video.mp4')]);
await editor.attachFileButton.click();
const videoLocator = editor.getNoteViewerIframe().locator('video');
const expectVideoToRender = async () => {
await expect(videoLocator).toBeSeekableMediaElement(6.9, 7);
};
await expectVideoToRender();
// Should be able to render again if the editor is closed and re-opened.
await mainScreen.noteEditor.toggleEditorsButton.click();
await mainScreen.noteEditor.richTextEditor.waitFor();
await mainScreen.noteEditor.toggleEditorsButton.click();
await expectVideoToRender();
});
test('arrow keys should navigate the toolbar', async ({ mainWindow }) => {
const mainScreen = new MainScreen(mainWindow);
await mainScreen.waitFor();
@@ -63,5 +134,61 @@ test.describe('markdownEditor', () => {
await mainWindow.keyboard.press('Home');
await expect(firstItemLocator).toBeFocused();
});
test('should sync local search between the viewer and editor', async ({ mainWindow }) => {
const mainScreen = new MainScreen(mainWindow);
await mainScreen.waitFor();
const noteEditor = mainScreen.noteEditor;
await mainScreen.createNewNote('Note');
await noteEditor.focusCodeMirrorEditor();
await mainWindow.keyboard.type('# Testing');
await mainWindow.keyboard.press('Enter');
await mainWindow.keyboard.press('Enter');
await mainWindow.keyboard.type('This is a test of search. `Test inline code`');
const viewer = noteEditor.getNoteViewerIframe();
await expect(viewer.locator('h1')).toHaveText('Testing');
const matches = viewer.locator('mark');
await expect(matches).toHaveCount(0);
await mainWindow.keyboard.press(process.platform === 'darwin' ? 'Meta+f' : 'Control+f');
await expect(noteEditor.editorSearchInput).toBeVisible();
await noteEditor.editorSearchInput.click();
await noteEditor.editorSearchInput.fill('test');
await mainWindow.keyboard.press('Enter');
// Should show at least one match in the viewer
await expect(matches).toHaveCount(3);
await expect(matches.first()).toBeAttached();
// Should show matches in code regions
await noteEditor.editorSearchInput.fill('inline code');
await mainWindow.keyboard.press('Enter');
await expect(matches).toHaveCount(1);
// Should continue searching after switching to view-only mode
await noteEditor.toggleEditorLayoutButton.click();
await noteEditor.toggleEditorLayoutButton.click();
await expect(noteEditor.codeMirrorEditor).not.toBeVisible();
await expect(noteEditor.editorSearchInput).not.toBeVisible();
await expect(noteEditor.viewerSearchInput).toBeVisible();
// Should stop searching after closing the search input
await noteEditor.viewerSearchInput.click();
await expect(matches).toHaveCount(1);
await mainWindow.keyboard.press('Escape');
await expect(noteEditor.viewerSearchInput).not.toBeVisible();
await expect(matches).toHaveCount(0);
// After showing the viewer again, search should still be hidden
await noteEditor.toggleEditorLayoutButton.click();
await expect(noteEditor.codeMirrorEditor).toBeVisible();
await expect(noteEditor.editorSearchInput).not.toBeVisible();
});
});

View File

@@ -4,10 +4,11 @@ import activateMainMenuItem from '../util/activateMainMenuItem';
import Sidebar from './Sidebar';
import GoToAnything from './GoToAnything';
import setFilePickerResponse from '../util/setFilePickerResponse';
import NoteList from './NoteList';
export default class MainScreen {
public readonly newNoteButton: Locator;
public readonly noteListContainer: Locator;
public readonly noteList: NoteList;
public readonly sidebar: Sidebar;
public readonly dialog: Locator;
public readonly noteEditor: NoteEditorScreen;
@@ -15,7 +16,7 @@ export default class MainScreen {
public constructor(private page: Page) {
this.newNoteButton = page.locator('.new-note-button');
this.noteListContainer = page.locator('.rli-noteList');
this.noteList = new NoteList(page);
this.sidebar = new Sidebar(page, this);
this.dialog = page.locator('.dialog-modal-layer');
this.noteEditor = new NoteEditorScreen(page);
@@ -24,7 +25,7 @@ export default class MainScreen {
public async waitFor() {
await this.newNoteButton.waitFor();
await this.noteListContainer.waitFor();
await this.noteList.waitFor();
}
// Follows the steps a user would use to create a new note.

View File

@@ -7,6 +7,9 @@ export default class NoteEditorPage {
public readonly noteTitleInput: Locator;
public readonly attachFileButton: Locator;
public readonly toggleEditorsButton: Locator;
public readonly toggleEditorLayoutButton: Locator;
public readonly editorSearchInput: Locator;
public readonly viewerSearchInput: Locator;
private readonly containerLocator: Locator;
public constructor(private readonly page: Page) {
@@ -16,6 +19,10 @@ export default class NoteEditorPage {
this.noteTitleInput = this.containerLocator.locator('.title-input');
this.attachFileButton = this.containerLocator.getByRole('button', { name: 'Attach file' });
this.toggleEditorsButton = this.containerLocator.getByRole('button', { name: 'Toggle editors' });
this.toggleEditorLayoutButton = this.containerLocator.getByRole('button', { name: 'Toggle editor layout' });
// The editor and viewer have slightly different search UI
this.editorSearchInput = this.containerLocator.getByPlaceholder('Find');
this.viewerSearchInput = this.containerLocator.getByPlaceholder('Search...');
}
public toolbarButtonLocator(title: string) {
@@ -26,7 +33,7 @@ export default class NoteEditorPage {
// The note viewer can change content when the note re-renders. As such,
// a new locator needs to be created after re-renders (and this can't be a
// static property).
return this.page.frame({ url: /.*note-viewer[/\\]index.html.*/ });
return this.page.frameLocator('[src$="note-viewer/index.html"]');
}
public getTinyMCEFrameLocator() {

View File

@@ -0,0 +1,38 @@
import activateMainMenuItem from '../util/activateMainMenuItem';
import { ElectronApplication, Locator, Page, expect } from '@playwright/test';
export default class NoteList {
public readonly container: Locator;
public constructor(page: Page) {
this.container = page.locator('.rli-noteList');
}
public waitFor() {
return this.container.waitFor();
}
private async sortBy(electronApp: ElectronApplication, sortMethod: string) {
const success = await activateMainMenuItem(electronApp, sortMethod, 'Sort notes by');
if (!success) {
throw new Error(`Unable to find sorting menu item: ${sortMethod}`);
}
}
public async sortByTitle(electronApp: ElectronApplication) {
return this.sortBy(electronApp, 'Title');
}
public async focusContent(electronApp: ElectronApplication) {
await activateMainMenuItem(electronApp, 'Note list', 'Focus');
}
// The resultant locator may fail to resolve if the item is not visible
public getNoteItemByTitle(title: string|RegExp) {
return this.container.getByRole('option', { name: title });
}
public async expectNoteToBeSelected(title: string|RegExp) {
await expect(this.getNoteItemByTitle(title)).toHaveAttribute('aria-selected', 'true');
}
}

View File

@@ -1,10 +1,9 @@
import { test, expect } from './util/test';
import MainScreen from './models/MainScreen';
import activateMainMenuItem from './util/activateMainMenuItem';
import setMessageBoxResponse from './util/setMessageBoxResponse';
test.describe('noteList', () => {
test('should be possible to edit notes in a different notebook when searching', async ({ mainWindow }) => {
test('should be possible to edit notes in a different notebook when searching', async ({ mainWindow, electronApp }) => {
const mainScreen = new MainScreen(mainWindow);
const sidebar = mainScreen.sidebar;
@@ -22,7 +21,8 @@ test.describe('noteList', () => {
// Search for and focus a note different from the folder we were in before searching.
await mainScreen.search('/note-1');
const note1Result = mainScreen.noteListContainer.getByText('note-1');
await mainScreen.noteList.focusContent(electronApp);
const note1Result = mainScreen.noteList.getNoteItemByTitle('note-1');
await expect(note1Result).toBeAttached();
await note1Result.click();
@@ -49,9 +49,10 @@ test.describe('noteList', () => {
await mainScreen.createNewNote('test note 1');
await mainScreen.createNewNote('test note 2');
await activateMainMenuItem(electronApp, 'Note list', 'Focus');
await expect(mainScreen.noteListContainer.getByText('test note 1')).toBeVisible();
await expect(mainScreen.noteListContainer.getByText('test note 2')).toBeVisible();
const noteList = mainScreen.noteList;
await noteList.focusContent(electronApp);
await expect(noteList.getNoteItemByTitle('test note 1')).toBeVisible();
await expect(noteList.getNoteItemByTitle('test note 2')).toBeVisible();
await setMessageBoxResponse(electronApp, /^Delete/i);
@@ -62,7 +63,7 @@ test.describe('noteList', () => {
await mainWindow.keyboard.up('Shift');
};
await pressShiftDelete();
await expect(mainScreen.noteListContainer.getByText('test note 2')).not.toBeVisible();
await expect(noteList.getNoteItemByTitle('test note 2')).not.toBeVisible();
// Should not delete when the editor is focused
await mainScreen.noteEditor.focusCodeMirrorEditor();
@@ -71,6 +72,35 @@ test.describe('noteList', () => {
await folderBHeader.click();
await folderAHeader.click();
await expect(mainScreen.noteListContainer.getByText('test note 1')).toBeVisible();
await expect(noteList.getNoteItemByTitle('test note 1')).toBeVisible();
});
test('arrow keys should navigate the note list', async ({ electronApp, mainWindow }) => {
const mainScreen = new MainScreen(mainWindow);
const sidebar = mainScreen.sidebar;
await sidebar.createNewFolder('Folder');
await mainScreen.createNewNote('note_1');
await mainScreen.createNewNote('note_2');
await mainScreen.createNewNote('note_3');
await mainScreen.createNewNote('note_4');
const noteList = mainScreen.noteList;
await noteList.sortByTitle(electronApp);
await noteList.focusContent(electronApp);
// The most recently-created note should be visible
const note4Item = noteList.getNoteItemByTitle('note_4');
await expect(note4Item).toBeVisible();
await noteList.expectNoteToBeSelected('note_4');
await noteList.container.press('ArrowUp');
await noteList.expectNoteToBeSelected('note_3');
await noteList.container.press('ArrowUp');
await noteList.expectNoteToBeSelected('note_2');
await noteList.container.press('ArrowDown');
await noteList.expectNoteToBeSelected('note_3');
});
});

View File

@@ -0,0 +1 @@
This is a test.

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