Compare commits
138 Commits
server-v3.
...
v3.6.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5d9646908 | ||
|
|
876ec80911 | ||
|
|
4051f88ce7 | ||
|
|
f194c111e4 | ||
|
|
e386246bc9 | ||
|
|
292b269f1d | ||
|
|
b2fc43da2b | ||
|
|
4a23a1ed3e | ||
|
|
c8878a18bf | ||
|
|
340fba7af5 | ||
|
|
271c4f4a2a | ||
|
|
c9dba20f59 | ||
|
|
b474cc206a | ||
|
|
9d4df8cc6e | ||
|
|
a4ddfe1f58 | ||
|
|
7d15215e66 | ||
|
|
5b74e206ed | ||
|
|
9873d02b0b | ||
|
|
57b7d98d8a | ||
|
|
f075b561a2 | ||
|
|
483d051de0 | ||
|
|
106cd2778f | ||
|
|
c3aea2db80 | ||
|
|
3f067b0f77 | ||
|
|
15cf025bc2 | ||
|
|
4677586e3b | ||
|
|
b8c5b7a153 | ||
|
|
e46e634c2e | ||
|
|
b3cf4e5a35 | ||
|
|
8589e10d6e | ||
|
|
18942f0d6a | ||
|
|
3be354cdcb | ||
|
|
0575f1aa3e | ||
|
|
caa9baa460 | ||
|
|
b5284804d8 | ||
|
|
6053b4296c | ||
|
|
615fec1d2c | ||
|
|
0bbcd9a59b | ||
|
|
6931b32f17 | ||
|
|
17ac501ddb | ||
|
|
94161c5f93 | ||
|
|
196255e960 | ||
|
|
f936390ee4 | ||
|
|
5638c4b812 | ||
|
|
4222caa423 | ||
|
|
bc705acc5c | ||
|
|
f1c968c19a | ||
|
|
26c5a6181e | ||
|
|
a3bf0cfdeb | ||
|
|
606b397326 | ||
|
|
fbd157283d | ||
|
|
2e879f65fc | ||
|
|
c727156a46 | ||
|
|
4e31f1918d | ||
|
|
a1cdf67779 | ||
|
|
5cb1db197f | ||
|
|
05c3065c72 | ||
|
|
25a5be09bf | ||
|
|
f0a3f73ddb | ||
|
|
1bb5d9ade5 | ||
|
|
e75875c1b0 | ||
|
|
cce4b76e3f | ||
|
|
b310bfd0c2 | ||
|
|
e19e1ac040 | ||
|
|
3bba2f6b2a | ||
|
|
ca9addcda0 | ||
|
|
c42a49c1cf | ||
|
|
a1e056670d | ||
|
|
6d7a70c21a | ||
|
|
14fd3c66c1 | ||
|
|
376f44a0ce | ||
|
|
4d81ee4c7f | ||
|
|
d9011800b2 | ||
|
|
e64f141b28 | ||
|
|
8bba68d920 | ||
|
|
e342f2d572 | ||
|
|
5951a66fef | ||
|
|
04f9bda128 | ||
|
|
7a8a94f557 | ||
|
|
ad000fb521 | ||
|
|
435b896142 | ||
|
|
b12f31c802 | ||
|
|
ddb6d7a677 | ||
|
|
f0a1d05284 | ||
|
|
27f7cb7ca6 | ||
|
|
9e43ebcf43 | ||
|
|
05cc0fa798 | ||
|
|
ee5b631d13 | ||
|
|
e4b6b34d37 | ||
|
|
6f1280f0f5 | ||
|
|
4c9015dab4 | ||
|
|
1adcafce9d | ||
|
|
cc9f55e115 | ||
|
|
e8b3b039df | ||
|
|
d9295a69d1 | ||
|
|
b92743b068 | ||
|
|
03f65a3fb1 | ||
|
|
32a22174f7 | ||
|
|
d154ef4f5c | ||
|
|
b8dd660c28 | ||
|
|
2b20315bf5 | ||
|
|
93b9108832 | ||
|
|
0538bf0720 | ||
|
|
54018c3a94 | ||
|
|
0cb120c321 | ||
|
|
0dab436420 | ||
|
|
77331ca471 | ||
|
|
d467205b91 | ||
|
|
7a2f686228 | ||
|
|
fa37b87c98 | ||
|
|
0eed352684 | ||
|
|
6ab281d299 | ||
|
|
5b94e0d470 | ||
|
|
5372eeb64a | ||
|
|
f6baf036dc | ||
|
|
610f00029f | ||
|
|
10be1a0240 | ||
|
|
99a9be535c | ||
|
|
614a95abb8 | ||
|
|
7cbaae3847 | ||
|
|
9e2a6d22ea | ||
|
|
f576e116a8 | ||
|
|
b0e912157b | ||
|
|
c5598242f9 | ||
|
|
57980ae916 | ||
|
|
9d1720b6e1 | ||
|
|
c4e0ed18eb | ||
|
|
150f6c9a3f | ||
|
|
6f3781f27a | ||
|
|
37c3d24650 | ||
|
|
bcb3f69d15 | ||
|
|
70ffb29af4 | ||
|
|
5f61bee712 | ||
|
|
496d007f74 | ||
|
|
5a9b389504 | ||
|
|
107290177e | ||
|
|
5055c9af3e | ||
|
|
2ed6650136 |
@@ -115,6 +115,7 @@ packages/app-cli/app/command-export.js
|
||||
packages/app-cli/app/command-geoloc.js
|
||||
packages/app-cli/app/command-help.js
|
||||
packages/app-cli/app/command-import.js
|
||||
packages/app-cli/app/command-keymap.js
|
||||
packages/app-cli/app/command-ls.js
|
||||
packages/app-cli/app/command-mkbook.test.js
|
||||
packages/app-cli/app/command-mkbook.js
|
||||
@@ -959,6 +960,7 @@ packages/app-mobile/utils/fs-driver/testUtil/verifyDirectoryMatches.js
|
||||
packages/app-mobile/utils/getPackageInfo.js
|
||||
packages/app-mobile/utils/getVersionInfoText.js
|
||||
packages/app-mobile/utils/hooks/useBackHandler.js
|
||||
packages/app-mobile/utils/hooks/useDebounced.js
|
||||
packages/app-mobile/utils/hooks/useIsScreenReaderEnabled.js
|
||||
packages/app-mobile/utils/hooks/useKeyboardState.js
|
||||
packages/app-mobile/utils/hooks/useOnLongPressProps.js
|
||||
@@ -1059,6 +1061,8 @@ 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/replaceInlineHtml.test.js
|
||||
packages/editor/CodeMirror/extensions/rendering/replaceInlineHtml.js
|
||||
packages/editor/CodeMirror/extensions/rendering/types.js
|
||||
packages/editor/CodeMirror/extensions/rendering/utils/makeBlockReplaceExtension.js
|
||||
packages/editor/CodeMirror/extensions/rendering/utils/makeInlineReplaceExtension.js
|
||||
@@ -1099,6 +1103,7 @@ packages/editor/CodeMirror/utils/getSearchState.js
|
||||
packages/editor/CodeMirror/utils/growSelectionToNode.js
|
||||
packages/editor/CodeMirror/utils/handleLinkEditRequests.js
|
||||
packages/editor/CodeMirror/utils/handlePasteEvent.js
|
||||
packages/editor/CodeMirror/utils/htmlNodeInfo.js
|
||||
packages/editor/CodeMirror/utils/isCursorAtBeginning.js
|
||||
packages/editor/CodeMirror/utils/isInSyntaxNode.js
|
||||
packages/editor/CodeMirror/utils/markdown/codeBlockLanguages/allLanguages.js
|
||||
@@ -1114,6 +1119,7 @@ packages/editor/CodeMirror/vendor/announceSearchMatch.js
|
||||
packages/editor/ProseMirror/commands/commands.test.js
|
||||
packages/editor/ProseMirror/commands/commands.js
|
||||
packages/editor/ProseMirror/commands/focusEditor.js
|
||||
packages/editor/ProseMirror/commands/selectDocumentEnd.js
|
||||
packages/editor/ProseMirror/createEditor.js
|
||||
packages/editor/ProseMirror/index.js
|
||||
packages/editor/ProseMirror/plugins/detailsPlugin.test.js
|
||||
@@ -1146,6 +1152,7 @@ packages/editor/ProseMirror/types.js
|
||||
packages/editor/ProseMirror/utils/SelectableNodeView.js
|
||||
packages/editor/ProseMirror/utils/UndoStackSynchronizer.js
|
||||
packages/editor/ProseMirror/utils/canReplaceSelectionWith.js
|
||||
packages/editor/ProseMirror/utils/clampPointToDocument.js
|
||||
packages/editor/ProseMirror/utils/computeSelectionFormatting.js
|
||||
packages/editor/ProseMirror/utils/dom/createButton.js
|
||||
packages/editor/ProseMirror/utils/dom/createTextArea.js
|
||||
@@ -1224,6 +1231,7 @@ packages/lib/InMemoryCache.js
|
||||
packages/lib/JoplinDatabase.js
|
||||
packages/lib/JoplinError.js
|
||||
packages/lib/JoplinServerApi.js
|
||||
packages/lib/ObjectUtils.test.js
|
||||
packages/lib/ObjectUtils.js
|
||||
packages/lib/PerformanceLogger.test.js
|
||||
packages/lib/PerformanceLogger.js
|
||||
@@ -1800,6 +1808,7 @@ packages/renderer/MdToHtml/renderMedia.js
|
||||
packages/renderer/MdToHtml/rules/abc.js
|
||||
packages/renderer/MdToHtml/rules/checkbox.js
|
||||
packages/renderer/MdToHtml/rules/code_inline.js
|
||||
packages/renderer/MdToHtml/rules/externalEmbed.js
|
||||
packages/renderer/MdToHtml/rules/fence.js
|
||||
packages/renderer/MdToHtml/rules/fountain.js
|
||||
packages/renderer/MdToHtml/rules/highlight_keywords.js
|
||||
@@ -1835,17 +1844,19 @@ packages/tools/checkIgnoredFiles.js
|
||||
packages/tools/checkLibPaths.test.js
|
||||
packages/tools/checkLibPaths.js
|
||||
packages/tools/convertThemesToCss.js
|
||||
packages/tools/fuzzer/ActionRunner.js
|
||||
packages/tools/fuzzer/ActionTracker.js
|
||||
packages/tools/fuzzer/Client.js
|
||||
packages/tools/fuzzer/ClientPool.js
|
||||
packages/tools/fuzzer/Server.js
|
||||
packages/tools/fuzzer/constants.js
|
||||
packages/tools/fuzzer/doRandomAction.js
|
||||
packages/tools/fuzzer/model/FolderRecord.js
|
||||
packages/tools/fuzzer/sync-fuzzer.js
|
||||
packages/tools/fuzzer/types.js
|
||||
packages/tools/fuzzer/utils/ProgressBar.js
|
||||
packages/tools/fuzzer/utils/SeededRandom.js
|
||||
packages/tools/fuzzer/utils/diffSortedStringArrays.test.js
|
||||
packages/tools/fuzzer/utils/diffSortedStringArrays.js
|
||||
packages/tools/fuzzer/utils/getNumberProperty.js
|
||||
packages/tools/fuzzer/utils/getProperty.js
|
||||
packages/tools/fuzzer/utils/getStringProperty.js
|
||||
|
||||
40
.github/workflows/build-macos-m1.yml
vendored
@@ -48,6 +48,7 @@ jobs:
|
||||
CSC_LINK: ${{ secrets.APPLE_CSC_LINK }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
GITHUB_EVENT_NAME: ${{ github.event_name }}
|
||||
IS_CONTINUOUS_INTEGRATION: 1
|
||||
BUILD_SEQUENCIAL: 1
|
||||
PUBLISH_ENABLED: ${{ env.PUBLISH_ENABLED }}
|
||||
@@ -57,25 +58,38 @@ jobs:
|
||||
yarn install
|
||||
cd packages/app-desktop
|
||||
npm pkg set 'build.mac.artifactName'='${productName}-${version}-${arch}.${ext}'
|
||||
|
||||
npm pkg delete 'build.mac.target'
|
||||
npm pkg set 'build.mac.target[0].target'='dmg'
|
||||
npm pkg set 'build.mac.target[0].arch[0]'='arm64'
|
||||
npm pkg set 'build.mac.target[1].target'='zip'
|
||||
npm pkg set 'build.mac.target[1].arch[0]'='arm64'
|
||||
|
||||
if [[ "$PUBLISH_ENABLED" == "true" ]]; then
|
||||
echo "Building and publishing desktop application..."
|
||||
PYTHON_PATH=$(which python) USE_HARD_LINKS=false yarn dist --mac --arm64
|
||||
# Only enable pkg build in the main repository CI. As of 01/15/2026, pkg
|
||||
# build fails when running on external pull requests.
|
||||
if [[ "$GITHUB_EVENT_NAME" != "pull_request" ]]; then
|
||||
npm pkg set 'build.mac.target[2].target'='pkg'
|
||||
npm pkg set 'build.mac.target[2].arch[0]'='arm64'
|
||||
fi
|
||||
|
||||
yarn modifyReleaseAssets --repo="$GH_REPO" --tag="$GIT_TAG_NAME" --token="$GITHUB_TOKEN"
|
||||
else
|
||||
echo "Building but *not* publishing desktop application..."
|
||||
build_dist() {
|
||||
if [[ "$PUBLISH_ENABLED" == "true" ]]; then
|
||||
echo "Building and publishing desktop application..."
|
||||
PYTHON_PATH=$(which python) USE_HARD_LINKS=false yarn dist --mac --arm64
|
||||
|
||||
# We also want to disable signing the app in this case, because
|
||||
# it doesn't work and we don't need it.
|
||||
# https://www.electron.build/code-signing#how-to-disable-code-signing-during-the-build-process-on-macos
|
||||
yarn modifyReleaseAssets --repo="$GH_REPO" --tag="$GIT_TAG_NAME" --token="$GITHUB_TOKEN"
|
||||
else
|
||||
echo "Building but *not* publishing desktop application..."
|
||||
|
||||
export CSC_IDENTITY_AUTO_DISCOVERY=false
|
||||
npm pkg set 'build.mac.identity'=null --json
|
||||
# We also want to disable signing the app in this case, because
|
||||
# it doesn't work and we don't need it.
|
||||
# https://www.electron.build/code-signing#how-to-disable-code-signing-during-the-build-process-on-macos
|
||||
|
||||
PYTHON_PATH=$(which python) USE_HARD_LINKS=false yarn dist --mac --arm64 --publish=never
|
||||
fi
|
||||
export CSC_IDENTITY_AUTO_DISCOVERY=false
|
||||
npm pkg set 'build.mac.identity'=null --json
|
||||
|
||||
PYTHON_PATH=$(which python) USE_HARD_LINKS=false yarn dist --mac --arm64 --publish=never
|
||||
fi
|
||||
}
|
||||
|
||||
build_dist || build_dist
|
||||
15
.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
_mydocs
|
||||
_releases
|
||||
_vieux/
|
||||
.claude
|
||||
!/var/cache
|
||||
!/var/logs
|
||||
!/var/sessions
|
||||
@@ -52,6 +53,7 @@ lerna-debug.log
|
||||
docs/**/*.mustache
|
||||
.idea
|
||||
/readme/i18n
|
||||
.watchman-cookie-*
|
||||
|
||||
# Yarn stuff
|
||||
# https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored
|
||||
@@ -87,6 +89,7 @@ packages/app-cli/app/command-export.js
|
||||
packages/app-cli/app/command-geoloc.js
|
||||
packages/app-cli/app/command-help.js
|
||||
packages/app-cli/app/command-import.js
|
||||
packages/app-cli/app/command-keymap.js
|
||||
packages/app-cli/app/command-ls.js
|
||||
packages/app-cli/app/command-mkbook.test.js
|
||||
packages/app-cli/app/command-mkbook.js
|
||||
@@ -931,6 +934,7 @@ packages/app-mobile/utils/fs-driver/testUtil/verifyDirectoryMatches.js
|
||||
packages/app-mobile/utils/getPackageInfo.js
|
||||
packages/app-mobile/utils/getVersionInfoText.js
|
||||
packages/app-mobile/utils/hooks/useBackHandler.js
|
||||
packages/app-mobile/utils/hooks/useDebounced.js
|
||||
packages/app-mobile/utils/hooks/useIsScreenReaderEnabled.js
|
||||
packages/app-mobile/utils/hooks/useKeyboardState.js
|
||||
packages/app-mobile/utils/hooks/useOnLongPressProps.js
|
||||
@@ -1031,6 +1035,8 @@ 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/replaceInlineHtml.test.js
|
||||
packages/editor/CodeMirror/extensions/rendering/replaceInlineHtml.js
|
||||
packages/editor/CodeMirror/extensions/rendering/types.js
|
||||
packages/editor/CodeMirror/extensions/rendering/utils/makeBlockReplaceExtension.js
|
||||
packages/editor/CodeMirror/extensions/rendering/utils/makeInlineReplaceExtension.js
|
||||
@@ -1071,6 +1077,7 @@ packages/editor/CodeMirror/utils/getSearchState.js
|
||||
packages/editor/CodeMirror/utils/growSelectionToNode.js
|
||||
packages/editor/CodeMirror/utils/handleLinkEditRequests.js
|
||||
packages/editor/CodeMirror/utils/handlePasteEvent.js
|
||||
packages/editor/CodeMirror/utils/htmlNodeInfo.js
|
||||
packages/editor/CodeMirror/utils/isCursorAtBeginning.js
|
||||
packages/editor/CodeMirror/utils/isInSyntaxNode.js
|
||||
packages/editor/CodeMirror/utils/markdown/codeBlockLanguages/allLanguages.js
|
||||
@@ -1086,6 +1093,7 @@ packages/editor/CodeMirror/vendor/announceSearchMatch.js
|
||||
packages/editor/ProseMirror/commands/commands.test.js
|
||||
packages/editor/ProseMirror/commands/commands.js
|
||||
packages/editor/ProseMirror/commands/focusEditor.js
|
||||
packages/editor/ProseMirror/commands/selectDocumentEnd.js
|
||||
packages/editor/ProseMirror/createEditor.js
|
||||
packages/editor/ProseMirror/index.js
|
||||
packages/editor/ProseMirror/plugins/detailsPlugin.test.js
|
||||
@@ -1118,6 +1126,7 @@ packages/editor/ProseMirror/types.js
|
||||
packages/editor/ProseMirror/utils/SelectableNodeView.js
|
||||
packages/editor/ProseMirror/utils/UndoStackSynchronizer.js
|
||||
packages/editor/ProseMirror/utils/canReplaceSelectionWith.js
|
||||
packages/editor/ProseMirror/utils/clampPointToDocument.js
|
||||
packages/editor/ProseMirror/utils/computeSelectionFormatting.js
|
||||
packages/editor/ProseMirror/utils/dom/createButton.js
|
||||
packages/editor/ProseMirror/utils/dom/createTextArea.js
|
||||
@@ -1196,6 +1205,7 @@ packages/lib/InMemoryCache.js
|
||||
packages/lib/JoplinDatabase.js
|
||||
packages/lib/JoplinError.js
|
||||
packages/lib/JoplinServerApi.js
|
||||
packages/lib/ObjectUtils.test.js
|
||||
packages/lib/ObjectUtils.js
|
||||
packages/lib/PerformanceLogger.test.js
|
||||
packages/lib/PerformanceLogger.js
|
||||
@@ -1772,6 +1782,7 @@ packages/renderer/MdToHtml/renderMedia.js
|
||||
packages/renderer/MdToHtml/rules/abc.js
|
||||
packages/renderer/MdToHtml/rules/checkbox.js
|
||||
packages/renderer/MdToHtml/rules/code_inline.js
|
||||
packages/renderer/MdToHtml/rules/externalEmbed.js
|
||||
packages/renderer/MdToHtml/rules/fence.js
|
||||
packages/renderer/MdToHtml/rules/fountain.js
|
||||
packages/renderer/MdToHtml/rules/highlight_keywords.js
|
||||
@@ -1807,17 +1818,19 @@ packages/tools/checkIgnoredFiles.js
|
||||
packages/tools/checkLibPaths.test.js
|
||||
packages/tools/checkLibPaths.js
|
||||
packages/tools/convertThemesToCss.js
|
||||
packages/tools/fuzzer/ActionRunner.js
|
||||
packages/tools/fuzzer/ActionTracker.js
|
||||
packages/tools/fuzzer/Client.js
|
||||
packages/tools/fuzzer/ClientPool.js
|
||||
packages/tools/fuzzer/Server.js
|
||||
packages/tools/fuzzer/constants.js
|
||||
packages/tools/fuzzer/doRandomAction.js
|
||||
packages/tools/fuzzer/model/FolderRecord.js
|
||||
packages/tools/fuzzer/sync-fuzzer.js
|
||||
packages/tools/fuzzer/types.js
|
||||
packages/tools/fuzzer/utils/ProgressBar.js
|
||||
packages/tools/fuzzer/utils/SeededRandom.js
|
||||
packages/tools/fuzzer/utils/diffSortedStringArrays.test.js
|
||||
packages/tools/fuzzer/utils/diffSortedStringArrays.js
|
||||
packages/tools/fuzzer/utils/getNumberProperty.js
|
||||
packages/tools/fuzzer/utils/getProperty.js
|
||||
packages/tools/fuzzer/utils/getStringProperty.js
|
||||
|
||||
BIN
Assets/WebsiteAssets/images/news/20260111-abc.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
Assets/WebsiteAssets/images/news/20260111-lowercase-tags.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
Assets/WebsiteAssets/images/news/20260111-mobile-tags.png
Normal file
|
After Width: | Height: | Size: 101 KiB |
BIN
Assets/WebsiteAssets/images/news/20260111-multi-select.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
Assets/WebsiteAssets/images/news/20260111-profiles.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
Assets/WebsiteAssets/images/news/20260111-rte1.png
Normal file
|
After Width: | Height: | Size: 120 KiB |
BIN
Assets/WebsiteAssets/images/news/20260111-rte2.png
Normal file
|
After Width: | Height: | Size: 111 KiB |
@@ -1,4 +1,47 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Joplin]]></title><description><![CDATA[Joplin, the open source note-taking application]]></description><link>https://joplinapp.org</link><generator>RSS for Node</generator><lastBuildDate>Mon, 22 Sep 2025 00:00:00 GMT</lastBuildDate><atom:link href="https://joplinapp.org/rss.xml" rel="self" type="application/rss+xml"/><pubDate>Mon, 22 Sep 2025 00:00:00 GMT</pubDate><item><title><![CDATA[What's new in Joplin 3.4]]></title><description><![CDATA[<p>Joplin 3.4 includes many bug fixes and improvements, with a focus on the mobile app.</p>
|
||||
<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Joplin]]></title><description><![CDATA[Joplin, the open source note-taking application]]></description><link>https://joplinapp.org</link><generator>RSS for Node</generator><lastBuildDate>Sun, 11 Jan 2026 00:00:00 GMT</lastBuildDate><atom:link href="https://joplinapp.org/rss.xml" rel="self" type="application/rss+xml"/><pubDate>Sun, 11 Jan 2026 00:00:00 GMT</pubDate><item><title><![CDATA[What's new in Joplin 3.5]]></title><description><![CDATA[<h2>Improvements across desktop and mobile<a name="improvements-across-desktop-and-mobile" href="#improvements-across-desktop-and-mobile" class="heading-anchor">🔗</a></h2>
|
||||
<h3>More stable and consistent Markdown editing<a name="more-stable-and-consistent-markdown-editing" href="#more-stable-and-consistent-markdown-editing" class="heading-anchor">🔗</a></h3>
|
||||
<p>The Markdown editor has been refined to feel more stable and closer to the final rendered view. Headings in the editor now more closely match how they appear when viewing a note, reducing the visual jump between editing and reading. Layout issues have also been addressed so elements like rendered checkboxes and images no longer cause the editor to shift unexpectedly while typing.</p>
|
||||
<p>The ABC music notation plugin appeared to be popular but had some limitations. With this new version, ABC is now part of the app, which means it can now work from published notes, and from the Rich Text editor!</p>
|
||||
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20260111-abc.png" alt="ABC music notation rendered directly in Joplin, showing a short musical phrase displayed from plain-text ABC syntax"></p>
|
||||
<h3>Smoother switching between notes<a name="smoother-switching-between-notes" href="#smoother-switching-between-notes" class="heading-anchor">🔗</a></h3>
|
||||
<p>Switching between notes is now less disruptive. Joplin restores cursor position and scroll location more reliably, making it easier to move back and forth between notes—especially when working with longer documents or comparing content—without losing your place.</p>
|
||||
<h3>Case insensitive tags<a name="case-insensitive-tags" href="#case-insensitive-tags" class="heading-anchor">🔗</a></h3>
|
||||
<p>Tags are now treated in a case-insensitive way, which helps prevent duplicate tags caused by differences in capitalisation, while still allowing mixed-case tag names. All this time we were hoping that @dpoulton <a href="https://discourse.joplinapp.org/t/tags-lower-case-only/4220/106">would just get used to lowercase tags</a>, but 5 years later it looks like it's not happening ;) So thank you @mrjo118 for implementing it!</p>
|
||||
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20260111-lowercase-tags.png" alt="Joplin tag list demonstrating case-insensitive tags, with mixed-case tag names merged into a single tag."></p>
|
||||
<h3>More reliable syncing and sharing<a name="more-reliable-syncing-and-sharing" href="#more-reliable-syncing-and-sharing" class="heading-anchor">🔗</a></h3>
|
||||
<p>Syncing and sharing have been made more robust in everyday use. Joplin now handles repeated syncs more efficiently, avoids unnecessary data usage, and is better at detecting and syncing all changes, particularly when using WebDAV and S3 sync targets.</p>
|
||||
<p>Moreover filesystem synchronisation is now more reliable, in particular when used alongside tools like SyncThing on both mobile and desktop.</p>
|
||||
<h3>Accessibility and readability improvements<a name="accessibility-and-readability-improvements" href="#accessibility-and-readability-improvements" class="heading-anchor">🔗</a></h3>
|
||||
<p>Accessibility has seen further refinements in this release. Dark mode readability has been improved, common editor elements are clearer, and animations are reduced or disabled when system “reduce motion” settings are enabled, making the app more comfortable to use for a wider range of users. Keyboard navigation has also been improved on the desktop application.</p>
|
||||
<h2>Desktop-specific improvements<a name="desktop-specific-improvements" href="#desktop-specific-improvements" class="heading-anchor">🔗</a></h2>
|
||||
<h3>Easier profile management<a name="easier-profile-management" href="#easier-profile-management" class="heading-anchor">🔗</a></h3>
|
||||
<p>Managing multiple profiles on desktop is now simpler thanks to a new, more user-friendly profile management interface. This removes the need to manually edit configuration files and makes switching between different setups easier and safer.</p>
|
||||
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20260111-profiles.png" alt="Desktop profile management screen in Joplin showing multiple profiles with options to rename or delete them."></p>
|
||||
<h3>Significantly improved OneNote import<a name="significantly-improved-onenote-import" href="#significantly-improved-onenote-import" class="heading-anchor">🔗</a></h3>
|
||||
<p>Importing content from OneNote is now more reliable and accurate. Support has been expanded to cover more OneNote file formats, and many edge cases have been addressed so imported notes more closely match their original structure and content. This makes migrating from OneNote to Joplin smoother and more trustworthy.</p>
|
||||
<h3>Better tools for organising large note collections<a name="better-tools-for-organising-large-note-collections" href="#better-tools-for-organising-large-note-collections" class="heading-anchor">🔗</a></h3>
|
||||
<p>Desktop users can now select multiple notebooks at once, making it easier to reorganise notebook structures, move groups of notes, or clean up larger collections without working notebook by notebook.</p>
|
||||
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20260111-multi-select.png" alt="Joplin desktop sidebar with several notebooks selected at the same time for bulk organisation."></p>
|
||||
<h3>Polished editing experience on desktop<a name="polished-editing-experience-on-desktop" href="#polished-editing-experience-on-desktop" class="heading-anchor">🔗</a></h3>
|
||||
<p>Both the Markdown and Rich Text editors have been further refined. Cursor behaviour is more predictable, visual consistency between editing and viewing has improved, and several layout and rendering issues have been fixed to reduce interruptions while writing.</p>
|
||||
<h3>More reliable search and navigation<a name="more-reliable-search-and-navigation" href="#more-reliable-search-and-navigation" class="heading-anchor">🔗</a></h3>
|
||||
<p>Search and navigation on desktop have been improved with fixes that ensure search results behave consistently and remain visible when moving between windows or views.</p>
|
||||
<h3>Improved math support in WebClipper<a name="improved-math-support-in-webclipper" href="#improved-math-support-in-webclipper" class="heading-anchor">🔗</a></h3>
|
||||
<p>The WebClipper is not forgotten in this release - clipping certain math formulas, in particular from Wikipedia but also other websites, has been improved. Additionally, certain scientific articles are now also better handled by the WebClipper.</p>
|
||||
<h2>Mobile-specific improvements<a name="mobile-specific-improvements" href="#mobile-specific-improvements" class="heading-anchor">🔗</a></h2>
|
||||
<h3>A more powerful Rich Text Editor on mobile<a name="a-more-powerful-rich-text-editor-on-mobile" href="#a-more-powerful-rich-text-editor-on-mobile" class="heading-anchor">🔗</a></h3>
|
||||
<p>The mobile Rich Text Editor continues to improve, with new and expanded support for tables, code blocks, and other structured content. These changes make it easier to create and edit more complex notes directly on mobile devices.</p>
|
||||
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20260111-rte1.png" alt="Joplin mobile Rich Text Editor showing table editing controls and an embedded code block inside a note."></p>
|
||||
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20260111-rte2.png" alt="Mobile code block editor in Joplin with a Python code snippet displayed in an editable dialog."></p>
|
||||
<h3>Easier tag management on mobile<a name="easier-tag-management-on-mobile" href="#easier-tag-management-on-mobile" class="heading-anchor">🔗</a></h3>
|
||||
<p>Managing tags on mobile is now more practical. You can rename and delete tags directly from the app, and searching through tags is easier, helping keep large tag lists organised over time.</p>
|
||||
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20260111-mobile-tags.png" alt="Joplin mobile tag management screen showing a tag options menu with rename and delete actions."></p>
|
||||
<h3>Improved stability and usability on mobile devices<a name="improved-stability-and-usability-on-mobile-devices" href="#improved-stability-and-usability-on-mobile-devices" class="heading-anchor">🔗</a></h3>
|
||||
<p>Several fixes improve overall stability and usability on mobile, particularly on smaller screens. Issues causing UI elements to appear off-screen have been addressed, and the app behaves more consistently in situations that previously caused hangs or visual glitches.</p>
|
||||
<h2>Bug fixes and security fixes across platforms<a name="bug-fixes-and-security-fixes-across-platforms" href="#bug-fixes-and-security-fixes-across-platforms" class="heading-anchor">🔗</a></h2>
|
||||
<h3>A large number of stability, correctness and security fixes<a name="a-large-number-of-stability-correctness-and-security-fixes" href="#a-large-number-of-stability-correctness-and-security-fixes" class="heading-anchor">🔗</a></h3>
|
||||
<p>Joplin 3.5 includes about 114 bug fixes across desktop and mobile, addressing issues in editing, syncing, importing, rendering, and general stability. Many fixes target edge cases that could lead to crashes, inconsistent behaviour, or rare data loss scenarios. Moreover, this version includes several vulnerability fixes to make the applications more secure.</p>
|
||||
]]></description><link>https://joplinapp.org/news/20260111-release-3-5</link><guid isPermaLink="false">20260111-release-3-5</guid><pubDate>Sun, 11 Jan 2026 00:00:00 GMT</pubDate><twitter-text></twitter-text></item><item><title><![CDATA[What's new in Joplin 3.4]]></title><description><![CDATA[<p>Joplin 3.4 includes many bug fixes and improvements, with a focus on the mobile app.</p>
|
||||
<h2>Mobile<a name="mobile" href="#mobile" class="heading-anchor">🔗</a></h2>
|
||||
<h3>Rich Text Editor<a name="rich-text-editor" href="#rich-text-editor" class="heading-anchor">🔗</a></h3>
|
||||
<p>The mobile app now includes a beta <a href="https://joplinapp.org/help/apps/rich_text_editor">Rich Text Editor</a>! The new editor renders formatting/math/images within the editor:</p>
|
||||
@@ -481,42 +524,4 @@ sys 0m38.013s</p>
|
||||
<p>This is a bit of an extra constraint but it is hard to avoid. Contributor License Agreements are very common for GPL or AGPL projects. For example Apache, Canonical or Python all require their contributors to sign a CLA.</p>
|
||||
<h2>Questions?<a name="questions" href="#questions" class="heading-anchor">🔗</a></h2>
|
||||
<p>If you have any questions please let us know. Overall we believe this is a positive improvements for Joplin as it means any work derives from it will also benefit the project.</p>
|
||||
]]></description><link>https://joplinapp.org/news/20221221-agpl</link><guid isPermaLink="false">20221221-agpl</guid><pubDate>Wed, 21 Dec 2022 00:00:00 GMT</pubDate><twitter-text>Joplin is switching to the GNU Affero General Public License v3 (AGPL-3.0)</twitter-text></item><item><title><![CDATA[What's new in Joplin 2.9]]></title><description><![CDATA[<h2>Proxy support<a name="proxy-support" href="#proxy-support" class="heading-anchor">🔗</a></h2>
|
||||
<p>Both the desktop and mobile application now support proxies thanks to the work of Jason Williams. This will allow you to use the apps in particular when you are behind a company proxy.</p>
|
||||
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20221216-proxy-support.png" alt=""></p>
|
||||
<h2>New PDF viewer<a name="new-pdf-viewer" href="#new-pdf-viewer" class="heading-anchor">🔗</a></h2>
|
||||
<p>The desktop application now features a new PDF viewer thanks to the work of Asrient during GSoC.</p>
|
||||
<p>The main advantage for now is that this viewer preserves the last PDF page that was read. In the next version, the viewer will also include a way to annotate PDF files.</p>
|
||||
<h2>Multi-language spell checking<a name="multi-language-spell-checking" href="#multi-language-spell-checking" class="heading-anchor">🔗</a></h2>
|
||||
<p>The desktop app include a multi-language spell checking features, which allows you, for example, to spell-check notes in your native language and in English.</p>
|
||||
<h2>New mobile text editor<a name="new-mobile-text-editor" href="#new-mobile-text-editor" class="heading-anchor">🔗</a></h2>
|
||||
<p>Writing formatted notes on mobile has always been cumbersome due to the need to enter special format characters like <code>*</code> or <code>[</code>, etc.</p>
|
||||
<p>Thanks to the work of Henry Heino during GSoC, writing notes on the go is now easier thanks to an improved Markdown editor.</p>
|
||||
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20221216-mobile-beta-editor.png" alt=""></p>
|
||||
<p>The most visible feature is the addition of a toolbar, which helps input those special characters, like on desktop.</p>
|
||||
<p>Moreover Henry made a lot of subtle but useful improvements to the editor, for example to improve the note appearance, to improve list continuation, etc. Search within a note is now also supported as well as spell-checking.</p>
|
||||
<p>At a more technical level, Henry also added many test units to ensure that the editor remains robust and reliable.</p>
|
||||
<p>To enable the feature, go to the configuration screen and selected "Opt-in to the editor beta". It is already very stable so we will probably promote it to be the main editor from the next version.</p>
|
||||
<h2>Improved alignment of notebook icons<a name="improved-alignment-of-notebook-icons" href="#improved-alignment-of-notebook-icons" class="heading-anchor">🔗</a></h2>
|
||||
<p>Previously, when you would assign an icon to a notebook, it would shift the title to the right, but notebook without an icon would not. It means that notebooks with and without an icon would not be vertically aligned.</p>
|
||||
<p>To tidy things up, this new version adds a default icons to notebooks without an explicitly assigned icon. This result in the notebook titles being correctly vertically aligned.</p>
|
||||
<p>Note that this feature is only enabled if you use custom icons - otherwise it will simply display the notebook titles without any default icons, as before.</p>
|
||||
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20221216-notebook-icons.png" alt=""></p>
|
||||
<h2>Improved handling of file attachments<a name="improved-handling-of-file-attachments" href="#improved-handling-of-file-attachments" class="heading-anchor">🔗</a></h2>
|
||||
<p>Self Not Found made a number of small but useful improvements to attachment handling, including increasing the maximum size to 200MB, adding support for attaching multiple files, and fixing issues with synchronising attachments via proxy.</p>
|
||||
<h2>Fixed filesystem sync on mobile<a name="fixed-filesystem-sync-on-mobile" href="#fixed-filesystem-sync-on-mobile" class="heading-anchor">🔗</a></h2>
|
||||
<p>This was a long and complex change due to the need to support new Android APIs but hopefully that should now be working again, thanks to the work of jd1378.</p>
|
||||
<p>So you can now sync again your notes with Syncthing and other file-based synchronisation systems.</p>
|
||||
<h2>And more...<a name="and-more" href="#and-more" class="heading-anchor">🔗</a></h2>
|
||||
<p>In total this new desktop version includes 36 improvements, bug fixes, and security fixes.</p>
|
||||
<p>As always, a lot of work went into the Android and iOS app too, which include 37 improvements, bug fixes, and security fixes.</p>
|
||||
<p>See here for the changelogs:</p>
|
||||
<ul>
|
||||
<li><a href="https://joplinapp.org/help/about/changelog/desktop">Desktop app changelog</a></li>
|
||||
<li><a href="https://joplinapp.org/help/about/changelog/android/">Android app changelog</a></li>
|
||||
</ul>
|
||||
<h2>About the Android version<a name="about-the-android-version" href="#about-the-android-version" class="heading-anchor">🔗</a></h2>
|
||||
<p>Unfortunately we cannot publish the Android version because it is based on a framework version that Google does not accept. To upgrade the app a lot of changes are needed and another round of pre-releases, and therefore there will not be a 2.9 version for Google Play. You may however download the official APK directly from there: <a href="https://github.com/laurent22/joplin-android/releases/tag/android-v2.9.8">Android 2.9 Official Release</a></p>
|
||||
<p>This is the reality of app stores in general - small developers being imposed never ending new requirements by all-powerful companies, and by the time a version is finally ready we can't even publish it because yet more requirements are in place.</p>
|
||||
<p>For the record the current 2.9 app works perfectly fine. It targets Android 11, which is only 2 years old and is still supported (and installed on millions of phones). Google requires us to target Android 12 which only came out last year.</p>
|
||||
]]></description><link>https://joplinapp.org/news/20221216-release-2-9</link><guid isPermaLink="false">20221216-release-2-9</guid><pubDate>Fri, 16 Dec 2022 00:00:00 GMT</pubDate><twitter-text>What's new in Joplin 2.9</twitter-text></item></channel></rss>
|
||||
]]></description><link>https://joplinapp.org/news/20221221-agpl</link><guid isPermaLink="false">20221221-agpl</guid><pubDate>Wed, 21 Dec 2022 00:00:00 GMT</pubDate><twitter-text>Joplin is switching to the GNU Affero General Public License v3 (AGPL-3.0)</twitter-text></item></channel></rss>
|
||||
@@ -11,11 +11,6 @@
|
||||
},
|
||||
"nodejs": "24.5.0",
|
||||
"pkg-config": "latest",
|
||||
"darwin.apple_sdk.frameworks.Foundation": { // satisfies missing CoreText/CoreText.h
|
||||
// https://github.com/NixOS/nixpkgs/blob/master/pkgs/os-specific/darwin/apple-sdk/default.nix
|
||||
"version": "",
|
||||
"platforms": ["aarch64-darwin", "x86_64-darwin"],
|
||||
},
|
||||
"python": "3.13.3",
|
||||
"bat": "latest",
|
||||
"electron": {
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
services:
|
||||
|
||||
postgresql-master:
|
||||
image: 'bitnamilegacy/postgresql:17.5.0'
|
||||
image: 'bitnamilegacy/postgresql:17.6.0'
|
||||
ports:
|
||||
- '5432:5432'
|
||||
environment:
|
||||
@@ -36,7 +36,7 @@ services:
|
||||
- POSTGRESQL_EXTRA_FLAGS=-c work_mem=100000 -c log_statement=all
|
||||
|
||||
postgresql-slave:
|
||||
image: 'bitnamilegacy/postgresql:17.5.0'
|
||||
image: 'bitnamilegacy/postgresql:17.6.0'
|
||||
ports:
|
||||
- '5433:5432'
|
||||
depends_on:
|
||||
|
||||
6
jest.config.base.js
Normal file
@@ -0,0 +1,6 @@
|
||||
// This is the base Jest configuration - all
|
||||
// jest.config.js files should inherit from it.
|
||||
|
||||
module.exports = {
|
||||
watchman: false,
|
||||
};
|
||||
@@ -16,6 +16,7 @@
|
||||
"./packages/app-cli/**/*.mo": true,
|
||||
"./packages/app-cli/**/build/": true,
|
||||
"./packages/app-cli/**/config.json": true,
|
||||
"**/.watchman-cookie-*": true,
|
||||
"./packages/app-cli/**/linkToLocal.sh": true,
|
||||
"./packages/app-cli/**/node_modules/": true,
|
||||
"./packages/app-cli/**/out.txt": true,
|
||||
|
||||
49
packages/app-cli/app/command-keymap.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import BaseCommand from './base-command';
|
||||
import app from './app';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
const { cliUtils } = require('./cli-utils.js');
|
||||
|
||||
interface Args { }
|
||||
|
||||
class Command extends BaseCommand {
|
||||
public override usage() {
|
||||
return 'keymap';
|
||||
}
|
||||
|
||||
public override description() {
|
||||
return _('Displays the configured keyboard shortcuts.');
|
||||
}
|
||||
|
||||
public override compatibleUis() {
|
||||
return ['cli', 'gui'];
|
||||
}
|
||||
|
||||
public override async action(_args: Args) {
|
||||
const keymaps = await app().loadKeymaps();
|
||||
|
||||
this.stdout(_('Configured keyboard shortcuts:'));
|
||||
this.stdout('\n');
|
||||
|
||||
const rows = [];
|
||||
const padding = ' ';
|
||||
|
||||
rows.push([`${padding}${_('KEYS')}`, _('TYPE'), _('COMMAND')]);
|
||||
rows.push([`${padding}----`, '----', '-------']);
|
||||
|
||||
for (const item of keymaps) {
|
||||
const formattedKeys = item.keys
|
||||
.map((k: string) => (k === ' ' ? `(${_('SPACE')})` : k))
|
||||
.join(', ');
|
||||
rows.push([padding + formattedKeys, item.type, item.command]);
|
||||
}
|
||||
|
||||
cliUtils.printArray(this.stdout.bind(this), rows);
|
||||
|
||||
if (app().gui() && !app().gui().isDummy()) {
|
||||
app().gui().showConsole();
|
||||
app().gui().maximizeConsole();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Command;
|
||||
@@ -24,9 +24,21 @@
|
||||
// 4. Remove tests one by one to narrow it down to the one with the async
|
||||
// call that's causing problem.
|
||||
|
||||
const baseConfig = require('../../jest.config.base.js');
|
||||
|
||||
module.exports = {
|
||||
...baseConfig,
|
||||
testMatch: [
|
||||
'**/tests/**/*.js',
|
||||
'**/tests/HtmlToHtml.js',
|
||||
'**/tests/HtmlToMd.js',
|
||||
'**/tests/MarkupToHtml.js',
|
||||
'**/tests/MdToHtml.js',
|
||||
'**/tests/feature_NoteHistory.js',
|
||||
'**/tests/feature_NoteList.js',
|
||||
'**/tests/feature_ShowAllNotes.js',
|
||||
'**/tests/feature_TagList.js',
|
||||
|
||||
'**/tests/services/**/*.js',
|
||||
'**/*.test.js',
|
||||
],
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"scripts": {
|
||||
"test": "jest --verbose=false --config=jest.config.js --bail --forceExit",
|
||||
"test-one": "jest --verbose=false --config=jest.config.js --bail --forceExit",
|
||||
"test-ci": "jest --config=jest.config.js --forceExit",
|
||||
"test-ci": "jest --config=jest.config.js --forceExit --testPathIgnorePatterns=cli-integration-tests.test",
|
||||
"build": "gulp build",
|
||||
"start": "gulp build -L && node \"build/main.js\" --stack-trace-enabled --log-level debug --env dev",
|
||||
"start-no-build": "node \"build/main.js\" --stack-trace-enabled --log-level debug --env dev",
|
||||
@@ -35,15 +35,15 @@
|
||||
],
|
||||
"owner": "Laurent Cozic"
|
||||
},
|
||||
"version": "3.5.1",
|
||||
"version": "3.6.0",
|
||||
"bin": "./main.js",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@joplin/lib": "~3.5",
|
||||
"@joplin/renderer": "~3.5",
|
||||
"@joplin/utils": "~3.5",
|
||||
"@joplin/lib": "~3.6",
|
||||
"@joplin/renderer": "~3.6",
|
||||
"@joplin/utils": "~3.6",
|
||||
"aws-sdk": "2.1340.0",
|
||||
"chalk": "4.1.2",
|
||||
"compare-version": "0.1.2",
|
||||
@@ -70,7 +70,7 @@
|
||||
"yargs-parser": "21.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@joplin/tools": "~3.5",
|
||||
"@joplin/tools": "~3.6",
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/node": "18.19.130",
|
||||
|
||||
10
packages/app-cli/tests/md_to_html/abc.html
Normal file
@@ -0,0 +1,10 @@
|
||||
|
||||
<div class="joplin-editable joplin-abc-notation">
|
||||
<pre class="joplin-source" data-abc-options="{"responsive":"resize"}" data-joplin-language="abc" data-joplin-source-open="```abc " data-joplin-source-close=" ``` ">{responsive:'resize'}
|
||||
---
|
||||
K:F
|
||||
!f!(fgag-g2c2)|</pre>
|
||||
<pre class="joplin-rendered joplin-abc-notation-rendered">K:F
|
||||
!f!(fgag-g2c2)|</pre>
|
||||
</div>
|
||||
|
||||
6
packages/app-cli/tests/md_to_html/abc.md
Normal file
@@ -0,0 +1,6 @@
|
||||
```abc
|
||||
{ responsive: 'resize' }
|
||||
---
|
||||
K:F
|
||||
!f!(fgag-g2c2)|
|
||||
```
|
||||
8
packages/app-cli/tests/md_to_html/external_embed.html
Normal file
@@ -0,0 +1,8 @@
|
||||
|
||||
<div class="joplin-editable">
|
||||
<span class="joplin-source" data-joplin-source-open="" data-joplin-source-close="">https://www.youtube.com/watch?v=iJqe9pC-z-Y</span>
|
||||
<div class="joplin-youtube-player-rendered">
|
||||
<iframe src="https://www.youtube-nocookie.com/embed/iJqe9pC-z-Y" title="YouTube video player" frameborder="0" allowfullscreen></iframe>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
1
packages/app-cli/tests/md_to_html/external_embed.md
Normal file
@@ -0,0 +1 @@
|
||||
https://www.youtube.com/watch?v=iJqe9pC-z-Y
|
||||
BIN
packages/app-cli/tests/support/onenote/test.onepkg
Normal file
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Joplin Web Clipper [DEV]",
|
||||
"version": "3.5.0",
|
||||
"version": "3.6.0",
|
||||
"description": "Capture and save web pages and screenshots from your browser to Joplin.",
|
||||
"homepage_url": "https://joplinapp.org",
|
||||
"content_security_policy": {
|
||||
|
||||
@@ -260,6 +260,15 @@ export default class ElectronAppWrapper {
|
||||
|
||||
require('@electron/remote/main').enable(this.win_.webContents);
|
||||
|
||||
// Add Referer header for YouTube embeds to fix Error 153
|
||||
this.win_.webContents.session.webRequest.onBeforeSendHeaders(
|
||||
{ urls: ['*://*.youtube.com/*', '*://*.youtube-nocookie.com/*'] },
|
||||
(details, callback) => {
|
||||
details.requestHeaders['Referer'] = 'https://joplinapp.org/';
|
||||
callback({ requestHeaders: details.requestHeaders });
|
||||
},
|
||||
);
|
||||
|
||||
if (!screen.getDisplayMatching(this.win_.getBounds())) {
|
||||
const { width: windowWidth, height: windowHeight } = this.win_.getBounds();
|
||||
const { width: primaryDisplayWidth, height: primaryDisplayHeight } = screen.getPrimaryDisplay().workArea;
|
||||
|
||||
@@ -95,6 +95,9 @@ export default class InteropServiceHelper {
|
||||
// Allows users to override the CSS page size.
|
||||
// See https://github.com/laurent22/joplin/issues/13096
|
||||
preferCSSPageSize: true,
|
||||
|
||||
// Include accessibility information in the output:
|
||||
generateTaggedPDF: true,
|
||||
});
|
||||
resolve(data);
|
||||
} catch (error) {
|
||||
|
||||
@@ -742,7 +742,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
|
||||
'media-src \'self\' blob: data: *', // Audio and video players
|
||||
|
||||
// Disallow certain unused features
|
||||
'child-src \'none\'', // Should not contain sub-frames
|
||||
'child-src https://*.youtube.com https://*.youtube-nocookie.com', // Allow YouTube embeds
|
||||
'object-src \'none\'', // Objects can be used for script injection
|
||||
'form-action \'none\'', // No submitting forms
|
||||
|
||||
|
||||
@@ -22,4 +22,8 @@ export const joplinCommandToTinyMceCommands: JoplinCommandToTinyMceCommands = {
|
||||
'search': { name: 'SearchReplace' },
|
||||
'attachFile': { name: 'joplinAttach' },
|
||||
'insertDateTime': true,
|
||||
'textCopy': true,
|
||||
'textCut': true,
|
||||
'textPaste': true,
|
||||
'textSelectAll': true,
|
||||
};
|
||||
|
||||
@@ -17,19 +17,19 @@ describe('editorCommandDeclarations', () => {
|
||||
test.each([
|
||||
[
|
||||
{},
|
||||
true,
|
||||
{ textBold: true },
|
||||
],
|
||||
[
|
||||
{
|
||||
markdownEditorPaneVisible: false,
|
||||
},
|
||||
false,
|
||||
{ textBold: false },
|
||||
],
|
||||
[
|
||||
{
|
||||
noteIsReadOnly: true,
|
||||
},
|
||||
false,
|
||||
{ textBold: false },
|
||||
],
|
||||
[
|
||||
// In the Markdown editor, but only the viewer is visible
|
||||
@@ -37,7 +37,7 @@ describe('editorCommandDeclarations', () => {
|
||||
markdownEditorPaneVisible: false,
|
||||
richTextEditorVisible: false,
|
||||
},
|
||||
false,
|
||||
{ textBold: false },
|
||||
],
|
||||
[
|
||||
// In the Markdown editor, and the viewer is visible
|
||||
@@ -45,7 +45,7 @@ describe('editorCommandDeclarations', () => {
|
||||
markdownEditorPaneVisible: true,
|
||||
richTextEditorVisible: false,
|
||||
},
|
||||
true,
|
||||
{ textBold: true },
|
||||
],
|
||||
[
|
||||
// In the RT editor
|
||||
@@ -53,7 +53,7 @@ describe('editorCommandDeclarations', () => {
|
||||
markdownEditorPaneVisible: false,
|
||||
richTextEditorVisible: true,
|
||||
},
|
||||
true,
|
||||
{ textBold: true },
|
||||
],
|
||||
[
|
||||
// In the Markdown editor, and the command palette is visible
|
||||
@@ -63,14 +63,57 @@ describe('editorCommandDeclarations', () => {
|
||||
gotoAnythingVisible: true,
|
||||
modalDialogVisible: true,
|
||||
},
|
||||
true,
|
||||
{ textBold: true },
|
||||
],
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
])('should create the enabledCondition', (context: Record<string, any>, expected: boolean) => {
|
||||
const condition = enabledCondition('textBold');
|
||||
const wc = new WhenClause(condition);
|
||||
const actual = wc.evaluate({ ...baseContext, ...context });
|
||||
expect(actual).toBe(expected);
|
||||
[
|
||||
// In the Markdown editor, and the command palette is visible
|
||||
{
|
||||
markdownEditorPaneVisible: true,
|
||||
richTextEditorVisible: false,
|
||||
gotoAnythingVisible: true,
|
||||
modalDialogVisible: true,
|
||||
},
|
||||
{ textBold: true },
|
||||
],
|
||||
[
|
||||
// Rich Text Editor, HTML note
|
||||
{
|
||||
markdownEditorPaneVisible: false,
|
||||
richTextEditorVisible: true,
|
||||
noteIsMarkdown: false,
|
||||
},
|
||||
{
|
||||
textCopy: true,
|
||||
textPaste: true,
|
||||
textSelectAll: true,
|
||||
},
|
||||
],
|
||||
[
|
||||
// Rich Text Editor, read-only note
|
||||
{
|
||||
markdownEditorPaneVisible: false,
|
||||
richTextEditorVisible: true,
|
||||
noteIsReadOnly: true,
|
||||
},
|
||||
{
|
||||
textBold: false,
|
||||
textPaste: false,
|
||||
|
||||
// TODO: textCopy should be enabled in read-only notes:
|
||||
// textCopy: false,
|
||||
},
|
||||
],
|
||||
])('should correctly determine whether command is enabled (case %#)', (context, expectedStates) => {
|
||||
const actualStates = [];
|
||||
for (const commandName of Object.keys(expectedStates)) {
|
||||
const condition = enabledCondition(commandName);
|
||||
const wc = new WhenClause(condition);
|
||||
const actual = wc.evaluate({ ...baseContext, ...context });
|
||||
actualStates.push([commandName, actual]);
|
||||
}
|
||||
|
||||
const expectedStatesArray = Object.entries(expectedStates);
|
||||
expect(actualStates).toEqual(expectedStatesArray);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -4,6 +4,10 @@ import { joplinCommandToTinyMceCommands } from './NoteBody/TinyMCE/utils/joplinC
|
||||
|
||||
const workWithHtmlNotes = [
|
||||
'attachFile',
|
||||
'textCopy',
|
||||
'textCut',
|
||||
'textPaste',
|
||||
'textSelectAll',
|
||||
];
|
||||
|
||||
export const enabledCondition = (commandName: string) => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { RefObject, Dispatch, SetStateAction, useEffect } from 'react';
|
||||
import { WindowCommandDependencies, NoteBodyEditorRef, OnChangeEvent, ScrollOptionTypes } from './types';
|
||||
import editorCommandDeclarations, { enabledCondition } from '../editorCommandDeclarations';
|
||||
import CommandService, { CommandDeclaration, CommandRuntime, CommandContext, RegisteredRuntime } from '@joplin/lib/services/CommandService';
|
||||
import time from '@joplin/lib/time';
|
||||
import { formatMsToLocal } from '@joplin/utils/time';
|
||||
import { reg } from '@joplin/lib/registry';
|
||||
import getWindowCommandPriority from './getWindowCommandPriority';
|
||||
|
||||
@@ -50,7 +50,7 @@ function editorCommandRuntime(
|
||||
if (declaration.name === 'insertDateTime') {
|
||||
return editorRef.current.execCommand({
|
||||
name: 'insertText',
|
||||
value: time.formatMsToLocal(new Date().getTime()),
|
||||
value: formatMsToLocal(Date.now()),
|
||||
});
|
||||
} else if (declaration.name === 'scrollToHash') {
|
||||
return editorRef.current.scrollTo({
|
||||
|
||||
@@ -51,9 +51,11 @@ const getParentOffset = (childIndex: number, listItems: ListItem[]): number|null
|
||||
};
|
||||
|
||||
const findNextTypeAheadMatch = (selectedIndex: number, query: string, listItems: ListItem[]) => {
|
||||
const normalize = (text: string) => text.trim().toLowerCase();
|
||||
const matches = (item: ListItem) => {
|
||||
return item.label.startsWith(query);
|
||||
return normalize(item.label).startsWith(normalize(query));
|
||||
};
|
||||
|
||||
const indexBefore = listItems.slice(0, selectedIndex).findIndex(matches);
|
||||
// Search in all results **after** the current. This prevents the current item from
|
||||
// always being identified as the next match, if the user repeatedly presses the
|
||||
|
||||
@@ -123,8 +123,8 @@ const ToolbarBaseComponent: React.FC<Props> = props => {
|
||||
};
|
||||
|
||||
const tabIndex = indexInFocusable === (selectedIndex % focusableItems.length) ? 0 : -1;
|
||||
const setButtonRefCallback = (button: HTMLButtonElement) => {
|
||||
if (tabIndex === 0 && containerHasFocus) {
|
||||
const setButtonRefCallback = (button: HTMLButtonElement | null) => {
|
||||
if (button && tabIndex === 0 && containerHasFocus) {
|
||||
focus('ToolbarBase', button);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -124,8 +124,7 @@ export const runtime = (control: WindowControl): CommandRuntime => {
|
||||
|
||||
void CommandService.instance().execute('showModalMessage', `${modalMessage}\n\n${statusStrings.join('\n')}`);
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
onError: (error: any) => {
|
||||
onError: (error: string|Error) => {
|
||||
errors.push(error);
|
||||
console.warn(error);
|
||||
},
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
default-src 'self' joplin-content://* ;
|
||||
connect-src 'self' * http://* https://* joplin-content://* blob: ;
|
||||
style-src 'unsafe-inline' 'self' blob: joplin-content://* https://* http://* ;
|
||||
child-src 'self' joplin-content://* ;
|
||||
child-src 'self' joplin-content://* https://*.youtube.com https://*.youtube-nocookie.com ;
|
||||
script-src 'self' 'unsafe-inline' joplin-content://* ;
|
||||
media-src 'self' * blob: data: https://* http://* joplin-content://* ;
|
||||
img-src 'self' blob: data: http://* https://* joplin-content://* ;
|
||||
|
||||
@@ -64,6 +64,10 @@ test.describe('sidebar', () => {
|
||||
await expect(mainWindow.locator(':focus')).toHaveText('Folder b');
|
||||
await mainWindow.keyboard.type('A');
|
||||
await expect(mainWindow.locator(':focus')).toHaveText('All notes');
|
||||
|
||||
// Should be case-insensitive
|
||||
await mainWindow.keyboard.type('f');
|
||||
await expect(mainWindow.locator(':focus')).toHaveText('Folder b');
|
||||
});
|
||||
|
||||
test('left/right arrow keys should expand/collapse notebooks', async ({ electronApp, mainWindow }) => {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
// For a detailed explanation regarding each configuration property, visit:
|
||||
// https://jestjs.io/docs/en/configuration.html
|
||||
|
||||
const baseConfig = require('../../jest.config.base.js');
|
||||
|
||||
module.exports = {
|
||||
...baseConfig,
|
||||
// All imported modules in your tests should be mocked automatically
|
||||
// automock: false,
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/app-desktop",
|
||||
"version": "3.5.9",
|
||||
"version": "3.6.2",
|
||||
"description": "Joplin for Desktop",
|
||||
"main": "main.bundle.js",
|
||||
"private": true,
|
||||
@@ -92,6 +92,12 @@
|
||||
"x64"
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "pkg",
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "zip",
|
||||
"arch": [
|
||||
@@ -139,19 +145,19 @@
|
||||
"@electron/rebuild": "3.7.2",
|
||||
"@fortawesome/fontawesome-free": "5.15.4",
|
||||
"@joeattardi/emoji-button": "4.6.4",
|
||||
"@joplin/default-plugins": "~3.5",
|
||||
"@joplin/editor": "~3.5",
|
||||
"@joplin/lib": "~3.5",
|
||||
"@joplin/renderer": "~3.5",
|
||||
"@joplin/tools": "~3.5",
|
||||
"@joplin/utils": "~3.5",
|
||||
"@playwright/test": "1.55.0",
|
||||
"@joplin/default-plugins": "~3.6",
|
||||
"@joplin/editor": "~3.6",
|
||||
"@joplin/lib": "~3.6",
|
||||
"@joplin/renderer": "~3.6",
|
||||
"@joplin/tools": "~3.6",
|
||||
"@joplin/utils": "~3.6",
|
||||
"@playwright/test": "1.55.1",
|
||||
"@sentry/electron": "4.24.0",
|
||||
"@testing-library/react-hooks": "8.0.1",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/mustache": "4.2.6",
|
||||
"@types/node": "18.19.130",
|
||||
"@types/react": "18.3.23",
|
||||
"@types/react": "18.3.26",
|
||||
"@types/react-dom": "18.3.7",
|
||||
"@types/react-redux": "7.1.33",
|
||||
"@types/styled-components": "5.1.32",
|
||||
@@ -208,7 +214,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@electron/remote": "2.1.3",
|
||||
"@joplin/onenote-converter": "~3.5",
|
||||
"@joplin/onenote-converter": "~3.6",
|
||||
"fs-extra": "11.3.2",
|
||||
"keytar": "7.9.0",
|
||||
"node-fetch": "2.6.7",
|
||||
|
||||
@@ -140,7 +140,10 @@ export default class AutoUpdaterService implements AutoUpdaterServiceInterface {
|
||||
// electron's autoUpdater appends automatically the platform's yml file to the link so we should remove it
|
||||
assetUrl = assetUrl.substring(0, assetUrl.lastIndexOf('/'));
|
||||
autoUpdater.setFeedURL({ provider: 'generic', url: assetUrl });
|
||||
await autoUpdater.checkForUpdates();
|
||||
const result = await autoUpdater.checkForUpdates();
|
||||
|
||||
// Wait for the installation to finish. By default, .checkForUpdates runs in the background
|
||||
await result.downloadPromise;
|
||||
} catch (error) {
|
||||
this.logger_.error(`Update download url failed: ${error.message}`);
|
||||
this.isUpdateInProgress = false;
|
||||
|
||||
@@ -130,6 +130,12 @@ const makeAccessDeniedResponse = (message: string) => {
|
||||
});
|
||||
};
|
||||
|
||||
const makeNotFoundResponse = () => {
|
||||
return new Response('not found', {
|
||||
status: 404,
|
||||
});
|
||||
};
|
||||
|
||||
// Creating a custom protocol allows us to isolate iframes by giving them
|
||||
// different domain names from the main Joplin app.
|
||||
//
|
||||
@@ -210,10 +216,24 @@ const handleCustomProtocols = (): CustomProtocolHandler => {
|
||||
|
||||
const rangeHeader = request.headers.get('Range');
|
||||
let response;
|
||||
if (!rangeHeader) {
|
||||
response = await net.fetch(asFileUrl);
|
||||
} else {
|
||||
response = await handleRangeRequest(request, pathname);
|
||||
try {
|
||||
if (!rangeHeader) {
|
||||
response = await net.fetch(asFileUrl);
|
||||
} else {
|
||||
response = await handleRangeRequest(request, pathname);
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
// Errors from NodeJS fs methods (e.g. fs.stat()
|
||||
error.code === 'ENOENT'
|
||||
// Errors from Electron's net.fetch(). Use error.message since these errors don't
|
||||
// seem to have a specific .code or .name.
|
||||
|| error.message === 'net::ERR_FILE_NOT_FOUND'
|
||||
) {
|
||||
response = makeNotFoundResponse();
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (mediaOnly) {
|
||||
|
||||
@@ -89,8 +89,8 @@ android {
|
||||
applicationId "net.cozic.joplin"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 2097783
|
||||
versionName "3.5.3"
|
||||
versionCode 2097788
|
||||
versionName "3.6.0"
|
||||
ndk {
|
||||
abiFilters "armeabi-v7a", "x86", "arm64-v8a", "x86_64"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { ForwardedRef, forwardRef, useCallback, useEffect, useImperativeHandle,
|
||||
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
|
||||
import { CameraRef, Props } from './types';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { Platform } from 'react-native';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
|
||||
const logger = Logger.create('Camera/expo');
|
||||
@@ -66,7 +65,9 @@ const Camera = (props: Props, ref: ForwardedRef<CameraRef>) => {
|
||||
// iOS issue workaround: Since upgrading to Expo SDK 52, closing and reopening the camera on iOS
|
||||
// never emits onCameraReady. As a workaround, call .resumePreview and wait for it to resolve,
|
||||
// rather than relying on the CameraView's onCameraReady prop.
|
||||
if (Platform.OS === 'ios' && camera) {
|
||||
//
|
||||
// Update 12/23/2025: This also happens on certain Android devices.
|
||||
if (camera) {
|
||||
// Work around an issue on iOS where the onCameraReady callback is never called.
|
||||
// Instead, wait for the preview to start using resumePreview:
|
||||
await camera.resumePreview();
|
||||
|
||||
@@ -85,7 +85,7 @@ const useStyles = ({ themeId, style, cameraRatio }: UseStyleProps) => {
|
||||
}, [themeId, style, outputPositioning]);
|
||||
};
|
||||
|
||||
const androidRatios = ['1:1', '4:3', '16:9'];
|
||||
const androidRatios = ['4:3', '16:9'];
|
||||
const iOSRatios: string[] = [];
|
||||
const useAvailableRatios = (): string[] => {
|
||||
return Platform.OS === 'android' ? androidRatios : iOSRatios;
|
||||
|
||||
@@ -67,6 +67,10 @@ const useStyles = (themeId: number, containerStyle: ViewStyle, size: DialogVaria
|
||||
alignSelf: 'center',
|
||||
},
|
||||
heading: {
|
||||
// Without flexShrink/flexGrow, the heading can push the close button
|
||||
// outside of the dialog.
|
||||
flexShrink: 1,
|
||||
flexGrow: 1,
|
||||
},
|
||||
modalBackground: {
|
||||
justifyContent: 'center',
|
||||
|
||||
@@ -27,7 +27,7 @@ interface WrapperProps {
|
||||
noteBody: string;
|
||||
highlightedKeywords?: string[];
|
||||
noteResources?: Record<string, ResourceInfo>;
|
||||
onScroll?: (percent: number)=> void;
|
||||
onScroll?: ()=> void;
|
||||
onMarkForDownload?: OnMarkForDownloadCallback;
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ const WrappedNoteViewer: React.FC<WrapperProps> = (
|
||||
highlightedKeywords={highlightedKeywords}
|
||||
noteResources={noteResources}
|
||||
paddingBottom={0}
|
||||
initialScroll={0}
|
||||
initialScrollPercent={0}
|
||||
noteHash={''}
|
||||
onMarkForDownload={onMarkForDownload}
|
||||
onScroll={onScroll}
|
||||
|
||||
@@ -15,6 +15,7 @@ import CommandService from '@joplin/lib/services/CommandService';
|
||||
import { AppState } from '../../utils/types';
|
||||
import { connect } from 'react-redux';
|
||||
import useWebViewSetup from '../../contentScripts/rendererBundle/useWebViewSetup';
|
||||
import { OnScrollCallback } from '../../contentScripts/rendererBundle/types';
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
@@ -25,12 +26,12 @@ interface Props {
|
||||
highlightedKeywords: string[];
|
||||
noteResources: Record<string, ResourceInfo>;
|
||||
paddingBottom: number;
|
||||
initialScroll: number|null;
|
||||
initialScrollPercent: number|null;
|
||||
noteHash: string;
|
||||
onCheckboxChange?: HandleMessageCallback;
|
||||
onRequestEditResource?: HandleMessageCallback;
|
||||
onMarkForDownload?: OnMarkForDownloadCallback;
|
||||
onScroll: (scrollTop: number)=> void;
|
||||
onScroll: OnScrollCallback;
|
||||
onLoadEnd?: ()=> void;
|
||||
pluginStates: PluginStates;
|
||||
}
|
||||
@@ -46,9 +47,7 @@ const onJoplinLinkClick = async (message: string) => {
|
||||
function NoteBodyViewer(props: Props) {
|
||||
const webviewRef = useRef<WebViewControl>(null);
|
||||
|
||||
const onScroll = useCallback(async (scrollTop: number) => {
|
||||
props.onScroll(scrollTop);
|
||||
}, [props.onScroll]);
|
||||
const onScroll = props.onScroll;
|
||||
|
||||
const onResourceLongPress = useOnResourceLongPress(
|
||||
{
|
||||
@@ -82,7 +81,7 @@ function NoteBodyViewer(props: Props) {
|
||||
highlightedKeywords: props.highlightedKeywords,
|
||||
noteResources: props.noteResources,
|
||||
noteHash: props.noteHash,
|
||||
initialScroll: props.initialScroll,
|
||||
initialScrollPercent: props.initialScrollPercent,
|
||||
|
||||
paddingBottom: props.paddingBottom,
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ interface Props {
|
||||
highlightedKeywords: string[];
|
||||
noteResources: Record<string, ResourceInfo>;
|
||||
noteHash: string;
|
||||
initialScroll: number|undefined;
|
||||
initialScrollPercent: number|undefined;
|
||||
|
||||
paddingBottom: number;
|
||||
}
|
||||
@@ -136,7 +136,7 @@ const useRerenderHandler = (props: Props) => {
|
||||
|
||||
// If the hash changed, we don't set initial scroll -- we want to scroll to the hash
|
||||
// instead.
|
||||
initialScroll: (previousHash && hashChanged) ? undefined : props.initialScroll,
|
||||
initialScrollPercent: (previousHash && hashChanged) ? undefined : props.initialScrollPercent,
|
||||
noteHash: props.noteHash,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { OnMessageEvent } from '../ExtendedWebView/types';
|
||||
|
||||
export type OnScrollCallback = (scrollTop: number)=> void;
|
||||
export { OnScrollCallback } from '../../contentScripts/rendererBundle/types';
|
||||
export type OnWebViewMessageHandler = (event: OnMessageEvent)=> void;
|
||||
|
||||
@@ -28,13 +28,16 @@ const defaultEditorProps = {
|
||||
globalSearch: '',
|
||||
noteId: '',
|
||||
noteHash: '',
|
||||
initialScroll: 0,
|
||||
style: {},
|
||||
toolbarEnabled: true,
|
||||
readOnly: false,
|
||||
onChange: ()=>{},
|
||||
onSelectionChange: ()=>{},
|
||||
onUndoRedoDepthChange: ()=>{},
|
||||
onScroll: ()=>{},
|
||||
onAttach: async ()=>{},
|
||||
onSearchVisibleChange: ()=>{},
|
||||
noteResources: {},
|
||||
plugins: {},
|
||||
mode: EditorType.Markdown,
|
||||
|
||||
@@ -13,7 +13,7 @@ import { editorFont } from '../global-style';
|
||||
import { EditorControl as EditorBodyControl, ContentScriptData } from '@joplin/editor/types';
|
||||
import { EditorControl, EditorSettings, EditorType } from './types';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { ChangeEvent, EditorEvent, EditorEventType, SelectionRangeChangeEvent, UndoRedoDepthChangeEvent } from '@joplin/editor/events';
|
||||
import { ChangeEvent, EditorEvent, EditorEventType, EditorScrolledEvent, SelectionRangeChangeEvent, UndoRedoDepthChangeEvent } from '@joplin/editor/events';
|
||||
import { EditorCommandType, EditorKeymap, EditorLanguageType, SearchState } from '@joplin/editor/types';
|
||||
import SelectionFormatting, { defaultSelectionFormatting } from '@joplin/editor/SelectionFormatting';
|
||||
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
|
||||
@@ -34,18 +34,23 @@ import { MarkupLanguage } from '@joplin/renderer';
|
||||
import WarningBanner from './WarningBanner';
|
||||
import useIsScreenReaderEnabled from '../../utils/hooks/useIsScreenReaderEnabled';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import { Second } from '@joplin/utils/time';
|
||||
import useDebounced from '../../utils/hooks/useDebounced';
|
||||
|
||||
const logger = Logger.create('NoteEditor');
|
||||
|
||||
type ChangeEventHandler = (event: ChangeEvent)=> void;
|
||||
type UndoRedoDepthChangeHandler = (event: UndoRedoDepthChangeEvent)=> void;
|
||||
type SelectionChangeEventHandler = (event: SelectionRangeChangeEvent)=> void;
|
||||
type OnAttachCallback = (filePath?: string)=> Promise<void>;
|
||||
type OnChange = (event: ChangeEvent)=> void;
|
||||
type OnSearchVisibleChange = (visible: boolean)=> void;
|
||||
type OnScroll = (event: EditorScrolledEvent)=> void;
|
||||
type OnUndoRedoDepthChange = (event: UndoRedoDepthChangeEvent)=> void;
|
||||
type OnSelectionChange = (event: SelectionRangeChangeEvent)=> void;
|
||||
type OnAttach = (filePath?: string)=> Promise<void>;
|
||||
|
||||
interface Props {
|
||||
ref: Ref<EditorControl>;
|
||||
themeId: number;
|
||||
initialText: string;
|
||||
initialScroll: number;
|
||||
mode: EditorType;
|
||||
markupLanguage: MarkupLanguage;
|
||||
noteId: string;
|
||||
@@ -58,10 +63,12 @@ interface Props {
|
||||
plugins: PluginStates;
|
||||
noteResources: ResourceInfos;
|
||||
|
||||
onChange: ChangeEventHandler;
|
||||
onSelectionChange: SelectionChangeEventHandler;
|
||||
onUndoRedoDepthChange: UndoRedoDepthChangeHandler;
|
||||
onAttach: OnAttachCallback;
|
||||
onScroll: OnScroll;
|
||||
onChange: OnChange;
|
||||
onSearchVisibleChange: OnSearchVisibleChange;
|
||||
onSelectionChange: OnSelectionChange;
|
||||
onUndoRedoDepthChange: OnUndoRedoDepthChange;
|
||||
onAttach: OnAttach;
|
||||
}
|
||||
|
||||
function fontFamilyFromSettings() {
|
||||
@@ -257,6 +264,19 @@ const useHighlightActiveLine = () => {
|
||||
return canHighlight && Setting.value('editor.highlightActiveLine');
|
||||
};
|
||||
|
||||
const useHasSpaceForToolbar = () => {
|
||||
const [hasSpaceForToolbar, setHasSpaceForToolbar] = useState(true);
|
||||
|
||||
const onContainerLayout = useCallback((event: LayoutChangeEvent) => {
|
||||
const containerHeight = event.nativeEvent.layout.height;
|
||||
|
||||
setHasSpaceForToolbar(containerHeight >= 140);
|
||||
}, []);
|
||||
|
||||
const debouncedHasSpaceForToolbar = useDebounced(hasSpaceForToolbar, Second / 4);
|
||||
return { hasSpaceForToolbar: debouncedHasSpaceForToolbar, onContainerLayout };
|
||||
};
|
||||
|
||||
function NoteEditor(props: Props) {
|
||||
const webviewRef = useRef<WebViewControl>(null);
|
||||
|
||||
@@ -292,6 +312,7 @@ function NoteEditor(props: Props) {
|
||||
const [searchState, setSearchState] = useState(defaultSearchState);
|
||||
|
||||
const editorControlRef = useRef<EditorControl|null>(null);
|
||||
const lastSearchVisibleRef = useRef<boolean|undefined>(undefined);
|
||||
const onEditorEvent = (event: EditorEvent) => {
|
||||
let exhaustivenessCheck: never;
|
||||
switch (event.kind) {
|
||||
@@ -322,21 +343,29 @@ function NoteEditor(props: Props) {
|
||||
// If the change to the search was done by this editor, it was already applied to the
|
||||
// search state. Skipping the update in this case also helps avoid overwriting the
|
||||
// search state with an older value.
|
||||
const showSearch = event.searchState.dialogVisible ?? lastSearchVisibleRef.current;
|
||||
if (hasExternalChange) {
|
||||
setSearchState(event.searchState);
|
||||
|
||||
if (event.searchState.dialogVisible) {
|
||||
if (showSearch) {
|
||||
editorControl.searchControl.showSearch();
|
||||
} else {
|
||||
editorControl.searchControl.hideSearch();
|
||||
}
|
||||
}
|
||||
|
||||
if (showSearch !== lastSearchVisibleRef.current) {
|
||||
props.onSearchVisibleChange(showSearch);
|
||||
lastSearchVisibleRef.current = showSearch;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case EditorEventType.Remove:
|
||||
case EditorEventType.Scroll:
|
||||
// Not handled
|
||||
break;
|
||||
case EditorEventType.Scroll:
|
||||
props.onScroll(event);
|
||||
break;
|
||||
default:
|
||||
exhaustivenessCheck = event;
|
||||
return exhaustivenessCheck;
|
||||
@@ -382,18 +411,6 @@ function NoteEditor(props: Props) {
|
||||
return editorControl;
|
||||
});
|
||||
|
||||
const [hasSpaceForToolbar, setHasSpaceForToolbar] = useState(true);
|
||||
const toolbarEnabled = props.toolbarEnabled && hasSpaceForToolbar;
|
||||
|
||||
const onContainerLayout = useCallback((event: LayoutChangeEvent) => {
|
||||
const containerHeight = event.nativeEvent.layout.height;
|
||||
|
||||
if (containerHeight < 140) {
|
||||
setHasSpaceForToolbar(false);
|
||||
} else {
|
||||
setHasSpaceForToolbar(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onAttach = useCallback(async (type: string, base64: string) => {
|
||||
const tempFilePath = join(Setting.value('tempDir'), `paste.${uuid.createNano()}.${toFileExtension(type)}`);
|
||||
@@ -411,7 +428,11 @@ function NoteEditor(props: Props) {
|
||||
searchVisible: searchState.dialogVisible,
|
||||
}), [selectionState, searchState.dialogVisible]);
|
||||
|
||||
|
||||
const { hasSpaceForToolbar, onContainerLayout } = useHasSpaceForToolbar();
|
||||
const toolbarEnabled = props.toolbarEnabled && hasSpaceForToolbar;
|
||||
const toolbar = <EditorToolbar editorState={toolbarEditorState} />;
|
||||
|
||||
const EditorComponent = props.mode === EditorType.Markdown ? MarkdownEditor : RichTextEditor;
|
||||
|
||||
return (
|
||||
@@ -442,6 +463,7 @@ function NoteEditor(props: Props) {
|
||||
noteHash={props.noteHash}
|
||||
initialText={props.initialText}
|
||||
initialSelection={props.initialSelection}
|
||||
initialScroll={props.initialScroll}
|
||||
editorSettings={editorSettings}
|
||||
globalSearch={props.globalSearch}
|
||||
onEditorEvent={onEditorEvent}
|
||||
|
||||
@@ -96,6 +96,8 @@ const RichTextEditor: React.FC<EditorProps> = props => {
|
||||
themeId: props.themeId,
|
||||
pluginStates: props.plugins,
|
||||
noteResources: props.noteResources,
|
||||
initialSelection: props.initialSelection,
|
||||
initialScroll: props.initialScroll,
|
||||
onPostMessage: onPostMessage,
|
||||
onAttachFile: props.onAttach,
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ const defaultWrapperProps: EditorProps = {
|
||||
noteHash: '',
|
||||
noteId: '',
|
||||
initialText: '',
|
||||
initialScroll: 0,
|
||||
editorSettings: defaultEditorSettings,
|
||||
initialSelection: { start: 0, end: 0 },
|
||||
globalSearch: '',
|
||||
|
||||
@@ -62,6 +62,7 @@ export interface EditorProps {
|
||||
noteHash: string;
|
||||
initialText: string;
|
||||
initialSelection: SelectionRange;
|
||||
initialScroll: number;
|
||||
editorSettings: EditorSettings;
|
||||
globalSearch: string;
|
||||
plugins: PluginStates;
|
||||
|
||||
@@ -7,7 +7,7 @@ import NoteBodyViewer from '../../NoteBodyViewer/NoteBodyViewer';
|
||||
import checkPermissions from '../../../utils/checkPermissions';
|
||||
import NoteEditor from '../../NoteEditor/NoteEditor';
|
||||
import * as React from 'react';
|
||||
import { Keyboard, View, TextInput, StyleSheet, Linking, Share, NativeSyntheticEvent } from 'react-native';
|
||||
import { Keyboard, View, TextInput, StyleSheet, Linking, Share, NativeSyntheticEvent, useWindowDimensions } from 'react-native';
|
||||
import { Platform, PermissionsAndroid } from 'react-native';
|
||||
import { connect } from 'react-redux';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
@@ -76,6 +76,11 @@ import { EditorType } from '../../NoteEditor/types';
|
||||
import { IconButton } from 'react-native-paper';
|
||||
import { writeTextToCacheFile } from '../../../utils/ShareUtils';
|
||||
import shareFile from '../../../utils/shareFile';
|
||||
import NotePositionService from '@joplin/lib/services/NotePositionService';
|
||||
import useKeyboardState from '../../../utils/hooks/useKeyboardState';
|
||||
import VoiceTyping from '../../../services/voiceTyping/VoiceTyping';
|
||||
import useDebounced from '../../../utils/hooks/useDebounced';
|
||||
import { Second } from '@joplin/utils/time';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const emptyArray: any[] = [];
|
||||
@@ -120,12 +125,14 @@ interface Props extends BaseProps {
|
||||
interface ComponentProps extends Props {
|
||||
dialogs: DialogControl;
|
||||
visibleEditorPluginIds: string[];
|
||||
lowVerticalSpace: boolean;
|
||||
}
|
||||
|
||||
interface State {
|
||||
note: NoteEntity;
|
||||
mode: NoteViewerMode;
|
||||
readOnly: boolean;
|
||||
searchVisible: boolean;
|
||||
folder: FolderEntity|null;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
lastSavedNote: any;
|
||||
@@ -154,6 +161,8 @@ interface State {
|
||||
multiline: boolean;
|
||||
}
|
||||
|
||||
type ScrollEventSlice = { fraction: number };
|
||||
|
||||
class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> implements BaseNoteScreenComponent<State> {
|
||||
// This isn't in this.state because we don't want changing scroll to trigger
|
||||
// a re-render.
|
||||
@@ -211,6 +220,7 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
showCamera: false,
|
||||
showImageEditor: false,
|
||||
showAudioRecorder: false,
|
||||
searchVisible: false,
|
||||
imageEditorResource: null,
|
||||
noteResources: {},
|
||||
imageEditorResourceFilepath: null,
|
||||
@@ -226,6 +236,13 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
multiline: false,
|
||||
};
|
||||
|
||||
const initialCursorLocation = NotePositionService.instance().getCursorPosition(props.noteId, defaultWindowId).markdown;
|
||||
if (initialCursorLocation) {
|
||||
this.selection = { start: initialCursorLocation, end: initialCursorLocation };
|
||||
}
|
||||
const initialScroll = NotePositionService.instance().getScrollPercent(props.noteId, defaultWindowId);
|
||||
this.lastBodyScroll = initialScroll;
|
||||
|
||||
this.titleTextFieldRef = React.createRef();
|
||||
|
||||
this.saveActionQueues_ = {};
|
||||
@@ -379,6 +396,9 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
return this.props.useEditorBeta;
|
||||
}
|
||||
|
||||
private onSearchVisibleChange_ = (visible: boolean) => {
|
||||
this.setState({ searchVisible: visible });
|
||||
};
|
||||
|
||||
private onUndoRedoDepthChange(event: UndoRedoDepthChangeEvent) {
|
||||
if (this.useEditorBeta()) {
|
||||
@@ -770,8 +790,12 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
this.selection = event.nativeEvent.selection;
|
||||
};
|
||||
|
||||
private onMarkdownEditorSelectionChange = (event: SelectionRangeChangeEvent) => {
|
||||
private onEditorSelectionChange = (event: SelectionRangeChangeEvent) => {
|
||||
this.selection = { start: event.from, end: event.to };
|
||||
|
||||
NotePositionService.instance().updateCursorPosition(
|
||||
this.props.noteId, defaultWindowId, { markdown: event.from },
|
||||
);
|
||||
};
|
||||
|
||||
public makeSaveAction(state: State) {
|
||||
@@ -1291,8 +1315,7 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
});
|
||||
}
|
||||
|
||||
const voiceTypingSupported = Platform.OS === 'android';
|
||||
if (voiceTypingSupported) {
|
||||
if (VoiceTyping.supported()) {
|
||||
output.push({
|
||||
title: _('Voice typing...'),
|
||||
onPress: () => {
|
||||
@@ -1487,10 +1510,16 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
return this.folderPickerOptions_;
|
||||
}
|
||||
|
||||
private onBodyViewerScroll = (scrollTop: number) => {
|
||||
this.lastBodyScroll = scrollTop;
|
||||
private onBodyViewerScroll = (event: ScrollEventSlice) => {
|
||||
this.lastBodyScroll = event.fraction;
|
||||
|
||||
NotePositionService.instance().updateScrollPosition(
|
||||
this.props.noteId, defaultWindowId, event.fraction,
|
||||
);
|
||||
};
|
||||
|
||||
private onMarkdownEditorScroll = () => {};
|
||||
|
||||
public onBodyViewerCheckboxChange(newBody: string) {
|
||||
void this.saveOneProperty('body', newBody);
|
||||
}
|
||||
@@ -1580,6 +1609,14 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
// Currently keyword highlighting is supported only when FTS is available.
|
||||
const keywords = this.props.searchQuery && !!this.props.ftsEnabled ? this.props.highlightedWords : emptyArray;
|
||||
|
||||
const increaseSpaceForEditor = this.props.lowVerticalSpace
|
||||
&& this.state.mode === 'edit'
|
||||
// For now, only dismiss other UI when search is visible. This provides a way to re-show the hidden UI (by dismissing search).
|
||||
&& this.state.searchVisible
|
||||
// Tapping on the title input when search is visible should edit the title, even if showing the keyboard decreases the
|
||||
// available space.
|
||||
&& !this.titleTextFieldRef.current?.isFocused();
|
||||
|
||||
let bodyComponent = null;
|
||||
|
||||
if (editorView) {
|
||||
@@ -1604,7 +1641,7 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
onMarkForDownload={this.onMarkForDownload}
|
||||
onRequestEditResource={this.onEditResource}
|
||||
onScroll={this.onBodyViewerScroll}
|
||||
initialScroll={this.lastBodyScroll}
|
||||
initialScrollPercent={this.lastBodyScroll}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
@@ -1649,7 +1686,7 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
|
||||
bodyComponent = <NoteEditor
|
||||
ref={this.editorRef}
|
||||
toolbarEnabled={this.props.toolbarEnabled}
|
||||
toolbarEnabled={this.props.toolbarEnabled && !increaseSpaceForEditor}
|
||||
themeId={this.props.themeId}
|
||||
noteId={this.props.noteId}
|
||||
noteHash={this.props.noteHash}
|
||||
@@ -1658,8 +1695,9 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
markupLanguage={this.state.note.markup_language}
|
||||
globalSearch={this.props.searchQuery}
|
||||
onChange={this.onMarkdownEditorTextChange}
|
||||
onSelectionChange={this.onMarkdownEditorSelectionChange}
|
||||
onSelectionChange={this.onEditorSelectionChange}
|
||||
onUndoRedoDepthChange={this.onUndoRedoDepthChange}
|
||||
onSearchVisibleChange={this.onSearchVisibleChange_}
|
||||
onAttach={this.onAttach}
|
||||
noteResources={this.state.noteResources}
|
||||
readOnly={this.state.readOnly}
|
||||
@@ -1671,6 +1709,14 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
paddingLeft: 0,
|
||||
paddingRight: 0,
|
||||
}}
|
||||
|
||||
// For now, only save/restore the scroll location for the Rich Text editor since that editor's
|
||||
// scroll should roughly match the viewer. In the future, it may make sense to refactor this to
|
||||
// use mapsToLine (similar to what's done on desktop) to sync the Markdown editor scroll, but this
|
||||
// will require refactoring.
|
||||
initialScroll={this.props.editorType === EditorType.RichText ? this.lastBodyScroll : undefined}
|
||||
onScroll={this.props.editorType === EditorType.RichText ? this.onBodyViewerScroll : this.onMarkdownEditorScroll}
|
||||
|
||||
mode={this.props.editorType}
|
||||
/>;
|
||||
}
|
||||
@@ -1762,25 +1808,27 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
|
||||
const { editorPlugin: activeEditorPlugin } = getActivePluginEditorView(this.props.plugins, this.props.windowId);
|
||||
|
||||
const header = <ScreenHeader
|
||||
folderPickerOptions={this.folderPickerOptions()}
|
||||
menuOptions={this.menuOptions()}
|
||||
showSaveButton={showSaveButton}
|
||||
saveButtonDisabled={saveButtonDisabled}
|
||||
onSaveButtonPress={this.saveNoteButton_press}
|
||||
showSideMenuButton={false}
|
||||
showSearchButton={false}
|
||||
showUndoButton={(this.state.undoRedoButtonState.canUndo || this.state.undoRedoButtonState.canRedo) && this.state.mode === 'edit'}
|
||||
showRedoButton={this.state.undoRedoButtonState.canRedo && this.state.mode === 'edit'}
|
||||
showPluginEditorButton={!!activeEditorPlugin}
|
||||
undoButtonDisabled={!this.state.undoRedoButtonState.canUndo && this.state.undoRedoButtonState.canRedo}
|
||||
onUndoButtonPress={this.screenHeader_undoButtonPress}
|
||||
onRedoButtonPress={this.screenHeader_redoButtonPress}
|
||||
title={getDisplayParentTitle(this.state.note, this.state.folder)}
|
||||
/>;
|
||||
|
||||
return (
|
||||
<View style={this.rootStyle(this.props.themeId).root}>
|
||||
<ScreenHeader
|
||||
folderPickerOptions={this.folderPickerOptions()}
|
||||
menuOptions={this.menuOptions()}
|
||||
showSaveButton={showSaveButton}
|
||||
saveButtonDisabled={saveButtonDisabled}
|
||||
onSaveButtonPress={this.saveNoteButton_press}
|
||||
showSideMenuButton={false}
|
||||
showSearchButton={false}
|
||||
showUndoButton={(this.state.undoRedoButtonState.canUndo || this.state.undoRedoButtonState.canRedo) && this.state.mode === 'edit'}
|
||||
showRedoButton={this.state.undoRedoButtonState.canRedo && this.state.mode === 'edit'}
|
||||
showPluginEditorButton={!!activeEditorPlugin}
|
||||
undoButtonDisabled={!this.state.undoRedoButtonState.canUndo && this.state.undoRedoButtonState.canRedo}
|
||||
onUndoButtonPress={this.screenHeader_undoButtonPress}
|
||||
onRedoButtonPress={this.screenHeader_redoButtonPress}
|
||||
title={getDisplayParentTitle(this.state.note, this.state.folder)}
|
||||
/>
|
||||
{titleComp}
|
||||
{!increaseSpaceForEditor && header}
|
||||
{!increaseSpaceForEditor && titleComp}
|
||||
{bodyComponent}
|
||||
{renderVoiceTypingDialogs()}
|
||||
{renderActionButton()}
|
||||
@@ -1798,6 +1846,17 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
}
|
||||
}
|
||||
|
||||
const useHasLowAvailableSpace = () => {
|
||||
const windowDimensions = useWindowDimensions();
|
||||
const keyboardState = useKeyboardState();
|
||||
const verticalSpaceAvailable = windowDimensions.height - keyboardState.dockedKeyboardHeight;
|
||||
|
||||
const lowVerticalScreenSpace = verticalSpaceAvailable < 270;
|
||||
// Debounce state updates to avoid multiple re-renders when the keyboard is hidden, then quickly
|
||||
// re-shown (e.g. when moving focus between text inputs).
|
||||
return useDebounced(lowVerticalScreenSpace, Second / 10);
|
||||
};
|
||||
|
||||
// We added this change to reset the component state when the props.noteId is changed.
|
||||
// NoteScreenComponent original implementation assumed that noteId would never change,
|
||||
// which can cause some bugs where previously set state to another note would interfere
|
||||
@@ -1805,9 +1864,16 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
const NoteScreenWrapper = (props: Props) => {
|
||||
const dialogs = useContext(DialogContext);
|
||||
const visibleEditorPluginIds = useVisiblePluginEditorViewIds(props.plugins, props.windowId);
|
||||
const lowVerticalSpace = useHasLowAvailableSpace();
|
||||
|
||||
return (
|
||||
<NoteScreenComponent key={props.noteId} dialogs={dialogs} visibleEditorPluginIds={visibleEditorPluginIds} {...props} />
|
||||
<NoteScreenComponent
|
||||
key={props.noteId}
|
||||
dialogs={dialogs}
|
||||
visibleEditorPluginIds={visibleEditorPluginIds}
|
||||
lowVerticalSpace={lowVerticalSpace}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { CommandRuntimeProps } from '../types';
|
||||
import time from '@joplin/lib/time';
|
||||
import { formatMsToLocal } from '@joplin/utils/time';
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'insertDateTime',
|
||||
@@ -12,7 +12,7 @@ export const declaration: CommandDeclaration = {
|
||||
export const runtime = (props: CommandRuntimeProps): CommandRuntime => {
|
||||
return {
|
||||
execute: async (_context: CommandContext) => {
|
||||
props.insertText(time.formatDateToLocal(new Date()));
|
||||
props.insertText(formatMsToLocal(Date.now()));
|
||||
},
|
||||
|
||||
enabledCondition: '!noteIsReadOnly',
|
||||
|
||||
@@ -22,6 +22,7 @@ import { themeStyle } from '../global-style';
|
||||
import getHelpMessage from '@joplin/lib/components/shared/NoteRevisionViewer/getHelpMessage';
|
||||
import { DialogContext } from '../DialogManager';
|
||||
import useDeleteHistoryClick from '@joplin/lib/components/shared/NoteRevisionViewer/useDeleteHistoryClick';
|
||||
import { OnScrollCallback } from '../NoteBodyViewer/types';
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
@@ -153,6 +154,10 @@ const NoteRevisionViewer: React.FC<Props> = props => {
|
||||
return result;
|
||||
}, [revisions]);
|
||||
|
||||
const onScroll: OnScrollCallback = useCallback((event) => {
|
||||
setInitialScroll(event.fraction);
|
||||
}, []);
|
||||
|
||||
const onOptionSelected = useCallback((value: string) => {
|
||||
setCurrentRevisionId(value);
|
||||
}, []);
|
||||
@@ -280,8 +285,8 @@ const NoteRevisionViewer: React.FC<Props> = props => {
|
||||
noteResources={resources}
|
||||
highlightedKeywords={emptyStringList}
|
||||
paddingBottom={0}
|
||||
initialScroll={initialScroll}
|
||||
onScroll={setInitialScroll}
|
||||
initialScrollPercent={initialScroll}
|
||||
onScroll={onScroll}
|
||||
noteHash={''}
|
||||
/>
|
||||
</View>;
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Text, Button } from 'react-native-paper';
|
||||
import { _, languageName } from '@joplin/lib/locale';
|
||||
import useAsyncEffect, { AsyncEffectEvent } from '@joplin/lib/hooks/useAsyncEffect';
|
||||
import VoiceTyping, { OnTextCallback, VoiceTypingSession } from '../../services/voiceTyping/VoiceTyping';
|
||||
import whisper from '../../services/voiceTyping/whisper';
|
||||
import { RecorderState } from './types';
|
||||
import RecordingControls from './RecordingControls';
|
||||
import { PrimaryButton } from '../buttons';
|
||||
@@ -38,7 +37,7 @@ const useVoiceTyping = ({ locale, onSetPreview, onText }: UseVoiceTypingProps) =
|
||||
voiceTypingRef.current = voiceTyping;
|
||||
|
||||
const builder = useMemo(() => {
|
||||
return new VoiceTyping(locale, [whisper]);
|
||||
return new VoiceTyping(locale);
|
||||
}, [locale]);
|
||||
|
||||
const [redownloadCounter, setRedownloadCounter] = useState(0);
|
||||
|
||||
@@ -80,6 +80,21 @@ const useCss = (editorTheme: Theme) => {
|
||||
.toolbar-edge-toolbar:not(.one-row) .toolwidget-tag--exit .toolbar-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
${Setting.value('buildFlag.ui.disableSmallScreenIncompatibleFeatures') ? `
|
||||
/* As of December 2025, the help overlay is difficult to use on small screens
|
||||
(slow to load, help text overlapping content in some cases). */
|
||||
.js-draw .toolbar-help-overlay-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* As of December 2025, the pipette button is difficult to use on small screens:
|
||||
It may not be clear that it's necessary to dismiss the tool menu in order to
|
||||
pick a color from the screen. */
|
||||
.js-draw .color-input-container > button.pipetteButton.pipetteButton {
|
||||
display: none;
|
||||
}
|
||||
` : ''}
|
||||
`;
|
||||
}, [editorTheme]);
|
||||
};
|
||||
|
||||
@@ -10,7 +10,7 @@ const defaultRendererSettings: RenderSettings = {
|
||||
resources: {},
|
||||
codeTheme: 'atom-one-light.css',
|
||||
noteHash: '',
|
||||
initialScroll: 0,
|
||||
initialScrollPercent: 0,
|
||||
readAssetBlob: async (_path: string) => new Blob(),
|
||||
removeUnusedPluginAssets: true,
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ export interface RenderSettings {
|
||||
resources: ResourceInfos;
|
||||
codeTheme: string;
|
||||
noteHash: string;
|
||||
initialScroll: number;
|
||||
initialScrollPercent: number;
|
||||
// If [null], plugin assets are not added to the document
|
||||
pluginAssetContainerSelector: string|null;
|
||||
removeUnusedPluginAssets: boolean;
|
||||
|
||||
@@ -64,7 +64,10 @@ export const initialize = (options: RendererWebViewOptions) => {
|
||||
const onMainContentScroll = () => {
|
||||
const newScrollTop = document.scrollingElement.scrollTop;
|
||||
if (lastScrollTop !== newScrollTop) {
|
||||
messenger.remoteApi.onScroll(newScrollTop);
|
||||
const scrollHeight = document.scrollingElement.scrollHeight;
|
||||
messenger.remoteApi.onScroll({
|
||||
fraction: newScrollTop / (scrollHeight || 1),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -19,13 +19,13 @@ const afterFullPageRender = (
|
||||
}
|
||||
|
||||
const hash = renderSettings.noteHash;
|
||||
const initialScroll = renderSettings.initialScroll;
|
||||
const initialScrollPercent = renderSettings.initialScrollPercent;
|
||||
|
||||
// Don't scroll to a hash if we're given initial scroll (initial scroll
|
||||
// overrides scrolling to a hash).
|
||||
if ((initialScroll ?? null) !== null) {
|
||||
if ((initialScrollPercent ?? null) !== null) {
|
||||
const scrollingElement = document.scrollingElement ?? document.documentElement;
|
||||
scrollingElement.scrollTop = initialScroll;
|
||||
scrollingElement.scrollTop = initialScrollPercent * scrollingElement.scrollHeight;
|
||||
} else if (hash) {
|
||||
// Gives it a bit of time before scrolling to the anchor
|
||||
// so that images are loaded.
|
||||
|
||||
@@ -30,20 +30,24 @@ export interface ExtraContentScriptSource {
|
||||
pluginId: string;
|
||||
}
|
||||
|
||||
export interface ScrollEvent {
|
||||
fraction: number; // e.g. 0.5 when scrolled 50% of the way through the document
|
||||
}
|
||||
|
||||
export type OnScrollCallback = (scrollTop: ScrollEvent)=> void;
|
||||
|
||||
export interface RendererProcessApi {
|
||||
renderer: Renderer;
|
||||
jumpToHash: (hash: string)=> void;
|
||||
}
|
||||
|
||||
export interface MainProcessApi {
|
||||
onScroll(scrollTop: number): void;
|
||||
onScroll: OnScrollCallback;
|
||||
onPostMessage(message: string): void;
|
||||
onPostPluginMessage(contentScriptId: string, message: unknown): Promise<unknown>;
|
||||
fsDriver: RendererFsDriver;
|
||||
}
|
||||
|
||||
export type OnScrollCallback = (scrollTop: number)=> void;
|
||||
|
||||
export interface MarkupRecord {
|
||||
language: MarkupLanguage;
|
||||
markup: string;
|
||||
@@ -62,7 +66,7 @@ export interface RenderOptions {
|
||||
removeUnusedPluginAssets: boolean;
|
||||
|
||||
noteHash: string;
|
||||
initialScroll: number;
|
||||
initialScrollPercent: number;
|
||||
|
||||
// Forwarded renderer settings
|
||||
splitted?: boolean;
|
||||
|
||||
@@ -12,12 +12,12 @@ import RNToWebViewMessenger from '../../utils/ipc/RNToWebViewMessenger';
|
||||
import useEditPopup from './utils/useEditPopup';
|
||||
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
|
||||
import { RenderSettings } from './contentScript/Renderer';
|
||||
import resolvePathWithinDir from '@joplin/lib/utils/resolvePathWithinDir';
|
||||
import Resource from '@joplin/lib/models/Resource';
|
||||
import { ResourceInfos } from '@joplin/renderer/types';
|
||||
import useContentScripts from './utils/useContentScripts';
|
||||
import uuid from '@joplin/lib/uuid';
|
||||
import AsyncActionQueue from '@joplin/lib/AsyncActionQueue';
|
||||
import resolvePathWithinDir from '@joplin/lib/utils/resolvePathWithinDir';
|
||||
|
||||
const logger = Logger.create('renderer/useWebViewSetup');
|
||||
|
||||
@@ -91,8 +91,8 @@ const useMessenger = (props: UseMessengerProps) => {
|
||||
|
||||
const messenger = useMemo(() => {
|
||||
const fsDriver = shim.fsDriver();
|
||||
const localApi = {
|
||||
onScroll: (fraction: number) => onScrollRef.current?.(fraction),
|
||||
const localApi: MainProcessApi = {
|
||||
onScroll: (event) => onScrollRef.current?.(event),
|
||||
onPostMessage: (message: string) => onPostMessageRef.current?.(message),
|
||||
onPostPluginMessage,
|
||||
fsDriver: {
|
||||
@@ -212,20 +212,32 @@ const useWebViewSetup = (props: Props): Result => {
|
||||
settingsChanged = true;
|
||||
}
|
||||
},
|
||||
readAssetBlob: (assetPath: string): Promise<Blob> => {
|
||||
// Built-in assets are in resourceDir, external plugin assets are in cacheDir.
|
||||
const assetsDirs = [Setting.value('resourceDir'), Setting.value('cacheDir')];
|
||||
// Handles plugin asset loading on web (where the WebView can't load assets directly).
|
||||
readAssetBlob: async (assetPath: string): Promise<Blob> => {
|
||||
if (assetPath.startsWith('pluginAssets/')) { // Built-in plugin asset
|
||||
assetPath = assetPath.replace(/^pluginAssets\//, '');
|
||||
|
||||
let resolvedPath = null;
|
||||
for (const assetDir of assetsDirs) {
|
||||
resolvedPath ??= resolvePathWithinDir(assetDir, assetPath);
|
||||
if (resolvedPath) break;
|
||||
}
|
||||
const fullPath = shim.fsDriver().resolveRelativePathWithinDir(
|
||||
Setting.value('pluginAssetDir'), assetPath,
|
||||
);
|
||||
return shim.fsDriver().fileAtPath(fullPath);
|
||||
} else { // Asset from an installed/development plugin
|
||||
// User-installed plugins are stored in cacheDir
|
||||
const allowedBasePaths = [Setting.value('cacheDir')];
|
||||
// Development plugins are stored in other locations. Add them separately:
|
||||
if (Setting.value('plugins.devPluginPaths')) {
|
||||
allowedBasePaths.push(...Setting.value('plugins.devPluginPaths').split(','));
|
||||
}
|
||||
|
||||
if (!resolvedPath) {
|
||||
throw new Error(`Failed to load asset at ${assetPath} -- not in any of the allowed asset directories: ${assetsDirs.join(',')}.`);
|
||||
for (const basePath of allowedBasePaths) {
|
||||
const resolved = resolvePathWithinDir(basePath, assetPath);
|
||||
if (resolved) {
|
||||
return shim.fsDriver().fileAtPath(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Unable to resolve plugin asset: ${assetPath}`);
|
||||
}
|
||||
return shim.fsDriver().fileAtPath(resolvedPath);
|
||||
},
|
||||
removeUnusedPluginAssets: options.removeUnusedPluginAssets,
|
||||
globalSettings: {
|
||||
|
||||
@@ -1,42 +1,17 @@
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { themeStyle } from '@joplin/lib/theme';
|
||||
import { Theme } from '@joplin/lib/themes/type';
|
||||
import { useMemo } from 'react';
|
||||
import { extname } from 'path';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import { Platform } from 'react-native';
|
||||
import { Ionicons as Icon } from '@react-native-vector-icons/ionicons';
|
||||
|
||||
export const editPopupClass = 'joplin-editPopup';
|
||||
|
||||
const getEditIconSrc = (theme: Theme) => {
|
||||
// Use an inline edit icon on web -- getImageSourceSync isn't supported there.
|
||||
if (Platform.OS === 'web') {
|
||||
const svgData = `
|
||||
<svg viewBox="-103 60 180 180" width="30" height="30" version="1.1" baseProfile="full" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="m 100,19 c -11.7,0 -21.1,9.5 -21.2,21.2 0,0 42.3,0 42.3,0 0,-11.7 -9.5,-21.2 -21.2,-21.2 z M 79,43 v 143 l 21.3,26.4 21,-26.5 V 42.8 Z" style="transform: rotate(45deg)" fill=${JSON.stringify(theme.color2)}/>
|
||||
</svg>
|
||||
`.replace(/[ \t\n]+/, ' ');
|
||||
return `data:image/svg+xml;utf8,${encodeURIComponent(svgData)}`;
|
||||
}
|
||||
|
||||
const iconUri = Icon.getImageSourceSync('pencil', 20, theme.color2).uri;
|
||||
|
||||
// Copy to a location that can be read within a WebView
|
||||
// (necessary on iOS)
|
||||
const destPath = `${Setting.value('resourceDir')}/edit-icon${extname(iconUri)}`;
|
||||
|
||||
// Copy in the background -- the edit icon popover script doesn't need the
|
||||
// icon immediately.
|
||||
void (async () => {
|
||||
// Can be '' in a testing environment.
|
||||
if (iconUri) {
|
||||
await shim.fsDriver().copy(iconUri, destPath);
|
||||
}
|
||||
})();
|
||||
|
||||
return destPath;
|
||||
const svgData = `
|
||||
<svg viewBox="-103 60 180 180" width="30" height="30" version="1.1" baseProfile="full" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="m 100,19 c -11.7,0 -21.1,9.5 -21.2,21.2 0,0 42.3,0 42.3,0 0,-11.7 -9.5,-21.2 -21.2,-21.2 z M 79,43 v 143 l 21.3,26.4 21,-26.5 V 42.8 Z" style="transform: rotate(45deg)" fill=${JSON.stringify(theme.color2)}/>
|
||||
</svg>
|
||||
`.replace(/[ \t\n]+/, ' ');
|
||||
return `data:image/svg+xml;utf8,${encodeURIComponent(svgData)}`;
|
||||
};
|
||||
|
||||
// Creates JavaScript/CSS that can be used to create an "Edit" button.
|
||||
|
||||
@@ -29,6 +29,8 @@ export const initialize = async (
|
||||
settings,
|
||||
initialText,
|
||||
initialNoteId,
|
||||
initialSelection,
|
||||
initialScroll,
|
||||
parentElementClassName,
|
||||
initialSearch,
|
||||
}: EditorProps,
|
||||
@@ -41,6 +43,14 @@ export const initialize = async (
|
||||
throw new Error('Parent node is not an element.');
|
||||
}
|
||||
|
||||
document.addEventListener('scrollend', () => {
|
||||
const fraction = document.scrollingElement.scrollTop / (document.scrollingElement.scrollHeight || 1);
|
||||
void messenger.remoteApi.onEditorEvent({
|
||||
kind: EditorEventType.Scroll,
|
||||
fraction,
|
||||
});
|
||||
});
|
||||
|
||||
const assetContainer = document.createElement('div');
|
||||
assetContainer.id = 'joplin-container-pluginAssetsContainer';
|
||||
document.body.appendChild(assetContainer);
|
||||
@@ -100,6 +110,13 @@ export const initialize = async (
|
||||
});
|
||||
editor.setSearchState(initialSearch, 'initialSearch');
|
||||
|
||||
if (initialSelection) {
|
||||
editor.select(initialSelection.start, initialSelection.end);
|
||||
}
|
||||
if (initialScroll) {
|
||||
editor.setScrollPercent(initialScroll);
|
||||
}
|
||||
|
||||
messenger.setLocalInterface({
|
||||
editor,
|
||||
});
|
||||
|
||||
@@ -3,9 +3,13 @@ import { EditorControl, EditorSettings, OnLocalize, SearchState } from '@joplin/
|
||||
import { MarkupRecord, RendererControl } from '../rendererBundle/types';
|
||||
import { RenderResult } from '@joplin/renderer/types';
|
||||
|
||||
type SelectionRange = { start: number; end: number };
|
||||
|
||||
export interface EditorProps {
|
||||
initialText: string;
|
||||
initialSearch: SearchState;
|
||||
initialSelection: SelectionRange;
|
||||
initialScroll: number;
|
||||
initialNoteId: string;
|
||||
parentElementClassName: string;
|
||||
settings: EditorSettings;
|
||||
|
||||
@@ -14,6 +14,7 @@ import { RendererControl, RenderOptions } from '../rendererBundle/types';
|
||||
import { ResourceInfos } from '@joplin/renderer/types';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { defaultSearchState } from '../../components/NoteEditor/SearchPanel';
|
||||
import { SelectionRange } from '../markdownEditorBundle/types';
|
||||
|
||||
const logger = Logger.create('useWebViewSetup');
|
||||
|
||||
@@ -21,6 +22,8 @@ interface Props {
|
||||
initialText: string;
|
||||
noteId: string;
|
||||
settings: EditorSettings;
|
||||
initialSelection: SelectionRange|null;
|
||||
initialScroll: number|null;
|
||||
parentElementClassName: string;
|
||||
globalSearch: string;
|
||||
themeId: number;
|
||||
@@ -53,7 +56,7 @@ const useMessenger = (props: UseMessengerProps) => {
|
||||
noteViewerFontSize: `${baseTheme.fontSize}${baseTheme.fontSizeUnits ?? 'px'}`,
|
||||
},
|
||||
noteHash: '',
|
||||
initialScroll: 0,
|
||||
initialScrollPercent: 0,
|
||||
pluginAssetContainerSelector: null,
|
||||
removeUnusedPluginAssets: true,
|
||||
};
|
||||
@@ -119,6 +122,8 @@ const useSource = (props: UseSourceProps) => {
|
||||
...defaultSearchState,
|
||||
searchText: propsRef.current.globalSearch,
|
||||
},
|
||||
initialScroll: propsRef.current.initialScroll,
|
||||
initialSelection: propsRef.current.initialSelection,
|
||||
settings: propsRef.current.settings,
|
||||
};
|
||||
|
||||
|
||||
@@ -345,7 +345,6 @@
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/boost/boost_privacy.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/glog/glog_privacy.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/react-native-image-picker/RNImagePickerPrivacyInfo.bundle",
|
||||
"${PODS_ROOT}/../../node_modules/@react-native-vector-icons/fontawesome5/fonts/FontAwesome5_Brands.ttf",
|
||||
"${PODS_ROOT}/../../node_modules/@react-native-vector-icons/fontawesome5/fonts/FontAwesome5_Regular.ttf",
|
||||
@@ -365,7 +364,6 @@
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/boost_privacy.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/glog_privacy.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNImagePickerPrivacyInfo.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FontAwesome5_Brands.ttf",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FontAwesome5_Regular.ttf",
|
||||
@@ -511,7 +509,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 147;
|
||||
CURRENT_PROJECT_VERSION = 149;
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Joplin/Info.plist;
|
||||
@@ -520,7 +518,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 13.5.1;
|
||||
MARKETING_VERSION = 13.6.0;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
@@ -546,7 +544,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 147;
|
||||
CURRENT_PROJECT_VERSION = 149;
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
INFOPLIST_FILE = Joplin/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
|
||||
@@ -554,7 +552,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 13.5.1;
|
||||
MARKETING_VERSION = 13.6.0;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
@@ -747,7 +745,7 @@
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 147;
|
||||
CURRENT_PROJECT_VERSION = 149;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
@@ -758,7 +756,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 13.5.1;
|
||||
MARKETING_VERSION = 13.6.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
OTHER_LDFLAGS = (
|
||||
@@ -790,7 +788,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 147;
|
||||
CURRENT_PROJECT_VERSION = 149;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
@@ -801,7 +799,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 13.5.1;
|
||||
MARKETING_VERSION = 13.6.0;
|
||||
MTL_FAST_MATH = YES;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
|
||||
@@ -77,21 +77,14 @@
|
||||
<key>UIAppFonts</key>
|
||||
<array>
|
||||
<string>AntDesign.ttf</string>
|
||||
<string>Entypo.ttf</string>
|
||||
<string>EvilIcons.ttf</string>
|
||||
<string>Feather.ttf</string>
|
||||
<string>FontAwesome.ttf</string>
|
||||
<string>FontAwesome5_Brands.ttf</string>
|
||||
<string>FontAwesome5_Regular.ttf</string>
|
||||
<string>FontAwesome5_Solid.ttf</string>
|
||||
<string>Foundation.ttf</string>
|
||||
<string>Ionicons.ttf</string>
|
||||
<string>MaterialIcons.ttf</string>
|
||||
<string>MaterialDesignIcons.ttf</string>
|
||||
<string>MaterialCommunityIcons.ttf</string>
|
||||
<string>SimpleLineIcons.ttf</string>
|
||||
<string>Octicons.ttf</string>
|
||||
<string>Zocial.ttf</string>
|
||||
<string>Fontisto.ttf</string>
|
||||
</array>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
|
||||
@@ -6,7 +6,7 @@ PODS:
|
||||
- ReactCommon/turbomodule/core
|
||||
- EXConstants (17.1.7):
|
||||
- ExpoModulesCore
|
||||
- Expo (53.0.20):
|
||||
- Expo (53.0.23):
|
||||
- DoubleConversion
|
||||
- ExpoModulesCore
|
||||
- glog
|
||||
@@ -1408,7 +1408,7 @@ PODS:
|
||||
- ReactCommon/turbomodule/core
|
||||
- react-native-alarm-notification (3.5.0):
|
||||
- React
|
||||
- react-native-document-picker (10.1.5):
|
||||
- react-native-document-picker (10.1.7):
|
||||
- DoubleConversion
|
||||
- glog
|
||||
- hermes-engine
|
||||
@@ -1550,7 +1550,7 @@ PODS:
|
||||
- react-native-vector-icons-material-icons (12.4.0)
|
||||
- react-native-version-info (1.1.1):
|
||||
- React-Core
|
||||
- react-native-webview (13.15.0):
|
||||
- react-native-webview (13.16.0):
|
||||
- DoubleConversion
|
||||
- glog
|
||||
- hermes-engine
|
||||
@@ -1902,7 +1902,7 @@ PODS:
|
||||
- React-Core
|
||||
- RNCPushNotificationIOS (1.11.0):
|
||||
- React-Core
|
||||
- RNDateTimePicker (8.4.4):
|
||||
- RNDateTimePicker (8.4.5):
|
||||
- React-Core
|
||||
- RNDeviceInfo (14.0.4):
|
||||
- React-Core
|
||||
@@ -2306,10 +2306,10 @@ EXTERNAL SOURCES:
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90
|
||||
DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb
|
||||
DoubleConversion: 76ab83afb40bddeeee456813d9c04f67f78771b5
|
||||
EXAV: ae28256069c4cdde93d185c007d8f68d92902c2e
|
||||
EXConstants: 98bcf0f22b820f9b28f9fee55ff2daededadd2f8
|
||||
Expo: b527631da3b11e085809e877b845f9e6cdd68f9c
|
||||
Expo: c8f323f74218c45c46e27eed40d8a53ba50667c3
|
||||
ExpoAsset: ef06e880126c375f580d4923fdd1cdf4ee6ee7d6
|
||||
ExpoCamera: e1879906d41184e84b57d7643119f8509414e318
|
||||
ExpoFileSystem: 7f92f7be2f5c5ed40a7c9efc8fa30821181d9d63
|
||||
@@ -2319,7 +2319,7 @@ SPEC CHECKSUMS:
|
||||
fast_float: 06eeec4fe712a76acc9376682e4808b05ce978b6
|
||||
FBLazyVector: 84b955f7b4da8b895faf5946f73748267347c975
|
||||
fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd
|
||||
glog: 5683914934d5b6e4240e497e0f4a3b42d1854183
|
||||
glog: c5d68082e772fa1c511173d6b30a9de2c05a69a2
|
||||
hermes-engine: 314be5250afa5692b57b4dd1705959e1973a8ebe
|
||||
JoplinCommonShareExtension: a8b60b02704d85a7305627912c0240e94af78db7
|
||||
JoplinRNShareExtension: e158a4b53ee0aa9cd3037a16221dc8adbd6f7860
|
||||
@@ -2356,7 +2356,7 @@ SPEC CHECKSUMS:
|
||||
React-Mapbuffer: c3f4b608e4a59dd2f6a416ef4d47a14400194468
|
||||
React-microtasksnativemodule: 054f34e9b82f02bd40f09cebd4083828b5b2beb6
|
||||
react-native-alarm-notification: a4326a743df72a94d361a4c3a21515556f650341
|
||||
react-native-document-picker: d7580f6e287bbf2c31c071d6b3f252ae1c6586f1
|
||||
react-native-document-picker: b6419b766863408dacbdf5e97b2f3a694c611150
|
||||
react-native-geolocation: ec15ffebc53790314885eb9e5f2132132fbc2600
|
||||
react-native-get-random-values: d16467cf726c618e9c7a8c3c39c31faa2244bbba
|
||||
react-native-image-picker: 7babe45e727db306b3f00d08c72eda3586d6e9c1
|
||||
@@ -2373,7 +2373,7 @@ SPEC CHECKSUMS:
|
||||
react-native-vector-icons-material-design-icons: 76cd460b3540b80527b4a80fb7f867f7deedb498
|
||||
react-native-vector-icons-material-icons: d67e485a05560416ff6b5977d5fa7e0eb6af6870
|
||||
react-native-version-info: f0b04e16111c4016749235ff6d9a757039189141
|
||||
react-native-webview: 0dceb35a9d050f5fa55f7fe2d8c4d1903651eb7d
|
||||
react-native-webview: 8ad427a520a3b94d2006a62bb7756be726116af5
|
||||
React-NativeModulesApple: 2c4377e139522c3d73f5df582e4f051a838ff25e
|
||||
React-oscompat: ef5df1c734f19b8003e149317d041b8ce1f7d29c
|
||||
React-perflogger: 9a151e0b4c933c9205fd648c246506a83f31395d
|
||||
@@ -2408,7 +2408,7 @@ SPEC CHECKSUMS:
|
||||
rn-fetch-blob: 25612b6d6f6e980c6f17ed98ba2f58f5696a51ca
|
||||
RNCClipboard: f6679d470d0da2bce2a37b0af7b9e0bf369ecda5
|
||||
RNCPushNotificationIOS: 6c4ca3388c7434e4a662b92e4dfeeee858e6f440
|
||||
RNDateTimePicker: 7d93eacf4bdf56350e4b7efd5cfc47639185e10c
|
||||
RNDateTimePicker: 8c12d12e8660697c2e176d2f98775764431c141f
|
||||
RNDeviceInfo: d863506092aef7e7af3a1c350c913d867d795047
|
||||
RNExitApp: 4432b9b7cc5ccec9f91c94e507849891282befd4
|
||||
RNFileViewer: 4b5d83358214347e4ab2d4ca8d5c1c90d869e251
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
const baseConfig = require('../../jest.config.base.js');
|
||||
|
||||
module.exports = {
|
||||
...baseConfig,
|
||||
|
||||
preset: 'react-native',
|
||||
|
||||
'moduleFileExtensions': [
|
||||
|
||||
@@ -114,10 +114,7 @@ jest.doMock('@expo/vector-icons/MaterialCommunityIcons', () => {
|
||||
|
||||
const mockIconLibrary = (libraryName, exportName) => {
|
||||
jest.doMock(libraryName, () => {
|
||||
const MockIconComponent = class extends require('react-native').View {
|
||||
// Used by the renderer
|
||||
static getImageSourceSync = () => ({ uri: '' });
|
||||
};
|
||||
const MockIconComponent = require('react-native').View;
|
||||
return {
|
||||
default: MockIconComponent,
|
||||
[exportName]: MockIconComponent,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@joplin/app-mobile",
|
||||
"description": "Joplin for Mobile",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"version": "3.5.0",
|
||||
"version": "3.6.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "BROWSERSLIST_IGNORE_OLD_DATA=true react-native start --reset-cache",
|
||||
@@ -16,25 +16,25 @@
|
||||
"clean": "node tools/clean.js",
|
||||
"buildInjectedJs": "gulp buildInjectedJs",
|
||||
"test": "jest",
|
||||
"test-ci": "yarn test",
|
||||
"test-ci": "node tools/runTestsConditionally.js",
|
||||
"watchInjectedJs": "gulp watchInjectedJs",
|
||||
"postinstall": "jetify"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bam.tech/react-native-image-resizer": "3.0.11",
|
||||
"@joplin/editor": "~3.5",
|
||||
"@joplin/lib": "~3.5",
|
||||
"@joplin/react-native-alarm-notification": "~3.5",
|
||||
"@joplin/react-native-saf-x": "~3.5",
|
||||
"@joplin/renderer": "~3.5",
|
||||
"@joplin/utils": "~3.5",
|
||||
"@js-draw/material-icons": "1.32.0",
|
||||
"@joplin/editor": "~3.6",
|
||||
"@joplin/lib": "~3.6",
|
||||
"@joplin/react-native-alarm-notification": "~3.6",
|
||||
"@joplin/react-native-saf-x": "~3.6",
|
||||
"@joplin/renderer": "~3.6",
|
||||
"@joplin/utils": "~3.6",
|
||||
"@js-draw/material-icons": "1.33.0",
|
||||
"@react-native-clipboard/clipboard": "1.16.3",
|
||||
"@react-native-community/datetimepicker": "8.4.5",
|
||||
"@react-native-community/geolocation": "3.4.0",
|
||||
"@react-native-community/netinfo": "11.4.1",
|
||||
"@react-native-community/push-notification-ios": "1.11.0",
|
||||
"@react-native-documents/picker": "10.1.6",
|
||||
"@react-native-documents/picker": "10.1.7",
|
||||
"@react-native-vector-icons/fontawesome5": "12.3.0",
|
||||
"@react-native-vector-icons/get-image": "12.3.0",
|
||||
"@react-native-vector-icons/ionicons": "12.3.0",
|
||||
@@ -47,11 +47,11 @@
|
||||
"crypto-browserify": "3.12.1",
|
||||
"deprecated-react-native-prop-types": "5.0.0",
|
||||
"events": "3.3.0",
|
||||
"expo": "53.0.20",
|
||||
"expo": "53.0.23",
|
||||
"expo-av": "15.1.7",
|
||||
"expo-camera": "16.1.11",
|
||||
"expo-local-authentication": "16.0.5",
|
||||
"js-draw": "1.32.0",
|
||||
"js-draw": "1.33.0",
|
||||
"lodash": "4.17.21",
|
||||
"md5": "2.3.0",
|
||||
"path-browserify": "1.0.1",
|
||||
@@ -59,14 +59,14 @@
|
||||
"punycode": "2.3.1",
|
||||
"react": "19.0.0",
|
||||
"react-native": "0.79.2",
|
||||
"react-native-device-info": "14.0.4",
|
||||
"react-native-device-info": "14.1.1",
|
||||
"react-native-dropdownalert": "5.2.0",
|
||||
"react-native-exit-app": "2.0.0",
|
||||
"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": "8.2.1",
|
||||
"react-native-localize": "3.5.2",
|
||||
"react-native-localize": "3.5.4",
|
||||
"react-native-modal-datetime-picker": "18.0.0",
|
||||
"react-native-paper": "5.14.5",
|
||||
"react-native-popup-menu": "0.17.0",
|
||||
@@ -80,7 +80,7 @@
|
||||
"react-native-svg": "15.13.0",
|
||||
"react-native-url-polyfill": "2.0.0",
|
||||
"react-native-version-info": "1.1.1",
|
||||
"react-native-webview": "13.15.0",
|
||||
"react-native-webview": "13.16.0",
|
||||
"react-native-zip-archive": "7.0.2",
|
||||
"react-redux": "8.1.3",
|
||||
"redux": "4.2.1",
|
||||
@@ -97,7 +97,7 @@
|
||||
"@babel/plugin-transform-export-namespace-from": "7.27.1",
|
||||
"@babel/preset-env": "7.25.3",
|
||||
"@babel/runtime": "7.25.0",
|
||||
"@joplin/tools": "~3.5",
|
||||
"@joplin/tools": "~3.6",
|
||||
"@joplin/turndown": "~4.0.80",
|
||||
"@joplin/turndown-plugin-gfm": "~1.0.62",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.0",
|
||||
@@ -114,13 +114,13 @@
|
||||
"@types/node": "18.19.130",
|
||||
"@types/react": "19.0.14",
|
||||
"@types/react-redux": "7.1.33",
|
||||
"@types/serviceworker": "0.0.153",
|
||||
"@types/serviceworker": "0.0.158",
|
||||
"@types/tar-stream": "3.1.4",
|
||||
"babel-jest": "29.7.0",
|
||||
"babel-loader": "9.1.3",
|
||||
"babel-plugin-module-resolver": "4.1.0",
|
||||
"babel-plugin-react-native-web": "0.21.1",
|
||||
"esbuild": "0.25.10",
|
||||
"babel-plugin-react-native-web": "0.21.2",
|
||||
"esbuild": "0.25.11",
|
||||
"fast-deep-equal": "3.1.3",
|
||||
"fs-extra": "11.3.2",
|
||||
"gulp": "4.0.2",
|
||||
@@ -131,7 +131,7 @@
|
||||
"nodemon": "3.1.10",
|
||||
"punycode": "2.3.1",
|
||||
"react-dom": "19.0.0",
|
||||
"react-native-web": "0.21.1",
|
||||
"react-native-web": "0.21.2",
|
||||
"react-refresh": "0.17.0",
|
||||
"react-test-renderer": "19.0.0",
|
||||
"sharp": "0.34.4",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
module.exports = {
|
||||
hash:"eff3d7e44d37c3c7f09b80c7a51d078b", files: {
|
||||
hash:"88e5d809af57eac7b86c4deaf21b9345", files: {
|
||||
'abc/abc_render.js': { data: require('./abc/abc_render.js.base64.js'), mime: 'application/javascript', encoding: 'base64' },
|
||||
'abc/abcjs-basic-min.js': { data: require('./abc/abcjs-basic-min.js.base64.js'), mime: 'application/javascript', encoding: 'base64' },
|
||||
'highlight.js/atom-one-dark-reasonable.css': { data: require('./highlight.js/atom-one-dark-reasonable.css.base64.js'), mime: 'text/css', encoding: 'base64' },
|
||||
|
||||
@@ -1 +1 @@
|
||||
module.exports = {"hash":"eff3d7e44d37c3c7f09b80c7a51d078b","files":["abc/abc_render.js","abc/abcjs-basic-min.js","highlight.js/atom-one-dark-reasonable.css","highlight.js/atom-one-light.css","katex/fonts/KaTeX_AMS-Regular.woff2","katex/fonts/KaTeX_Caligraphic-Bold.woff2","katex/fonts/KaTeX_Caligraphic-Regular.woff2","katex/fonts/KaTeX_Fraktur-Bold.woff2","katex/fonts/KaTeX_Fraktur-Regular.woff2","katex/fonts/KaTeX_Main-Bold.woff2","katex/fonts/KaTeX_Main-BoldItalic.woff2","katex/fonts/KaTeX_Main-Italic.woff2","katex/fonts/KaTeX_Main-Regular.woff2","katex/fonts/KaTeX_Math-BoldItalic.woff2","katex/fonts/KaTeX_Math-Italic.woff2","katex/fonts/KaTeX_SansSerif-Bold.woff2","katex/fonts/KaTeX_SansSerif-Italic.woff2","katex/fonts/KaTeX_SansSerif-Regular.woff2","katex/fonts/KaTeX_Script-Regular.woff2","katex/fonts/KaTeX_Size1-Regular.woff2","katex/fonts/KaTeX_Size2-Regular.woff2","katex/fonts/KaTeX_Size3-Regular.woff2","katex/fonts/KaTeX_Size4-Regular.woff2","katex/fonts/KaTeX_Typewriter-Regular.woff2","katex/katex.css","mermaid/mermaid.min.js","mermaid/mermaid_render.js"]}
|
||||
module.exports = {"hash":"88e5d809af57eac7b86c4deaf21b9345","files":["abc/abc_render.js","abc/abcjs-basic-min.js","highlight.js/atom-one-dark-reasonable.css","highlight.js/atom-one-light.css","katex/fonts/KaTeX_AMS-Regular.woff2","katex/fonts/KaTeX_Caligraphic-Bold.woff2","katex/fonts/KaTeX_Caligraphic-Regular.woff2","katex/fonts/KaTeX_Fraktur-Bold.woff2","katex/fonts/KaTeX_Fraktur-Regular.woff2","katex/fonts/KaTeX_Main-Bold.woff2","katex/fonts/KaTeX_Main-BoldItalic.woff2","katex/fonts/KaTeX_Main-Italic.woff2","katex/fonts/KaTeX_Main-Regular.woff2","katex/fonts/KaTeX_Math-BoldItalic.woff2","katex/fonts/KaTeX_Math-Italic.woff2","katex/fonts/KaTeX_SansSerif-Bold.woff2","katex/fonts/KaTeX_SansSerif-Italic.woff2","katex/fonts/KaTeX_SansSerif-Regular.woff2","katex/fonts/KaTeX_Script-Regular.woff2","katex/fonts/KaTeX_Size1-Regular.woff2","katex/fonts/KaTeX_Size2-Regular.woff2","katex/fonts/KaTeX_Size3-Regular.woff2","katex/fonts/KaTeX_Size4-Regular.woff2","katex/fonts/KaTeX_Typewriter-Regular.woff2","katex/katex.css","mermaid/mermaid.min.js","mermaid/mermaid_render.js"]}
|
||||
@@ -39,15 +39,17 @@ export interface VoiceTypingProvider {
|
||||
|
||||
export default class VoiceTyping {
|
||||
private provider: VoiceTypingProvider|null = null;
|
||||
public constructor(
|
||||
private locale: string,
|
||||
allProviders: VoiceTypingProvider[],
|
||||
) {
|
||||
this.provider = allProviders.find(p => p.supported()) ?? null;
|
||||
public constructor(private locale: string) {
|
||||
this.provider = VoiceTyping.providers_.find(p => p.supported()) ?? null;
|
||||
}
|
||||
|
||||
public supported() {
|
||||
return this.provider !== null;
|
||||
private static providers_: VoiceTypingProvider[] = [];
|
||||
public static initialize(providers: VoiceTypingProvider[]) {
|
||||
this.providers_ = providers;
|
||||
}
|
||||
|
||||
public static supported() {
|
||||
return this.providers_.some(p => p.supported());
|
||||
}
|
||||
|
||||
private getModelPath() {
|
||||
|
||||
@@ -213,7 +213,7 @@ const modelLocalFilepath = () => {
|
||||
};
|
||||
|
||||
const whisper: VoiceTypingProvider = {
|
||||
supported: () => !!SpeechToTextModule,
|
||||
supported: () => !!SpeechToTextModule && Setting.value('buildFlag.voiceTypingEnabled'),
|
||||
modelLocalFilepath: modelLocalFilepath,
|
||||
getDownloadUrl: (locale) => {
|
||||
const lang = languageCodeOnly(locale).toLowerCase();
|
||||
|
||||
13
packages/app-mobile/tools/runTestsConditionally.js
Normal file
@@ -0,0 +1,13 @@
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
if (process.env.RUNNER_OS === 'macOS') {
|
||||
console.info('Skipping app-mobile tests on macOS');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
try {
|
||||
execSync('yarn test', { stdio: 'inherit' });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -90,6 +90,8 @@ import PerformanceLogger from '@joplin/lib/PerformanceLogger';
|
||||
import { Profile } from '@joplin/lib/services/profileConfig/types';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import { Platform } from 'react-native';
|
||||
import VoiceTyping from '../services/voiceTyping/VoiceTyping';
|
||||
import whisper from '../services/voiceTyping/whisper';
|
||||
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
@@ -362,6 +364,9 @@ const buildStartupTasks = (
|
||||
addTask('buildStartupTasks/migrate PPK', async () => {
|
||||
await migratePpk();
|
||||
});
|
||||
addTask('buildStartupTasks/set up voice typing', async () => {
|
||||
VoiceTyping.initialize([whisper]);
|
||||
});
|
||||
addTask('buildStartupTasks/load folders', async () => {
|
||||
await refreshFolders(dispatch, '');
|
||||
|
||||
|
||||
14
packages/app-mobile/utils/hooks/useDebounced.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import useQueuedAsyncEffect from '@joplin/lib/hooks/useQueuedAsyncEffect';
|
||||
import { useState } from 'react';
|
||||
|
||||
const useDebounced = <T> (value: T, interval: number) => {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
|
||||
useQueuedAsyncEffect(() => {
|
||||
setDebouncedValue(value);
|
||||
}, [value], { interval });
|
||||
|
||||
return debouncedValue;
|
||||
};
|
||||
|
||||
export default useDebounced;
|
||||
@@ -12,7 +12,7 @@
|
||||
default-src 'self' ;
|
||||
connect-src 'self' * http://* https://* blob: ;
|
||||
style-src 'unsafe-inline' 'self' blob: ;
|
||||
child-src 'self' ;
|
||||
child-src 'self' https://*.youtube.com https://*.youtube-nocookie.com ;
|
||||
script-src 'self' 'unsafe-eval' 'unsafe-inline' ;
|
||||
media-src 'self' blob: data: https://* http://* ;
|
||||
img-src 'self' blob: data: http://* https://* ;
|
||||
|
||||
@@ -7,115 +7,129 @@ import { tmpdir } from 'os';
|
||||
import { chdir, cwd } from 'process';
|
||||
import { execCommand } from '@joplin/utils';
|
||||
import { glob } from 'glob';
|
||||
import readRepositoryJson from './utils/readRepositoryJson';
|
||||
import readRepositoryJson, { BuiltInPluginType, RepositoryData } from './utils/readRepositoryJson';
|
||||
import getPathToPatchFileFor from './utils/getPathToPatchFileFor';
|
||||
import getCurrentCommitHash from './utils/getCurrentCommitHash';
|
||||
import { waitForCliInput } from '@joplin/utils/cli';
|
||||
|
||||
interface Options {
|
||||
outputParentDir: string|null;
|
||||
beforeInstall: (buildDir: string, pluginName: string)=> Promise<void>;
|
||||
beforePatch: ()=> Promise<void>;
|
||||
}
|
||||
|
||||
const buildDefaultPlugins = async (outputParentDir: string|null, options: Options) => {
|
||||
const pluginSourcesDir = resolve(join(__dirname, 'plugin-sources'));
|
||||
const buildDefaultPlugins = async (options: Options) => {
|
||||
const pluginRepositoryData = await readRepositoryJson(join(__dirname, 'pluginRepositories.json'));
|
||||
|
||||
const originalDirectory = cwd();
|
||||
|
||||
const logStatus = (...message: string[]) => {
|
||||
const blue = '\x1b[96m';
|
||||
const reset = '\x1b[0m';
|
||||
console.log(blue, ...message, reset);
|
||||
};
|
||||
|
||||
for (const pluginId in pluginRepositoryData) {
|
||||
const repositoryData = pluginRepositoryData[pluginId];
|
||||
const outputPath = options.outputParentDir && join(options.outputParentDir, `${pluginId}.jpl`);
|
||||
|
||||
const buildDir = await mkdtemp(join(tmpdir(), 'default-plugin-build'));
|
||||
try {
|
||||
logStatus('Building plugin', pluginId, 'at', buildDir);
|
||||
const pluginDir = resolve(join(pluginSourcesDir, pluginId));
|
||||
|
||||
// Clone the repository if not done yet
|
||||
if (!(await exists(pluginDir)) || (await readdir(pluginDir)).length === 0) {
|
||||
logStatus(`Cloning from repository ${repositoryData.cloneUrl}`);
|
||||
await execCommand(['git', 'clone', '--', repositoryData.cloneUrl, pluginDir]);
|
||||
chdir(pluginDir);
|
||||
if (repositoryData.type === BuiltInPluginType.Built) {
|
||||
await buildPlugin(pluginId, repositoryData, outputPath, options);
|
||||
} else {
|
||||
if (!outputPath) {
|
||||
console.warn('Skipping NPM plugin,', pluginId, ': missing output path.');
|
||||
continue;
|
||||
}
|
||||
|
||||
chdir(pluginDir);
|
||||
const expectedCommitHash = repositoryData.commit;
|
||||
|
||||
logStatus(`Switching to commit ${expectedCommitHash}`);
|
||||
await execCommand(['git', 'switch', repositoryData.branch]);
|
||||
|
||||
try {
|
||||
await execCommand(['git', 'checkout', expectedCommitHash]);
|
||||
} catch (error) {
|
||||
logStatus(`git checkout failed with error ${error}. Fetching...`);
|
||||
await execCommand(['git', 'fetch']);
|
||||
await execCommand(['git', 'checkout', expectedCommitHash]);
|
||||
}
|
||||
|
||||
if (await getCurrentCommitHash() !== expectedCommitHash) {
|
||||
throw new Error(`Unable to checkout commit ${expectedCommitHash}`);
|
||||
}
|
||||
|
||||
logStatus('Copying repository files...');
|
||||
await copy(pluginDir, buildDir, {
|
||||
filter: fileName => {
|
||||
return basename(fileName) !== '.git';
|
||||
},
|
||||
});
|
||||
|
||||
chdir(buildDir);
|
||||
|
||||
logStatus('Initializing repository.');
|
||||
await execCommand('git init . -b main');
|
||||
|
||||
logStatus('Running before-patch hook.');
|
||||
await options.beforePatch();
|
||||
|
||||
const patchFile = getPathToPatchFileFor(pluginId);
|
||||
if (await exists(patchFile)) {
|
||||
logStatus('Applying patch.');
|
||||
await execCommand(['git', 'apply', patchFile]);
|
||||
}
|
||||
|
||||
await options.beforeInstall(buildDir, pluginId);
|
||||
|
||||
logStatus('Installing dependencies.');
|
||||
await execCommand('npm install');
|
||||
|
||||
const jplFiles = await glob('publish/*.jpl');
|
||||
logStatus(`Found built .jpl files: ${JSON.stringify(jplFiles)}`);
|
||||
|
||||
if (jplFiles.length === 0) {
|
||||
throw new Error(`No published files found in ${buildDir}/publish`);
|
||||
}
|
||||
|
||||
if (outputParentDir !== null) {
|
||||
logStatus(`Checking output directory in ${outputParentDir}`);
|
||||
const outputPath = join(outputParentDir, `${pluginId}.jpl`);
|
||||
|
||||
const sourceFile = jplFiles[0];
|
||||
logStatus(`Copying built file from ${sourceFile} to ${outputPath}`);
|
||||
await copy(sourceFile, outputPath);
|
||||
} else {
|
||||
console.warn('No output directory specified. Not copying built .jpl files.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
console.log('Build directory', buildDir);
|
||||
await waitForCliInput();
|
||||
throw error;
|
||||
} finally {
|
||||
chdir(originalDirectory);
|
||||
await remove(buildDir);
|
||||
logStatus('Removed build directory');
|
||||
logStatus('Copying plugin', pluginId, 'JPL file to', outputPath);
|
||||
await copy(join(__dirname, 'node_modules', repositoryData.package, 'publish', `${pluginId}.jpl`), outputPath);
|
||||
logStatus('Copied.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const logStatus = (...message: string[]) => {
|
||||
const blue = '\x1b[96m';
|
||||
const reset = '\x1b[0m';
|
||||
console.log(blue, ...message, reset);
|
||||
};
|
||||
|
||||
const buildPlugin = async (pluginId: string, repositoryData: RepositoryData, outputPath: string|null, options: Options) => {
|
||||
const pluginSourcesDir = resolve(join(__dirname, 'plugin-sources'));
|
||||
const originalDirectory = cwd();
|
||||
|
||||
const buildDir = await mkdtemp(join(tmpdir(), 'default-plugin-build'));
|
||||
try {
|
||||
logStatus('Building plugin', pluginId, 'at', buildDir);
|
||||
const pluginDir = resolve(join(pluginSourcesDir, pluginId));
|
||||
|
||||
// Clone the repository if not done yet
|
||||
if (!(await exists(pluginDir)) || (await readdir(pluginDir)).length === 0) {
|
||||
logStatus(`Cloning from repository ${repositoryData.cloneUrl}`);
|
||||
await execCommand(['git', 'clone', '--', repositoryData.cloneUrl, pluginDir]);
|
||||
chdir(pluginDir);
|
||||
}
|
||||
|
||||
chdir(pluginDir);
|
||||
const expectedCommitHash = repositoryData.commit;
|
||||
|
||||
logStatus(`Switching to commit ${expectedCommitHash}`);
|
||||
await execCommand(['git', 'switch', repositoryData.branch]);
|
||||
|
||||
try {
|
||||
await execCommand(['git', 'checkout', expectedCommitHash]);
|
||||
} catch (error) {
|
||||
logStatus(`git checkout failed with error ${error}. Fetching...`);
|
||||
await execCommand(['git', 'fetch']);
|
||||
await execCommand(['git', 'checkout', expectedCommitHash]);
|
||||
}
|
||||
|
||||
if (await getCurrentCommitHash() !== expectedCommitHash) {
|
||||
throw new Error(`Unable to checkout commit ${expectedCommitHash}`);
|
||||
}
|
||||
|
||||
logStatus('Copying repository files...');
|
||||
await copy(pluginDir, buildDir, {
|
||||
filter: fileName => {
|
||||
return basename(fileName) !== '.git';
|
||||
},
|
||||
});
|
||||
|
||||
chdir(buildDir);
|
||||
|
||||
logStatus('Initializing repository.');
|
||||
await execCommand('git init . -b main');
|
||||
|
||||
logStatus('Running before-patch hook.');
|
||||
await options.beforePatch();
|
||||
|
||||
const patchFile = getPathToPatchFileFor(pluginId);
|
||||
if (await exists(patchFile)) {
|
||||
logStatus('Applying patch.');
|
||||
await execCommand(['git', 'apply', patchFile]);
|
||||
}
|
||||
|
||||
await options.beforeInstall(buildDir, pluginId);
|
||||
|
||||
logStatus('Installing dependencies.');
|
||||
await execCommand('npm install');
|
||||
|
||||
const jplFiles = await glob('publish/*.jpl');
|
||||
logStatus(`Found built .jpl files: ${JSON.stringify(jplFiles)}`);
|
||||
|
||||
if (jplFiles.length === 0) {
|
||||
throw new Error(`No published files found in ${buildDir}/publish`);
|
||||
}
|
||||
|
||||
if (outputPath !== null) {
|
||||
const sourceFile = jplFiles[0];
|
||||
logStatus(`Copying built file from ${sourceFile} to ${outputPath}`);
|
||||
await copy(sourceFile, outputPath);
|
||||
} else {
|
||||
console.warn('No output directory specified. Not copying built .jpl files.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
console.log('Build directory', buildDir);
|
||||
await waitForCliInput();
|
||||
throw error;
|
||||
} finally {
|
||||
chdir(originalDirectory);
|
||||
await remove(buildDir);
|
||||
logStatus('Removed build directory');
|
||||
}
|
||||
};
|
||||
|
||||
export default buildDefaultPlugins;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import buildDefaultPlugins from '../buildDefaultPlugins';
|
||||
|
||||
const buildAll = (outputDirectory: string) => {
|
||||
return buildDefaultPlugins(outputDirectory, {
|
||||
return buildDefaultPlugins({
|
||||
outputParentDir: outputDirectory,
|
||||
beforeInstall: async () => { },
|
||||
beforePatch: async () => { },
|
||||
});
|
||||
|
||||
@@ -8,7 +8,8 @@ import getPathToPatchFileFor from '../utils/getPathToPatchFileFor';
|
||||
const editPatch = async (targetPluginId: string, outputParentDir: string|null) => {
|
||||
let patchedPlugin = false;
|
||||
|
||||
await buildDefaultPlugins(outputParentDir, {
|
||||
await buildDefaultPlugins({
|
||||
outputParentDir: outputParentDir,
|
||||
beforePatch: async () => {
|
||||
// To make updating just the patch possible, a commit is created just before applying
|
||||
// the patch.
|
||||
@@ -34,7 +35,7 @@ const editPatch = async (targetPluginId: string, outputParentDir: string|null) =
|
||||
});
|
||||
|
||||
if (!patchedPlugin) {
|
||||
throw new Error(`No default plugin with ID ${targetPluginId} found!`);
|
||||
throw new Error(`No patchable default plugin with ID ${targetPluginId} found! Make sure that the plugin has a "cloneUrl" and "branch" in pluginRepositories.json.`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/default-plugins",
|
||||
"version": "3.5.0",
|
||||
"version": "3.6.0",
|
||||
"description": "Default plugins bundler",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -14,11 +14,12 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/yargs": "17.0.33",
|
||||
"joplin-plugin-freehand-drawing": "4.3.0",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "5.8.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@joplin/utils": "~3.5",
|
||||
"@joplin/utils": "~3.6",
|
||||
"fs-extra": "11.3.2",
|
||||
"yargs": "17.7.2"
|
||||
}
|
||||
|
||||
@@ -2,11 +2,10 @@
|
||||
"io.github.jackgruber.backup": {
|
||||
"cloneUrl": "https://github.com/JackGruber/joplin-plugin-backup.git",
|
||||
"branch": "master",
|
||||
"commit": "abb58175e2d2bf34899f1b32cb74137e5c788bf9"
|
||||
"commit": "2c3da7056e7ac39c86c2051a4fdb99d9534dd0a1"
|
||||
},
|
||||
"io.github.personalizedrefrigerator.js-draw": {
|
||||
"cloneUrl": "https://github.com/personalizedrefrigerator/joplin-plugin-freehand-drawing.git",
|
||||
"branch": "main",
|
||||
"commit": "63b6d3f185b5b3664632e498df7c7ad7824038d0"
|
||||
"package": "joplin-plugin-freehand-drawing"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,28 @@
|
||||
import { readFile } from 'fs-extra';
|
||||
|
||||
export enum BuiltInPluginType {
|
||||
// Plugins that need to be built when building Joplin (e.g. if the plugin
|
||||
// needs to be patched)
|
||||
Built,
|
||||
// Plugins that can be fetched directly from NPM. Must also be marked as a
|
||||
// dev dependency.
|
||||
FromNpm,
|
||||
}
|
||||
|
||||
export interface RepositoryData {
|
||||
type: BuiltInPluginType.Built;
|
||||
cloneUrl: string;
|
||||
branch: string;
|
||||
commit: string;
|
||||
}
|
||||
|
||||
export interface NpmReference {
|
||||
type: BuiltInPluginType.FromNpm;
|
||||
package: string;
|
||||
}
|
||||
|
||||
export interface AllRepositoryData {
|
||||
[pluginId: string]: RepositoryData;
|
||||
[pluginId: string]: RepositoryData|NpmReference;
|
||||
}
|
||||
|
||||
const readRepositoryJson = async (repositoryDataFilepath: string): Promise<AllRepositoryData> => {
|
||||
@@ -26,9 +41,17 @@ const readRepositoryJson = async (repositoryDataFilepath: string): Promise<AllRe
|
||||
}
|
||||
};
|
||||
|
||||
assertPropertyIsString('cloneUrl');
|
||||
assertPropertyIsString('branch');
|
||||
assertPropertyIsString('commit');
|
||||
let type;
|
||||
if ('branch' in parsedJson[pluginId]) {
|
||||
assertPropertyIsString('cloneUrl');
|
||||
assertPropertyIsString('branch');
|
||||
assertPropertyIsString('commit');
|
||||
type = BuiltInPluginType.Built;
|
||||
} else {
|
||||
assertPropertyIsString('package');
|
||||
type = BuiltInPluginType.FromNpm;
|
||||
}
|
||||
parsedJson[pluginId] = { ...parsedJson[pluginId], type };
|
||||
}
|
||||
|
||||
return parsedJson;
|
||||
|
||||
@@ -50,7 +50,7 @@ describe('createEditor', () => {
|
||||
|
||||
const headerLine = document.body.querySelector('.cm-headerLine')!;
|
||||
expect(headerLine.textContent).toBe(headerLineText);
|
||||
expect(getComputedStyle(headerLine).fontSize).toBe('1.6em');
|
||||
expect(getComputedStyle(headerLine).fontSize).toBe('1.5em');
|
||||
|
||||
// CodeMirror nests the tag that styles the header within .cm-headerLine:
|
||||
// <div class='cm-headerLine'><span class='someclass'>Testing...</span></div>
|
||||
|
||||
@@ -3,39 +3,17 @@ import { EditorSelection } from '@codemirror/state';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import uslug from '@joplin/fork-uslug/lib/uslug';
|
||||
import { SyntaxNodeRef } from '@lezer/common';
|
||||
import htmlNodeInfo from '../utils/htmlNodeInfo';
|
||||
|
||||
const jumpToHash = (view: EditorView, hash: string) => {
|
||||
const state = view.state;
|
||||
const timeout = 1_000; // Maximum time to spend parsing the syntax tree
|
||||
let targetLocation: number|undefined = undefined;
|
||||
|
||||
const removeQuotes = (quoted: string) => quoted.replace(/^["'](.*)["']$/, '$1');
|
||||
|
||||
const makeEnterNode = (offset: number) => (node: SyntaxNodeRef) => {
|
||||
const nodeToText = (node: SyntaxNodeRef) => {
|
||||
return state.sliceDoc(node.from + offset, node.to + offset);
|
||||
};
|
||||
// Returns the attribute with the given name for [node]
|
||||
const getHtmlNodeAttr = (node: SyntaxNodeRef, attrName: string) => {
|
||||
if (node.from === node.to) return null; // Empty
|
||||
const content = node.node.resolveInner(node.from + 1);
|
||||
|
||||
// Search for the "id" attribute
|
||||
const attributes = content.getChildren('Attribute');
|
||||
for (const attribute of attributes) {
|
||||
const nameNode = attribute.getChild('AttributeName');
|
||||
const valueNode = attribute.getChild('AttributeValue');
|
||||
|
||||
if (nameNode && valueNode) {
|
||||
const name = nodeToText(nameNode).toLowerCase().replace(/^"(.*)"$/, '$1');
|
||||
if (name === attrName) {
|
||||
return removeQuotes(nodeToText(valueNode));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const found = targetLocation !== undefined;
|
||||
if (found) return false; // Skip this node
|
||||
@@ -46,13 +24,14 @@ const jumpToHash = (view: EditorView, hash: string) => {
|
||||
.replace(/^#+\s/, '') // Leading #s in headers
|
||||
.replace(/\n-+$/, ''); // Trailing --s in headers
|
||||
matches = hash === uslug(nodeText);
|
||||
} else if (node.name === 'HTMLTag' || node.name === 'HTMLBlock') {
|
||||
} else if (node.name === 'HTMLBlock') {
|
||||
// CodeMirror adds HTML information to Markdown documents using overlays attached
|
||||
// to HTMLTag and HTMLBlock nodes.
|
||||
// Use .enter to enter the overlay and visit the HTML nodes:
|
||||
node.node.enter(node.from, 1).toTree().iterate({ enter: makeEnterNode(node.from) });
|
||||
} else if (node.name === 'OpenTag') {
|
||||
matches = getHtmlNodeAttr(node, 'id') === hash || getHtmlNodeAttr(node, 'name') === hash;
|
||||
} else if (node.name === 'OpenTag' || node.name === 'HTMLTag') {
|
||||
const htmlNodeDetails = htmlNodeInfo(node, state);
|
||||
matches = htmlNodeDetails.getAttr('id') === hash || htmlNodeDetails.getAttr('name') === hash;
|
||||
}
|
||||
|
||||
if (matches) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import replaceBulletLists from './replaceBulletLists';
|
||||
import replaceCheckboxes from './replaceCheckboxes';
|
||||
import replaceDividers from './replaceDividers';
|
||||
import replaceFormatCharacters from './replaceFormatCharacters';
|
||||
import replaceInlineHtml from './replaceInlineHtml';
|
||||
|
||||
export default () => {
|
||||
return [
|
||||
@@ -13,5 +14,6 @@ export default () => {
|
||||
replaceBackslashEscapes,
|
||||
replaceDividers,
|
||||
addFormattingClasses,
|
||||
replaceInlineHtml,
|
||||
];
|
||||
};
|
||||
|
||||
@@ -3,20 +3,31 @@ import { SyntaxNodeRef } from '@lezer/common';
|
||||
import makeReplaceExtension from './utils/makeInlineReplaceExtension';
|
||||
import toggleCheckboxAt from '../../utils/markdown/toggleCheckboxAt';
|
||||
|
||||
const checkboxClassName = 'cm-ext-checkbox-toggle';
|
||||
const checkboxContainerClassName = 'cm-ext-checkbox-toggle';
|
||||
const checkboxClassName = 'cm-ext-checkbox';
|
||||
|
||||
|
||||
class CheckboxWidget extends WidgetType {
|
||||
public constructor(private checked: boolean, private depth: number, private label: string) {
|
||||
public constructor(
|
||||
private checked: boolean,
|
||||
private depth: number,
|
||||
private label: string,
|
||||
private markup: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
public eq(other: CheckboxWidget) {
|
||||
return other.checked === this.checked && other.depth === this.depth && other.label === this.label;
|
||||
return other.checked === this.checked
|
||||
&& other.depth === this.depth
|
||||
&& other.label === this.label
|
||||
&& other.markup === this.markup;
|
||||
}
|
||||
|
||||
private applyContainerClasses(container: HTMLElement) {
|
||||
container.classList.add(checkboxClassName);
|
||||
container.classList.add(checkboxContainerClassName);
|
||||
// For sizing: Should have the same font/styles as non-rendered checkboxes:
|
||||
container.classList.add('cm-taskMarker');
|
||||
|
||||
for (const className of [...container.classList]) {
|
||||
if (className.startsWith('-depth-')) {
|
||||
@@ -30,12 +41,22 @@ class CheckboxWidget extends WidgetType {
|
||||
public toDOM(view: EditorView) {
|
||||
const container = document.createElement('span');
|
||||
|
||||
const sizingNode = document.createElement('span');
|
||||
sizingNode.classList.add('sizing');
|
||||
sizingNode.textContent = this.markup;
|
||||
container.appendChild(sizingNode);
|
||||
|
||||
const checkboxWrapper = document.createElement('span');
|
||||
checkboxWrapper.classList.add('content');
|
||||
container.appendChild(checkboxWrapper);
|
||||
|
||||
const checkbox = document.createElement('input');
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.checked = this.checked;
|
||||
checkbox.ariaLabel = this.label;
|
||||
checkbox.title = this.label;
|
||||
container.appendChild(checkbox);
|
||||
checkbox.classList.add(checkboxClassName);
|
||||
checkboxWrapper.appendChild(checkbox);
|
||||
|
||||
checkbox.oninput = () => {
|
||||
toggleCheckboxAt(view.posAtDOM(container))(view);
|
||||
@@ -66,16 +87,32 @@ const completedListItemDecoration = Decoration.line({ class: completedTaskClassN
|
||||
|
||||
const replaceCheckboxes = [
|
||||
EditorView.theme({
|
||||
[`& .${checkboxContainerClassName}`]: {
|
||||
position: 'relative',
|
||||
|
||||
'& > .sizing': {
|
||||
visibility: 'hidden',
|
||||
},
|
||||
|
||||
'& > .content': {
|
||||
position: 'absolute',
|
||||
left: '0',
|
||||
right: '0',
|
||||
top: '0',
|
||||
bottom: '0',
|
||||
textAlign: 'center',
|
||||
},
|
||||
},
|
||||
[`& .${checkboxClassName}`]: {
|
||||
'& > input': {
|
||||
width: '1.1em',
|
||||
height: '1.1em',
|
||||
margin: '4px',
|
||||
verticalAlign: 'middle',
|
||||
},
|
||||
'&:not(.-depth-1) > input': {
|
||||
marginInlineStart: 0,
|
||||
},
|
||||
verticalAlign: 'middle',
|
||||
|
||||
// Ensure that the checkbox grows as the font size increases:
|
||||
width: '100%',
|
||||
minHeight: '70%',
|
||||
|
||||
// Shift the checkbox slightly so that it's aligned with the list item bullet point
|
||||
margin: '0',
|
||||
marginBottom: '3px',
|
||||
},
|
||||
[`& .${completedTaskClassName}`]: {
|
||||
opacity: 0.69,
|
||||
@@ -84,7 +121,7 @@ const replaceCheckboxes = [
|
||||
EditorView.domEventHandlers({
|
||||
mousedown: (event) => {
|
||||
const target = event.target as Element;
|
||||
if (target.nodeName === 'INPUT' && target.parentElement?.classList?.contains(checkboxClassName)) {
|
||||
if (target.nodeName === 'INPUT' && target.classList?.contains(checkboxClassName)) {
|
||||
// Let the checkbox handle the event
|
||||
return true;
|
||||
}
|
||||
@@ -101,8 +138,14 @@ const replaceCheckboxes = [
|
||||
if (node.name === 'TaskMarker') {
|
||||
const containerLine = state.doc.lineAt(node.from);
|
||||
const labelText = state.doc.sliceString(node.to, containerLine.to);
|
||||
const markerText = state.doc.sliceString(node.from, node.to);
|
||||
|
||||
return new CheckboxWidget(markerIsChecked(node), parentTags.get('ListItem') ?? 0, labelText);
|
||||
return new CheckboxWidget(
|
||||
markerIsChecked(node),
|
||||
parentTags.get('ListItem') ?? 0,
|
||||
labelText,
|
||||
markerText,
|
||||
);
|
||||
} else if (node.name === 'Task') {
|
||||
const marker = node.node.getChild('TaskMarker');
|
||||
if (marker && markerIsChecked(marker)) {
|
||||
@@ -119,7 +162,7 @@ const replaceCheckboxes = [
|
||||
return null;
|
||||
}
|
||||
|
||||
return [listMarker.from, node.to];
|
||||
return [node.from, node.to];
|
||||
} else if (node.name === 'Task') {
|
||||
const taskLine = state.doc.lineAt(node.from);
|
||||
return [taskLine.from];
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { EditorSelection } from '@codemirror/state';
|
||||
import createTestEditor from '../../testing/createTestEditor';
|
||||
import replaceInlineHtml from './replaceInlineHtml';
|
||||
|
||||
const createEditor = async (initialMarkdown: string, expectedTags: string[] = ['HTMLTag']) => {
|
||||
const editor = await createTestEditor(
|
||||
initialMarkdown,
|
||||
EditorSelection.cursor(0),
|
||||
expectedTags,
|
||||
[replaceInlineHtml],
|
||||
);
|
||||
return editor;
|
||||
};
|
||||
|
||||
describe('replaceInlineHtml', () => {
|
||||
test.each([
|
||||
{ markdown: '<sup>Test</sup>', expectedTagsQuery: 'sup' },
|
||||
{ markdown: '<strike>Test</strike>', expectedTagsQuery: 'strike' },
|
||||
{ markdown: 'Test: <span style="color: red;">Test</span>', expectedTagsQuery: 'span[style]' },
|
||||
{ markdown: 'Test: <span style="color: rgb(123, 0, 0);">Test</span>', expectedTagsQuery: 'span[style]' },
|
||||
])('should render inline HTML (case %#)', async ({ markdown, expectedTagsQuery }) => {
|
||||
// Add additional newlines: Ensure that the cursor isn't initially on the same line as the content to be rendered:
|
||||
const editor = await createEditor(`\n\n${markdown}\n\n`);
|
||||
|
||||
expect(editor.contentDOM.querySelector(expectedTagsQuery)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import makeInlineReplaceExtension from './utils/makeInlineReplaceExtension';
|
||||
import { Decoration } from '@codemirror/view';
|
||||
import htmlNodeInfo, { HtmlNodeInfo } from '../../utils/htmlNodeInfo';
|
||||
import { SyntaxNodeRef } from '@lezer/common';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
|
||||
const hideDecoration = Decoration.replace({});
|
||||
|
||||
type OnRenderTagContent = (openingTag: HtmlNodeInfo)=> Decoration;
|
||||
const createHtmlReplacementExtension = (tagName: string, onRenderContent: OnRenderTagContent) => {
|
||||
const isMatchingTag = (info: HtmlNodeInfo) => {
|
||||
return info.tagName().toLowerCase() === tagName;
|
||||
};
|
||||
const isMatchingOpeningTag = (info: HtmlNodeInfo) => {
|
||||
return isMatchingTag(info) && info.opening;
|
||||
};
|
||||
const isMatchingClosingTag = (info: HtmlNodeInfo) => {
|
||||
return isMatchingTag(info) && info.closing;
|
||||
};
|
||||
|
||||
const findClosingTag = (openingTag: SyntaxNodeRef, state: EditorState) => {
|
||||
const openingTagInfo = htmlNodeInfo(openingTag, state);
|
||||
// Self-closing?
|
||||
if (openingTagInfo.closing) {
|
||||
return openingTag;
|
||||
}
|
||||
|
||||
let cursor = openingTag.node.nextSibling;
|
||||
let nestedTagCounter = 1;
|
||||
|
||||
// Find the matching closing tag
|
||||
for (; !!cursor && nestedTagCounter > 0; cursor = cursor.nextSibling) {
|
||||
const info = htmlNodeInfo(cursor, state);
|
||||
if (isMatchingOpeningTag(info)) {
|
||||
nestedTagCounter ++;
|
||||
} else if (isMatchingClosingTag(info)) {
|
||||
nestedTagCounter --;
|
||||
}
|
||||
|
||||
if (nestedTagCounter === 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return cursor;
|
||||
};
|
||||
|
||||
const hideTags = makeInlineReplaceExtension({
|
||||
createDecoration: (node, state) => {
|
||||
const info = htmlNodeInfo(node, state);
|
||||
return info && isMatchingTag(info) ? hideDecoration : null;
|
||||
},
|
||||
});
|
||||
|
||||
const styleContent = makeInlineReplaceExtension({
|
||||
createDecoration: (node, state) => {
|
||||
const info = htmlNodeInfo(node, state);
|
||||
if (!info || !isMatchingOpeningTag(info)) return null;
|
||||
return onRenderContent(info);
|
||||
},
|
||||
getDecorationRange(node, state) {
|
||||
const closingTag = findClosingTag(node, state);
|
||||
|
||||
if (closingTag) {
|
||||
return [node.to, closingTag.from];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return [hideTags, styleContent];
|
||||
};
|
||||
|
||||
|
||||
export default [
|
||||
createHtmlReplacementExtension('sub', () => Decoration.mark({ tagName: 'sub' })),
|
||||
createHtmlReplacementExtension('sup', () => Decoration.mark({ tagName: 'sup' })),
|
||||
createHtmlReplacementExtension('strike', () => Decoration.mark({ tagName: 'strike' })),
|
||||
createHtmlReplacementExtension('span', (info) => {
|
||||
const styles = info.getAttr('style') ?? '';
|
||||
const colorMatch = styles.match(/color:\s*(#?[a-z0-9A-Z]+|rgba?\([0-9, ]+\))(;|$)/);
|
||||
|
||||
return Decoration.mark({
|
||||
attributes: {
|
||||
style: colorMatch ? `color: ${colorMatch[1]};` : '',
|
||||
},
|
||||
});
|
||||
}),
|
||||
].flat();
|
||||
@@ -77,7 +77,9 @@ const makeBlockReplaceExtension = (extensionSpec: ReplacementExtension) => {
|
||||
return extensionSpec.shouldFullReRender(transaction);
|
||||
};
|
||||
|
||||
if (transaction.docChanged || selectionChanged || wasRerenderRequested()) {
|
||||
const treeChanged = syntaxTree(transaction.state) !== syntaxTree(transaction.startState);
|
||||
|
||||
if (transaction.docChanged || selectionChanged || wasRerenderRequested() || treeChanged) {
|
||||
decorations = updateDecorations(transaction.state, extensionSpec);
|
||||
}
|
||||
|
||||
|
||||
8
packages/editor/CodeMirror/theme.ts
vendored
@@ -86,6 +86,7 @@ const createTheme = (theme: EditorTheme): Extension[] => {
|
||||
const baseHeadingStyle = {
|
||||
fontWeight: 'bold',
|
||||
fontFamily: theme.fontFamily,
|
||||
paddingBottom: '0.2em',
|
||||
};
|
||||
|
||||
const codeMirrorTheme = EditorView.theme({
|
||||
@@ -210,7 +211,12 @@ const createTheme = (theme: EditorTheme): Extension[] => {
|
||||
// small.
|
||||
'& .cm-h1': {
|
||||
...baseHeadingStyle,
|
||||
fontSize: '1.6em',
|
||||
fontSize: '1.5em',
|
||||
},
|
||||
// Only underline level 1 headings not in block quotes. The underline overlaps with the blockquote border.
|
||||
'& .cm-h1:not(.cm-blockQuote)': {
|
||||
borderBottom: `1px solid ${theme.dividerColor}`,
|
||||
marginBottom: '0.1em',
|
||||
},
|
||||
'& .cm-h2': {
|
||||
...baseHeadingStyle,
|
||||
|
||||
88
packages/editor/CodeMirror/utils/htmlNodeInfo.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import { SyntaxNodeRef } from '@lezer/common';
|
||||
|
||||
export interface HtmlNodeInfo {
|
||||
node: SyntaxNodeRef;
|
||||
opening: boolean;
|
||||
closing: boolean;
|
||||
from: number;
|
||||
to: number;
|
||||
tagName: ()=> string;
|
||||
getAttr: (attributeName: string)=> string;
|
||||
}
|
||||
|
||||
type OnGetNodeContent = (node: SyntaxNodeRef)=> string;
|
||||
|
||||
const removeQuotes = (quoted: string) => quoted.replace(/^["'](.*)["']$/, '$1');
|
||||
|
||||
const getHtmlNodeAttr = (node: SyntaxNodeRef, attrName: string, getText: OnGetNodeContent) => {
|
||||
if (node.from === node.to) return null; // Empty
|
||||
const content = node.node.resolveInner(node.from + 1);
|
||||
|
||||
// Search for the "id" attribute
|
||||
const attributes = content.getChildren('Attribute');
|
||||
for (const attribute of attributes) {
|
||||
const nameNode = attribute.getChild('AttributeName');
|
||||
const valueNode = attribute.getChild('AttributeValue');
|
||||
|
||||
if (nameNode && valueNode) {
|
||||
const name = getText(nameNode).toLowerCase().replace(/^"(.*)"$/, '$1');
|
||||
if (name === attrName) {
|
||||
return removeQuotes(getText(valueNode));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Utility function to access CodeMirror HTML node information, based on
|
||||
// the corresponding Markdown node.
|
||||
const htmlNodeInfo = (node: SyntaxNodeRef, state: EditorState, offset = 0): HtmlNodeInfo|null => {
|
||||
// Already an HTML node?
|
||||
if (node.name === 'OpenTag' || node.name === 'CloseTag' || node.name === 'SelfClosingTag') {
|
||||
const getNodeText = (childNode: SyntaxNodeRef) => state.sliceDoc(childNode.from + offset, childNode.to + offset);
|
||||
const selfClosing = node.name === 'SelfClosingTag';
|
||||
|
||||
return {
|
||||
node,
|
||||
opening: node.name === 'OpenTag' || selfClosing,
|
||||
closing: node.name === 'CloseTag' || selfClosing,
|
||||
from: node.from + offset,
|
||||
to: node.to + offset,
|
||||
tagName: () => {
|
||||
const nodeText = getNodeText(node).trim();
|
||||
const tagNameMatch = nodeText.match(/^<\/?([^>\s]+)/);
|
||||
if (tagNameMatch) {
|
||||
return tagNameMatch[1];
|
||||
}
|
||||
return null;
|
||||
},
|
||||
getAttr: (name: string) => {
|
||||
return getHtmlNodeAttr(node, name, getNodeText);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Convert Markdown HTML nodes to HTML nodes
|
||||
if (node.name === 'HTMLTag' || node.name === 'HTMLBlock') {
|
||||
const globalOffset = node.from + offset;
|
||||
let resolved: HtmlNodeInfo|null = null;
|
||||
|
||||
// CodeMirror adds HTML information to Markdown documents using overlays attached
|
||||
// to HTMLTag and HTMLBlock nodes.
|
||||
// Use .enter to enter the overlay and visit the HTML nodes:
|
||||
node.node.enter(node.from, 1).toTree().iterate({
|
||||
enter: (subNode) => {
|
||||
resolved ??= htmlNodeInfo(subNode, state, globalOffset);
|
||||
return !resolved;
|
||||
},
|
||||
});
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default htmlNodeInfo;
|
||||
@@ -2,11 +2,16 @@ import { EditorView } from 'prosemirror-view';
|
||||
import { EditorCommandType } from '../../types';
|
||||
import commands from './commands';
|
||||
import createTestEditor from '../testing/createTestEditor';
|
||||
import selectDocumentEnd from './selectDocumentEnd';
|
||||
|
||||
const selectAll = (editor: EditorView) => {
|
||||
commands[EditorCommandType.SelectAll](editor.state, editor.dispatch, editor);
|
||||
};
|
||||
|
||||
const moveCursorToEnd = (editor: EditorView) => {
|
||||
selectDocumentEnd(editor.state, editor.dispatch, editor);
|
||||
};
|
||||
|
||||
describe('ProseMirror/commands', () => {
|
||||
test('textBold should toggle bold formatting', () => {
|
||||
const editor = createTestEditor({ html: '<p>Test</p>' });
|
||||
@@ -93,4 +98,50 @@ describe('ProseMirror/commands', () => {
|
||||
}],
|
||||
});
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
label: 'should indent the selected paragraph',
|
||||
before: '<p>Test</p>',
|
||||
select: selectAll,
|
||||
expectedDoc: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: ' Test' }] },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'should not throw an error if the selection is at the end of the document (after the last block)',
|
||||
before: '<p>Test</p><p>Test 2</p>',
|
||||
select: moveCursorToEnd,
|
||||
expectedDoc: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'Test' }] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'Test 2' }] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: ' ' }] },
|
||||
],
|
||||
},
|
||||
])('indentMore should add spaces to the beginning of the selected lines ($label)', ({ before, select, expectedDoc }) => {
|
||||
const editor = createTestEditor({ html: before });
|
||||
select(editor);
|
||||
|
||||
commands[EditorCommandType.IndentMore](editor.state, editor.dispatch, editor);
|
||||
|
||||
expect(editor.state.doc.toJSON()).toMatchObject({
|
||||
content: expectedDoc,
|
||||
});
|
||||
});
|
||||
|
||||
test('indentLess should remove spaces from the beginning of the line', () => {
|
||||
const editor = createTestEditor({ html: '<p> test</p>' });
|
||||
selectAll(editor);
|
||||
|
||||
commands[EditorCommandType.IndentLess](editor.state, editor.dispatch, editor);
|
||||
|
||||
expect(editor.state.doc.toJSON()).toMatchObject({
|
||||
content: [{
|
||||
content: [
|
||||
{ type: 'text', text: 'test' },
|
||||
],
|
||||
type: 'paragraph',
|
||||
}],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||