1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-08-24 20:19:10 +02:00

Compare commits

...

115 Commits

Author SHA1 Message Date
Joplin Bot
227e41b69a Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-08-24 12:31:05 +00:00
renovate[bot]
a616e26a0f Update dependency react-native-safe-area-context to v5.4.1 (#13000)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2025-08-24 13:02:03 +03:00
Joplin Bot
ba0e7e2226 Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-08-23 12:30:39 +00:00
Laurent Cozic
b5a4ba554d Doc: Add sponsor 2025-08-23 13:14:58 +03:00
Arda Kılıçdağı
9037da8f2d All: Translation: Update tr_TR.po (#13019) 2025-08-22 16:07:31 -04:00
renovate[bot]
6998606ec9 Update dependency pg to v8.15.6 (#13021)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-22 17:00:30 +00:00
Laurent Cozic
66d52c90a3 Desktop release v3.4.7 2025-08-22 13:19:27 +03:00
renovate[bot]
f6fb1f7fbf Update dependency pg to v8.15.5 (#13001)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2025-08-22 13:14:06 +03:00
Henry Heino
3aac6043da Chore: Sync fuzzer: Support testing Joplin Cloud readonly shares (#13003) 2025-08-22 11:33:54 +03:00
Henry Heino
ae170e0aa0 Desktop: Fixes #12998: Fix error logged when rendering a non-existent resource (#13004) 2025-08-22 11:33:16 +03:00
Henry Heino
371f027a24 MacOS: Fix startup failure when unable to access the keychain (#13006) 2025-08-22 11:32:59 +03:00
Henry Heino
37422f316e Desktop: Downgrade to Electron 35.7.5 (#13013) 2025-08-22 11:30:39 +03:00
Henry Heino
a9f284ae45 Desktop: Fixes #13009: Fix custom root CA support (#13018) 2025-08-22 11:29:54 +03:00
Milo Ivir
fd2f69cc73 All: Translation: Update hr_HR.po (#13011) 2025-08-21 18:45:39 -04:00
Joplin Bot
c4eab3c79c Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-08-21 01:03:33 +00:00
renovate[bot]
a0b9c6376e Update dependency react-native-image-picker to v8 (#12997)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-20 23:37:39 +03:00
Henry Heino
e2fc056369 Desktop,Mobile,Cli: Fixes #12648: Fix unshare action requires two syncs to be reflected locally (#12999) 2025-08-20 23:36:47 +03:00
renovate[bot]
453b4705b1 Update dependency @types/node to v18.19.103 (#12985)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-20 19:25:54 +00:00
Laurent Cozic
4128061e40 Desktop release v3.4.6 2025-08-20 22:22:42 +03:00
Joplin Bot
432b0ca870 Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-08-20 12:32:59 +00:00
renovate[bot]
c484cd2e48 Update dependency sass to v1.87.0 (#12995)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-20 15:06:57 +03:00
Laurent Cozic
58f0725c6b Doc: Add sponsor 2025-08-20 15:05:28 +03:00
Henry Heino
bf8fbec0cd Chore: Sync fuzzer: Add support for adding and removing share participants (#12988) 2025-08-20 09:46:23 +03:00
pedr
f1d452f130 Server: Fixes #12983: Not handling correctly non JSON error responses from Transcribe (#12986) 2025-08-20 09:46:15 +03:00
Henry Heino
26012cd7d5 Cli,Mobile,Desktop: Shared folders: Fix moving shared subfolder to toplevel briefly marks it as a toplevel share (#12964) 2025-08-20 09:39:39 +03:00
mrjo118
a414241541 Mobile: Improve tag screen usability to allow add or remove tag with a single press, when the keyboard is open (#12954) 2025-08-20 09:33:31 +03:00
Henry Heino
0f13bf9d51 Mobile: Rich Text Editor: Support rendering subscript, superscript, and highlighted formatting (#12944) 2025-08-20 09:33:13 +03:00
Henry Heino
c142c5c5c0 Desktop,Mobile: Markdown editor: Toggle checkboxes on ctrl-click (#12927) 2025-08-20 09:32:16 +03:00
Henry Heino
af5c0135dc Mobile: Rich Text Editor: Enable syntax highlighting and auto-indent in the code block editor (#12909) 2025-08-20 09:29:30 +03:00
pedr
8a811b9e78 Doc: Resolves #12861: Add end point documentation for Transcribe (#12870)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2025-08-20 09:29:12 +03:00
Henry Heino
602484f143 Desktop: Upgrade to Electron v37.3.0 (#12951) 2025-08-20 08:53:50 +03:00
renovate[bot]
dc84db1657 Update dependency sharp to v0.34.2 (#12982)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2025-08-20 08:33:29 +03:00
Henry Heino
f5882ecfcc Chore: Improve type safety (#12992) 2025-08-20 08:33:10 +03:00
Laurent Cozic
30000c34ec Cli: If no notebook is provided when importing a file, use the default one 2025-08-19 23:33:52 +03:00
renovate[bot]
6e3df1bd90 Update dependency @types/react to v18.3.22 (#12990)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-19 18:59:17 +03:00
Joplin Bot
67196ac0b2 Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-08-19 12:33:44 +00:00
Laurent Cozic
69646b5522 Doc: Update sponsors 2025-08-19 12:31:13 +03:00
Laurent Cozic
9147afce9a Server v3.4.2 2025-08-18 19:54:35 +03:00
Henry Heino
c92701c52f iOS: Rich Text Editor: Fix the "edit" button for code blocks (#12924) 2025-08-18 18:46:02 +03:00
pedr
ab3e9d1a3e Transcribe: Fixes: Use latest version of joplin/htr-cli available (#12875) 2025-08-18 18:45:52 +03:00
Henry Heino
f9cab8843b Chore: Fix tsc (#12981) 2025-08-18 18:40:58 +03:00
yuudi
c36289c024 Server: Fixes #12947 Skip CORS check for SAML callback (#12948)
Co-authored-by: yuudi <yuudi@users.noreply.github.com>
2025-08-18 16:10:20 +03:00
Henry Heino
60b6db8cd4 Mobile: Rich Text Editor: Add basic support for collapsible <details> blocks (#12946) 2025-08-18 16:10:00 +03:00
Henry Heino
bbd8f6f40e Mobile: Rich Text Editor: Fix adding headings moves the cursor to the next line (#12934) 2025-08-18 16:07:55 +03:00
Henry Heino
34b7f4e1f8 Chore: Sync fuzzer: Fix "DecryptionWorker: Cannot start because..." warning (#12925) 2025-08-18 16:04:26 +03:00
Henry Heino
06b681d897 Chore: Sync fuzzer: Add new possible actions: Adding and syncing a new temporary client on an existing account (#12741) 2025-08-18 15:57:44 +03:00
pedr
f02a94bef5 Transcribe: Fixes #12766: Remove processed files and clean up after a retention period (#12827) 2025-08-18 15:57:34 +03:00
Miguel Matos
ae6b57c5a5 CLI: Add collapsible notebooks functionality (#12718) 2025-08-18 15:55:55 +03:00
Henry Heino
88ab916008 Mobile: Rich Text Editor: Support rendering table of contents blocks (#12949) 2025-08-18 11:35:48 +03:00
pedr
97b0ffc263 Transcribe: #12883: Disable JobProcessor tests by default (#12955) 2025-08-18 11:34:26 +03:00
pedr
ff8848d138 Desktop: Fixes #12315: Clicking Edit URL button in Note properties does not focus in url field (#12970) 2025-08-18 11:20:54 +03:00
renovate[bot]
2b686e6318 Update dependency @playwright/test to v1.52.0 (#12972)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-18 11:17:35 +03:00
renovate[bot]
b913d18882 Update dependency @adobe/css-tools to v4.4.3 (#12979)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-17 22:02:58 +00:00
renovate[bot]
a2c9a01722 Update dependency @types/node to v18.19.101 (#12978)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-17 05:42:21 +00:00
Milo Ivir
000d23c20f All: Translation: Update hr_HR.po (#12961) 2025-08-16 21:46:55 -04:00
Liffindra Angga Zaaldian
9e9f2f2930 All: Translation: Update id_ID.po (#12977) 2025-08-16 20:42:49 -04:00
VortexP
c5a1a759c7 All: Translation: Update fi_FI.po (#12971) 2025-08-15 18:15:58 -04:00
cedecode
0b6a1c75ba All: Translation: Update de_DE.po (#12966) 2025-08-14 22:09:33 -04:00
renovate[bot]
53a0f8ddbc Update dependency python to v3.13.3 (#12965)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-15 01:08:28 +02:00
Laurent Cozic
67eabb5038 Doc: Update recommended Postgres for JSB 2025-08-14 22:32:00 +02:00
Laurent Cozic
983fced410 Doc: Fixed Transcribe graph 2025-08-14 22:32:00 +02:00
Jozef Gaal
4f5bbc1132 All: Translation: Update sk_SK.po (#12950) 2025-08-14 00:22:49 -04:00
ERYpTION
2f10235ecb All: Translation: Update da_DK.po (#12945) 2025-08-13 17:22:50 -04:00
Joplin Bot
cfa7d6cb31 Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-08-13 18:27:34 +00:00
renovate[bot]
f5d62a50fe Update dependency @types/serviceworker to v0.0.135 (#12937)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-13 10:12:25 +00:00
Laurent Cozic
b52f5435aa Doc: Add Transcribe System Architecture documentation 2025-08-12 19:24:27 +01:00
renovate[bot]
bfd5bfc004 Update dependency git to v2.48.1 (#12921)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-11 15:32:53 +01:00
renovate[bot]
82965fe991 Update dependency jsdom to v26.1.0 (#12922)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-11 15:32:45 +01:00
summoner
b2c162c25b All: Translation: Update hu_HU.po (#12918) 2025-08-10 16:05:34 -04:00
Mihai Vasiliu
022e76fe8d All: Translation: Update ro_RO.po and ro_MD.po (#12917) 2025-08-10 16:04:28 -04:00
Joplin Bot
4b2d1895fd Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-08-10 18:26:11 +00:00
Joplin Bot
534507a31f Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-08-10 12:32:07 +00:00
Laurent Cozic
5b4a300c81 iOS 13.4.1 2025-08-10 10:41:49 +01:00
Laurent Cozic
1de0a59313 Chore: Fix iOS IPHONEOS_DEPLOYMENT_TARGET 2025-08-10 10:41:25 +01:00
Laurent Cozic
f4dff92d2e Android 3.4.4 2025-08-10 10:34:02 +01:00
Laurent Cozic
a5d37a0dca Desktop release v3.4.5 2025-08-10 10:26:53 +01:00
Laurent Cozic
75ef418b39 Update translations 2025-08-10 10:26:37 +01:00
Henry Heino
6bd702ae24 Mobile: Resolves #12843: Rich Text Editor: Improve support for HTML notes (#12912) 2025-08-10 09:32:42 +01:00
Laurent Cozic
9ea1808766 Update translations 2025-08-10 09:31:13 +01:00
Suchith
59f8dd36a6 Desktop: Fixes #12358: Selected emoji for new notebooks display too large until Joplin is restarted (#12888) 2025-08-10 09:30:42 +01:00
Henry Heino
ea1d2e4878 Desktop, Mobile: Move several features from Extra Markdown Editor Settings into the main app (#12747) 2025-08-10 09:17:12 +01:00
Henry Heino
46ab00bfe4 Chore: Sync fuzzer: Re-use the same CLI process for commands run on the same client (#12913) 2025-08-10 09:14:25 +01:00
renovate[bot]
07465dd349 Update dependency dotenv to v16.5.0 (#12914)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-09 10:46:41 +01:00
renovate[bot]
a288ffe338 Update dependency @types/node to v18.19.100 (#12904)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-08 21:20:11 +00:00
renovate[bot]
dba62386b6 Update dependency @types/serviceworker to v0.0.134 (#12907)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-07 12:12:36 +01:00
Henry Heino
6704ab0d13 Mobile: Resolves #12841: Allow editing code blocks from the Rich Text Editor (#12906) 2025-08-07 10:18:09 +01:00
Arda Kılıçdağı
0312f2213d All: Translation: Update tr_TR.po (#12905) 2025-08-06 20:23:39 -04:00
Chaitanya Gupta
2ac0b66ef6 Doc: Fix link to E2EE spec (#12902) 2025-08-06 13:05:42 +01:00
Henry Heino
639b261ee4 Mobile: Fixes #12844: Rich Text Editor: Make initial search behavior match the Markdown editor (#12878) 2025-08-06 11:10:14 +01:00
mrjo118
82bc819a21 Mobile: Fixes #12822: Fix switching between note and todo on mobile (#12849) 2025-08-06 11:09:05 +01:00
w568w
72f8ebe4ff Desktop: Fixes #11871: Put crash dump files at the platform-compliant locations (#12839) 2025-08-06 11:08:29 +01:00
pedr
8c8a38e704 Desktop: Resolves #2059: Add option to transform HTML notes into Markdown (#12730)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2025-08-06 11:02:13 +01:00
mrjo118
358134038c Desktop, Mobile: Fixes #12104: Ensure merges to revisions during cleaning are synced to the target (#12444) 2025-08-06 10:52:28 +01:00
Henry Heino
1f4b32a241 Desktop: Fixes #12235: Fix switching to the Markdown editor after pasting links (#12241) 2025-08-06 10:50:17 +01:00
Henry Heino
2a216f1e61 Server: Fix notebooks remain shared after being permanently deleted by the share owner (#12583) 2025-08-06 10:37:38 +01:00
pedr
3f75d770f7 Desktop: Resolves #12224: Add an option to enable or disable search in OCR text (#12578) 2025-08-06 10:37:20 +01:00
Henry Heino
b6d32831c6 Mobile: Fixes #12880: Fix plugin support (#12890) 2025-08-06 10:23:40 +01:00
Henry Heino
788033cb5f Mobile: Fixes #12891: Fix error logged when opening the Markdown editor (#12892) 2025-08-06 10:23:07 +01:00
klaas0
4e685ec687 Mobile: Resolves #12858: Fixed missing filename when a file is shared with the app (#12895) 2025-08-06 10:22:47 +01:00
renovate[bot]
c60b703b9c Update dependency ldapts to v7.4.0 (#12900)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-06 10:19:03 +01:00
renovate[bot]
f23e10a975 Update dependency @types/node to v18.19.99 (#12899)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-06 06:25:58 +00:00
renovate[bot]
b9a71c0c3d Update dependency sharp to v0.34.1 (#12898)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-06 04:43:57 +00:00
renovate[bot]
f525c4179f Update dependency react-native-share to v12.0.11 (#12897)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-06 02:34:38 +00:00
renovate[bot]
1dd0ec619f Update dependency lint-staged to v15.5.2 (#12896)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-06 02:32:29 +00:00
renovate[bot]
d2ee5411d0 Update dependency @types/react to v18.3.21 (#12884)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-05 22:56:45 +00:00
krevad
a2472cb3b7 All: Translation: Update sv.po (#12893) 2025-08-05 18:55:12 -04:00
renovate[bot]
ca8415f74a Update dependency esbuild to v0.25.4 (#12889)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-05 20:34:24 +00:00
renovate[bot]
853b792367 Update dependency @types/node to v18.19.98 (#12881)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-05 20:31:35 +00:00
renovate[bot]
56d477f1c1 Update dependency esbuild to v0.25.4 (#12887)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-05 16:37:20 +00:00
Laurent Cozic
020ba10c56 Update renovate.json5 2025-08-05 13:35:34 +01:00
pedr
be09873c58 Desktop: Resolves #12087: Add shortcut to toggle between editors (#12869) 2025-08-05 12:43:58 +01:00
renovate[bot]
4d8a16bda7 Update dependency @types/mustache to v4.2.6 (#12867)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-05 12:28:03 +01:00
pedr
f725d3895f Transcribe: Resolves #12862: Add log statement signaling that the startup has finished (#12876) 2025-08-05 12:26:10 +01:00
pedr
0e19dce0d1 Transcribe: Fixes #12863: Improve error handling (#12873) 2025-08-05 10:53:42 +01:00
Joplin Bot
31c5058d5e Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-08-04 18:29:30 +00:00
299 changed files with 46598 additions and 30452 deletions

View File

@@ -10,13 +10,16 @@ QUEUE_TTL=900000
QUEUE_RETRY_COUNT=2
QUEUE_MAINTENANCE_INTERVAL=30000
HTR_CLI_DOCKER_IMAGE=joplin/htr-cli:0.0.2
# Fullpath to images folder
HTR_CLI_IMAGES_FOLDER=/home/user/joplin/packages/transcribe/images
HTR_CLI_DOCKER_IMAGE=joplin/htr-cli:latest
# Fullpath to images folder e.g.:
#HTR_CLI_IMAGES_FOLDER=/home/user/joplin/packages/transcribe/images
HTR_CLI_IMAGES_FOLDER=
QUEUE_DRIVER=pg
# QUEUE_DRIVER=sqlite
FILE_STORAGE_MAINTENANCE_INTERVAL=3600000
FILE_STORAGE_TTL=604800000 # one week
# =============================================================================
# Queue driver

View File

@@ -98,6 +98,7 @@ packages/app-cli/app/app.js
packages/app-cli/app/base-command.js
packages/app-cli/app/command-apidoc.js
packages/app-cli/app/command-attach.js
packages/app-cli/app/command-batch.js
packages/app-cli/app/command-cat.js
packages/app-cli/app/command-config.js
packages/app-cli/app/command-cp.js
@@ -135,6 +136,7 @@ packages/app-cli/app/gui/StatusBarWidget.js
packages/app-cli/app/services/plugins/PluginRunner.js
packages/app-cli/app/setupCommand.js
packages/app-cli/app/utils/initializeCommandService.js
packages/app-cli/app/utils/iterateStdin.js
packages/app-cli/app/utils/shimInitCli.js
packages/app-cli/app/utils/testUtils.js
packages/app-cli/tests/HtmlToMd.js
@@ -157,6 +159,8 @@ packages/app-desktop/app.reducer.js
packages/app-desktop/app.js
packages/app-desktop/bridge.js
packages/app-desktop/checkForUpdates.js
packages/app-desktop/commands/convertNoteToMarkdown.test.js
packages/app-desktop/commands/convertNoteToMarkdown.js
packages/app-desktop/commands/copyDevCommand.js
packages/app-desktop/commands/copyToClipboard.js
packages/app-desktop/commands/editProfileConfig.js
@@ -197,6 +201,7 @@ packages/app-desktop/gui/ConfigScreen/controls/ToggleAdvancedSettingsButton.js
packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.js
packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.js
packages/app-desktop/gui/ConfigScreen/controls/plugins/SearchPlugins.js
packages/app-desktop/gui/ConversionNotification/ConversionNotification.js
packages/app-desktop/gui/Dialog.js
packages/app-desktop/gui/DialogButtonRow.js
packages/app-desktop/gui/DialogButtonRow/useKeyboardHandler.js
@@ -298,6 +303,7 @@ packages/app-desktop/gui/NoteEditor/utils/clipboardUtils.test.js
packages/app-desktop/gui/NoteEditor/utils/clipboardUtils.js
packages/app-desktop/gui/NoteEditor/utils/contextMenu.js
packages/app-desktop/gui/NoteEditor/utils/contextMenuUtils.js
packages/app-desktop/gui/NoteEditor/utils/getResourceBaseUrl.js
packages/app-desktop/gui/NoteEditor/utils/getWindowCommandPriority.js
packages/app-desktop/gui/NoteEditor/utils/index.js
packages/app-desktop/gui/NoteEditor/utils/markupRenderOptions.js
@@ -692,7 +698,6 @@ packages/app-mobile/components/NoteEditor/RichTextEditor.js
packages/app-mobile/components/NoteEditor/SearchPanel.js
packages/app-mobile/components/NoteEditor/WarningBanner.js
packages/app-mobile/components/NoteEditor/commandDeclarations.js
packages/app-mobile/components/NoteEditor/hooks/useCodeMirrorPlugins.js
packages/app-mobile/components/NoteEditor/hooks/useEditorCommandHandler.test.js
packages/app-mobile/components/NoteEditor/hooks/useEditorCommandHandler.js
packages/app-mobile/components/NoteEditor/testing/createTestEditorProps.js
@@ -739,6 +744,7 @@ packages/app-mobile/components/getResponsiveValue.js
packages/app-mobile/components/global-style.js
packages/app-mobile/components/plugins/PluginNotification.js
packages/app-mobile/components/plugins/PluginRunner.js
packages/app-mobile/components/plugins/PluginRunnerWebView.test.js
packages/app-mobile/components/plugins/PluginRunnerWebView.js
packages/app-mobile/components/plugins/backgroundPage/initializeDialogWebView.js
packages/app-mobile/components/plugins/backgroundPage/initializePluginBackgroundIframe.js
@@ -861,6 +867,7 @@ packages/app-mobile/contentScripts/imageEditorBundle/utils/useEditorMessenger.js
packages/app-mobile/contentScripts/markdownEditorBundle/contentScript.js
packages/app-mobile/contentScripts/markdownEditorBundle/types.js
packages/app-mobile/contentScripts/markdownEditorBundle/useWebViewSetup.js
packages/app-mobile/contentScripts/markdownEditorBundle/utils/useCodeMirrorPlugins.js
packages/app-mobile/contentScripts/rendererBundle/contentScript/Renderer.test.js
packages/app-mobile/contentScripts/rendererBundle/contentScript/Renderer.js
packages/app-mobile/contentScripts/rendererBundle/contentScript/index.js
@@ -997,15 +1004,38 @@ packages/editor/CodeMirror/editorCommands/sortSelectedLines.test.js
packages/editor/CodeMirror/editorCommands/sortSelectedLines.js
packages/editor/CodeMirror/editorCommands/supportsCommand.js
packages/editor/CodeMirror/extensions/biDirectionalTextExtension.js
packages/editor/CodeMirror/extensions/ctrlClickActionExtension.js
packages/editor/CodeMirror/extensions/ctrlClickCheckboxExtension.js
packages/editor/CodeMirror/extensions/keyUpHandlerExtension.js
packages/editor/CodeMirror/extensions/links/ctrlClickLinksExtension.js
packages/editor/CodeMirror/extensions/links/followLinkTooltipExtension.test.js
packages/editor/CodeMirror/extensions/links/followLinkTooltipExtension.js
packages/editor/CodeMirror/extensions/links/referenceLinksStateField.js
packages/editor/CodeMirror/extensions/links/utils/findLineMatchingLink.test.js
packages/editor/CodeMirror/extensions/links/utils/findLineMatchingLink.js
packages/editor/CodeMirror/extensions/links/utils/getUrlAtPosition.js
packages/editor/CodeMirror/extensions/links/utils/openLink.js
packages/editor/CodeMirror/extensions/markdownDecorationExtension.test.js
packages/editor/CodeMirror/extensions/markdownDecorationExtension.js
packages/editor/CodeMirror/extensions/markdownHighlightExtension.test.js
packages/editor/CodeMirror/extensions/markdownHighlightExtension.js
packages/editor/CodeMirror/extensions/markdownMathExtension.test.js
packages/editor/CodeMirror/extensions/markdownMathExtension.js
packages/editor/CodeMirror/extensions/modifierKeyCssExtension.js
packages/editor/CodeMirror/extensions/overwriteModeExtension.test.js
packages/editor/CodeMirror/extensions/overwriteModeExtension.js
packages/editor/CodeMirror/extensions/rendering/addFormattingClasses.js
packages/editor/CodeMirror/extensions/rendering/renderBlockImages.test.js
packages/editor/CodeMirror/extensions/rendering/renderBlockImages.js
packages/editor/CodeMirror/extensions/rendering/renderingExtension.js
packages/editor/CodeMirror/extensions/rendering/replaceBulletLists.js
packages/editor/CodeMirror/extensions/rendering/replaceCheckboxes.js
packages/editor/CodeMirror/extensions/rendering/replaceDividers.js
packages/editor/CodeMirror/extensions/rendering/replaceFormatCharacters.js
packages/editor/CodeMirror/extensions/rendering/types.js
packages/editor/CodeMirror/extensions/rendering/utils/makeBlockReplaceExtension.js
packages/editor/CodeMirror/extensions/rendering/utils/makeInlineReplaceExtension.js
packages/editor/CodeMirror/extensions/rendering/utils/nodeIntersectsSelection.js
packages/editor/CodeMirror/extensions/searchExtension.js
packages/editor/CodeMirror/extensions/selectedNoteIdExtension.js
packages/editor/CodeMirror/getScrollFraction.js
@@ -1046,16 +1076,23 @@ packages/editor/CodeMirror/utils/isInSyntaxNode.js
packages/editor/CodeMirror/utils/markdown/codeBlockLanguages/allLanguages.js
packages/editor/CodeMirror/utils/markdown/codeBlockLanguages/defaultLanguage.js
packages/editor/CodeMirror/utils/markdown/codeBlockLanguages/lookUpLanguage.js
packages/editor/CodeMirror/utils/markdown/getCheckboxAtPosition.js
packages/editor/CodeMirror/utils/markdown/renumberSelectedLists.test.js
packages/editor/CodeMirror/utils/markdown/renumberSelectedLists.js
packages/editor/CodeMirror/utils/markdown/stripBlockquote.js
packages/editor/CodeMirror/utils/markdown/toggleCheckboxAt.js
packages/editor/CodeMirror/utils/setupVim.js
packages/editor/ProseMirror/commands.test.js
packages/editor/ProseMirror/commands.js
packages/editor/ProseMirror/createEditor.js
packages/editor/ProseMirror/index.js
packages/editor/ProseMirror/plugins/detailsPlugin.test.js
packages/editor/ProseMirror/plugins/detailsPlugin.js
packages/editor/ProseMirror/plugins/inputRulesPlugin.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin/createEditorDialog.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.test.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin/postProcessRenderedHtml.js
packages/editor/ProseMirror/plugins/joplinEditorApiPlugin.js
packages/editor/ProseMirror/plugins/keymapPlugin.js
packages/editor/ProseMirror/plugins/linkTooltipPlugin.test.js
@@ -1071,9 +1108,15 @@ packages/editor/ProseMirror/types.js
packages/editor/ProseMirror/utils/UndoStackSynchronizer.js
packages/editor/ProseMirror/utils/canReplaceSelectionWith.js
packages/editor/ProseMirror/utils/computeSelectionFormatting.js
packages/editor/ProseMirror/utils/dom/createButton.js
packages/editor/ProseMirror/utils/dom/createTextArea.js
packages/editor/ProseMirror/utils/dom/createTextNode.js
packages/editor/ProseMirror/utils/dom/createUniqueId.js
packages/editor/ProseMirror/utils/extractSelectedLinesTo.test.js
packages/editor/ProseMirror/utils/extractSelectedLinesTo.js
packages/editor/ProseMirror/utils/forEachHeading.js
packages/editor/ProseMirror/utils/jumpToHash.js
packages/editor/ProseMirror/utils/makeLinksClickableInElement.js
packages/editor/ProseMirror/utils/preprocessEditorInput.test.js
packages/editor/ProseMirror/utils/preprocessEditorInput.js
packages/editor/ProseMirror/utils/sanitizeHtml.js
@@ -1150,6 +1193,8 @@ packages/lib/array.js
packages/lib/callbackUrlUtils.test.js
packages/lib/callbackUrlUtils.js
packages/lib/clipperUtils.js
packages/lib/commands/convertHtmlToMarkdown.test.js
packages/lib/commands/convertHtmlToMarkdown.js
packages/lib/commands/deleteNote.js
packages/lib/commands/historyBackward.js
packages/lib/commands/historyForward.js
@@ -1584,6 +1629,7 @@ packages/lib/shim-init-node.js
packages/lib/shim.js
packages/lib/string-utils.test.js
packages/lib/string-utils.js
packages/lib/testing/plugins/createTestPlugin.js
packages/lib/testing/share/makeMockShareInvitation.js
packages/lib/testing/share/mockShareService.js
packages/lib/testing/syncTargetUtils.js
@@ -1728,12 +1774,14 @@ packages/tools/fuzzer/Client.js
packages/tools/fuzzer/ClientPool.js
packages/tools/fuzzer/Server.js
packages/tools/fuzzer/constants.js
packages/tools/fuzzer/model/FolderRecord.js
packages/tools/fuzzer/sync-fuzzer.js
packages/tools/fuzzer/types.js
packages/tools/fuzzer/utils/SeededRandom.js
packages/tools/fuzzer/utils/getNumberProperty.js
packages/tools/fuzzer/utils/getProperty.js
packages/tools/fuzzer/utils/getStringProperty.js
packages/tools/fuzzer/utils/openDebugSession.js
packages/tools/fuzzer/utils/retryWithCount.js
packages/tools/generate-database-types.js
packages/tools/generate-images.js

52
.gitignore vendored
View File

@@ -71,6 +71,7 @@ packages/app-cli/app/app.js
packages/app-cli/app/base-command.js
packages/app-cli/app/command-apidoc.js
packages/app-cli/app/command-attach.js
packages/app-cli/app/command-batch.js
packages/app-cli/app/command-cat.js
packages/app-cli/app/command-config.js
packages/app-cli/app/command-cp.js
@@ -108,6 +109,7 @@ packages/app-cli/app/gui/StatusBarWidget.js
packages/app-cli/app/services/plugins/PluginRunner.js
packages/app-cli/app/setupCommand.js
packages/app-cli/app/utils/initializeCommandService.js
packages/app-cli/app/utils/iterateStdin.js
packages/app-cli/app/utils/shimInitCli.js
packages/app-cli/app/utils/testUtils.js
packages/app-cli/tests/HtmlToMd.js
@@ -130,6 +132,8 @@ packages/app-desktop/app.reducer.js
packages/app-desktop/app.js
packages/app-desktop/bridge.js
packages/app-desktop/checkForUpdates.js
packages/app-desktop/commands/convertNoteToMarkdown.test.js
packages/app-desktop/commands/convertNoteToMarkdown.js
packages/app-desktop/commands/copyDevCommand.js
packages/app-desktop/commands/copyToClipboard.js
packages/app-desktop/commands/editProfileConfig.js
@@ -170,6 +174,7 @@ packages/app-desktop/gui/ConfigScreen/controls/ToggleAdvancedSettingsButton.js
packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.js
packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.js
packages/app-desktop/gui/ConfigScreen/controls/plugins/SearchPlugins.js
packages/app-desktop/gui/ConversionNotification/ConversionNotification.js
packages/app-desktop/gui/Dialog.js
packages/app-desktop/gui/DialogButtonRow.js
packages/app-desktop/gui/DialogButtonRow/useKeyboardHandler.js
@@ -271,6 +276,7 @@ packages/app-desktop/gui/NoteEditor/utils/clipboardUtils.test.js
packages/app-desktop/gui/NoteEditor/utils/clipboardUtils.js
packages/app-desktop/gui/NoteEditor/utils/contextMenu.js
packages/app-desktop/gui/NoteEditor/utils/contextMenuUtils.js
packages/app-desktop/gui/NoteEditor/utils/getResourceBaseUrl.js
packages/app-desktop/gui/NoteEditor/utils/getWindowCommandPriority.js
packages/app-desktop/gui/NoteEditor/utils/index.js
packages/app-desktop/gui/NoteEditor/utils/markupRenderOptions.js
@@ -665,7 +671,6 @@ packages/app-mobile/components/NoteEditor/RichTextEditor.js
packages/app-mobile/components/NoteEditor/SearchPanel.js
packages/app-mobile/components/NoteEditor/WarningBanner.js
packages/app-mobile/components/NoteEditor/commandDeclarations.js
packages/app-mobile/components/NoteEditor/hooks/useCodeMirrorPlugins.js
packages/app-mobile/components/NoteEditor/hooks/useEditorCommandHandler.test.js
packages/app-mobile/components/NoteEditor/hooks/useEditorCommandHandler.js
packages/app-mobile/components/NoteEditor/testing/createTestEditorProps.js
@@ -712,6 +717,7 @@ packages/app-mobile/components/getResponsiveValue.js
packages/app-mobile/components/global-style.js
packages/app-mobile/components/plugins/PluginNotification.js
packages/app-mobile/components/plugins/PluginRunner.js
packages/app-mobile/components/plugins/PluginRunnerWebView.test.js
packages/app-mobile/components/plugins/PluginRunnerWebView.js
packages/app-mobile/components/plugins/backgroundPage/initializeDialogWebView.js
packages/app-mobile/components/plugins/backgroundPage/initializePluginBackgroundIframe.js
@@ -834,6 +840,7 @@ packages/app-mobile/contentScripts/imageEditorBundle/utils/useEditorMessenger.js
packages/app-mobile/contentScripts/markdownEditorBundle/contentScript.js
packages/app-mobile/contentScripts/markdownEditorBundle/types.js
packages/app-mobile/contentScripts/markdownEditorBundle/useWebViewSetup.js
packages/app-mobile/contentScripts/markdownEditorBundle/utils/useCodeMirrorPlugins.js
packages/app-mobile/contentScripts/rendererBundle/contentScript/Renderer.test.js
packages/app-mobile/contentScripts/rendererBundle/contentScript/Renderer.js
packages/app-mobile/contentScripts/rendererBundle/contentScript/index.js
@@ -970,15 +977,38 @@ packages/editor/CodeMirror/editorCommands/sortSelectedLines.test.js
packages/editor/CodeMirror/editorCommands/sortSelectedLines.js
packages/editor/CodeMirror/editorCommands/supportsCommand.js
packages/editor/CodeMirror/extensions/biDirectionalTextExtension.js
packages/editor/CodeMirror/extensions/ctrlClickActionExtension.js
packages/editor/CodeMirror/extensions/ctrlClickCheckboxExtension.js
packages/editor/CodeMirror/extensions/keyUpHandlerExtension.js
packages/editor/CodeMirror/extensions/links/ctrlClickLinksExtension.js
packages/editor/CodeMirror/extensions/links/followLinkTooltipExtension.test.js
packages/editor/CodeMirror/extensions/links/followLinkTooltipExtension.js
packages/editor/CodeMirror/extensions/links/referenceLinksStateField.js
packages/editor/CodeMirror/extensions/links/utils/findLineMatchingLink.test.js
packages/editor/CodeMirror/extensions/links/utils/findLineMatchingLink.js
packages/editor/CodeMirror/extensions/links/utils/getUrlAtPosition.js
packages/editor/CodeMirror/extensions/links/utils/openLink.js
packages/editor/CodeMirror/extensions/markdownDecorationExtension.test.js
packages/editor/CodeMirror/extensions/markdownDecorationExtension.js
packages/editor/CodeMirror/extensions/markdownHighlightExtension.test.js
packages/editor/CodeMirror/extensions/markdownHighlightExtension.js
packages/editor/CodeMirror/extensions/markdownMathExtension.test.js
packages/editor/CodeMirror/extensions/markdownMathExtension.js
packages/editor/CodeMirror/extensions/modifierKeyCssExtension.js
packages/editor/CodeMirror/extensions/overwriteModeExtension.test.js
packages/editor/CodeMirror/extensions/overwriteModeExtension.js
packages/editor/CodeMirror/extensions/rendering/addFormattingClasses.js
packages/editor/CodeMirror/extensions/rendering/renderBlockImages.test.js
packages/editor/CodeMirror/extensions/rendering/renderBlockImages.js
packages/editor/CodeMirror/extensions/rendering/renderingExtension.js
packages/editor/CodeMirror/extensions/rendering/replaceBulletLists.js
packages/editor/CodeMirror/extensions/rendering/replaceCheckboxes.js
packages/editor/CodeMirror/extensions/rendering/replaceDividers.js
packages/editor/CodeMirror/extensions/rendering/replaceFormatCharacters.js
packages/editor/CodeMirror/extensions/rendering/types.js
packages/editor/CodeMirror/extensions/rendering/utils/makeBlockReplaceExtension.js
packages/editor/CodeMirror/extensions/rendering/utils/makeInlineReplaceExtension.js
packages/editor/CodeMirror/extensions/rendering/utils/nodeIntersectsSelection.js
packages/editor/CodeMirror/extensions/searchExtension.js
packages/editor/CodeMirror/extensions/selectedNoteIdExtension.js
packages/editor/CodeMirror/getScrollFraction.js
@@ -1019,16 +1049,23 @@ packages/editor/CodeMirror/utils/isInSyntaxNode.js
packages/editor/CodeMirror/utils/markdown/codeBlockLanguages/allLanguages.js
packages/editor/CodeMirror/utils/markdown/codeBlockLanguages/defaultLanguage.js
packages/editor/CodeMirror/utils/markdown/codeBlockLanguages/lookUpLanguage.js
packages/editor/CodeMirror/utils/markdown/getCheckboxAtPosition.js
packages/editor/CodeMirror/utils/markdown/renumberSelectedLists.test.js
packages/editor/CodeMirror/utils/markdown/renumberSelectedLists.js
packages/editor/CodeMirror/utils/markdown/stripBlockquote.js
packages/editor/CodeMirror/utils/markdown/toggleCheckboxAt.js
packages/editor/CodeMirror/utils/setupVim.js
packages/editor/ProseMirror/commands.test.js
packages/editor/ProseMirror/commands.js
packages/editor/ProseMirror/createEditor.js
packages/editor/ProseMirror/index.js
packages/editor/ProseMirror/plugins/detailsPlugin.test.js
packages/editor/ProseMirror/plugins/detailsPlugin.js
packages/editor/ProseMirror/plugins/inputRulesPlugin.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin/createEditorDialog.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.test.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin/postProcessRenderedHtml.js
packages/editor/ProseMirror/plugins/joplinEditorApiPlugin.js
packages/editor/ProseMirror/plugins/keymapPlugin.js
packages/editor/ProseMirror/plugins/linkTooltipPlugin.test.js
@@ -1044,9 +1081,15 @@ packages/editor/ProseMirror/types.js
packages/editor/ProseMirror/utils/UndoStackSynchronizer.js
packages/editor/ProseMirror/utils/canReplaceSelectionWith.js
packages/editor/ProseMirror/utils/computeSelectionFormatting.js
packages/editor/ProseMirror/utils/dom/createButton.js
packages/editor/ProseMirror/utils/dom/createTextArea.js
packages/editor/ProseMirror/utils/dom/createTextNode.js
packages/editor/ProseMirror/utils/dom/createUniqueId.js
packages/editor/ProseMirror/utils/extractSelectedLinesTo.test.js
packages/editor/ProseMirror/utils/extractSelectedLinesTo.js
packages/editor/ProseMirror/utils/forEachHeading.js
packages/editor/ProseMirror/utils/jumpToHash.js
packages/editor/ProseMirror/utils/makeLinksClickableInElement.js
packages/editor/ProseMirror/utils/preprocessEditorInput.test.js
packages/editor/ProseMirror/utils/preprocessEditorInput.js
packages/editor/ProseMirror/utils/sanitizeHtml.js
@@ -1123,6 +1166,8 @@ packages/lib/array.js
packages/lib/callbackUrlUtils.test.js
packages/lib/callbackUrlUtils.js
packages/lib/clipperUtils.js
packages/lib/commands/convertHtmlToMarkdown.test.js
packages/lib/commands/convertHtmlToMarkdown.js
packages/lib/commands/deleteNote.js
packages/lib/commands/historyBackward.js
packages/lib/commands/historyForward.js
@@ -1557,6 +1602,7 @@ packages/lib/shim-init-node.js
packages/lib/shim.js
packages/lib/string-utils.test.js
packages/lib/string-utils.js
packages/lib/testing/plugins/createTestPlugin.js
packages/lib/testing/share/makeMockShareInvitation.js
packages/lib/testing/share/mockShareService.js
packages/lib/testing/syncTargetUtils.js
@@ -1701,12 +1747,14 @@ packages/tools/fuzzer/Client.js
packages/tools/fuzzer/ClientPool.js
packages/tools/fuzzer/Server.js
packages/tools/fuzzer/constants.js
packages/tools/fuzzer/model/FolderRecord.js
packages/tools/fuzzer/sync-fuzzer.js
packages/tools/fuzzer/types.js
packages/tools/fuzzer/utils/SeededRandom.js
packages/tools/fuzzer/utils/getNumberProperty.js
packages/tools/fuzzer/utils/getProperty.js
packages/tools/fuzzer/utils/getStringProperty.js
packages/tools/fuzzer/utils/openDebugSession.js
packages/tools/fuzzer/utils/retryWithCount.js
packages/tools/generate-database-types.js
packages/tools/generate-images.js

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -0,0 +1,215 @@
msgid ""
msgstr ""
"Project-Id-Version: \n"
"POT-Creation-Date: \n"
"PO-Revision-Date: \n"
"Last-Translator: Milo Ivir <mail@mivirtype.de>\n"
"Language-Team: \n"
"Language: hr_HR\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: Poedit 3.6\n"
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/partials/plan.mustache:13
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/partials/plan.mustache:9
msgid "/month"
msgstr "/mjesec"
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/partials/plan.mustache:19
msgid "/year"
msgstr "/godina"
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/plans.mustache:8
msgid ""
"<a href=\"https://joplincloud.com\">Joplin Cloud</a> allows you to "
"synchronise your notes across devices. It also lets you publish notes, and "
"collaborate on notebooks with your friends, family or colleagues."
msgstr ""
"<a href=\"https://joplincloud.com\">Joplin Cloud</a> omogućuje "
"sinkronizaciju bilješki na različitim uređajima. Omogućuje i objavljivanje "
"bilješki i suradnju na bilježnicama s prijateljima, obitelji ili kolegama."
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:205
msgid "<span class=\"frame-bg frame-bg-yellow-lg\">Customise</span> it"
msgstr "<span class=\"frame-bg frame-bg-yellow-lg\">Prilagodi</span> uslugu"
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:104
msgid "<span class=\"frame-bg frame-bg-yellow\">Multimedia</span> notes"
msgstr "<span class=\"frame-bg frame-bg-yellow\">Multimedijske</span> bilješke"
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:256
msgid "100% <span class=\"frame-bg frame-bg-yellow-lg\">your data</span>"
msgstr "100 % <span class=\"frame-bg frame-bg-yellow-lg\">tvoji podaci</span>"
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:298
msgid "A <span class=\"frame-bg frame-bg-yellow-lg\">French</span> Alternative"
msgstr ""
"<span class=\"frame-bg frame-bg-yellow-lg\">Francuska</span> alternativa"
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:236
msgid ""
"Access your notes from your computer, phone or tablet by synchronising with "
"various services, including Joplin Cloud, Dropbox and OneDrive. The app is "
"available on Windows, macOS, Linux, Android and iOS. A terminal app is also "
"available!"
msgstr ""
"Pristupi svojim bilješkama s računala, mobitela ili tableta sinkronizacijom "
"s raznim uslugama, uključujući Joplin Cloud, Dropbox i OneDrive. Program je "
"dostupan za Windows, macOS, Linux, Android i iOS sustave. Dostupan je i "
"program za terminal!"
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/plans.mustache:49
msgid ""
"Already have a Joplin Cloud account? <a href=\"https://"
"joplincloud.com\">Login now</a>"
msgstr ""
"Već imaš Joplin Cloud račun? <a href=\"https://joplincloud.com\">Prijavi se "
"sada</a>"
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:208
msgid ""
"Customise the app with plugins, custom themes and multiple text editors "
"(Rich Text or Markdown). Or create your own scripts and plugins using the "
"Extension API."
msgstr ""
"Prilagodi program pomoću dodataka, prilagođenih tema i uređivača teksta "
"(formatirani tekst ili Markdown). Ili izradi vlastita skripta i dodatke "
"pomoću API-ja za proširenja."
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:242
msgid "Download it now"
msgstr "Preuzmi sada"
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:112
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:63
msgid "Download the app"
msgstr "Preuzmi program"
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:213
msgid "Find out more"
msgstr "Saznaj više"
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:54
msgid "Free your <span class=\"frame-bg frame-bg-blue\">notes</span>"
msgstr "Oslobodi svoje <span class=\"frame-bg frame-bg-blue\">bilješke</span>"
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:175
msgid "Get the clipper"
msgstr "Nabavi Clipper"
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:107
msgid ""
"Images, videos, PDFs and audio files are supported. Create math expressions "
"and diagrams directly from the app. Take photos with the mobile app and save "
"them to a note."
msgstr ""
"Podržane su slike, videozapisi, PDF-ovi i audio datoteke. Stvori matematičke "
"izraze i dijagrame izravno iz programa. Snimaj fotografije s programom za "
"mobitel i spremi ih u bilješku."
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:327
msgid "In the <span class=\"frame-bg frame-bg-yellow\">Press</span>"
msgstr "<span class=\"frame-bg frame-bg-yellow\">Recenzije</span>"
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/plans.mustache:5
msgid "Joplin Cloud <span class=\"frame-bg frame-bg-yellow\">plans</span>"
msgstr "Joplin Cloud <span class=\"frame-bg frame-bg-yellow\">tarife</span>"
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:301
msgid ""
"Joplin Cloud is based in France. This means your data is protected by strict "
"European Union privacy laws. In addition, Joplin Cloud implements strong end-"
"to-end encryption so that not even us can have access to your data."
msgstr ""
"Joplin Cloud ima sjedište u Francuskoj. To znači da su tvoji podaci "
"zaštićeni strogim zakonima o privatnosti Europske unije. Osim toga, Joplin "
"Cloud implementira snažno sveobuhvatno šifriranje (end-to-end encryption) "
"tako da čak ni mi ne možemo pristupiti tvojim podacima."
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:57
msgid ""
"Joplin is an open source note-taking app. Capture your thoughts and securely "
"access them from any device."
msgstr ""
"Joplin je program za bilješke otvorenog koda. Zabilježi svoje misli i "
"sigurno im pristupi s bilo kojeg uređaja."
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:262
msgid "More about E2EE"
msgstr "Više o E2EE"
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:391
msgid "Our <span class=\"frame-bg frame-bg-blue-lg\">sponsors</span>"
msgstr "Naši <span class=\"frame-bg frame-bg-blue-lg\">sponzori</span>"
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/plans.mustache:23
msgid "Pay Monthly"
msgstr "Plaćaj mjesečno"
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/plans.mustache:30
msgid "Pay Yearly"
msgstr "Plaćaj godišnje"
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:167
msgid ""
"Save <span class=\"frame-bg frame-bg-blue\">web pages</span> <br>as notes"
msgstr ""
"Spremaj <span class=\"frame-bg frame-bg-blue\">web stranice</span> <br>kao "
"bilješke"
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:65
msgid "Sign up with Joplin Cloud"
msgstr "Registriraj se na Joplin Cloud"
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:394
msgid "Thank you for your support!"
msgstr "Hvala ti na podršci!"
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:257
msgid ""
"The app is open source and your notes are saved to an open format, so you'll "
"always have access to them. Uses End-To-End Encryption (E2EE) to secure your "
"notes and ensure no-one but yourself can access them."
msgstr ""
"Program je otvorenog koda i tvoje se bilješke spremaju u otvorenom formatu, "
"tako da ćeš im uvijek moći pristupiti. Program koristi sveobuhvatno "
"šifriranje – engl. End-To-End Encryption (E2EE) – kako bi zaštitila tvoje "
"bilješke i osigurala da im nitko osim tebe ne može pristupiti."
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:144
msgid "Try it now"
msgstr "Isprobaj sada"
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:170
msgid ""
"Use the web clipper extension, available on Chrome and Firefox, to save web "
"pages or take screenshots as notes."
msgstr ""
"Koristi proširenje Web Clipper, dostupno za Chrome i Firefox, za spremanje "
"web stranica ili snimanje ekrana kao bilješku."
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:138
msgid ""
"With Joplin Cloud, share your notes with your friends, family or colleagues "
"and collaborate on them."
msgstr ""
"Joplin Cloud ti omogućuje da dijeliš bilješke s prijateljima, obitelji ili "
"kolegama te da na njima surađujete."
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:137
msgid "Work <span class=\"frame-bg frame-bg-yellow\">together</span>"
msgstr "<span class=\"frame-bg frame-bg-yellow\">Surađuj</span> s drugima"
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:141
msgid ""
"You can also publish a note to the internet and share the URL with others."
msgstr "Bilješke možeš objaviti i na internetu te dijeliti URL s drugima."
#: /Users/laurent/src/joplin/Assets/WebsiteAssets/templates/front.mustache:233
msgid ""
"Your notes, <span class=\"frame-bg frame-bg-blue-lg\">everywhere</span> you "
"are"
msgstr ""
"Tvoje bilješke, <span class=\"frame-bg frame-bg-blue-lg\">gdje god</span> se "
"nalaziš"

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://citricsheep.com"><img title="Citric Sheep" width="256" src="https://joplinapp.org/images/sponsors/CitricSheep.png"/></a> <a href="https://sorted.travel/?utm_source=joplinapp"><img title="Sorted Travel" width="256" src="https://joplinapp.org/images/sponsors/SortedTravel.png"/></a> <a href="https://celebian.com"><img title="Celebian" width="256" src="https://joplinapp.org/images/sponsors/Celebian.png"/></a> <a href="https://bestkru.com"><img title="BestKru" width="256" src="https://joplinapp.org/images/sponsors/BestKru.png"/></a> <a href="https://www.socialfollowers.uk/buy-tiktok-followers/"><img title="Social Followers" width="256" src="https://joplinapp.org/images/sponsors/SocialFollowers.png"/></a> <a href="https://stormlikes.com/"><img title="Stormlikes" width="256" src="https://joplinapp.org/images/sponsors/Stormlikes.png"/></a> <a href="https://route4me.com"><img title="Route4Me" width="256" src="https://joplinapp.org/images/sponsors/Route4Me.png"/></a> <a href="https://topagency.webflow.io"><img title="WebDesignAgency" width="256" src="https://joplinapp.org/images/sponsors/WebDesignAgency.png" alt="topagency"/></a> <a href="https://realgambling.ca/"><img title="RealGambling.ca" width="256" src="https://joplinapp.org/images/sponsors/RealGambling.png" alt="RealGambling.ca"/></a> <a href="https://essaypro.com/"><img title="write an essay online with EssayPro" width="256" src="https://joplinapp.org/images/sponsors/EssayPro.png" alt="write an essay online with EssayPro"/></a> <a href="https://www.slotozilla.com/nz/no-deposit-bonus"><img title="casino without making any upfront cost" width="256" src="https://joplinapp.org/images/sponsors/Slotozilla.png" alt="casino without making any upfront cost"/></a> <a href="https://essaywriter.pro"><img title="write my essay services by EssayWriter" width="256" src="https://joplinapp.org/images/sponsors/EssayWriterPro.png" alt="write my essay services by EssayWriter"/></a> <a href="https://essayservice.com"><img title="quick and reliable service to write my paper for me" width="256" src="https://joplinapp.org/images/sponsors/EssayService.png" alt="quick and reliable service to write my paper for me"/></a> <a href="https://writepaper.com/"><img title="best service to write my paper for me" width="256" src="https://joplinapp.org/images/sponsors/WritePaper.png" alt="best service to write my paper for me"/></a> <a href="https://paperwriter.com/"><img title="high-quality paper writing service PaperWriter" width="256" src="https://joplinapp.org/images/sponsors/PaperWriter.png" alt="high-quality paper writing service PaperWriter"/></a> <a href="https://homeworkguy.org/someone-to-take-my-online-class"><img title="someone to take my online class" width="256" src="https://joplinapp.org/images/sponsors/HomeworkGuy.png" alt="someone to take my online class"/></a> <a href="https://www.bestetf.net/"><img title="BestETF" width="256" src="https://joplinapp.org/images/sponsors/BestEtf.png" alt="BestETF"/></a> <a href="https://freespinny.io/free-spins-no-deposit/"><img title="Freespinny.io Free Spins Bonus site" width="256" src="https://joplinapp.org/images/sponsors/Freespinny.png" alt="Freespinny.io Free Spins Bonus site"/></a>
<a href="https://seirei.ne.jp"><img title="Serei Network" width="256" src="https://joplinapp.org/images/sponsors/SeireiNetwork.png"/></a> <a href="https://citricsheep.com"><img title="Citric Sheep" width="256" src="https://joplinapp.org/images/sponsors/CitricSheep.png"/></a> <a href="https://sorted.travel/?utm_source=joplinapp"><img title="Sorted Travel" width="256" src="https://joplinapp.org/images/sponsors/SortedTravel.png"/></a> <a href="https://celebian.com"><img title="Celebian" width="256" src="https://joplinapp.org/images/sponsors/Celebian.png"/></a> <a href="https://bestkru.com"><img title="BestKru" width="256" src="https://joplinapp.org/images/sponsors/BestKru.png"/></a> <a href="https://www.socialfollowers.uk/buy-tiktok-followers/"><img title="Social Followers" width="256" src="https://joplinapp.org/images/sponsors/SocialFollowers.png"/></a> <a href="https://stormlikes.com/"><img title="Stormlikes" width="256" src="https://joplinapp.org/images/sponsors/Stormlikes.png"/></a> <a href="https://route4me.com"><img title="Route4Me" width="256" src="https://joplinapp.org/images/sponsors/Route4Me.png"/></a> <a href="https://topagency.webflow.io"><img title="WebDesignAgency" width="256" src="https://joplinapp.org/images/sponsors/WebDesignAgency.png" alt="topagency"/></a> <a href="https://www.slotozilla.com/nz/no-deposit-bonus"><img title="casino without making any upfront cost" width="256" src="https://joplinapp.org/images/sponsors/Slotozilla.png" alt="casino without making any upfront cost"/></a> <a href="https://essayservice.com"><img title="quick and reliable service to write my paper for me" width="256" src="https://joplinapp.org/images/sponsors/EssayService.png" alt="quick and reliable service to write my paper for me"/></a> <a href="https://writepaper.com/"><img title="best service to write my paper for me" width="256" src="https://joplinapp.org/images/sponsors/WritePaper.png" alt="best service to write my paper for me"/></a> <a href="https://paperwriter.com/"><img title="high-quality paper writing service PaperWriter" width="256" src="https://joplinapp.org/images/sponsors/PaperWriter.png" alt="high-quality paper writing service PaperWriter"/></a> <a href="https://www.bestetf.net/"><img title="BestETF" width="256" src="https://joplinapp.org/images/sponsors/BestEtf.png" alt="BestETF"/></a> <a href="https://freespinny.io/free-spins-no-deposit/"><img title="Freespinny.io Free Spins Bonus site" width="256" src="https://joplinapp.org/images/sponsors/Freespinny.png" alt="Freespinny.io Free Spins Bonus site"/></a> <a href="https://damangameplay.in"><img title="Daman Game" width="256" src="https://joplinapp.org/images/sponsors/DamanGame.png" alt="Daman Game"/></a> <a href="https://essayshark.com"><img title="EssayShark - essay writers for hire" width="256" src="https://joplinapp.org/images/sponsors/EssayShark.png" alt="EssayShark - essay writers for hire"/></a>
<!-- SPONSORS-ORG -->
* * *
@@ -40,9 +40,8 @@ Please see the [donation page](https://github.com/laurent22/joplin/blob/dev/read
| | | | |
| :---: | :---: | :---: | :---: |
| <img width="50" src="https://avatars2.githubusercontent.com/u/97193607?s=96&v=4"/></br>[Akhil-CM](https://github.com/Akhil-CM) | <img width="50" src="https://avatars2.githubusercontent.com/u/552452?s=96&v=4"/></br>[andypiper](https://github.com/andypiper) | <img width="50" src="https://avatars2.githubusercontent.com/u/215668?s=96&v=4"/></br>[avanderberg](https://github.com/avanderberg) | <img width="50" src="https://avatars2.githubusercontent.com/u/67130?s=96&v=4"/></br>[chr15m](https://github.com/chr15m) |
| <img width="50" src="https://avatars2.githubusercontent.com/u/1177810?s=96&v=4"/></br>[felixstorm](https://github.com/felixstorm) | <img width="50" src="https://avatars2.githubusercontent.com/u/8030470?s=96&v=4"/></br>[Galliver7](https://github.com/Galliver7) | <img width="50" src="https://avatars2.githubusercontent.com/u/64712218?s=96&v=4"/></br>[Hegghammer](https://github.com/Hegghammer) | <img width="50" src="https://avatars2.githubusercontent.com/u/11947658?s=96&v=4"/></br>[KentBrockman](https://github.com/KentBrockman) |
| <img width="50" src="https://avatars2.githubusercontent.com/u/42319182?s=96&v=4"/></br>[marcdw1289](https://github.com/marcdw1289) | <img width="50" src="https://avatars2.githubusercontent.com/u/1788010?s=96&v=4"/></br>[maxtruxa](https://github.com/maxtruxa) | <img width="50" src="https://avatars2.githubusercontent.com/u/327998?s=96&v=4"/></br>[sif](https://github.com/sif) | <img width="50" src="https://avatars2.githubusercontent.com/u/765564?s=96&v=4"/></br>[taskcruncher](https://github.com/taskcruncher) |
| <img width="50" src="https://avatars2.githubusercontent.com/u/668977?s=96&v=4"/></br>[ugoertz](https://github.com/ugoertz) | | | |
| <img width="50" src="https://avatars2.githubusercontent.com/u/1177810?s=96&v=4"/></br>[felixstorm](https://github.com/felixstorm) | <img width="50" src="https://avatars2.githubusercontent.com/u/11947658?s=96&v=4"/></br>[KentBrockman](https://github.com/KentBrockman) | <img width="50" src="https://avatars2.githubusercontent.com/u/42319182?s=96&v=4"/></br>[marcdw1289](https://github.com/marcdw1289) | <img width="50" src="https://avatars2.githubusercontent.com/u/668977?s=96&v=4"/></br>[ugoertz](https://github.com/ugoertz) |
| | | | |
<!-- SPONSORS-GITHUB -->
# Community

View File

@@ -16,13 +16,13 @@
"version": "",
"platforms": ["aarch64-darwin", "x86_64-darwin"],
},
"python": "3.13.2",
"python": "3.13.3",
"bat": "latest",
"electron": {
"version": "latest",
"excluded_platforms": ["aarch64-darwin", "x86_64-darwin"],
},
"git": "2.47.2",
"git": "2.48.1",
},
"shell": {
"init_hook": [

View File

@@ -0,0 +1,13 @@
<strong>Joplin</strong> je besplatan program otvorenog koda za bilješke i popis zadataka koji može obraditi veliki broj bilješki organizirane u bilježnice. Bilješke se mogu pretraživati, kopirati, označavati i mijenjati izravno iz programa ili iz vlastitog uređivača teksta.
Bilješke su u <a href="https://joplinapp.org/help/apps/markdown">Markdown formatu</a>.
Iz Evernotea izvezene bilješke <a href="https://joplinapp.org/help/apps/import_export">mogu se uvesti</a> u Joplin, uključujući formatirani sadržaj (koji se pretvara u Markdown), resurse (slike, privitke itd.) i potpune metapodatke (geografski podaci mjesta, vrijeme aktualiziranja, vrijeme stvaranja itd.). Mogu se uvesti i obične Markdown datoteke.
Joplin radi ponajprije s lokalnim podacima (offline first), što znači da uvijek imaš sve svoje podatke na mobitelu ili računalu. To osigurava da su tvoje bilješke uvijek dostupne, bez obzira je li imaš internetsku vezu ili ne.</p>
Bilješke se mogu sigurno <a href="https://joplinapp.org/help/apps/sync">sinkronizirati</a> pomoću <a href="https://joplinapp.org/help/apps/sync/e2ee">sveobuhvatnog šifriranja</a> s raznim uslugama u oblaku, uključujući Nextcloud, Dropbox, OneDrive i <a href="https://joplinapp.org/plans/">Joplin Cloud</a>.
Pretraživanje cijelog teksta dostupno je na svim platformama za brzo pronalaženje potrebnih informacija. Program se može prilagoditi pomoću dodataka i tema, a možeš stvoriti i vlastite.
Program je dostupan za Windows, Linux, macOS, Android i iOS sustave. <a href="https://joplinapp.org/help/apps/clipper">Web Clipper</a>, za spremanje web stranica i snimaka ekrana iz tvog preglednika, je također dostupan za <a href="https://addons.mozilla.org/firefox/addon/joplin-web-clipper/">Firefox</a> i <a href="https://chrome.google.com/webstore/detail/joplin-web-clipper/alofnhikmmkdbbbgpnglcpdollgjjfek">Chrome</a>.

View File

@@ -0,0 +1 @@
Program za bilješke i popis zadataka sa sinkronizacijom između Linuxa, macOS-a, Windowsa i mobitela

View File

@@ -86,7 +86,7 @@
"gulp": "4.0.2",
"husky": "9.1.7",
"lerna": "3.22.1",
"lint-staged": "15.5.1",
"lint-staged": "15.5.2",
"madge": "8.0.0",
"npm-package-json-lint": "8.0.0",
"typescript": "5.8.2"

View File

@@ -380,6 +380,13 @@ class AppGui {
this.widget('noteList').toggleShowIds();
}
toggleFolderCollapse() {
const folderList = this.widget('folderList');
if (folderList && folderList.toggleFolderCollapse) {
folderList.toggleFolderCollapse();
}
}
widget(name) {
if (name === 'root') return this.rootWidget_;
return this.rootWidget_.childByName(name);
@@ -506,6 +513,8 @@ class AppGui {
this.toggleNoteMetadata();
} else if (cmd === 'toggle_ids') {
this.toggleFolderIds();
} else if (cmd === 'toggle_folder_collapse') {
this.toggleFolderCollapse();
} else if (cmd === 'enter_command_line_mode') {
const cmd = await this.widget('statusBar').prompt();
if (!cmd) return;

View File

@@ -9,7 +9,6 @@ import Tag from '@joplin/lib/models/Tag';
import Setting, { Env } from '@joplin/lib/models/Setting';
import { reg } from '@joplin/lib/registry.js';
import { dirname, fileExtension } from '@joplin/lib/path-utils';
import { splitCommandString } from '@joplin/utils';
import { _ } from '@joplin/lib/locale';
import { pathExists, readFile, readdirSync } from 'fs-extra';
import RevisionService from '@joplin/lib/services/RevisionService';
@@ -19,7 +18,6 @@ import { FolderEntity, NoteEntity } from '@joplin/lib/services/database/types';
import initializeCommandService from './utils/initializeCommandService';
const { cliUtils } = require('./cli-utils.js');
const Cache = require('@joplin/lib/Cache');
const { splitCommandBatch } = require('@joplin/lib/string-utils');
class Application extends BaseApplication {
@@ -222,6 +220,7 @@ class Application extends BaseApplication {
return { ...this.commandMetadata_ };
}
public hasGui() {
return this.gui() && !this.gui().isDummy();
}
@@ -332,6 +331,7 @@ class Application extends BaseApplication {
{ keys: ['mb'], type: 'prompt', command: 'mkbook ""', cursorPosition: -2 },
{ keys: ['yn'], type: 'prompt', command: 'cp $n ""', cursorPosition: -2 },
{ keys: ['dn'], type: 'prompt', command: 'mv $n ""', cursorPosition: -2 },
{ keys: ['z'], type: 'function', command: 'toggle_folder_collapse' },
];
// Filter the keymap item by command so that items in keymap.json can override
@@ -381,22 +381,6 @@ class Application extends BaseApplication {
return output;
}
public async commandList(argv: string[]) {
if (argv.length && argv[0] === 'batch') {
const commands = [];
const commandLines = splitCommandBatch(await readFile(argv[1], 'utf-8'));
for (const commandLine of commandLines) {
if (!commandLine.trim()) continue;
const splitted = splitCommandString(commandLine.trim());
commands.push(splitted);
}
return commands;
} else {
return [argv];
}
}
// We need this special case here because by the time the `version` command
// runs, the keychain has already been setup.
public checkIfKeychainEnabled(argv: string[]) {
@@ -433,15 +417,10 @@ class Application extends BaseApplication {
if (argv.length) {
this.gui_ = this.dummyGui();
this.currentFolder_ = await Folder.load(Setting.value('activeFolderId'));
await this.applySettingsSideEffects();
await this.refreshCurrentFolder();
try {
const commands = await this.commandList(argv);
for (const command of commands) {
await this.execCommand(command);
}
await this.execCommand(argv);
} catch (error) {
if (this.showStackTraces_) {
console.error(error);

View File

@@ -1,19 +0,0 @@
const BaseCommand = require('./base-command').default;
const { _ } = require('@joplin/lib/locale');
class Command extends BaseCommand {
usage() {
return 'batch <file-path>';
}
description() {
return _('Runs the commands contained in the text file. There should be one command per line.');
}
async action() {
// Implementation is in app.js::commandList()
throw new Error('No implemented');
}
}
module.exports = Command;

View File

@@ -0,0 +1,79 @@
import { splitCommandBatch } from '@joplin/lib/string-utils';
import BaseCommand from './base-command';
import { _ } from '@joplin/lib/locale';
import { splitCommandString } from '@joplin/utils';
import iterateStdin from './utils/iterateStdin';
import { readFile } from 'fs-extra';
import app from './app';
interface Options {
'file-path': string;
options: {
'continue-on-failure': boolean;
};
}
class Command extends BaseCommand {
public usage() {
return 'batch <file-path>';
}
public options() {
return [
// These are present mostly for testing purposes
['--continue-on-failure', 'Continue running commands when one command in the batch fails.'],
];
}
public description() {
return _('Runs the commands contained in the text file. There should be one command per line.');
}
private streamCommands_ = async function*(filePath: string) {
const processLines = function*(lines: string) {
const commandLines = splitCommandBatch(lines);
for (const command of commandLines) {
if (!command.trim()) continue;
yield splitCommandString(command.trim());
}
};
if (filePath === '-') { // stdin
// Iterating over standard input conflicts with the CLI app's GUI.
if (app().hasGui()) {
throw new Error(_('Reading commands from standard input is only available in CLI mode.'));
}
for await (const lines of iterateStdin('command> ')) {
yield* processLines(lines);
}
} else {
const data = await readFile(filePath, 'utf-8');
yield* processLines(data);
}
};
public async action(options: Options) {
let lastError;
for await (const command of this.streamCommands_(options['file-path'])) {
try {
await app().refreshCurrentFolder();
await app().execCommand(command);
} catch (error) {
if (options.options['continue-on-failure']) {
app().stdout(error.message);
lastError = error;
} else {
throw error;
}
}
}
if (lastError) {
throw lastError;
}
}
}
module.exports = Command;

View File

@@ -6,6 +6,7 @@ import app from './app';
import { _ } from '@joplin/lib/locale';
import { ImportOptions } from '@joplin/lib/services/interop/types';
import { unique } from '@joplin/lib/array';
import Folder from '@joplin/lib/models/Folder';
class Command extends BaseCommand {
public override usage() {
@@ -32,14 +33,16 @@ class Command extends BaseCommand {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
public override async action(args: any) {
const folder = await app().loadItem(BaseModel.TYPE_FOLDER, args.notebook);
let destinationFolder = await app().loadItem(BaseModel.TYPE_FOLDER, args.notebook);
if (args.notebook && !folder) throw new Error(_('Cannot find "%s".', args.notebook));
if (args.notebook && !destinationFolder) throw new Error(_('Cannot find "%s".', args.notebook));
if (!destinationFolder) destinationFolder = await Folder.defaultFolder();
const importOptions: ImportOptions = {};
importOptions.path = args.path;
importOptions.format = args.options.format ? args.options.format : 'auto';
importOptions.destinationFolderId = folder ? folder.id : null;
importOptions.destinationFolderId = destinationFolder ? destinationFolder.id : null;
let lastProgress = '';

View File

@@ -14,17 +14,25 @@ class Command extends BaseCommand {
return `${_('Start, stop or check the API server. To specify on which port it should run, set the api.port config variable. Commands are (%s).', ['start', 'stop', 'status'].join('|'))} This is an experimental feature - use at your own risks! It is recommended that the server runs off its own separate profile so that no two CLI instances access that profile at the same time. Use --profile to specify the profile path.`;
}
options() {
return [
['--exit-early', 'Allow the command to exit while the server is still running. The server will still stop when the app exits. Valid only for the `start` subcommand.'],
['--quiet', 'Log less information to the console. More verbose logs will still be available through log-clipper.txt.'],
];
}
async action(args) {
const command = args.command;
const ClipperServer = require('@joplin/lib/ClipperServer').default;
ClipperServer.instance().initialize();
const stdoutFn = (...s) => this.stdout(s.join(' '));
const ignoreOutputFn = ()=>{};
const clipperLogger = new Logger();
clipperLogger.addTarget('file', { path: `${Setting.value('profileDir')}/log-clipper.txt` });
clipperLogger.addTarget('console', { console: {
info: stdoutFn,
warn: stdoutFn,
info: args.options.quiet ? ignoreOutputFn : stdoutFn,
warn: args.options.quiet ? ignoreOutputFn : stdoutFn,
error: stdoutFn,
} });
ClipperServer.instance().setDispatch(() => {});
@@ -38,7 +46,11 @@ class Command extends BaseCommand {
this.stdout(_('Server is already running on port %d', runningOnPort));
} else {
await shim.fsDriver().writeFile(pidPath, process.pid.toString(), 'utf-8');
await ClipperServer.instance().start(); // Never exit
const promise = ClipperServer.instance().start();
if (!args.options['exit-early']) {
await promise; // Never exit
}
}
} else if (command === 'status') {
this.stdout(runningOnPort ? _('Server is running on port %d', runningOnPort) : _('Server is not running.'));

View File

@@ -149,6 +149,7 @@ class Command extends BaseCommand {
waiting: invitation.status === ShareUserStatus.Waiting,
rejected: invitation.status === ShareUserStatus.Rejected,
folderId: invitation.share.folder_id,
canWrite: !!invitation.can_write,
fromUser: {
email: invitation.share.user?.email,
},

View File

@@ -2,6 +2,7 @@ import BaseCommand from './base-command';
import app from './app';
import { _ } from '@joplin/lib/locale';
import BaseModel from '@joplin/lib/BaseModel';
import Folder from '@joplin/lib/models/Folder';
class Command extends BaseCommand {
public override usage() {
@@ -20,6 +21,18 @@ class Command extends BaseCommand {
public override async action(args: any) {
const folder = await app().loadItem(BaseModel.TYPE_FOLDER, args['notebook']);
if (!folder) throw new Error(_('Cannot find "%s".', args['notebook']));
// Auto-expand parent folders in GUI if present
if (app().gui() && app().gui().widget && app().gui().widget('folderList')) {
const folderListWidget = app().gui().widget('folderList');
if (folderListWidget.expandToFolder) {
// Get all folders to pass to expandToFolder
const folders = await Folder.all();
folderListWidget.folders = folders; // Ensure widget has current folders
folderListWidget.expandToFolder(folder.id);
}
}
app().switchCurrentFolder(folder);
}
}

View File

@@ -4,11 +4,14 @@ import BaseModel from '@joplin/lib/BaseModel';
import Setting from '@joplin/lib/models/Setting';
import { _ } from '@joplin/lib/locale';
import { FolderEntity } from '@joplin/lib/services/database/types';
import { getDisplayParentId, getTrashFolderId } from '@joplin/lib/services/trash';
import {
getDisplayParentId,
getTrashFolderId,
} from '@joplin/lib/services/trash';
const ListWidget = require('tkwidgets/ListWidget.js');
export default class FolderListWidget extends ListWidget {
export default class FolderListWidget extends ListWidget {
private folders_: FolderEntity[] = [];
public constructor() {
@@ -31,7 +34,18 @@ export default class FolderListWidget extends ListWidget {
if (item === '-') {
output.push('-'.repeat(this.innerWidth));
} else if (item.type_ === Folder.modelType()) {
output.push(' '.repeat(this.folderDepth(this.folders, item.id)));
const depth = this.folderDepth(this.folders, item.id);
output.push(' '.repeat(depth));
// Add collapse/expand indicator
const hasChildren = this.folderHasChildren_(this.folders, item.id);
if (hasChildren) {
const collapsedFolders = Setting.value('collapsedFolderIds');
const isCollapsed = collapsedFolders.includes(item.id);
output.push(isCollapsed ? '[+] ' : '[-] ');
} else {
output.push(' '); // Space for alignment
}
if (this.showIds) {
output.push(Folder.shortId(item.id));
@@ -65,7 +79,10 @@ export default class FolderListWidget extends ListWidget {
let output = 0;
while (true) {
const folder = BaseModel.byId(folders, folderId);
const folderParentId = getDisplayParentId(folder, folders.find(f => f.id === folder.parent_id));
const folderParentId = getDisplayParentId(
folder,
folders.find((f) => f.id === folder.parent_id),
);
if (!folder || !folderParentId) return output;
output++;
folderId = folderParentId;
@@ -153,7 +170,10 @@ export default class FolderListWidget extends ListWidget {
public folderHasChildren_(folders: FolderEntity[], folderId: string) {
for (let i = 0; i < folders.length; i++) {
const folder = folders[i];
const folderParentId = getDisplayParentId(folder, folders.find(f => f.id === folder.parent_id));
const folderParentId = getDisplayParentId(
folder,
folders.find((f) => f.id === folder.parent_id),
);
if (folderParentId === folderId) return true;
}
return false;
@@ -161,7 +181,12 @@ export default class FolderListWidget extends ListWidget {
public render() {
if (this.updateItems_) {
this.logger().debug('Rebuilding items...', this.notesParentType, this.selectedJoplinItemId, this.selectedSearchId);
this.logger().debug(
'Rebuilding items...',
this.notesParentType,
this.selectedJoplinItemId,
this.selectedSearchId,
);
const wasSelectedItemId = this.selectedJoplinItemId;
const previousParentType = this.notesParentType;
@@ -170,12 +195,20 @@ export default class FolderListWidget extends ListWidget {
const orderFolders = (parentId: string) => {
for (let i = 0; i < this.folders.length; i++) {
const f = this.folders[i];
const originalParent = this.folders_.find(f => f.id === f.parent_id);
const originalParent = this.folders_.find(
(f) => f.id === f.parent_id,
);
const folderParentId = getDisplayParentId(f, originalParent); // f.parent_id ? f.parent_id : '';
if (folderParentId === parentId) {
newItems.push(f);
if (this.folderHasChildren_(this.folders, f.id)) orderFolders(f.id);
// Only recurse into children if the folder is not collapsed
if (this.folderHasChildren_(this.folders, f.id)) {
const collapsedFolders = Setting.value('collapsedFolderIds');
if (!collapsedFolders.includes(f.id)) {
orderFolders(f.id);
}
}
}
}
};
@@ -221,4 +254,53 @@ export default class FolderListWidget extends ListWidget {
const index = this.itemIndexByKey('id', itemId);
this.currentIndex = index >= 0 ? index : 0;
}
public toggleFolderCollapse() {
const item = this.currentItem;
if (item && item.type_ === Folder.modelType() && this.folderHasChildren_(this.folders, item.id)) {
const collapsedFolders = Setting.value('collapsedFolderIds');
const isCollapsed = collapsedFolders.includes(item.id);
if (isCollapsed) {
const newCollapsed = collapsedFolders.filter((id: string) => id !== item.id);
Setting.setValue('collapsedFolderIds', newCollapsed);
} else {
Setting.setValue('collapsedFolderIds', [...collapsedFolders, item.id]);
}
this.updateItems_ = true;
this.invalidate();
return true;
}
return false;
}
public expandToFolder(folderId: string) {
// Find all parent folders and expand them
const parentsToExpand: string[] = [];
let currentId = folderId;
while (currentId) {
const folder = BaseModel.byId(this.folders, currentId);
if (!folder) break;
const parentId = getDisplayParentId(
folder,
this.folders.find((f) => f.id === folder.parent_id),
);
if (parentId) {
parentsToExpand.unshift(parentId);
currentId = parentId;
} else {
break;
}
}
// Expand all parent folders
const collapsedFolders = Setting.value('collapsedFolderIds');
const newCollapsed = collapsedFolders.filter((id: string) => !parentsToExpand.includes(id));
Setting.setValue('collapsedFolderIds', newCollapsed);
this.updateItems_ = true;
this.invalidate();
}
}

View File

@@ -0,0 +1,54 @@
import { createInterface } from 'readline/promises';
const iterateStdin = async function*(prompt: string) {
let nextLineListeners: (()=> void)[] = [];
const dispatchAllListeners = () => {
const listeners = nextLineListeners;
nextLineListeners = [];
for (const listener of listeners) {
listener();
}
};
const rl = createInterface({
input: process.stdin,
output: process.stdout,
});
rl.setPrompt(prompt);
let buffer: string[] = [];
rl.on('line', (line) => {
buffer.push(line);
dispatchAllListeners();
});
let done = false;
rl.on('close', () => {
done = true;
dispatchAllListeners();
});
const readNextLines = () => {
return new Promise<string|null>(resolve => {
if (done) {
resolve(null);
} else if (buffer.length > 0) {
resolve(buffer.join('\n'));
buffer = [];
} else {
nextLineListeners.push(() => {
resolve(buffer.join('\n'));
buffer = [];
});
}
});
};
while (!done) {
rl.prompt();
const lines = await readNextLines();
yield lines;
}
};
export default iterateStdin;

View File

@@ -9,7 +9,7 @@ const shimInitCli = (options: ShimInitOptions) => {
shim.showMessageBox = async (message: string, options: ShowMessageBoxOptions) => {
const gui = app()?.gui();
let answers = options.buttons ?? [_('Ok'), _('Cancel')];
let answers = options.buttons ?? [_('OK'), _('Cancel')];
if (options.type === 'error' || options.type === 'info') {
answers = [];

View File

@@ -57,7 +57,7 @@
"proper-lockfile": "4.1.2",
"redux": "4.2.1",
"server-destroy": "1.0.1",
"sharp": "0.34.0",
"sharp": "0.34.2",
"sprintf-js": "1.1.3",
"sqlite3": "5.1.6",
"string-padding": "1.0.2",
@@ -73,7 +73,7 @@
"@joplin/tools": "~3.4",
"@types/fs-extra": "11.0.4",
"@types/jest": "29.5.14",
"@types/node": "18.19.87",
"@types/node": "18.19.103",
"@types/proper-lockfile": "^4.1.2",
"gulp": "4.0.2",
"jest": "29.7.0",

View File

@@ -8,8 +8,8 @@ import { urlDecode } from '@joplin/lib/string-utils';
import * as Sentry from '@sentry/electron/main';
import { homedir } from 'os';
import { msleep } from '@joplin/utils/time';
import { pathExists, pathExistsSync, writeFileSync } from 'fs-extra';
import { extname, normalize } from 'path';
import { pathExists, pathExistsSync, writeFileSync, ensureDirSync } from 'fs-extra';
import { extname, normalize, join } from 'path';
import isSafeToOpen from './utils/isSafeToOpen';
import { closeSync, openSync, readSync, statSync } from 'fs';
import { KB } from '@joplin/utils/bytes';
@@ -67,6 +67,30 @@ export class Bridge {
this.logFilePath_ = v;
}
private getCrashDumpDirectory(): string {
try {
const platformName = shim.platformName();
switch (platformName) {
case 'win32':
// Windows: Use %LOCALAPPDATA%\CrashDumps
return join(process.env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local'), 'CrashDumps');
case 'darwin':
// macOS: Use ~/Library/Logs/DiagnosticReports
return join(homedir(), 'Library', 'Logs', 'DiagnosticReports');
case 'linux':
// Linux: Use XDG_STATE_HOME (for logs) or fallback to ~/.local/state
return join(process.env.XDG_STATE_HOME || join(homedir(), '.local', 'state'), 'joplin');
default:
// For unknown platforms, default to the home directory
return homedir();
}
} catch (error) {
// If we can't get the platform name, fallback to the home directory
return homedir();
}
}
private sentryInit() {
const getLogLines = () => {
try {
@@ -109,7 +133,10 @@ export class Bridge {
log: logAttachment ? logAttachment.data.trim().split('\n') : [],
};
writeFileSync(`${homedir()}/joplin_crash_dump_${date}.json`, JSON.stringify(errorEventWithLog, null, '\t'), 'utf-8');
const crashDumpDir = this.getCrashDumpDirectory();
ensureDirSync(crashDumpDir);
const crashDumpPath = join(crashDumpDir, `joplin_crash_dump_${date}.json`);
writeFileSync(crashDumpPath, JSON.stringify(errorEventWithLog, null, '\t'), 'utf-8');
} catch (error) {
// Ignore the error since we can't handle it here
}

View File

@@ -0,0 +1,96 @@
import * as convertHtmlToMarkdown from './convertNoteToMarkdown';
import { AppState, createAppDefaultState } from '../app.reducer';
import Note from '@joplin/lib/models/Note';
import { MarkupLanguage } from '@joplin/renderer';
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
import Folder from '@joplin/lib/models/Folder';
import { NoteEntity } from '@joplin/lib/services/database/types';
describe('convertNoteToMarkdown', () => {
let state: AppState = undefined;
beforeEach(async () => {
state = createAppDefaultState({});
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
});
it('should set the original note to be trashed', async () => {
const folder = await Folder.save({ title: 'test_folder' });
const htmlNote = await Note.save({ title: 'test', body: '<p>Hello</p>', parent_id: folder.id, markup_language: MarkupLanguage.Html });
state.selectedNoteIds = [htmlNote.id];
await convertHtmlToMarkdown.runtime().execute({ state, dispatch: () => {} });
const refreshedNote = await Note.load(htmlNote.id);
expect(htmlNote.deleted_time).toBe(0);
expect(refreshedNote.deleted_time).not.toBe(0);
});
it('should recreate a new note that is a clone of the original', async () => {
let noteConvertedToMarkdownId = '';
const dispatchFn = jest.fn()
.mockImplementationOnce(() => {})
.mockImplementationOnce(action => {
noteConvertedToMarkdownId = action.id;
});
const folder = await Folder.save({ title: 'test_folder' });
const htmlNoteProperties = {
title: 'test',
body: '<p>Hello</p>',
parent_id: folder.id,
markup_language: MarkupLanguage.Html,
author: 'test-author',
is_todo: 1,
todo_completed: 1,
};
const htmlNote = await Note.save(htmlNoteProperties);
state.selectedNoteIds = [htmlNote.id];
await convertHtmlToMarkdown.runtime().execute({ state, dispatch: dispatchFn });
expect(dispatchFn).toHaveBeenCalledTimes(2);
expect(noteConvertedToMarkdownId).not.toBe('');
const markdownNote = await Note.load(noteConvertedToMarkdownId);
const fields: (keyof NoteEntity)[] = ['parent_id', 'title', 'author', 'is_todo', 'todo_completed'];
for (const field of fields) {
expect(htmlNote[field]).toEqual(markdownNote[field]);
}
});
it('should generate action to trigger notification', async () => {
let originalHtmlNoteId = '';
let actionType = '';
const dispatchFn = jest.fn()
.mockImplementationOnce(action => {
originalHtmlNoteId = action.value;
actionType = action.type;
})
.mockImplementationOnce(() => {});
const folder = await Folder.save({ title: 'test_folder' });
const htmlNoteProperties = {
title: 'test',
body: '<p>Hello</p>',
parent_id: folder.id,
markup_language: MarkupLanguage.Html,
author: 'test-author',
is_todo: 1,
todo_completed: 1,
};
const htmlNote = await Note.save(htmlNoteProperties);
state.selectedNoteIds = [htmlNote.id];
await convertHtmlToMarkdown.runtime().execute({ state, dispatch: dispatchFn });
expect(dispatchFn).toHaveBeenCalledTimes(2);
expect(originalHtmlNoteId).toBe(htmlNote.id);
expect(actionType).toBe('NOTE_HTML_TO_MARKDOWN_DONE');
});
});

View File

@@ -0,0 +1,52 @@
import { _ } from '@joplin/lib/locale';
import Note from '@joplin/lib/models/Note';
import { stateUtils } from '@joplin/lib/reducer';
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
import { MarkupLanguage } from '@joplin/renderer';
import { runtime as convertHtmlToMarkdown } from '@joplin/lib/commands/convertHtmlToMarkdown';
import bridge from '../services/bridge';
export const declaration: CommandDeclaration = {
name: 'convertNoteToMarkdown',
label: () => _('Convert note to Markdown'),
};
export const runtime = (): CommandRuntime => {
return {
execute: async (context: CommandContext, noteId: string = null) => {
noteId = noteId || stateUtils.selectedNoteId(context.state);
const note = await Note.load(noteId);
if (!note) return;
try {
const markdownBody = await convertHtmlToMarkdown().execute(context, note.body);
const newNote = await Note.duplicate(note.id);
newNote.body = markdownBody;
newNote.markup_language = MarkupLanguage.Markdown;
await Note.save(newNote);
await Note.delete(note.id, { toTrash: true });
context.dispatch({
type: 'NOTE_HTML_TO_MARKDOWN_DONE',
value: note.id,
});
context.dispatch({
type: 'NOTE_SELECT',
id: newNote.id,
});
} catch (error) {
bridge().showErrorMessageBox(_('Could not convert note to Markdown: %s', error.message));
}
},
enabledCondition: 'oneNoteSelected && noteIsHtml && !noteIsReadOnly',
};
};

View File

@@ -1,4 +1,5 @@
// AUTO-GENERATED using `gulp buildScriptIndexes`
import * as convertNoteToMarkdown from './convertNoteToMarkdown';
import * as copyDevCommand from './copyDevCommand';
import * as copyToClipboard from './copyToClipboard';
import * as editProfileConfig from './editProfileConfig';
@@ -24,6 +25,7 @@ import * as toggleSafeMode from './toggleSafeMode';
import * as toggleTabMovesFocus from './toggleTabMovesFocus';
const index: any[] = [
convertNoteToMarkdown,
copyDevCommand,
copyToClipboard,
editProfileConfig,

View File

@@ -0,0 +1,28 @@
import * as React from 'react';
import { useContext, useEffect } from 'react';
import { _ } from '@joplin/lib/locale';
import { Dispatch } from 'redux';
import { PopupNotificationContext } from '../PopupNotification/PopupNotificationProvider';
import { NotificationType } from '../PopupNotification/types';
interface Props {
noteId: string;
dispatch: Dispatch;
}
export default (props: Props) => {
const popupManager = useContext(PopupNotificationContext);
useEffect(() => {
if (!props.noteId || props.noteId === '') return;
props.dispatch({ type: 'NOTE_HTML_TO_MARKDOWN_DONE', value: '' });
const notification = popupManager.createPopup(() => (
<div>{_('The note has been converted to Markdown and the original note has been moved to the trash')}</div>
), { type: NotificationType.Success });
notification.scheduleDismiss();
}, [props.dispatch, popupManager, props.noteId]);
return <div style={{ display: 'none' }}/>;
};

View File

@@ -1,4 +1,4 @@
import { useMemo, useRef, useState } from 'react';
import { useMemo, useRef, useState, useCallback } from 'react';
interface Props {
width: number;
@@ -9,40 +9,62 @@ interface Props {
const fontSizeCache_: Record<string, number> = {};
export default (props: Props) => {
const containerRef = useRef(null);
const containerRef = useRef<HTMLDivElement>(null);
const [containerReady, setContainerReady] = useState(false);
const refCallback = useCallback((el: HTMLDivElement | null) => {
if (el && !containerRef.current) {
containerRef.current = el;
requestAnimationFrame(() => {
setContainerReady(true);
});
}
}, []);
const fontSize = useMemo(() => {
if (!containerReady) return props.height;
if (!containerReady || !containerRef.current) {
return Math.min(props.height * 0.7, 14);
}
const cacheKey = [props.width, props.height, props.emoji].join('-');
if (fontSizeCache_[cacheKey]) {
return fontSizeCache_[cacheKey];
}
// Set the emoji font size so that it fits within the specified width
// and height. In fact, currently it only looks at the height.
let spanFontSize = props.height;
const span = document.createElement('span');
span.innerText = props.emoji;
span.style.fontSize = `${spanFontSize}px`;
span.style.visibility = 'hidden';
span.style.position = 'absolute';
span.style.whiteSpace = 'nowrap';
containerRef.current.appendChild(span);
let rect = span.getBoundingClientRect();
while (rect.height > props.height) {
spanFontSize -= .5;
while ((rect.height > props.height || rect.width > props.width) && spanFontSize > 1) {
spanFontSize -= 0.5;
span.style.fontSize = `${spanFontSize}px`;
rect = span.getBoundingClientRect();
}
span.remove();
fontSizeCache_[cacheKey] = spanFontSize;
return spanFontSize;
}, [props.width, props.height, props.emoji, containerReady, containerRef]);
}, [props.width, props.height, props.emoji, containerReady]);
return <div className="emoji-box" ref={el => { containerRef.current = el; setContainerReady(true); }} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: props.width, height: props.height, fontSize }}>{props.emoji}</div>;
return <div
ref={refCallback}
style={{
width: props.width,
height: props.height,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
fontSize,
}}
>
{props.emoji}
</div>;
};

View File

@@ -38,12 +38,14 @@ import restart from '../services/restart';
import { connect } from 'react-redux';
import { NoteListColumns } from '@joplin/lib/services/plugins/api/noteListType';
import validateColumns from './NoteListHeader/utils/validateColumns';
import ConversionNotification from './ConversionNotification/ConversionNotification';
import TrashNotification from './TrashNotification/TrashNotification';
import UpdateNotification from './UpdateNotification/UpdateNotification';
import NoteEditor from './NoteEditor/NoteEditor';
import PluginNotification from './PluginNotification/PluginNotification';
import { Toast } from '@joplin/lib/services/plugins/api/types';
import PluginService from '@joplin/lib/services/plugins/PluginService';
import { Dispatch } from 'redux';
const ipcRenderer = require('electron').ipcRenderer;
@@ -84,6 +86,7 @@ interface Props {
showInvalidJoplinCloudCredential: boolean;
toast: Toast;
shouldSwitchToAppleSiliconVersion: boolean;
noteHtmlToMarkdownDone: string;
}
interface ShareFolderDialogOptions {
@@ -797,6 +800,10 @@ class MainScreenComponent extends React.Component<Props, State> {
return (
<div style={style}>
<ConversionNotification
noteId={this.props.noteHtmlToMarkdownDone}
dispatch={this.props.dispatch as Dispatch}
/>
<TrashNotification
lastDeletion={this.props.lastDeletion}
lastDeletionNotificationTime={this.props.lastDeletionNotificationTime}
@@ -853,6 +860,7 @@ const mapStateToProps = (state: AppState) => {
showInvalidJoplinCloudCredential: state.settings['sync.target'] === 10 && state.mustAuthenticate,
toast: state.toast,
shouldSwitchToAppleSiliconVersion: shim.isAppleSilicon() && process.arch !== 'arm64',
noteHtmlToMarkdownDone: state.noteHtmlToMarkdownDone,
};
};

View File

@@ -803,6 +803,7 @@ function useMenu(props: Props) {
menuItemDic.toggleNoteList,
menuItemDic.toggleVisiblePanes,
menuItemDic.toggleEditorPlugin,
menuItemDic.toggleEditors,
{
label: _('Layout button sequence'),
submenu: layoutButtonSequenceMenuItems,
@@ -906,6 +907,7 @@ function useMenu(props: Props) {
separator(),
menuItemDic.setTags,
menuItemDic.showShareNoteDialog,
menuItemDic.convertNoteToMarkdown,
separator(),
menuItemDic.showNoteProperties,
menuItemDic.showNoteContentProperties,

View File

@@ -340,6 +340,8 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
props.setShowLocalSearch(event.searchState.dialogVisible);
}
lastSearchState.current = event.searchState;
} else if (event.kind === EditorEventType.FollowLink) {
void CommandService.instance().execute('openItem', event.link);
}
}, [editor_scroll, codeMirror_change, props.setLocalSearch, props.setShowLocalSearch]);
@@ -362,6 +364,8 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
readOnly: props.disabled,
markdownMarkEnabled: Setting.value('markdown.plugin.mark'),
katexEnabled: Setting.value('markdown.plugin.katex'),
inlineRenderingEnabled: Setting.value('editor.inlineRendering'),
imageRenderingEnabled: Setting.value('editor.imageRendering'),
themeData: {
...styles.globalTheme,
marginLeft: 0,
@@ -410,6 +414,7 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
onSelectPastBeginning={onSelectPastBeginning}
externalSearch={props.searchMarkers}
useLocalSearch={props.useLocalSearch}
onLocalize={_}
/>
</div>
);

View File

@@ -15,6 +15,10 @@ import useEditorSearch from '../utils/useEditorSearchExtension';
import CommandService from '@joplin/lib/services/CommandService';
import { SearchMarkers } from '../../../utils/useSearchMarkers';
import localisation from './utils/localisation';
import Resource from '@joplin/lib/models/Resource';
import { parseResourceUrl } from '@joplin/lib/urlUtils';
import { resourceFilename } from '@joplin/lib/models/utils/resourceUtils';
import getResourceBaseUrl from '../../../utils/getResourceBaseUrl';
interface Props extends EditorProps {
style: React.CSSProperties;
@@ -104,7 +108,16 @@ const Editor = (props: Props, ref: ForwardedRef<CodeMirrorControl>) => {
onLogMessage: message => onLogMessageRef.current(message),
};
const editor = createEditor(editorContainerRef.current, editorProps);
const editor = createEditor(editorContainerRef.current, {
...editorProps,
resolveImageSrc: async src => {
const url = parseResourceUrl(src);
if (!url.itemId) return null;
const item = await Resource.load(url.itemId);
if (!item) return null;
return `${getResourceBaseUrl()}/${resourceFilename(item)}`;
},
});
editor.addStyles({
'.cm-scroller': { overflow: 'auto' },
'&.CodeMirror': {

View File

@@ -5,6 +5,7 @@ import shim from '@joplin/lib/shim';
const useLinkTooltips = (editor: Editor|null) => {
const resetModifiedTitles = useCallback(() => {
if (!editor) return;
for (const element of editor.getDoc().querySelectorAll('a[data-joplin-original-title]')) {
element.setAttribute('title', element.getAttribute('data-joplin-original-title') ?? '');
element.removeAttribute('data-joplin-original-title');

View File

@@ -56,6 +56,7 @@ import useResourceUnwatcher from './utils/useResourceUnwatcher';
import StatusBar from './StatusBar';
import useVisiblePluginEditorViewIds from '@joplin/lib/hooks/plugins/useVisiblePluginEditorViewIds';
import useConnectToEditorPlugin from './utils/useConnectToEditorPlugin';
import getResourceBaseUrl from './utils/getResourceBaseUrl';
const debounce = require('debounce');
@@ -169,7 +170,7 @@ function NoteEditorContent(props: NoteEditorProps) {
const theme = themeStyle(options.themeId ? options.themeId : props.themeId);
const markupToHtml = markupLanguageUtils.newMarkupToHtml(props.plugins, {
resourceBaseUrl: `joplin-content://note-viewer/${Setting.value('resourceDir')}/`,
resourceBaseUrl: getResourceBaseUrl(),
customCss: props.customCss,
});
@@ -466,6 +467,7 @@ function NoteEditorContent(props: NoteEditorProps) {
// It is currently used to remember pdf scroll position for each attachments of each note uniquely.
noteId: props.noteId,
watchedNoteFiles: props.watchedNoteFiles,
enableHtmlToMarkdownBanner: props.enableHtmlToMarkdownBanner,
};
let editor = null;
@@ -488,6 +490,17 @@ function NoteEditorContent(props: NoteEditorProps) {
setShowRevisions(false);
}, []);
const onBannerConvertItToMarkdown = useCallback(async (event: React.MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
if (!props.selectedNoteIds || props.selectedNoteIds.length === 0) return;
await CommandService.instance().execute('convertNoteToMarkdown', props.selectedNoteIds[0]);
}, [props.selectedNoteIds]);
const onHideBannerConvertItToMarkdown = async (event: React.MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
Setting.setValue('editor.enableHtmlToMarkdownBanner', false);
};
const onBannerResourceClick = useCallback(async (event: React.MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
const resourceId = event.currentTarget.getAttribute('data-resource-id');
@@ -632,9 +645,30 @@ function NoteEditorContent(props: NoteEditorProps) {
const theme = themeStyle(props.themeId);
function renderConvertHtmlToMarkdown(): React.ReactNode {
if (!props.enableHtmlToMarkdownBanner) return null;
const note = props.notes.find(n => n.id === props.selectedNoteIds[0]);
if (!note) return null;
if (note.markup_language !== MarkupLanguage.Html) return null;
return (
<div style={styles.resourceWatchBanner}>
<p style={styles.resourceWatchBannerLine}>
{_('This note is in HTML format. Convert it to Markdown to edit it more easily.')}
&nbsp;
<a href="#" style={styles.resourceWatchBannerAction} onClick={onBannerConvertItToMarkdown}>{`${_('Convert it')}`}</a>
{' / '}
<a href="#" style={styles.resourceWatchBannerAction} onClick={onHideBannerConvertItToMarkdown}>{_('Don\'t show this message again')}</a>
</p>
</div>
);
}
return (
<div style={styles.root} onDragOver={onDragOver} onDrop={onDrop} ref={containerRef}>
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{renderConvertHtmlToMarkdown()}
{renderResourceWatchingNotification()}
{renderResourceInSearchResultsNotification()}
<NoteTitleBar
@@ -722,6 +756,7 @@ const mapStateToProps = (state: AppState, ownProps: ConnectProps) => {
syncUserId: state.settings['sync.userId'],
shareCacheSetting: state.settings['sync.shareCache'],
searchResults: state.searchResults,
enableHtmlToMarkdownBanner: state.settings['editor.enableHtmlToMarkdownBanner'],
};
};

View File

@@ -69,6 +69,10 @@ export default function styles(props: NoteEditorProps) {
marginTop: 0,
marginBottom: 10,
},
resourceWatchBannerAction: {
textDecoration: 'underline',
color: theme.colorWarnUrl,
},
};
});
}

View File

@@ -0,0 +1,4 @@
import Setting from '@joplin/lib/models/Setting';
const getResourceBaseUrl = () => `joplin-content://note-viewer/${Setting.value('resourceDir')}/`;
export default getResourceBaseUrl;

View File

@@ -67,6 +67,7 @@ export interface NoteEditorProps {
onTitleChange?: (title: string)=> void;
bodyEditor: string;
startupPluginsLoaded: boolean;
enableHtmlToMarkdownBanner: boolean;
}
export interface NoteBodyEditorRef {
@@ -138,6 +139,7 @@ export interface NoteBodyEditorProps {
noteId: string;
useCustomPdfViewer: boolean;
watchedNoteFiles: string[];
enableHtmlToMarkdownBanner: boolean;
}
export interface NoteBodyEditorPropsAndRef extends NoteBodyEditorProps {

View File

@@ -49,7 +49,7 @@ const useScheduleSaveCallbacks = (props: Props) => {
}, [props.dispatch, props.editorId, props.setFormNote]);
const saveNoteIfWillChange = useCallback(async (formNote: FormNote) => {
if (!formNote.id || !formNote.bodyWillChangeId) return;
if (!formNote.id || !formNote.bodyWillChangeId || !props.editorRef.current) return;
const body = await props.editorRef.current.content();

View File

@@ -343,6 +343,7 @@ class NotePropertiesDialog extends React.Component<Props, State> {
style={styles.input}
id={uniqueId(key)}
name={uniqueId(key)}
autoFocus
/>;
editCompHandler = () => {
@@ -363,6 +364,7 @@ class NotePropertiesDialog extends React.Component<Props, State> {
id={uniqueId(key)}
name={uniqueId(key)}
aria-invalid={!this.state.isValid.location}
autoFocus
/>
{
this.state.isValid.location ? null
@@ -387,6 +389,7 @@ class NotePropertiesDialog extends React.Component<Props, State> {
style={styles.input}
id={uniqueId(key)}
name={uniqueId(key)}
autoFocus
/>
);
}

View File

@@ -79,5 +79,7 @@ export default function() {
'switchProfile3',
'pasteAsText',
'showNoteProperties',
'convertNoteToMarkdown',
'toggleEditors',
];
}

View File

@@ -1,6 +1,6 @@
{
"name": "@joplin/app-desktop",
"version": "3.4.4",
"version": "3.4.7",
"description": "Joplin for Desktop",
"main": "main.bundle.js",
"private": true,
@@ -142,13 +142,13 @@
"@joplin/renderer": "~3.4",
"@joplin/tools": "~3.4",
"@joplin/utils": "~3.4",
"@playwright/test": "1.51.1",
"@playwright/test": "1.52.0",
"@sentry/electron": "4.24.0",
"@testing-library/react-hooks": "8.0.1",
"@types/jest": "29.5.14",
"@types/mustache": "4.2.5",
"@types/node": "18.19.87",
"@types/react": "18.3.20",
"@types/mustache": "4.2.6",
"@types/node": "18.19.103",
"@types/react": "18.3.22",
"@types/react-dom": "18.3.7",
"@types/react-redux": "7.1.33",
"@types/styled-components": "5.1.32",
@@ -160,7 +160,7 @@
"compare-versions": "6.1.1",
"countable": "3.0.1",
"debounce": "1.2.1",
"electron": "35.5.1",
"electron": "35.7.5",
"electron-builder": "24.13.3",
"electron-updater": "6.6.2",
"electron-window-state": "5.0.3",
@@ -179,7 +179,6 @@
"moment": "2.30.1",
"mustache": "4.2.0",
"nan": "2.22.2",
"node-fetch": "2.6.7",
"node-notifier": "10.0.1",
"node-rsa": "1.1.1",
"pdfjs-dist": "3.11.174",
@@ -211,6 +210,7 @@
"@joplin/onenote-converter": "~3.4",
"fs-extra": "11.2.0",
"keytar": "7.9.0",
"node-fetch": "2.6.7",
"sqlite3": "5.1.6"
}
}

View File

@@ -30,7 +30,7 @@ const makeBuildContext = (entryPoint: string, renderer: boolean, computeFileSize
// in the final bundle.
name: 'joplin--relative-imports-for-externals',
setup: build => {
const externalRegex = /^(.*\.node|sqlite3|electron|@electron\/remote\/.*|electron\/.*|@mapbox\/node-pre-gyp|jsdom)$/;
const externalRegex = /^(.*\.node|sqlite3|node-fetch|electron|@electron\/remote\/.*|electron\/.*|@mapbox\/node-pre-gyp|jsdom)$/;
build.onResolve({ filter: externalRegex }, args => {
// Electron packages don't need relative requires
if (args.path === 'electron' || args.path.startsWith('electron/')) {

View File

@@ -25,7 +25,7 @@ async function main() {
// wrong one. However it means it will have to be manually upgraded for each
// new Electron release. Some ABI map there:
// https://github.com/electron/node-abi/tree/master/test
const forceAbiArgs = '--force-abi 134';
const forceAbiArgs = '--force-abi 138';
if (isWindows()) {
// Cannot run this in parallel, or the 64-bit version might end up

View File

@@ -89,8 +89,8 @@ android {
applicationId "net.cozic.joplin"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 2097776
versionName "3.4.3"
versionCode 2097777
versionName "3.4.4"
ndk {
abiFilters "armeabi-v7a", "x86", "arm64-v8a", "x86_64"
}

View File

@@ -540,6 +540,7 @@ const ComboBox: React.FC<Props> = ({
};
const activeId = `${baseId}-${selectedIndex}`;
const searchResults = <NestableFlatList
keyboardShouldPersistTaps="handled"
ref={listRef}
data={results}
{...searchResultProps}

View File

@@ -63,7 +63,7 @@ const TextInputDialog: React.FC<Props> = ({ dialog, containerStyle, themeId }) =
/>
<PromptButton
buttonSpec={{
text: _('Okay'),
text: _('OK'),
onPress: () => dialog.onSubmit(text),
}}
themeId={themeId}

View File

@@ -15,7 +15,7 @@ const logger = Logger.create('ExtendedWebView');
const ExtendedWebView = (props: Props, ref: Ref<WebViewControl>) => {
const dom = useMemo(() => {
// Note: Adding `runScripts: 'dangerously'` to allow running inline <script></script>s.
// Use with caution.
// Use with caution -- don't load untrusted WebView HTML while testing.
return new JSDOM(props.html, { runScripts: 'dangerously', pretendToBeVisual: true });
}, [props.html]);
@@ -57,6 +57,43 @@ const ExtendedWebView = (props: Props, ref: Ref<WebViewControl>) => {
// JSDOM polyfills
dom.window.eval(`
window.scrollBy = (_amount) => { };
// JSDOM iframes are missing certain functionality required by Joplin,
// including:
// - MessageEvent.source: Should point to the window that created a message.
// Joplin uses this to determine the source of messages in iframe-related IPC.
// - iframe.srcdoc: Used by Joplin to create plugin windows.
const polyfillIframeContentWindow = (contentWindow) => {
contentWindow.addEventListener('message', event => {
// Work around a missing ".source" property on events.
// See https://github.com/jsdom/jsdom/issues/2745#issuecomment-1207414024
if (!event.source) {
contentWindow.dispatchEvent(new MessageEvent('message', {
source: window,
data: event.data,
}));
event.stopImmediatePropagation();
}
});
contentWindow.parent.postMessage = (message) => {
window.dispatchEvent(new MessageEvent('message', {
data: message,
source: contentWindow,
}));
};
};
Object.defineProperty(HTMLIFrameElement.prototype, 'srcdoc', {
set(value) {
this.src = 'about:blank';
setTimeout(() => {
this.contentDocument.write(value);
polyfillIframeContentWindow(this.contentWindow);
}, 0);
},
});
`);
dom.window.eval(`
@@ -71,7 +108,12 @@ const ExtendedWebView = (props: Props, ref: Ref<WebViewControl>) => {
},
});
dom.window.eval(injectedJavaScriptRef.current);
// Wrap the injected JavaScript in (() => {...})() to more closely
// match the behavior of injectedJavaScript on Android -- variables
// declared with "var" or "const" should not become global variables.
dom.window.eval(`(() => {
${injectedJavaScriptRef.current}
})()`);
}, [dom]);
const onLoadEndRef = useRef(props.onLoadEnd);

View File

@@ -132,6 +132,7 @@ const useRerenderHandler = (props: Props) => {
highlightedKeywords: props.highlightedKeywords,
resources: props.noteResources,
pluginAssetContainerSelector: '#joplin-container-pluginAssetsContainer',
removeUnusedPluginAssets: true,
// If the hash changed, we don't set initial scroll -- we want to scroll to the hash
// instead.

View File

@@ -3,13 +3,11 @@ import themeToCss from '@joplin/lib/services/style/themeToCss';
import ExtendedWebView from '../ExtendedWebView';
import * as React from 'react';
import { useEffect } from 'react';
import { useMemo, useCallback } from 'react';
import { NativeSyntheticEvent } from 'react-native';
import { EditorProps } from './types';
import { _ } from '@joplin/lib/locale';
import useCodeMirrorPlugins from './hooks/useCodeMirrorPlugins';
import { WebViewErrorEvent } from 'react-native-webview/lib/RNCWebViewNativeComponent';
import Logger from '@joplin/utils/Logger';
import { OnMessageEvent } from '../ExtendedWebView/types';
@@ -117,15 +115,16 @@ const MarkdownEditor: React.FC<EditorProps> = props => {
onEditorEvent: props.onEditorEvent,
onAttachFile: props.onAttach,
editorOptions: {
parentElementClassName: 'CodeMirror',
parentElementOrClassName: 'CodeMirror',
initialText: props.initialText,
initialNoteId: props.noteId,
settings: props.editorSettings,
},
webviewRef,
pluginStates: props.plugins,
});
props.editorRef.current = editorWebViewSetup.api.editor;
props.editorRef.current = editorWebViewSetup.api.mainEditor;
const injectedJavaScript = `
window.onerror = (message, source, lineno) => {
@@ -153,11 +152,6 @@ const MarkdownEditor: React.FC<EditorProps> = props => {
const css = useCss(props.themeId);
const html = useHtml();
const codeMirrorPlugins = useCodeMirrorPlugins(props.plugins);
useEffect(() => {
void editorWebViewSetup.api.editor.setContentScripts(codeMirrorPlugins);
}, [codeMirrorPlugins, editorWebViewSetup]);
const onMessage = useCallback((event: OnMessageEvent) => {
const data = event.nativeEvent.data;
@@ -182,7 +176,7 @@ const MarkdownEditor: React.FC<EditorProps> = props => {
html={html}
injectedJavaScript={injectedJavaScript}
css={css}
hasPluginScripts={codeMirrorPlugins.length > 0}
hasPluginScripts={editorWebViewSetup.hasPlugins}
onMessage={onMessage}
onLoadEnd={editorWebViewSetup.webViewEventHandlers.onLoadEnd}
onError={onError}

View File

@@ -232,6 +232,10 @@ const useEditorControl = (
onResourceDownloaded: (id: string) => {
editorRef.current.onResourceDownloaded(id);
},
remove: () => {
editorRef.current.remove();
},
};
return control;
@@ -246,6 +250,8 @@ function NoteEditor(props: Props) {
markdownMarkEnabled: Setting.value('markdown.plugin.mark'),
katexEnabled: Setting.value('markdown.plugin.katex'),
spellcheckEnabled: Setting.value('editor.mobile.spellcheckEnabled'),
inlineRenderingEnabled: Setting.value('editor.inlineRendering'),
imageRenderingEnabled: Setting.value('editor.imageRendering'),
language: props.markupLanguage === MarkupLanguage.Html ? EditorLanguageType.Html : EditorLanguageType.Markdown,
useExternalSearch: true,
readOnly: props.readOnly,
@@ -298,6 +304,7 @@ function NoteEditor(props: Props) {
editorControl.searchControl.hideSearch();
}
break;
case EditorEventType.Remove:
case EditorEventType.Scroll:
// Not handled
break;

View File

@@ -173,6 +173,21 @@ describe('RichTextEditor', () => {
});
});
it('should save repeated spaces using nonbreaking spaces', async () => {
let body = 'Test';
render(<WrappedEditor
noteBody={body}
onBodyChange={newBody => { body = newBody; }}
/>);
const window = await getEditorWindow();
mockTyping(window, ' test');
await waitFor(async () => {
expect(body.trim()).toBe('Test \u00A0test');
});
});
it('should render clickable checkboxes', async () => {
let body = '- [ ] Test\n- [x] Another test';
render(<WrappedEditor
@@ -354,4 +369,69 @@ describe('RichTextEditor', () => {
expect(body.trim()).toBe('Test:\n\n$$\n3^2 + 4^2 = \\sqrt{625}\n$$\n\nTest. testing');
});
});
it('should be possible show an editor for math blocks', async () => {
let body = 'Test:\n\n$$3^2 + 4^2 = 5^2$$';
render(<WrappedEditor
noteBody={body}
onBodyChange={newBody => { body = newBody; }}
/>);
const editButton = await findElement<HTMLButtonElement>('button.edit');
editButton.click();
const editor = await findElement('dialog .cm-editor');
expect(editor).toBeTruthy();
expect(editor.textContent).toContain('3^2 + 4^2 = 5^2');
});
it('should preserve table of contents blocks on edit', async () => {
let body = '# Heading\n\n# Heading 2\n\n[toc]\n\nTest.';
render(<WrappedEditor
noteBody={body}
onBodyChange={newBody => { body = newBody; }}
/>);
// Should render the [toc] as a joplin-editable
const renderedTableOfContents = await findElement<HTMLElement>('div.joplin-editable');
expect(renderedTableOfContents).toBeTruthy();
// Should have a link for each heading
expect(renderedTableOfContents.querySelectorAll('a[href]')).toHaveLength(2);
const window = await getEditorWindow();
mockTyping(window, ' testing');
await waitFor(async () => {
expect(body.trim()).toBe('# Heading\n\n# Heading 2\n\n[toc]\n\nTest. testing');
});
});
it.each([
'**bold**',
'*italic*',
'$\\text{math}$',
'<span style="color: red;">test</span>',
'`code`',
'==highlight==ed',
'<sup>Super</sup>script',
'<sub>Sub</sub>script',
])('should preserve inline markup on edit (case %#)', async (initialBody) => {
initialBody += 'test'; // Ensure that typing will add new content outside the formatting
let body = initialBody;
render(<WrappedEditor
noteBody={body}
onBodyChange={newBody => { body = newBody; }}
/>);
await findElement<HTMLElement>('div.prosemirror-editor');
const window = await getEditorWindow();
mockTyping(window, ' testing');
await waitFor(async () => {
expect(body.trim()).toBe(`${initialBody} testing`);
});
});
});

View File

@@ -84,6 +84,7 @@ const RichTextEditor: React.FC<EditorProps> = props => {
initialText: props.initialText,
noteId: props.noteId,
settings: props.editorSettings,
globalSearch: props.globalSearch,
webviewRef,
themeId: props.themeId,
pluginStates: props.plugins,

View File

@@ -1,11 +1,10 @@
import * as React from 'react';
import TextInput from './TextInput';
import { View, StyleSheet, TextInputProps, ViewStyle, TextInput as ReactNativeTextInput } from 'react-native';
import { View, StyleSheet, TextInputProps, ViewStyle, TextInput as ReactNativeTextInput, Keyboard } from 'react-native';
import { _ } from '@joplin/lib/locale';
import { Ref, useCallback, useMemo } from 'react';
import { themeStyle } from './global-style';
import IconButton from './IconButton';
import Icon from './Icon';
interface SearchInputProps extends TextInputProps {
@@ -58,11 +57,12 @@ const SearchInput: React.FC<SearchInputProps> = ({ inputRef, themeId, value, con
}, [onChangeText]);
return <View style={[styles.root, containerStyle]}>
<Icon
aria-hidden={true}
name='material magnify'
accessibilityLabel={null}
style={styles.icon}
<IconButton
iconName='material magnify'
onPress={() => Keyboard.dismiss()}
description={_('Hide keyboard')}
iconStyle={styles.icon}
themeId={themeId}
/>
<TextInput
ref={inputRef}

View File

@@ -171,6 +171,7 @@ const TagsBox: React.FC<TagsBoxProps> = props => {
return <View style={props.styles.tagBoxRoot}>
<Text style={props.styles.header} role='heading'>{_('Associated tags:')}</Text>
<ScrollView
keyboardShouldPersistTaps="handled"
style={props.styles.tagBoxScrollView}
// On web, specifying aria-live here announces changes to the associated tags.
// However, on Android (and possibly iOS), this breaks focus behavior:

View File

@@ -0,0 +1,71 @@
import * as React from 'react';
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
import { AppState } from '../../utils/types';
import { Store } from 'redux';
import createMockReduxStore from '../../utils/testing/createMockReduxStore';
import setupGlobalStore from '../../utils/testing/setupGlobalStore';
import PluginRunnerWebView from './PluginRunnerWebView';
import TestProviderStack from '../testing/TestProviderStack';
import { render, waitFor } from '../../utils/testing/testingLibrary';
import createTestPlugin from '@joplin/lib/testing/plugins/createTestPlugin';
import getWebViewDomById from '../../utils/testing/getWebViewDomById';
import Setting from '@joplin/lib/models/Setting';
import PluginService from '@joplin/lib/services/plugins/PluginService';
let store: Store<AppState>;
interface WrapperProps { }
const WrappedPluginRunnerWebView: React.FC<WrapperProps> = _props => {
return <TestProviderStack store={store}>
<PluginRunnerWebView/>
</TestProviderStack>;
};
const defaultManifestProperties = {
manifest_version: 1,
version: '0.1.0',
app_min_version: '2.3.4',
platforms: ['desktop', 'mobile'],
name: 'Some plugin name',
};
describe('PluginRunnerWebView', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(0);
await switchClient(0);
store = createMockReduxStore();
setupGlobalStore(store);
Setting.setValue('plugins.pluginSupportEnabled', true);
});
test('should load a plugin that shows a dialog', async () => {
const testPlugin = await createTestPlugin({
...defaultManifestProperties,
id: 'org.joplinapp.dialog-test',
}, {
onStart: `
const dialogs = joplin.views.dialogs;
const dialogHandle = await dialogs.create('test-dialog');
await dialogs.setHtml(
dialogHandle,
'<h1>Test!</h1>',
);
await joplin.views.dialogs.open(dialogHandle)
`,
});
render(<WrappedPluginRunnerWebView/>);
// Should load the plugin
await waitFor(async () => {
expect(PluginService.instance().pluginById(testPlugin.manifest.id)).toBeTruthy();
});
// Should show the dialog
await waitFor(async () => {
const dom = await getWebViewDomById('joplin__PluginDialogWebView');
expect(dom.querySelector('h1').textContent).toBe('Test!');
});
});
});

View File

@@ -158,6 +158,7 @@ const PluginRunnerWebViewComponent: React.FC<Props> = props => {
const injectedJs = `
if (!window.loadedBackgroundPage) {
${shim.injectedJs('pluginBackgroundPage')}
window.pluginBackgroundPage = pluginBackgroundPage;
console.log('Loaded PluginRunnerWebView.');
// Necessary, because React Native WebView can re-run injectedJs

View File

@@ -101,6 +101,8 @@ const PluginUserWebView = (props: Props) => {
return `
if (!window.backgroundPageLoaded) {
${shim.injectedJs('pluginBackgroundPage')}
window.pluginBackgroundPage = pluginBackgroundPage;
pluginBackgroundPage.initializeDialogWebView(
${JSON.stringify(messageChannelId)}
);
@@ -120,6 +122,7 @@ const PluginUserWebView = (props: Props) => {
<ExtendedWebView
style={props.style}
baseDirectory={plugin.baseDir}
testID='joplin__PluginDialogWebView'
webviewInstanceId='joplin__PluginDialogWebView'
html={html}
hasPluginScripts={true}

View File

@@ -923,7 +923,7 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
resource = await Resource.save(resource, { isNew: true });
const resourceTag = Resource.markupTag(resource);
const resourceTag = Resource.markupTag(resource, this.state.note.markup_language);
const newNote = await this.insertText(resourceTag, { newLine: true });
void this.refreshResource(resource, newNote.body);
@@ -1043,9 +1043,9 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
};
private toggleIsTodo_onPress() {
shared.toggleIsTodo_onPress(this);
const newNote = shared.toggleIsTodo_onPress(this);
this.scheduleSave(this.state);
this.scheduleSave({ ...this.state, note: newNote });
}
private async share_onPress() {

View File

@@ -1,28 +1,57 @@
import { createEditor } from '@joplin/editor/CodeMirror';
import { focus } from '@joplin/lib/utils/focusHandler';
import WebViewToRNMessenger from '../../utils/ipc/WebViewToRNMessenger';
import { EditorProcessApi, EditorProps, MainProcessApi } from './types';
import { EditorProcessApi, EditorProps, EditorWithParentProps, ExportedWebViewGlobals, MainProcessApi } from './types';
import readFileToBase64 from '../utils/readFileToBase64';
import { EditorControl } from '@joplin/editor/types';
import { EditorEventType } from '@joplin/editor/events';
export { default as setUpLogger } from '../utils/setUpLogger';
export const initializeEditor = ({
parentElementClassName,
interface ExtendedWindow extends ExportedWebViewGlobals, Window { }
declare const window: ExtendedWindow;
let mainEditor: EditorControl|null = null;
let allEditors: EditorControl[] = [];
const messenger = new WebViewToRNMessenger<EditorProcessApi, MainProcessApi>('markdownEditor', {
get mainEditor() {
return mainEditor;
},
updatePlugins(contentScripts) {
for (const editor of allEditors) {
void editor.setContentScripts(contentScripts);
}
},
updateSettings(settings) {
for (const editor of allEditors) {
editor.updateSettings(settings);
}
},
});
export const createEditorWithParent = ({
parentElementOrClassName,
initialText,
initialNoteId,
settings,
}: EditorProps) => {
const messenger = new WebViewToRNMessenger<EditorProcessApi, MainProcessApi>('markdownEditor', null);
const parentElement = document.getElementsByClassName(parentElementClassName)[0] as HTMLElement;
onEvent,
}: EditorWithParentProps) => {
const parentElement = (() => {
if (parentElementOrClassName instanceof HTMLElement) {
return parentElementOrClassName;
}
return document.getElementsByClassName(parentElementOrClassName)[0] as HTMLElement;
})();
if (!parentElement) {
throw new Error(`Unable to find parent element for editor (class name: ${JSON.stringify(parentElementClassName)})`);
throw new Error(`Unable to find parent element for editor (class name: ${JSON.stringify(parentElementOrClassName)})`);
}
const control = createEditor(parentElement, {
initialText,
initialNoteId,
settings,
onLocalize: messenger.remoteApi.onLocalize,
onPasteFile: async (data) => {
const base64 = await readFileToBase64(data);
@@ -32,7 +61,28 @@ export const initializeEditor = ({
onLogMessage: message => {
void messenger.remoteApi.logMessage(message);
},
onEvent: (event): void => {
onEvent: (event) => {
onEvent(event);
if (event.kind === EditorEventType.Remove) {
allEditors = allEditors.filter(other => other !== control);
}
},
resolveImageSrc: (src) => {
return messenger.remoteApi.onResolveImageSrc(src);
},
});
allEditors.push(control);
void messenger.remoteApi.onEditorAdded();
return control;
};
export const createMainEditor = (props: EditorProps) => {
const control = createEditorWithParent({
...props,
onEvent: (event) => {
void messenger.remoteApi.onEditorEvent(event);
},
});
@@ -52,6 +102,7 @@ export const initializeEditor = ({
// Note: Just adding an onclick listener seems sufficient to focus the editor when its background
// is tapped.
const parentElement = control.editor.dom.parentElement;
parentElement.addEventListener('click', (event) => {
const activeElement = document.querySelector(':focus');
if (!parentElement.contains(activeElement) && event.target === parentElement) {
@@ -59,8 +110,9 @@ export const initializeEditor = ({
}
});
messenger.setLocalInterface({
editor: control,
});
mainEditor = control;
return control;
};
window.createEditorWithParent = createEditorWithParent;
window.createMainEditor = createMainEditor;

View File

@@ -1,8 +1,29 @@
import { EditorEvent } from '@joplin/editor/events';
import { EditorControl, EditorSettings } from '@joplin/editor/types';
import { ContentScriptData, EditorControl, EditorSettings, LocalizationResult } from '@joplin/editor/types';
export interface EditorProps {
parentElementOrClassName: HTMLElement|string;
initialText: string;
initialNoteId: string|null;
settings: EditorSettings;
}
export interface EditorWithParentProps extends EditorProps {
onEvent: (editorEvent: EditorEvent)=> void;
}
// The Markdown editor exposes global functions within its <WebView>.
// These functions can be used externally.
export interface ExportedWebViewGlobals {
createEditorWithParent: (options: EditorWithParentProps)=> EditorControl;
createMainEditor: (props: EditorProps)=> EditorControl;
}
export interface EditorProcessApi {
editor: EditorControl;
mainEditor: EditorControl;
updateSettings: (settings: EditorSettings)=> void;
updatePlugins: (contentScripts: ContentScriptData[])=> void;
}
export interface SelectionRange {
@@ -10,15 +31,11 @@ export interface SelectionRange {
end: number;
}
export interface EditorProps {
parentElementClassName: string;
initialText: string;
initialNoteId: string;
settings: EditorSettings;
}
export interface MainProcessApi {
onLocalize(text: string): LocalizationResult;
onEditorEvent(event: EditorEvent): Promise<void>;
onEditorAdded(): Promise<void>;
logMessage(message: string): Promise<void>;
onPasteFile(type: string, dataBase64: string): Promise<void>;
onResolveImageSrc(src: string): Promise<string|null>;
}

View File

@@ -7,14 +7,21 @@ import { OnMessageEvent, WebViewControl } from '../../components/ExtendedWebView
import { EditorEvent } from '@joplin/editor/events';
import Logger from '@joplin/utils/Logger';
import RNToWebViewMessenger from '../../utils/ipc/RNToWebViewMessenger';
import { _ } from '@joplin/lib/locale';
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
import useCodeMirrorPlugins from './utils/useCodeMirrorPlugins';
import Resource from '@joplin/lib/models/Resource';
import { parseResourceUrl } from '@joplin/lib/urlUtils';
const { isImageMimeType } = require('@joplin/lib/resourceUtils');
const logger = Logger.create('markdownEditor');
interface Props {
editorOptions: EditorOptions;
initialSelection: SelectionRange;
initialSelection: SelectionRange|null;
noteHash: string;
globalSearch: string;
pluginStates: PluginStates;
onEditorEvent: (event: EditorEvent)=> void;
onAttachFile: (mime: string, base64: string)=> void;
@@ -30,9 +37,11 @@ const defaultSearchState: SearchState = {
dialogVisible: false,
};
type Result = SetUpResult<EditorProcessApi> & { hasPlugins: boolean };
const useWebViewSetup = ({
editorOptions, initialSelection, noteHash, globalSearch, webviewRef, onEditorEvent, onAttachFile,
}: Props): SetUpResult<EditorProcessApi> => {
editorOptions, pluginStates, initialSelection, noteHash, globalSearch, webviewRef, onEditorEvent, onAttachFile,
}: Props): Result => {
const setInitialSelectionJs = initialSelection ? `
cm.select(${initialSelection.start}, ${initialSelection.end});
cm.execCommand('scrollSelectionIntoView');
@@ -48,23 +57,34 @@ const useWebViewSetup = ({
` : '';
const injectedJavaScript = useMemo(() => `
if (!window.cm) {
if (typeof markdownEditorBundle === 'undefined') {
${shim.injectedJs('markdownEditorBundle')};
window.markdownEditorBundle = markdownEditorBundle;
markdownEditorBundle.setUpLogger();
}
window.cm = markdownEditorBundle.initializeEditor(
${JSON.stringify(editorOptions)}
);
if (!window.cm) {
const parentClassName = ${JSON.stringify(editorOptions?.parentElementOrClassName)};
const foundParent = !!parentClassName && document.getElementsByClassName(parentClassName).length > 0;
${jumpToHashJs}
// Set the initial selection after jumping to the header -- the initial selection,
// if specified, should take precedence.
${setInitialSelectionJs}
${setInitialSearchJs}
// On Android, injectedJavaScript can be run multiple times, including once before the
// document has loaded. To avoid logging an error each time the editor starts, don't throw
// if the parent element can't be found:
if (foundParent) {
window.cm = markdownEditorBundle.createMainEditor(${JSON.stringify(editorOptions)});
window.onresize = () => {
cm.execCommand('scrollSelectionIntoView');
};
${jumpToHashJs}
// Set the initial selection after jumping to the header -- the initial selection,
// if specified, should take precedence.
${setInitialSelectionJs}
${setInitialSearchJs}
window.onresize = () => {
cm.execCommand('scrollSelectionIntoView');
};
} else if (parentClassName) {
console.log('No parent element found with class name ', parentClassName);
}
}
`, [jumpToHashJs, setInitialSearchJs, setInitialSelectionJs, editorOptions]);
@@ -88,6 +108,10 @@ const useWebViewSetup = ({
const onAttachRef = useRef(onAttachFile);
onAttachRef.current = onAttachFile;
const codeMirrorPlugins = useCodeMirrorPlugins(pluginStates);
const codeMirrorPluginsRef = useRef(codeMirrorPlugins);
codeMirrorPluginsRef.current = codeMirrorPlugins;
const editorMessenger = useMemo(() => {
const localApi: MainProcessApi = {
async onEditorEvent(event) {
@@ -99,6 +123,30 @@ const useWebViewSetup = ({
async onPasteFile(type, data) {
onAttachRef.current(type, data);
},
async onLocalize(text) {
const localizationFunction = _;
return localizationFunction(text);
},
async onEditorAdded() {
messenger.remoteApi.updatePlugins(codeMirrorPluginsRef.current);
},
async onResolveImageSrc(src) {
const url = parseResourceUrl(src);
if (!url.itemId) return null;
const item = await Resource.load(url.itemId);
if (shim.mobilePlatform() === 'web') {
// Maximum 6 MiB on web
const maximumSize = 6 * 1024 * 1024;
if (isImageMimeType(item.mime) && item.size < maximumSize) {
const data = await shim.fsDriver().readFile(Resource.fullPath(item), 'base64');
return `data:${item.mime};base64,${data}`;
}
return null;
} else {
return Resource.fullPath(item);
}
},
};
const messenger = new RNToWebViewMessenger<MainProcessApi, EditorProcessApi>(
'markdownEditor', webviewRef, localApi,
@@ -123,17 +171,22 @@ const useWebViewSetup = ({
const editorSettings = editorOptions.settings;
useEffect(() => {
api.editor.updateSettings(editorSettings);
api.updateSettings(editorSettings);
}, [api, editorSettings]);
useEffect(() => {
api.updatePlugins(codeMirrorPlugins);
}, [codeMirrorPlugins, api]);
return useMemo(() => ({
pageSetup: {
js: injectedJavaScript,
css: '',
},
hasPlugins: codeMirrorPlugins.length > 0,
api,
webViewEventHandlers,
}), [injectedJavaScript, api, webViewEventHandlers]);
}), [injectedJavaScript, api, webViewEventHandlers, codeMirrorPlugins]);
};
export default useWebViewSetup;

View File

@@ -12,6 +12,7 @@ const defaultRendererSettings: RenderSettings = {
noteHash: '',
initialScroll: 0,
readAssetBlob: async (_path: string) => new Blob(),
removeUnusedPluginAssets: true,
createEditPopupSyntax: '',
destroyEditPopupSyntax: '',

View File

@@ -23,6 +23,7 @@ export interface RenderSettings {
initialScroll: number;
// If [null], plugin assets are not added to the document
pluginAssetContainerSelector: string|null;
removeUnusedPluginAssets: boolean;
splitted?: boolean; // Move CSS into a separate output
mapsToLine?: boolean; // Sourcemaps
@@ -156,6 +157,7 @@ export default class Renderer {
inlineAssets: this.setupOptions_.useTransferredFiles,
readAssetBlob: settings.readAssetBlob,
container: document.querySelector(settings.pluginAssetContainerSelector),
removeUnusedPluginAssets: settings.removeUnusedPluginAssets,
});
// Some plugins require this event to be dispatched just after being added.

View File

@@ -39,6 +39,7 @@ const rewriteInternalAssetLinks = async (asset: RenderResultPluginAsset, content
interface Options {
inlineAssets: boolean;
removeUnusedPluginAssets: boolean;
container: HTMLElement;
readAssetBlob?(path: string): Promise<Blob>;
}
@@ -137,16 +138,22 @@ const addPluginAssets = async (assets: RenderResultPluginAsset[], options: Optio
// light to dark theme, and then back to light theme - in that case
// the viewer would remain dark because it would use the dark
// stylesheet that would still be in the DOM.
for (const [assetId, asset] of Object.entries(pluginAssetsAdded_)) {
if (!processedAssetIds.includes(assetId)) {
try {
asset.element.remove();
} catch (error) {
// We don't throw an exception but we log it since
// it shouldn't happen
console.warn('Tried to remove an asset but got an error', error);
//
// In some cases, however, we only want to rerender part of the document.
// In this case, old plugin assets may have been from the last full-page
// render and should not be removed.
if (options.removeUnusedPluginAssets) {
for (const [assetId, asset] of Object.entries(pluginAssetsAdded_)) {
if (!processedAssetIds.includes(assetId)) {
try {
asset.element.remove();
} catch (error) {
// We don't throw an exception but we log it since
// it shouldn't happen
console.warn('Tried to remove an asset but got an error', error);
}
pluginAssetsAdded_[assetId] = null;
}
pluginAssetsAdded_[assetId] = null;
}
}
};

View File

@@ -54,8 +54,13 @@ export interface RenderOptions {
highlightedKeywords: string[];
resources: ResourceInfos;
themeOverrides: Record<string, string|number>;
// If null, plugin assets will not be added to the document.
pluginAssetContainerSelector: string|null;
// When true, plugin assets are removed from the container when not used by the render result.
// This should be true for full-page renders.
removeUnusedPluginAssets: boolean;
noteHash: string;
initialScroll: number;

View File

@@ -219,6 +219,7 @@ const useWebViewSetup = (props: Props): SetUpResult<RendererControl> => {
}
return shim.fsDriver().fileAtPath(resolvedPath);
},
removeUnusedPluginAssets: options.removeUnusedPluginAssets,
};
await transferResources(options.resources);

View File

@@ -7,6 +7,8 @@ import '@joplin/editor/ProseMirror/styles';
import readFileToBase64 from '../../utils/readFileToBase64';
import { EditorLanguageType } from '@joplin/editor/types';
import convertHtmlToMarkdown from './convertHtmlToMarkdown';
import { ExportedWebViewGlobals as MarkdownEditorWebViewGlobals } from '../../markdownEditorBundle/types';
import { EditorEventType } from '@joplin/editor/events';
const postprocessHtml = (html: HTMLElement) => {
// Fix resource URLs
@@ -16,22 +18,6 @@ const postprocessHtml = (html: HTMLElement) => {
resource.src = `:/${resourceId}`;
}
// Re-add newlines to data-joplin-source-* that were removed
// by ProseMirror.
// TODO: Try to find a better solution
const sourceBlocks = html.querySelectorAll<HTMLPreElement>(
'pre[data-joplin-source-open][data-joplin-source-close].joplin-source',
);
for (const sourceBlock of sourceBlocks) {
const isBlock = sourceBlock.parentElement.tagName !== 'SPAN';
if (isBlock) {
const originalOpen = sourceBlock.getAttribute('data-joplin-source-open');
const originalClose = sourceBlock.getAttribute('data-joplin-source-close');
sourceBlock.setAttribute('data-joplin-source-open', `${originalOpen}\n`);
sourceBlock.setAttribute('data-joplin-source-close', `\n${originalClose}`);
}
}
return html;
};
@@ -51,12 +37,16 @@ const htmlToMarkdown = (html: HTMLElement): string => {
return convertHtmlToMarkdown(html);
};
export const initialize = async ({
settings,
initialText,
initialNoteId,
parentElementClassName,
}: EditorProps) => {
export const initialize = async (
{
settings,
initialText,
initialNoteId,
parentElementClassName,
initialSearch,
}: EditorProps,
markdownEditorApi: MarkdownEditorWebViewGlobals,
) => {
const messenger = new WebViewToRNMessenger<EditorProcessApi, MainProcessApi>('rich-text-editor', null);
const parentElement = document.getElementsByClassName(parentElementClassName)[0];
if (!parentElement) throw new Error('Parent element not found');
@@ -72,6 +62,7 @@ export const initialize = async ({
settings,
initialText,
initialNoteId,
onLocalize: messenger.remoteApi.onLocalize,
onPasteFile: async (data) => {
const base64 = await readFileToBase64(data);
@@ -84,14 +75,20 @@ export const initialize = async ({
void messenger.remoteApi.onEditorEvent(event);
},
}, {
renderMarkupToHtml: async (markup) => {
renderMarkupToHtml: async (markup, options) => {
let language = MarkupLanguage.Markdown;
if (settings.language === EditorLanguageType.Html && !options.forceMarkdown) {
language = MarkupLanguage.Html;
}
return await messenger.remoteApi.onRender({
markup,
language: settings.language === EditorLanguageType.Html ? MarkupLanguage.Html : MarkupLanguage.Markdown,
language,
}, {
pluginAssetContainerSelector: `#${assetContainer.id}`,
splitted: true,
mapsToLine: true,
removeUnusedPluginAssets: options.isFullPageRender,
});
},
renderHtmlToMarkup: (node) => {
@@ -117,7 +114,20 @@ export const initialize = async ({
return postprocessHtml(html).outerHTML;
}
},
}, (parent, language, onChange) => {
return markdownEditorApi.createEditorWithParent({
initialText: '',
initialNoteId: '',
parentElementOrClassName: parent,
settings: { ...editor.getSettings(), language },
onEvent: (event) => {
if (event.kind === EditorEventType.Change) {
onChange(event.value);
}
},
});
});
editor.setSearchState(initialSearch);
messenger.setLocalInterface({
editor,

View File

@@ -1,10 +1,11 @@
import { EditorEvent } from '@joplin/editor/events';
import { EditorControl, EditorSettings } from '@joplin/editor/types';
import { EditorControl, EditorSettings, OnLocalize, SearchState } from '@joplin/editor/types';
import { MarkupRecord, RendererControl } from '../rendererBundle/types';
import { RenderResult } from '@joplin/renderer/types';
export interface EditorProps {
initialText: string;
initialSearch: SearchState;
initialNoteId: string;
parentElementClassName: string;
settings: EditorSettings;
@@ -18,6 +19,7 @@ type RenderOptionsSlice = {
pluginAssetContainerSelector: string;
splitted: boolean;
mapsToLine: true;
removeUnusedPluginAssets: boolean;
};
export interface MainProcessApi {
@@ -25,6 +27,7 @@ export interface MainProcessApi {
logMessage(message: string): Promise<void>;
onRender(markup: MarkupRecord, options: RenderOptionsSlice): Promise<RenderResult>;
onPasteFile(type: string, base64: string): Promise<void>;
onLocalize: OnLocalize;
}
export interface RichTextEditorControl {

View File

@@ -4,6 +4,7 @@ import { SetUpResult } from '../types';
import { EditorControl, EditorSettings } from '@joplin/editor/types';
import RNToWebViewMessenger from '../../utils/ipc/RNToWebViewMessenger';
import { EditorProcessApi, EditorProps, MainProcessApi } from './types';
import useMarkdownEditorSetup from '../markdownEditorBundle/useWebViewSetup';
import useRendererSetup from '../rendererBundle/useWebViewSetup';
import { EditorEvent } from '@joplin/editor/events';
import Logger from '@joplin/utils/Logger';
@@ -11,6 +12,8 @@ import shim from '@joplin/lib/shim';
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
import { RendererControl, RenderOptions } from '../rendererBundle/types';
import { ResourceInfos } from '@joplin/renderer/types';
import { _ } from '@joplin/lib/locale';
import { defaultSearchState } from '../../components/NoteEditor/SearchPanel';
const logger = Logger.create('useWebViewSetup');
@@ -19,6 +22,7 @@ interface Props {
noteId: string;
settings: EditorSettings;
parentElementClassName: string;
globalSearch: string;
themeId: number;
pluginStates: PluginStates;
noteResources: ResourceInfos;
@@ -48,6 +52,7 @@ const useMessenger = (props: UseMessengerProps) => {
noteHash: '',
initialScroll: 0,
pluginAssetContainerSelector: null,
removeUnusedPluginAssets: true,
};
return useMemo(() => {
@@ -68,6 +73,7 @@ const useMessenger = (props: UseMessengerProps) => {
splitted: options.splitted,
pluginAssetContainerSelector: options.pluginAssetContainerSelector,
mapsToLine: options.mapsToLine,
removeUnusedPluginAssets: options.removeUnusedPluginAssets,
},
);
return renderResult;
@@ -75,6 +81,7 @@ const useMessenger = (props: UseMessengerProps) => {
onPasteFile: async (type: string, base64: string) => {
onAttachRef.current(type, base64);
},
onLocalize: _,
};
const messenger = new RNToWebViewMessenger<MainProcessApi, EditorProcessApi>(
@@ -86,7 +93,10 @@ const useMessenger = (props: UseMessengerProps) => {
}, [props.webviewRef]);
};
type UseSourceProps = Props & { renderer: SetUpResult<RendererControl> };
type UseSourceProps = Props & {
renderer: SetUpResult<RendererControl>;
markdownEditor: SetUpResult<unknown>;
};
const useSource = (props: UseSourceProps) => {
const propsRef = useRef(props);
@@ -94,12 +104,18 @@ const useSource = (props: UseSourceProps) => {
const rendererJs = props.renderer.pageSetup.js;
const rendererCss = props.renderer.pageSetup.css;
const markdownEditorJs = props.markdownEditor.pageSetup.js;
const markdownEditorCss = props.markdownEditor.pageSetup.css;
return useMemo(() => {
const editorOptions: EditorProps = {
parentElementClassName: propsRef.current.parentElementClassName,
initialText: propsRef.current.initialText,
initialNoteId: propsRef.current.noteId,
initialSearch: {
...defaultSearchState,
searchText: propsRef.current.globalSearch,
},
settings: propsRef.current.settings,
};
@@ -107,6 +123,7 @@ const useSource = (props: UseSourceProps) => {
css: `
${shim.injectedCss('richTextEditorBundle')}
${rendererCss}
${markdownEditorCss}
/* Increase the size of the editor to make it easier to focus the editor. */
.prosemirror-editor {
@@ -115,19 +132,23 @@ const useSource = (props: UseSourceProps) => {
`,
js: `
${rendererJs}
${markdownEditorJs}
if (!window.richTextEditorCreated) {
window.richTextEditorCreated = true;
${shim.injectedJs('richTextEditorBundle')}
richTextEditorBundle.setUpLogger();
richTextEditorBundle.initialize(${JSON.stringify(editorOptions)}).then(function(editor) {
richTextEditorBundle.initialize(
${JSON.stringify(editorOptions)},
window,
).then(function(editor) {
/* For testing */
window.joplinRichTextEditor_ = editor;
});
}
`,
};
}, [rendererJs, rendererCss]);
}, [rendererJs, rendererCss, markdownEditorCss, markdownEditorJs]);
};
const useWebViewSetup = (props: Props): SetUpResult<EditorControl> => {
@@ -138,8 +159,23 @@ const useWebViewSetup = (props: Props): SetUpResult<EditorControl> => {
pluginStates: props.pluginStates,
themeId: props.themeId,
});
const markdownEditor = useMarkdownEditorSetup({
webviewRef: props.webviewRef,
onAttachFile: props.onAttachFile,
initialSelection: null,
noteHash: '',
globalSearch: props.globalSearch,
editorOptions: {
settings: props.settings,
initialNoteId: null,
parentElementOrClassName: '',
initialText: '',
},
onEditorEvent: (_event)=>{},
pluginStates: props.pluginStates,
});
const messenger = useMessenger({ ...props, renderer });
const pageSetup = useSource({ ...props, renderer });
const pageSetup = useSource({ ...props, renderer, markdownEditor });
useEffect(() => {
void messenger.remoteApi.editor.updateSettings(props.settings);
@@ -153,14 +189,16 @@ const useWebViewSetup = (props: Props): SetUpResult<EditorControl> => {
onLoadEnd: () => {
messenger.onWebViewLoaded();
renderer.webViewEventHandlers.onLoadEnd();
markdownEditor.webViewEventHandlers.onLoadEnd();
},
onMessage: (event) => {
messenger.onWebViewMessage(event);
renderer.webViewEventHandlers.onMessage(event);
markdownEditor.webViewEventHandlers.onMessage(event);
},
},
};
}, [messenger, pageSetup, renderer.webViewEventHandlers]);
}, [messenger, pageSetup, renderer.webViewEventHandlers, markdownEditor.webViewEventHandlers]);
};
export default useWebViewSetup;

View File

@@ -533,7 +533,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
CURRENT_PROJECT_VERSION = 142;
CURRENT_PROJECT_VERSION = 143;
DEVELOPMENT_TEAM = A9BXAFS6CT;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Joplin/Info.plist;
@@ -542,7 +542,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 13.4.0;
MARKETING_VERSION = 13.4.1;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@@ -568,7 +568,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
CURRENT_PROJECT_VERSION = 142;
CURRENT_PROJECT_VERSION = 143;
DEVELOPMENT_TEAM = A9BXAFS6CT;
INFOPLIST_FILE = Joplin/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
@@ -576,7 +576,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 13.4.0;
MARKETING_VERSION = 13.4.1;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@@ -769,18 +769,18 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 142;
CURRENT_PROJECT_VERSION = 143;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = A9BXAFS6CT;
GCC_C_LANGUAGE_STANDARD = gnu11;
INFOPLIST_FILE = ShareExtension/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.4;
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 13.4.0;
MARKETING_VERSION = 13.4.1;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
OTHER_LDFLAGS = (
@@ -812,18 +812,18 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 142;
CURRENT_PROJECT_VERSION = 143;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = A9BXAFS6CT;
GCC_C_LANGUAGE_STANDARD = gnu11;
INFOPLIST_FILE = ShareExtension/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.4;
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 13.4.0;
MARKETING_VERSION = 13.4.1;
MTL_FAST_MATH = YES;
OTHER_LDFLAGS = (
"$(inherited)",

View File

@@ -1890,7 +1890,7 @@ PODS:
- React
- RNSecureRandom (1.0.1):
- React
- RNShare (12.0.9):
- RNShare (12.0.11):
- DoubleConversion
- glog
- hermes-engine
@@ -2390,7 +2390,7 @@ SPEC CHECKSUMS:
RNLocalize: 15463c4d79c7da45230064b4adcf5e9bb984667e
RNQuickAction: c2c8f379e614428be0babe4d53a575739667744d
RNSecureRandom: b64d263529492a6897e236a22a2c4249aa1b53dc
RNShare: ef61d9be34bf9881d515851d0710a023cdc4f0f4
RNShare: 675e8e4a84f0137baf33057cac8f7334b0bb4b98
RNVectorIcons: d53917643fddb261b22bd6d889776f336893622b
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748
Yoga: c758bfb934100bb4bf9cbaccb52557cee35e8bdf

View File

@@ -58,7 +58,7 @@
"react-native-file-viewer": "2.1.5",
"react-native-fs": "2.20.0",
"react-native-get-random-values": "1.11.0",
"react-native-image-picker": "7.2.3",
"react-native-image-picker": "8.0.0",
"react-native-localize": "3.4.1",
"react-native-modal-datetime-picker": "18.0.0",
"react-native-paper": "5.13.5",
@@ -66,9 +66,9 @@
"react-native-quick-actions": "0.3.13",
"react-native-quick-crypto": "0.7.13",
"react-native-rsa-native": "2.0.5",
"react-native-safe-area-context": "5.4.0",
"react-native-safe-area-context": "5.4.1",
"react-native-securerandom": "1.0.1",
"react-native-share": "12.0.9",
"react-native-share": "12.0.11",
"react-native-sqlite-storage": "6.0.1",
"react-native-url-polyfill": "2.0.0",
"react-native-vector-icons": "10.2.0",
@@ -106,16 +106,16 @@
"@testing-library/react-native": "13.2.0",
"@types/fs-extra": "11.0.4",
"@types/jest": "29.5.14",
"@types/node": "18.19.87",
"@types/node": "18.19.103",
"@types/react": "19.0.14",
"@types/react-redux": "7.1.33",
"@types/serviceworker": "0.0.133",
"@types/serviceworker": "0.0.135",
"@types/tar-stream": "3.1.3",
"babel-jest": "29.7.0",
"babel-loader": "9.1.3",
"babel-plugin-module-resolver": "4.1.0",
"babel-plugin-react-native-web": "0.20.0",
"esbuild": "0.25.3",
"esbuild": "0.25.4",
"fast-deep-equal": "3.1.3",
"fs-extra": "11.2.0",
"gulp": "4.0.2",
@@ -123,14 +123,14 @@
"jest-environment-jsdom": "29.7.0",
"jetifier": "2.0.0",
"js-draw": "1.30.0",
"jsdom": "26.0.0",
"jsdom": "26.1.0",
"nodemon": "3.1.10",
"punycode": "2.3.1",
"react-dom": "19.0.0",
"react-native-web": "0.20.0",
"react-refresh": "0.17.0",
"react-test-renderer": "19.0.0",
"sharp": "0.34.0",
"sharp": "0.34.2",
"sqlite3": "5.1.6",
"timers-browserify": "2.0.12",
"ts-jest": "29.3.1",

View File

@@ -14,8 +14,10 @@ import { vim } from '@replit/codemirror-vim';
import { indentUnit } from '@codemirror/language';
import { Prec } from '@codemirror/state';
import insertNewlineContinueMarkup from './editorCommands/insertNewlineContinueMarkup';
import renderingExtension from './extensions/rendering/renderingExtension';
import { RenderedContentContext } from './extensions/rendering/types';
const configFromSettings = (settings: EditorSettings) => {
const configFromSettings = (settings: EditorSettings, context: RenderedContentContext) => {
const languageExtension = (() => {
const openingBrackets = '`([{\'"‘“(《「『【〔〖〘〚'.split('');
@@ -84,6 +86,12 @@ const configFromSettings = (settings: EditorSettings) => {
extensions.push(Prec.low(keymap.of(defaultKeymap)));
}
if (settings.inlineRenderingEnabled) {
extensions.push(renderingExtension(context, {
renderImages: settings.imageRenderingEnabled,
}));
}
return extensions;
};

View File

@@ -40,7 +40,9 @@ describe('createEditor', () => {
settings: editorSettings,
onEvent: _event => {},
onLogMessage: _message => {},
onLocalize: input => input,
onPasteFile: null,
resolveImageSrc: src => Promise.resolve(src),
});
// Force the generation of the syntax tree now.
@@ -69,7 +71,9 @@ describe('createEditor', () => {
settings: editorSettings,
onEvent: _event => {},
onLogMessage: _message => {},
onLocalize: input => input,
onPasteFile: null,
resolveImageSrc: src=>Promise.resolve(src),
});
const getContentScriptJs = jest.fn(async () => {
@@ -138,7 +142,9 @@ describe('createEditor', () => {
settings: editorSettings,
onEvent: _event => {},
onLogMessage: _message => {},
onLocalize: input => input,
onPasteFile: null,
resolveImageSrc: src=>Promise.resolve(src),
});
const getContentScriptJs = jest.fn(async () => {
@@ -188,7 +194,9 @@ describe('createEditor', () => {
settings: editorSettings,
onEvent: () => {},
onLogMessage: () => {},
onLocalize: input => input,
onPasteFile: null,
resolveImageSrc: src=>Promise.resolve(src),
});
const editorState = editor.editor.state;
const idFacet = editor.joplinExtensions.noteIdFacet;

View File

@@ -36,6 +36,10 @@ import isCursorAtBeginning from './utils/isCursorAtBeginning';
import overwriteModeExtension from './extensions/overwriteModeExtension';
import handleLinkEditRequests, { showLinkEditor } from './utils/handleLinkEditRequests';
import selectedNoteIdExtension, { setNoteIdEffect } from './extensions/selectedNoteIdExtension';
import ctrlKeyStateClassExtension from './extensions/modifierKeyCssExtension';
import ctrlClickLinksExtension from './extensions/links/ctrlClickLinksExtension';
import { RenderedContentContext } from './extensions/rendering/types';
import ctrlClickCheckboxExtension from './extensions/ctrlClickCheckboxExtension';
// Newer versions of CodeMirror by default use Chrome's EditContext API.
// While this might be stable enough for desktop use, it causes significant
@@ -47,14 +51,26 @@ import selectedNoteIdExtension, { setNoteIdEffect } from './extensions/selectedN
type ExtendedEditorView = typeof EditorView & { EDIT_CONTEXT: boolean };
(EditorView as ExtendedEditorView).EDIT_CONTEXT = false;
export type ResolveImageCallback = (imageSrc: string)=> Promise<string>;
interface CodeMirrorProps {
resolveImageSrc: ResolveImageCallback;
}
const createEditor = (
parentElement: HTMLElement, props: EditorProps,
parentElement: HTMLElement, props: EditorProps&CodeMirrorProps,
): CodeMirrorControl => {
const initialText = props.initialText;
let settings = props.settings;
props.onLogMessage('Initializing CodeMirror...');
const context: RenderedContentContext = {
resolveImageSrc: (src) => {
return props.resolveImageSrc(src);
},
};
// Handles firing an event when the undo/redo stack changes
let schedulePostUndoRedoDepthChangeId_: ReturnType<typeof setTimeout>|null = null;
@@ -228,7 +244,7 @@ const createEditor = (
extensions: [
keymapConfig,
dynamicConfig.of(configFromSettings(props.settings)),
dynamicConfig.of(configFromSettings(props.settings, context)),
historyCompartment.of(history()),
searchExtension(props.onEvent, props.settings),
@@ -237,6 +253,10 @@ const createEditor = (
EditorState.allowMultipleSelections.of(true),
rectangularSelection(),
drawSelection(),
ctrlClickLinksExtension(link => {
props.onEvent({ kind: EditorEventType.FollowLink, link });
}),
ctrlClickCheckboxExtension(),
highlightSpecialChars(),
indentOnInput(),
@@ -274,6 +294,7 @@ const createEditor = (
biDirectionalTextExtension,
overwriteModeExtension,
ctrlKeyStateClassExtension,
selectedNoteIdExtension,
@@ -320,7 +341,7 @@ const createEditor = (
settings = newSettings;
editor.dispatch({
effects: dynamicConfig.reconfigure(
configFromSettings(newSettings),
configFromSettings(newSettings, context),
),
});
},
@@ -332,6 +353,9 @@ const createEditor = (
onLogMessage: props.onLogMessage,
onRemove: () => {
editor.destroy();
props.onEvent({
kind: EditorEventType.Remove,
});
},
});

View File

@@ -0,0 +1,33 @@
import { EditorView } from '@codemirror/view';
import { Prec } from '@codemirror/state';
const hasMultipleCursors = (view: EditorView) => {
return view.state.selection.ranges.length > 1;
};
type OnCtrlClick = (view: EditorView, event: MouseEvent)=> boolean;
const ctrlClickActionExtension = (onCtrlClick: OnCtrlClick) => {
return [
Prec.high([
EditorView.domEventHandlers({
mousedown: (event: MouseEvent, view: EditorView) => {
const hasModifier = event.ctrlKey || event.metaKey;
// The default CodeMirror action for ctrl-click is to add another cursor
// to the document. If the user already has multiple cursors, assume that
// the ctrl-click action is intended to add another.
if (hasModifier && !hasMultipleCursors(view)) {
const handled = onCtrlClick(view, event);
if (handled) {
event.preventDefault();
return true;
}
}
return false;
},
}),
]),
];
};
export default ctrlClickActionExtension;

View File

@@ -0,0 +1,30 @@
import { EditorView } from '@codemirror/view';
import modifierKeyCssExtension from './modifierKeyCssExtension';
import { syntaxTree } from '@codemirror/language';
import getCheckboxAtPosition from '../utils/markdown/getCheckboxAtPosition';
import toggleCheckboxAt from '../utils/markdown/toggleCheckboxAt';
import ctrlClickActionExtension from './ctrlClickActionExtension';
const ctrlClickCheckboxExtension = () => {
return [
modifierKeyCssExtension,
EditorView.theme({
'&.-ctrl-or-cmd-pressed .cm-taskMarker': {
cursor: 'pointer',
},
}),
ctrlClickActionExtension((view, event) => {
const target = view.posAtCoords(event);
const taskMarker = getCheckboxAtPosition(target, syntaxTree(view.state));
if (taskMarker) {
toggleCheckboxAt(target)(view);
return true;
}
return false;
}),
];
};
export default ctrlClickCheckboxExtension;

View File

@@ -0,0 +1,33 @@
import { EditorView } from '@codemirror/view';
import referenceLinkStateField from './referenceLinksStateField';
import modifierKeyCssExtension from '../modifierKeyCssExtension';
import openLink from './utils/openLink';
import getUrlAtPosition from './utils/getUrlAtPosition';
import { syntaxTree } from '@codemirror/language';
import ctrlClickActionExtension from '../ctrlClickActionExtension';
type OnOpenLink = (url: string, view: EditorView)=> void;
const ctrlClickLinksExtension = (onOpenExternalLink: OnOpenLink) => {
return [
modifierKeyCssExtension,
referenceLinkStateField,
EditorView.theme({
'&.-ctrl-or-cmd-pressed .cm-url, &.-ctrl-or-cmd-pressed .tok-link': {
cursor: 'pointer',
},
}),
ctrlClickActionExtension((view: EditorView, event: MouseEvent) => {
const target = view.posAtCoords(event);
const url = getUrlAtPosition(target, syntaxTree(view.state), view.state);
if (url) {
openLink(url.url, view, onOpenExternalLink);
return true;
}
return false;
}),
];
};
export default ctrlClickLinksExtension;

View File

@@ -0,0 +1,26 @@
import { forceParsing } from '@codemirror/language';
import createTestEditor from '../../testing/createTestEditor';
import followLinkTooltip from './followLinkTooltipExtension';
import { EditorSelection } from '@codemirror/state';
describe('followLinkTooltip', () => {
it('should show a clickable tooltip for a URL link', async () => {
const doc = '[link](http://example.com/)';
const onOpenLink = jest.fn();
const editor = await createTestEditor(doc, EditorSelection.cursor(0), [], [followLinkTooltip(url => onOpenLink(url))]);
forceParsing(editor, editor.state.doc.length);
editor.dispatch({
userEvent: 'select',
selection: { anchor: 4 },
});
const tooltip = editor.dom.querySelector('.cm-md-link-tooltip');
if (!tooltip) throw new Error('No tooltip found.');
const link = tooltip.querySelector('button');
link!.click();
expect(onOpenLink).toHaveBeenCalledWith('http://example.com/');
});
});

View File

@@ -0,0 +1,86 @@
import { syntaxTree } from '@codemirror/language';
import { EditorState, StateField } from '@codemirror/state';
import { EditorView, showTooltip, Tooltip } from '@codemirror/view';
import referenceLinkStateField from './referenceLinksStateField';
import getUrlAtPosition from './utils/getUrlAtPosition';
import openLink from './utils/openLink';
import ctrlClickLinksExtension from './ctrlClickLinksExtension';
type OnOpenLink = (url: string, view: EditorView)=> void;
// Returns tooltips for the links under the cursor(s).
const getLinkTooltips = (onOpenLink: OnOpenLink, state: EditorState) => {
const tree = syntaxTree(state);
return state.selection.ranges.map((range): Tooltip|null => {
if (!range.empty) return null;
const url = getUrlAtPosition(range.anchor, tree, state);
if (!url) return null;
return {
pos: range.head,
arrow: true,
create: (view) => {
const dom = document.createElement('div');
dom.classList.add('cm-md-link-tooltip');
const link = document.createElement('button');
link.role = 'link';
link.textContent = `🔗 ${url.url}${url.label ? `: ${url.label}` : ''}`;
link.title = state.phrase('Follow link: $1', url.url);
link.onclick = () => {
onOpenLink(url.url, view);
};
dom.appendChild(link);
return { dom };
},
};
}).filter(tooltip => !!tooltip) as Tooltip[];
};
const followLinkTooltip = (onOpenExternalLink: OnOpenLink) => {
const onOpenLink = (link: string, view: EditorView) => {
openLink(link, view, onOpenExternalLink);
};
const followLinkTooltipField = StateField.define<readonly Tooltip[]>({
create: state => getLinkTooltips(onOpenLink, state),
update: (tooltips, transaction) => {
if (!transaction.docChanged && !transaction.selection) {
return tooltips;
}
return getLinkTooltips(onOpenLink, transaction.state);
},
provide: field => {
const tooltipsFromState = (state: EditorState) => state.field(field);
return showTooltip.computeN([field], tooltipsFromState);
},
});
return [
referenceLinkStateField,
EditorView.theme({
'& .cm-md-link-tooltip > button': {
backgroundColor: 'transparent',
border: 'transparent',
fontSize: 'inherit',
whiteSpace: 'pre',
maxWidth: '95vw',
textOverflow: 'ellipsis',
overflowX: 'hidden',
textDecoration: 'underline',
cursor: 'pointer',
color: 'var(--joplin-url-color)',
},
}),
followLinkTooltipField,
ctrlClickLinksExtension(onOpenExternalLink),
];
};
export default followLinkTooltip;

View File

@@ -0,0 +1,94 @@
import { EditorState, RangeSet, Range, RangeValue, StateField, Text } from '@codemirror/state';
class ReferenceLinkValue extends RangeValue {
public constructor(public readonly key: string, public readonly value: string) {
super();
}
}
export const resolveReferenceById = (referenceId: string, state: EditorState) => {
const cursor = state.field(referenceLinkStateField).iter();
for (; cursor.value; cursor.next()) {
if (cursor.value.key === referenceId) {
return cursor.value.value;
}
}
return null;
};
const referenceLinkExp = /^(\[[^\]]+\])\s*(\[[^\]]+\])?$/;
export const isReferenceLink = (link: string) => {
return !!link.trim().match(referenceLinkExp);
};
export const resolveReferenceFromLink = (link: string, state: EditorState) => {
const referenceMatch = link.trim().match(referenceLinkExp);
if (!referenceMatch) return null;
const resolved = resolveReferenceById(referenceMatch[2] ?? referenceMatch[1], state);
return resolved?.trim() ?? null;
};
// Returns the key and value for a link reference definition in the form
// [a test]: http://some/def/here/
const parseReferenceDef = (lineText: string) => {
const linkStart = lineText.match(/^(\[[^[\]]+\]):/);
if (!linkStart) return null;
const key = linkStart[1];
return {
key,
value: lineText.substring(linkStart[0].length),
};
};
const addReferencesToSet = (set: RangeSet<ReferenceLinkValue>, fromIdx: number, toIdx: number, doc: Text) => {
const newRanges: Range<ReferenceLinkValue>[] = [];
const fromLine = doc.lineAt(fromIdx);
const toLine = doc.lineAt(toIdx);
for (let i = fromLine.number; i <= toLine.number; i++) {
const line = doc.line(i);
const parsedRef = parseReferenceDef(line.text);
if (parsedRef) {
newRanges.push(
new ReferenceLinkValue(parsedRef.key, parsedRef.value).range(line.from),
);
}
}
return set.update({ add: newRanges });
};
const referenceLinkStateField = StateField.define<RangeSet<ReferenceLinkValue>>({
create(state): RangeSet<ReferenceLinkValue> {
return addReferencesToSet(RangeSet.empty, 0, state.doc.length, state.doc);
},
update(value, transaction) {
if (!transaction.docChanged) return value.map(transaction.changes);
// Remove deleted/modified definitions
transaction.changes.iterChangedRanges((fromA, toA) => {
value = value.update({
filterFrom: fromA,
filterTo: toA,
filter: () => false,
});
});
// Switch line numbers to match the new document
value = value.map(transaction.changes);
transaction.changes.iterChangedRanges((_fromA, _fromB, fromB, toB) => {
value = addReferencesToSet(value, fromB, toB, transaction.newDoc);
});
return value;
},
});
export default referenceLinkStateField;

View File

@@ -0,0 +1,36 @@
import { EditorSelection } from '@codemirror/state';
import createTestEditor from '../../../testing/createTestEditor';
import findLineMatchingLink from './findLineMatchingLink';
describe('findLineMatchingLink', () => {
test.each([
// Should match headings
['# Heading\n', '#heading', 1],
['# Heading', '#heading', 1],
['## Heading', '#heading', 1],
['### Heading', '#heading', 1],
// Should match headings not on the first line
['\n### Heading', '#heading', 2],
['# Test\n\n### Heading', '#heading', 3],
['# Test\n\n### Heading\n\ntest', '#heading', 3],
// Should return null when there are no matches
['# Heading', '#missing-heading', null],
// Should match footnotes
['[^1]: Footnote!\n', '[^1]', 1],
['[^1]: Footnote!\n[^2]: Other footnote.', '[^1]', 1],
['# ^1\n[^1]: Footnote!\n[^2]: Other footnote.', '[^1]', 2],
['# ^1\n[^1]: Footnote!\n[^2]: Other footnote.', '[^not a footnote]', null],
// Should not process http:// links
['# Test', 'http://example.com', null],
])('should correctly find lines matching the given link (doc: %j, link: %j) (case %#)', async (
doc, link, expectedMatchingLine,
) => {
const editor = await createTestEditor(doc, EditorSelection.cursor(0), []);
expect(
findLineMatchingLink(link, editor.state)?.number ?? null,
).toBe(expectedMatchingLine);
});
});

View File

@@ -0,0 +1,36 @@
import { EditorState, Line } from '@codemirror/state';
import uslug from '@joplin/fork-uslug/lib/uslug';
// Searches the given `state` for a line that matches the target link.
const findLineMatchingLink = (link: string, state: EditorState): Line|null => {
const isAnchorLink = link.startsWith('#');
const isFootnote = link.startsWith('[^') && link.endsWith(']');
if (!isAnchorLink && !isFootnote) return null;
const matchesLine = (line: string) => {
if (isAnchorLink) {
line = line.replace(/^#+/, '').trim();
return uslug(line) === link.substring(1);
} else if (isFootnote) {
return line.trim().startsWith(`${link}:`);
}
return false;
};
let iterator = state.doc.iterLines();
let lineNumber = 0;
while (!iterator.done && lineNumber <= state.doc.lines) {
lineNumber ++;
iterator = iterator.next();
const line = iterator.value;
if (matchesLine(line)) {
return state.doc.line(lineNumber);
}
}
return null;
};
export default findLineMatchingLink;

View File

@@ -0,0 +1,53 @@
import { EditorState } from '@codemirror/state';
import { resolveReferenceFromLink } from '../referenceLinksStateField';
import { SyntaxNodeRef, Tree } from '@lezer/common';
enum MatchedUrlType {
Footnote,
Link,
}
type MatchedUrl = {
type: MatchedUrlType;
url: string;
label?: string;
};
const getUrlAtPosition = (pos: number, tree: Tree, state: EditorState): MatchedUrl|null => {
const nodeText = (node: SyntaxNodeRef) => {
return state.doc.sliceString(node.from, node.to);
};
let iterator = tree.resolveStack(pos);
while (true) {
if (iterator.node.name === 'Link') {
const urlNode = iterator.node.getChild('URL');
if (urlNode) {
return { type: MatchedUrlType.Link, url: nodeText(urlNode) };
}
const fullLinkText = nodeText(iterator.node);
const referenceLink = resolveReferenceFromLink(fullLinkText, state);
if (referenceLink) {
const isFootnote = fullLinkText.match(/^\[\^\d+\]$/);
if (isFootnote) {
return { type: MatchedUrlType.Footnote, url: fullLinkText, label: referenceLink };
} else {
return { type: MatchedUrlType.Link, url: referenceLink };
}
}
} else if (iterator.node.name === 'URL') {
return { type: MatchedUrlType.Link, url: nodeText(iterator.node) };
}
if (!iterator.next) {
break;
} else {
iterator = iterator.next;
}
}
return null;
};
export default getUrlAtPosition;

View File

@@ -0,0 +1,22 @@
import { EditorView } from '@codemirror/view';
import findLineMatchingLink from './findLineMatchingLink';
export type OnOpenExternalLink = (url: string, view: EditorView)=> void;
const openLink = (link: string, view: EditorView, onOpenExternalLink: OnOpenExternalLink) => {
const targetLine = findLineMatchingLink(link, view.state);
if (targetLine) {
view.dispatch({
selection: { anchor: targetLine.to },
scrollIntoView: true,
effects: [
EditorView.announce.of(`Jumped to line ${targetLine.number}`),
],
});
// eslint-disable-next-line no-restricted-properties -- Old code from before rule was applied
view.focus();
} else {
onOpenExternalLink(link, view);
}
};
export default openLink;

View File

@@ -0,0 +1,45 @@
import { StateEffect, StateField, Transaction } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
const ctrlOrMetaChangedEffect = StateEffect.define<boolean>();
const ctrlOrMetaPressedField = StateField.define<boolean>({
create: () => false,
update: (value: boolean, transaction: Transaction) => {
const toggleEffect = transaction.effects.find(effect => effect.is(ctrlOrMetaChangedEffect));
if (toggleEffect) {
return toggleEffect.value;
}
return value;
},
provide: (field) => [
EditorView.editorAttributes.from(field, on => ({
class: on ? '-ctrl-or-cmd-pressed' : '',
})),
...(() => {
const onEvent = (event: KeyboardEvent|MouseEvent, view: EditorView) => {
const ctrlOrCmdPressed = event.ctrlKey || event.metaKey;
if (ctrlOrCmdPressed !== view.state.field(ctrlOrMetaPressedField)) {
view.dispatch({
effects: [
ctrlOrMetaChangedEffect.of(ctrlOrCmdPressed),
],
});
}
};
return [
EditorView.domEventObservers({
keydown: onEvent,
keyup: onEvent,
mouseenter: onEvent,
mousemove: onEvent,
}),
];
})(),
],
});
export default [
ctrlOrMetaPressedField,
];

View File

@@ -0,0 +1,31 @@
import { Decoration, EditorView } from '@codemirror/view';
import makeInlineReplaceExtension from './utils/makeInlineReplaceExtension';
const linkClassName = 'cm-ext-unfocused-link';
const urlMarkDecoration = Decoration.mark({ class: linkClassName });
const strikethroughClassName = 'cm-ext-strikethrough';
const strikethroughMarkDecoration = Decoration.mark({ class: strikethroughClassName });
const addFormattingClasses = [
EditorView.theme({
[`& .${linkClassName}, & .${linkClassName} span`]: {
textDecoration: 'underline',
},
[`& .${strikethroughClassName}, & .${strikethroughClassName} span`]: {
textDecoration: 'line-through',
},
}),
makeInlineReplaceExtension({
createDecoration: (node) => {
if (node.name === 'URL' || node.name === 'Link') {
return urlMarkDecoration;
}
if (node.name === 'Strikethrough') {
return strikethroughMarkDecoration;
}
return null;
},
}),
];
export default addFormattingClasses;

View File

@@ -0,0 +1,42 @@
import { EditorSelection } from '@codemirror/state';
import createTestEditor from '../../testing/createTestEditor';
import renderBlockImages from './renderBlockImages';
import { EditorView } from '@codemirror/view';
const createEditor = (initialMarkdown: string, hasImage: boolean) => {
const resolveImageSrc = jest.fn(src => Promise.resolve(src));
return createTestEditor(
initialMarkdown,
EditorSelection.cursor(0),
hasImage ? ['Image'] : [],
[renderBlockImages({ resolveImageSrc })],
);
};
const findImage = (editor: EditorView) => {
return editor.dom.querySelector('div.cm-md-image > .image');
};
describe('renderBlockImages', () => {
test.each([
{ spaceBefore: '', spaceAfter: '\n\n', alt: 'test' },
{ spaceBefore: '', spaceAfter: '', alt: 'This is a test!' },
{ spaceBefore: ' ', spaceAfter: ' ', alt: 'test' },
{ spaceBefore: '', spaceAfter: '', alt: '!!!!' },
])('should render images below their Markdown source (case %#)', async ({ spaceBefore, spaceAfter, alt }) => {
const editor = await createEditor(`${spaceBefore}![${alt}](:/0123456789abcdef0123456789abcdef)${spaceAfter}`, true);
const image = findImage(editor);
expect(image).toBeTruthy();
expect(image.role).toBe('image');
expect(image.ariaLabel).toBe(alt);
});
// For now, only Joplin resources are rendered. This simplifies the implementation and avoids
// potentially-unwanted web requests when opening a note with only the editor open.
test('should not render web images', async () => {
const editor = await createEditor('![test](https://example.com/test.png)\n\n', true);
const image = findImage(editor);
expect(image).toBeNull();
});
});

View File

@@ -0,0 +1,134 @@
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
import { SyntaxNodeRef } from '@lezer/common';
import { EditorState } from '@codemirror/state';
import { RenderedContentContext } from './types';
import makeBlockReplaceExtension from './utils/makeBlockReplaceExtension';
const imageClassName = 'cm-md-image';
// Pre-set the image height for performance (allows CodeMirror to better calculate
// the document height while scrolling).
const imageHeight = 200;
class ImageWidget extends WidgetType {
private resolvedSrc_: string;
public constructor(
private readonly context_: RenderedContentContext,
private readonly src_: string,
private readonly alt_: string,
) {
super();
}
public eq(other: ImageWidget) {
return this.src_ === other.src_ && this.alt_ === other.alt_;
}
public toDOM() {
const container = document.createElement('div');
container.classList.add(imageClassName);
const image = document.createElement('div');
image.role = 'image';
image.ariaLabel = this.alt_;
image.classList.add('image');
const updateImageUrl = () => {
if (this.resolvedSrc_) {
// Use a background-image style property rather than img[src=]. This
// simplifies setting the image to the correct size/position.
image.style.backgroundImage = `url(${JSON.stringify(this.resolvedSrc_)})`;
}
};
if (!this.resolvedSrc_) {
void (async () => {
this.resolvedSrc_ = await this.context_.resolveImageSrc(this.src_);
updateImageUrl();
})();
} else {
updateImageUrl();
}
container.appendChild(image);
return container;
}
public get estimatedHeight() {
return imageHeight;
}
}
const getImageSrc = (node: SyntaxNodeRef, state: EditorState) => {
const nodeText = state.sliceDoc(node.from, node.to);
// For now, only render Joplin resource images (avoid auto-fetching images from
// the internet if just the Markdown editor is open).
const match = nodeText.match(/:\/[a-zA-Z0-9]{32}/);
if (match) {
return match[0];
} else {
return null;
}
};
const getImageAlt = (node: SyntaxNodeRef, state: EditorState) => {
const nodeText = state.sliceDoc(node.from, node.to);
const match = nodeText.match(/!\s*\[(.+)\]/);
if (match) {
return match[1];
} else {
return null;
}
};
const renderBlockImages = (context: RenderedContentContext) => [
EditorView.theme({
[`& .${imageClassName} > div`]: {
height: `${imageHeight}px`,
backgroundSize: 'contain',
backgroundRepeat: 'no-repeat',
backgroundPosition: 'center',
display: 'block',
},
}),
makeBlockReplaceExtension({
createDecoration: (node, state) => {
if (node.name === 'Image') {
const lineFrom = state.doc.lineAt(node.from);
const lineTo = state.doc.lineAt(node.to);
const textBefore = state.sliceDoc(lineFrom.from, node.from);
const textAfter = state.sliceDoc(node.to, lineTo.to);
if (textBefore.trim() === '' && textAfter.trim() === '') {
const src = getImageSrc(node, state);
const alt = getImageAlt(node, state);
if (src) {
const isLastLine = lineTo.number === state.doc.lines;
return Decoration.widget({
widget: new ImageWidget(context, src, alt),
// "side: -1": In general, when the cursor is at the widget's location, it should be at
// the start of the next line (and so "side" should be -1).
//
// "side: 1": However, when the widget is at the end of the document, the widget's
// position is **one index less** than when it isn't (to prevent the widget's
// position from being outside the document, which would break CodeMirror).
// This means that we need "side: 1" to put the cursor before the widget
// when at the end of the document.
side: isLastLine ? 1 : -1,
block: true,
});
}
}
}
return null;
},
getDecorationRange: (node, state) => {
const nodeLine = state.doc.lineAt(node.to);
return [Math.min(nodeLine.to + 1, state.doc.length)];
},
hideWhenContainsSelection: false,
}),
];
export default renderBlockImages;

View File

@@ -0,0 +1,22 @@
import addFormattingClasses from './addFormattingClasses';
import renderBlockImages from './renderBlockImages';
import replaceBulletLists from './replaceBulletLists';
import replaceCheckboxes from './replaceCheckboxes';
import replaceDividers from './replaceDividers';
import replaceFormatCharacters from './replaceFormatCharacters';
import { RenderedContentContext } from './types';
interface Options {
renderImages: boolean;
}
export default (context: RenderedContentContext, options: Options) => {
return [
replaceCheckboxes,
replaceBulletLists,
replaceFormatCharacters,
replaceDividers,
addFormattingClasses,
...(options.renderImages ? [renderBlockImages(context)] : []),
];
};

View File

@@ -0,0 +1,97 @@
import { EditorView, WidgetType } from '@codemirror/view';
import makeReplaceExtension from './utils/makeInlineReplaceExtension';
const listMarkerClassName = 'cm-bullet-list-marker';
class BulletListMarker extends WidgetType {
private className: string;
public constructor(depth: number) {
super();
if (depth % 3 === 0) {
this.className = '-depth-0';
} else if (depth % 3 === 1) {
this.className = '-depth-1';
} else {
this.className = '-depth-2';
}
}
public eq(other: BulletListMarker) {
return other.className === this.className;
}
public toDOM() {
const container = document.createElement('span');
container.classList.add(listMarkerClassName, this.className);
container.setAttribute('aria-label', 'bullet');
container.role = 'img';
const sizingNode = document.createElement('span');
sizingNode.classList.add('sizing');
sizingNode.textContent = '-';
container.appendChild(sizingNode);
const content = document.createElement('span');
content.classList.add('content');
container.appendChild(content);
return container;
}
public updateDOM(other: HTMLElement) {
other.classList.remove('-depth-0', '-depth-1', '-depth-2');
other.classList.add(this.className);
return true;
}
}
const replaceBulletLists = [
EditorView.theme({
[`& .${listMarkerClassName}`]: {
'pointer-events': 'none',
'position': 'relative',
'&.-depth-0 > .content': {
'border-radius': 0,
},
'&.-depth-2 > .content': {
'border': '1px solid currentcolor',
'background-color': 'transparent',
},
'& > .sizing': {
'color': 'transparent',
},
'& > .content': {
'position': 'absolute',
'left': '0',
'--size': '4px',
// Push the content to the center of the container
'--vertical-offset': 'calc(50% - calc(var(--size) / 2))',
'top': 'var(--vertical-offset)',
'bottom': 'var(--vertical-offset)',
'width': 'var(--size)',
'height': 'var(--size)',
'box-sizing': 'border-box',
'border-radius': 'var(--size)',
'background-color': 'currentcolor',
},
},
}),
makeReplaceExtension({
createDecoration: (node, _view, parentTagCounts) => {
if (node.name === 'ListMark') {
const parent = node.node.parent;
if (parent?.name === 'ListItem' && parent?.parent?.name === 'BulletList') {
return new BulletListMarker(parentTagCounts.get('BulletList') ?? 1);
}
}
return null;
},
}),
];
export default replaceBulletLists;

View File

@@ -0,0 +1,133 @@
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
import { SyntaxNodeRef } from '@lezer/common';
import makeReplaceExtension from './utils/makeInlineReplaceExtension';
import toggleCheckboxAt from '../../utils/markdown/toggleCheckboxAt';
const checkboxClassName = 'cm-ext-checkbox-toggle';
class CheckboxWidget extends WidgetType {
public constructor(private checked: boolean, private depth: number, private label: string) {
super();
}
public eq(other: CheckboxWidget) {
return other.checked === this.checked && other.depth === this.depth && other.label === this.label;
}
private applyContainerClasses(container: HTMLElement) {
container.classList.add(checkboxClassName);
for (const className of [...container.classList]) {
if (className.startsWith('-depth-')) {
container.classList.remove(className);
}
}
container.classList.add(`-depth-${this.depth}`);
}
public toDOM(view: EditorView) {
const container = document.createElement('span');
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = this.checked;
checkbox.ariaLabel = this.label;
checkbox.title = this.label;
container.appendChild(checkbox);
checkbox.oninput = () => {
toggleCheckboxAt(view.posAtDOM(container))(view);
};
this.applyContainerClasses(container);
return container;
}
public updateDOM(dom: HTMLElement): boolean {
this.applyContainerClasses(dom);
const input = dom.querySelector('input');
if (input) {
input.checked = this.checked;
return true;
}
return false;
}
public ignoreEvent() {
return false;
}
}
const completedTaskClassName = 'cm-md-completed-item';
const completedListItemDecoration = Decoration.line({ class: completedTaskClassName, isFullLine: true });
const replaceCheckboxes = [
EditorView.theme({
[`& .${checkboxClassName}`]: {
'& > input': {
width: '1.1em',
height: '1.1em',
margin: '4px',
verticalAlign: 'middle',
},
'&:not(.-depth-1) > input': {
marginInlineStart: 0,
},
},
[`& .${completedTaskClassName}`]: {
opacity: 0.69,
},
}),
EditorView.domEventHandlers({
mousedown: (event) => {
const target = event.target as Element;
if (target.nodeName === 'INPUT' && target.parentElement?.classList?.contains(checkboxClassName)) {
// Let the checkbox handle the event
return true;
}
return false;
},
}),
makeReplaceExtension({
createDecoration: (node, state, parentTags) => {
const markerIsChecked = (marker: SyntaxNodeRef) => {
const content = state.doc.sliceString(marker.from, marker.to);
return content.toLowerCase().indexOf('x') !== -1;
};
if (node.name === 'TaskMarker') {
const containerLine = state.doc.lineAt(node.from);
const labelText = state.doc.sliceString(node.to, containerLine.to);
return new CheckboxWidget(markerIsChecked(node), parentTags.get('ListItem') ?? 0, labelText);
} else if (node.name === 'Task') {
const marker = node.node.getChild('TaskMarker');
if (marker && markerIsChecked(marker)) {
return completedListItemDecoration;
}
}
return null;
},
getDecorationRange: (node, state) => {
if (node.name === 'TaskMarker') {
const container = node.node.parent?.parent;
const listMarker = container?.getChild('ListMark');
if (!listMarker) {
return null;
}
return [listMarker.from, node.to];
} else if (node.name === 'Task') {
const taskLine = state.doc.lineAt(node.from);
return [taskLine.from];
}
return null;
},
}),
];
export default replaceCheckboxes;

View File

@@ -0,0 +1,70 @@
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
import makeInlineReplaceExtension from './utils/makeInlineReplaceExtension';
const dividerClassName = 'cm-md-divider';
const dividerLineClassName = 'cm-md-divider-line';
class DividerWidget extends WidgetType {
public constructor() {
super();
}
public eq(_other: DividerWidget) {
return true;
}
public toDOM() {
const container = document.createElement('hr');
container.classList.add(dividerClassName);
return container;
}
public ignoreEvent() {
return true;
}
}
const dividerLineMark = Decoration.line({ class: dividerLineClassName });
const replaceDividers = [
EditorView.theme({
[`& .cm-line.${dividerLineClassName}`]: {
// Use flex layout to allow the divider to fill the remainder of the line.
// This applies, for example, to the case where the divider is in a blockquote or
// a sub list item.
display: 'flex',
flexWrap: 'wrap',
},
[`& .${dividerClassName}`]: {
// Fill remaining width
flexGrow: 1,
flexShrink: 1,
border: 'none',
borderBottom: '2px solid var(--joplin-divider-color)',
position: 'relative',
},
}),
makeInlineReplaceExtension({
createDecoration: (node) => {
if (node.name === 'HorizontalRule') {
return new DividerWidget();
}
return null;
},
}),
makeInlineReplaceExtension({
createDecoration: (node) => {
if (node.name === 'HorizontalRule') {
return dividerLineMark;
}
return null;
},
getDecorationRange: (node, state) => {
const line = state.doc.lineAt(node.from);
return [line.from];
},
}),
];
export default replaceDividers;

View File

@@ -0,0 +1,81 @@
import makeInlineReplaceExtension from './utils/makeInlineReplaceExtension';
import { SyntaxNodeRef } from '@lezer/common';
import { EditorState } from '@codemirror/state';
import referenceLinkStateField, { isReferenceLink, resolveReferenceFromLink } from '../links/referenceLinksStateField';
import { Decoration } from '@codemirror/view';
const shouldFullReplace = (node: SyntaxNodeRef, state: EditorState) => {
const getParentName = () => node.node.parent?.name;
const getNodeStartLine = () => state.doc.lineAt(node.from);
if (['HeaderMark', 'CodeMark', 'EmphasisMark', 'StrikethroughMark', 'HighlightMarker'].includes(node.name)) {
return true;
}
if ((node.name === 'URL' || node.name === 'LinkMark') && getParentName() === 'Link') {
const parent = node.node.parent!;
const parentContent = state.sliceDoc(parent.from, parent.to);
if (node.name === 'LinkMark') {
if (isReferenceLink(parentContent)) {
return !!resolveReferenceFromLink(parentContent, state);
}
} else if (node.name === 'URL') {
// Find all closing link marks
const closingBracketNodes = parent.getChildren('LinkMark').filter(mark => {
const isClosingBracket = state.sliceDoc(mark.from, mark.to) === ']';
return isClosingBracket;
});
// URLs can only be hidden if after the last ].
const lastClosingBracketIdx = closingBracketNodes.length > 0 ? closingBracketNodes[closingBracketNodes.length - 1].from : null;
if (!lastClosingBracketIdx || node.from < lastClosingBracketIdx) {
return false;
}
}
return true;
}
if (node.name === 'QuoteMark' && node.from === getNodeStartLine().from) {
return true;
}
return false;
};
const hideDecoration = Decoration.replace({});
const replaceFormatCharacters = [
// Dependency
referenceLinkStateField,
makeInlineReplaceExtension({
createDecoration: (node, state) => {
if (shouldFullReplace(node, state)) {
return hideDecoration;
}
return null;
},
getDecorationRange: (node, state) => {
// Headers in the form "## Header" should have the "##"s and the
// space immediately after hidden
if (node.name === 'HeaderMark') {
const markerLine = state.doc.lineAt(node.from);
// Certain header styles DON'T have a space after the header mark:
const hasRoomForSpace = node.to + 1 >= markerLine.to;
if (hasRoomForSpace) {
return null;
}
// Include the space in the hidden region, if it's available
if (state.doc.sliceString(node.to, node.to + 1) === ' ') {
return [node.from, node.to + 1];
}
}
return null;
},
}),
];
export default replaceFormatCharacters;

View File

@@ -0,0 +1,20 @@
import type { EditorState } from '@codemirror/state';
import type { Decoration, WidgetType } from '@codemirror/view';
import type { SyntaxNodeRef } from '@lezer/common';
export interface ReplacementExtension {
// Should return the widget that replaces `node`. Returning `null` preserves `node` without replacement.
createDecoration(node: SyntaxNodeRef, state: EditorState, parentTags: Readonly<Map<string, number>>): Decoration|WidgetType|null;
// Returns a range ([from, to]) to which the decoration should be applied. Returning `null`
// replaces the entire widget with the decoration.
// Only a single number should be returned to create a point/full line range.
getDecorationRange?(node: SyntaxNodeRef, state: EditorState): [number]|[number, number]|null;
// Disable the decoration when near the cursor. Defaults to true.
hideWhenContainsSelection?: boolean;
}
export interface RenderedContentContext {
resolveImageSrc(src: string): Promise<string>;
}

View File

@@ -0,0 +1,89 @@
import { EditorView, Decoration, DecorationSet, WidgetType } from '@codemirror/view';
import { syntaxTree } from '@codemirror/language';
import { EditorState, Range, StateField } from '@codemirror/state';
import { ReplacementExtension } from '../types';
import nodeIntersectsSelection from './nodeIntersectsSelection';
const updateDecorations = (state: EditorState, extensionSpec: ReplacementExtension) => {
const doc = state.doc;
const cursorLine = doc.lineAt(state.selection.main.anchor);
const parentTagCounts = new Map<string, number>();
const widgets: Range<Decoration>[] = [];
syntaxTree(state).iterate({
enter: node => {
parentTagCounts.set(node.name, (parentTagCounts.get(node.name) ?? 0) + 1);
const nodeLineFrom = doc.lineAt(node.from);
const nodeLineTo = doc.lineAt(node.to);
const selectionIsNearNode = Math.abs(nodeLineFrom.number - cursorLine.number) <= 1 || Math.abs(nodeLineTo.number - cursorLine.number) <= 1;
const shouldHide = (
(extensionSpec.hideWhenContainsSelection ?? true) && (
nodeIntersectsSelection(state.selection, node) || selectionIsNearNode
)
);
if (!shouldHide) {
const widget = extensionSpec.createDecoration(node, state, parentTagCounts);
if (widget) {
let decoration;
if (widget instanceof WidgetType) {
decoration = Decoration.replace({
widget,
block: true,
});
} else {
decoration = widget;
}
let rangeFrom = nodeLineFrom.from;
let rangeTo = nodeLineTo.to;
let skip = false;
if (extensionSpec.getDecorationRange) {
const range = extensionSpec.getDecorationRange(node, state);
if (range) {
rangeFrom = range[0];
rangeTo = range.length === 1 ? range[0] : range[1];
} else {
skip = true;
}
}
if (!skip) {
widgets.push(decoration.range(rangeFrom, rangeTo));
}
}
}
},
leave: node => {
parentTagCounts.set(node.name, (parentTagCounts.get(node.name) ?? 0) - 1);
},
});
return Decoration.set(widgets, true);
};
const makeBlockReplaceExtension = (extensionSpec: ReplacementExtension) => {
const blockDecorationField = StateField.define<DecorationSet>({
create(state) {
return updateDecorations(state, extensionSpec);
},
update(decorations, transaction) {
decorations = decorations.map(transaction.changes);
const selectionChanged = !transaction.newSelection.eq(transaction.startState.selection);
if (transaction.docChanged || selectionChanged) {
decorations = updateDecorations(transaction.state, extensionSpec);
}
return decorations;
},
provide: f => EditorView.decorations.from(f),
});
return [
blockDecorationField,
];
};
export default makeBlockReplaceExtension;

View File

@@ -0,0 +1,91 @@
// Ref: https://codemirror.net/examples/bundle/
// and https://codemirror.net/examples/decoration/
import { EditorView, Decoration, DecorationSet, WidgetType } from '@codemirror/view';
import { ViewPlugin, ViewUpdate } from '@codemirror/view';
import { syntaxTree } from '@codemirror/language';
import { Range } from '@codemirror/state';
import { SyntaxNodeRef } from '@lezer/common';
import { ReplacementExtension } from '../types';
import nodeIntersectsSelection from './nodeIntersectsSelection';
export const makeInlineReplaceExtension = (extensionSpec: ReplacementExtension) => ViewPlugin.fromClass(class {
public decorations: DecorationSet;
public constructor(view: EditorView) {
this.updateDecorations(view);
}
private updateDecorations(view: EditorView) {
const doc = view.state.doc;
const cursorLine = doc.lineAt(view.state.selection.main.anchor);
const selection = view.state.selection;
const parentTagCounts = new Map<string, number>();
const decorateNode = (node: SyntaxNodeRef) => {
const widgetOrDecoration = extensionSpec.createDecoration(node, view.state, parentTagCounts);
let decoration;
if (widgetOrDecoration instanceof WidgetType) {
decoration = Decoration.replace({
widget: widgetOrDecoration,
});
} else if (widgetOrDecoration instanceof Decoration) {
decoration = widgetOrDecoration;
}
if (decoration) {
const range = extensionSpec.getDecorationRange?.(node, view.state) ?? [node.from, node.to];
const rangeLineFrom = doc.lineAt(range[0]);
const rangeLineTo = range.length === 2 ? doc.lineAt(range[1]) : rangeLineFrom;
// A different start/end line causes errors.
if (rangeLineFrom.number === rangeLineTo.number) {
if (range.length === 1) {
widgets.push(decoration.range(range[0]));
} else {
widgets.push(decoration.range(range[0], range[1]));
}
}
}
};
const widgets: Range<Decoration>[] = [];
for (const { from, to } of view.visibleRanges) {
parentTagCounts.clear();
syntaxTree(view.state).iterate({
from, to,
enter: node => {
parentTagCounts.set(node.name, (parentTagCounts.get(node.name) ?? 0) + 1);
const nodeLineFrom = doc.lineAt(node.from);
const nodeLineTo = doc.lineAt(node.from);
const nodeLineContainsSelection = cursorLine.number === nodeLineFrom.number || cursorLine.number === nodeLineTo.number;
const shouldHide = (
(extensionSpec.hideWhenContainsSelection ?? true) && (
nodeIntersectsSelection(selection, node) || nodeLineContainsSelection
)
);
if (!shouldHide) {
decorateNode(node);
}
},
leave: node => {
parentTagCounts.set(node.name, (parentTagCounts.get(node.name) ?? 0) - 1);
},
});
}
this.decorations = Decoration.set(widgets, true);
}
public update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged || update.selectionSet) {
this.updateDecorations(update.view);
}
}
}, {
decorations: view => view.decorations,
});
export default makeInlineReplaceExtension;

View File

@@ -0,0 +1,17 @@
import { EditorSelection } from '@codemirror/state';
import { SyntaxNodeRef } from '@lezer/common';
const nodeIntersectsSelection = (selection: EditorSelection, node: SyntaxNodeRef) => {
const mainSelection = selection.main;
const nodeContains = (point: number) => {
return point >= node.from && point <= node.to;
};
const selectionContains = (point: number) => {
return point >= mainSelection.from && point <= mainSelection.to;
};
return nodeContains(mainSelection.from) || nodeContains(mainSelection.to)
|| selectionContains(node.from) || selectionContains(node.to);
};
export default nodeIntersectsSelection;

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