1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-12-08 23:07:32 +02:00

Compare commits

..

2 Commits

Author SHA1 Message Date
Laurent Cozic
d41212d340 Doc: Update sponsors 2025-09-22 22:28:59 +01:00
Laurent Cozic
bb1ec1cc24 bitnamilegacy 2025-09-18 09:03:08 +01:00
242 changed files with 2744 additions and 6398 deletions

View File

@@ -9,7 +9,6 @@ API_KEY=random-string
QUEUE_TTL=900000
QUEUE_RETRY_COUNT=2
QUEUE_MAINTENANCE_INTERVAL=30000
IMAGE_MAX_DIMENSION=400
HTR_CLI_DOCKER_IMAGE=joplin/htr-cli:latest
# Fullpath to images folder e.g.:

View File

@@ -725,8 +725,6 @@ packages/app-mobile/components/SearchInput.js
packages/app-mobile/components/SelectDateTimeDialog.js
packages/app-mobile/components/SideMenu.js
packages/app-mobile/components/SideMenuContentNote.js
packages/app-mobile/components/SyncWizard/JoplinCloudIcon.js
packages/app-mobile/components/SyncWizard/SyncWizard.js
packages/app-mobile/components/TagEditor.test.js
packages/app-mobile/components/TagEditor.js
packages/app-mobile/components/TextInput.js
@@ -743,7 +741,6 @@ packages/app-mobile/components/base-screen.js
packages/app-mobile/components/biometrics/BiometricPopup.js
packages/app-mobile/components/biometrics/biometricAuthenticate.js
packages/app-mobile/components/biometrics/sensorInfo.js
packages/app-mobile/components/buttons/CardButton.js
packages/app-mobile/components/buttons/FloatingActionButton.js
packages/app-mobile/components/buttons/LabelledIconButton.js
packages/app-mobile/components/buttons/MultiTouchableOpacity.js
@@ -844,7 +841,6 @@ packages/app-mobile/components/screens/NoteTagsDialog.js
packages/app-mobile/components/screens/Notes/NewNoteButton.test.js
packages/app-mobile/components/screens/Notes/NewNoteButton.js
packages/app-mobile/components/screens/Notes/Notes.js
packages/app-mobile/components/screens/SearchScreen/SearchResults.test.js
packages/app-mobile/components/screens/SearchScreen/SearchResults.js
packages/app-mobile/components/screens/SearchScreen/index.js
packages/app-mobile/components/screens/ShareManager/AcceptedShareItem.js
@@ -1050,7 +1046,6 @@ packages/editor/CodeMirror/extensions/rendering/types.js
packages/editor/CodeMirror/extensions/rendering/utils/makeBlockReplaceExtension.js
packages/editor/CodeMirror/extensions/rendering/utils/makeInlineReplaceExtension.js
packages/editor/CodeMirror/extensions/rendering/utils/nodeIntersectsSelection.js
packages/editor/CodeMirror/extensions/searchExtension.test.js
packages/editor/CodeMirror/extensions/searchExtension.js
packages/editor/CodeMirror/extensions/selectedNoteIdExtension.js
packages/editor/CodeMirror/getScrollFraction.js
@@ -1097,15 +1092,12 @@ packages/editor/CodeMirror/utils/markdown/renumberSelectedLists.js
packages/editor/CodeMirror/utils/markdown/stripBlockquote.js
packages/editor/CodeMirror/utils/markdown/toggleCheckboxAt.js
packages/editor/CodeMirror/utils/setupVim.js
packages/editor/CodeMirror/vendor/announceSearchMatch.js
packages/editor/ProseMirror/commands.test.js
packages/editor/ProseMirror/commands.js
packages/editor/ProseMirror/createEditor.js
packages/editor/ProseMirror/index.js
packages/editor/ProseMirror/plugins/detailsPlugin.test.js
packages/editor/ProseMirror/plugins/detailsPlugin.js
packages/editor/ProseMirror/plugins/imagePlugin.test.js
packages/editor/ProseMirror/plugins/imagePlugin.js
packages/editor/ProseMirror/plugins/inputRulesPlugin.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin/createEditorDialog.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.test.js
@@ -1117,15 +1109,12 @@ packages/editor/ProseMirror/plugins/linkTooltipPlugin.test.js
packages/editor/ProseMirror/plugins/linkTooltipPlugin.js
packages/editor/ProseMirror/plugins/listPlugin.js
packages/editor/ProseMirror/plugins/originalMarkupPlugin.js
packages/editor/ProseMirror/plugins/resourcePlaceholderPlugin.js
packages/editor/ProseMirror/plugins/searchPlugin.js
packages/editor/ProseMirror/plugins/utils/createExternalEditorPlugin.js
packages/editor/ProseMirror/plugins/utils/createFloatingButtonPlugin.js
packages/editor/ProseMirror/schema.js
packages/editor/ProseMirror/styles.js
packages/editor/ProseMirror/testing/createTestEditor.js
packages/editor/ProseMirror/testing/createTestEditorWithSerializer.js
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/computeSelectionFormatting.js
@@ -1133,7 +1122,6 @@ packages/editor/ProseMirror/utils/dom/createButton.js
packages/editor/ProseMirror/utils/dom/createTextArea.js
packages/editor/ProseMirror/utils/dom/createTextNode.js
packages/editor/ProseMirror/utils/dom/createUniqueId.js
packages/editor/ProseMirror/utils/dom/showModal.js
packages/editor/ProseMirror/utils/extractSelectedLinesTo.test.js
packages/editor/ProseMirror/utils/extractSelectedLinesTo.js
packages/editor/ProseMirror/utils/forEachHeading.js
@@ -1144,7 +1132,6 @@ packages/editor/ProseMirror/utils/postprocessEditorOutput.js
packages/editor/ProseMirror/utils/preprocessEditorInput.test.js
packages/editor/ProseMirror/utils/preprocessEditorInput.js
packages/editor/ProseMirror/utils/sanitizeHtml.js
packages/editor/ProseMirror/utils/selectFirstInstanceOfNode.js
packages/editor/ProseMirror/utils/trimEmptyParagraphs.js
packages/editor/ProseMirror/vendor/changedDescendants.js
packages/editor/ProseMirror/vendor/splitBlockAs.js
@@ -1538,7 +1525,6 @@ packages/lib/services/plugins/utils/createViewHandle.js
packages/lib/services/plugins/utils/executeSandboxCall.js
packages/lib/services/plugins/utils/getActivePluginEditorView.js
packages/lib/services/plugins/utils/getActivePluginEditorViews.js
packages/lib/services/plugins/utils/getPluginHelpUrl.js
packages/lib/services/plugins/utils/getPluginIssueReportUrl.test.js
packages/lib/services/plugins/utils/getPluginIssueReportUrl.js
packages/lib/services/plugins/utils/getPluginNamespacedSettingKey.js
@@ -1743,7 +1729,6 @@ packages/plugin-repo-cli/lib/gitCompareUrl.test.js
packages/plugin-repo-cli/lib/gitCompareUrl.js
packages/plugin-repo-cli/lib/overrideUtils.test.js
packages/plugin-repo-cli/lib/overrideUtils.js
packages/plugin-repo-cli/lib/searchPlugins.js
packages/plugin-repo-cli/lib/types.js
packages/plugin-repo-cli/lib/updateReadme.test.js
packages/plugin-repo-cli/lib/updateReadme.js
@@ -1859,8 +1844,6 @@ packages/tools/updateMarkdownDoc.js
packages/tools/utils/discourse.test.js
packages/tools/utils/discourse.js
packages/tools/utils/loadSponsors.js
packages/tools/utils/parsePluralLocalizationForm.js
packages/tools/utils/parsePlurallLocalizationForm.test.js
packages/tools/utils/translation.js
packages/tools/validateFilenames.js
packages/tools/website/build.js

View File

@@ -59,10 +59,10 @@ jobs:
fi
# The build-tools/ directory contains different subdirectories
# for each build tools version. As a result, there may be multiple
# zipalign tools. Select the most recent (biggest two-digit version number):
ZIPALIGN_PATH="$(find $BUILD_TOOLS_PATH -name "zipalign" -print | sort | tail -n1)"
# zipalign tools. Select one of them:
ZIPALIGN_PATH="$(find $BUILD_TOOLS_PATH -name "zipalign" -print | head -n1)"
if test ! -x "$ZIPALIGN_PATH" ; then
echo "zipalign not found (searching in $BUILD_TOOLS_PATH, candidate: $ZIPALIGN_PATH)"
exit 1
fi
"$ZIPALIGN_PATH" -c -P 16 -v 4 "$APK_FILE"
"$ZIPALIGN_PATH" -c -P 16 -v 4 "$APK_FILE"

View File

@@ -9,7 +9,7 @@ jobs:
matrix:
# Do not use unbuntu-latest because it causes `The operation was canceled` failures:
# https://github.com/actions/runner-images/issues/6709
os: [macos-15-intel, ubuntu-22.04, windows-2025, ubuntu-22.04-arm]
os: [macos-13, ubuntu-22.04, windows-2025, ubuntu-22.04-arm]
steps:
- uses: actions/checkout@v4
@@ -31,16 +31,6 @@ jobs:
sudo apt-get update || true
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin
- name: Free disk space
if: runner.os == 'Linux'
run: |
sudo rm -rf /usr/local/lib/android || true
sudo rm -rf /usr/share/dotnet || true
sudo rm -rf /opt/ghc || true
docker system prune -af || true
docker builder prune -af || true
sudo rm -rf /var/lib/docker/tmp/* || true
# Login to Docker only if we're on a server release tag. If we run this on
# a pull request it will fail because the PR doesn't have access to
# secrets
@@ -50,22 +40,6 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# - name: Test Windows app signing
# if: runner.os == 'Windows'
# env:
# GH_TOKEN: ${{ secrets.GH_TOKEN }}
# IS_CONTINUOUS_INTEGRATION: 1
# BUILD_SEQUENCIAL: 1
# SSL_ESIGNER_USER_NAME: ${{ secrets.SSL_ESIGNER_USER_NAME }}
# SSL_ESIGNER_USER_PASSWORD: ${{ secrets.SSL_ESIGNER_USER_PASSWORD }}
# SSL_ESIGNER_CREDENTIAL_ID: ${{ secrets.SSL_ESIGNER_CREDENTIAL_ID }}
# SSL_ESIGNER_USER_TOTP: ${{ secrets.SSL_ESIGNER_USER_TOTP }}
# SIGN_APPLICATION: 1
# # To ensure that the operations stop on failure, all commands
# # should be on one line with "&&" in between.
# run: |
# yarn install && cd packages/app-desktop && yarn dist
- name: Run tests, build and publish Linux and macOS apps
if: runner.os == 'Linux' || runner.os == 'macOs'
env:
@@ -87,14 +61,11 @@ jobs:
- name: Build and publish Windows app
if: runner.os == 'Windows' && startsWith(github.ref, 'refs/tags/v')
env:
CSC_KEY_PASSWORD: ${{ secrets.WINDOWS_CSC_KEY_PASSWORD }}
CSC_LINK: ${{ secrets.WINDOWS_CSC_LINK }}
GH_TOKEN: ${{ secrets.GH_TOKEN }}
IS_CONTINUOUS_INTEGRATION: 1
BUILD_SEQUENCIAL: 1
SSL_ESIGNER_USER_NAME: ${{ secrets.SSL_ESIGNER_USER_NAME }}
SSL_ESIGNER_USER_PASSWORD: ${{ secrets.SSL_ESIGNER_USER_PASSWORD }}
SSL_ESIGNER_CREDENTIAL_ID: ${{ secrets.SSL_ESIGNER_CREDENTIAL_ID }}
SSL_ESIGNER_USER_TOTP: ${{ secrets.SSL_ESIGNER_USER_TOTP }}
SIGN_APPLICATION: 1
# To ensure that the operations stop on failure, all commands
# should be on one line with "&&" in between.
run: |
@@ -151,16 +122,6 @@ jobs:
with:
node-version: '18'
- name: Free disk space
if: runner.os == 'Linux'
run: |
sudo rm -rf /usr/local/lib/android || true
sudo rm -rf /usr/share/dotnet || true
sudo rm -rf /opt/ghc || true
docker system prune -af || true
docker builder prune -af || true
sudo rm -rf /var/lib/docker/tmp/* || true
- name: Install Yarn
run: |
# https://yarnpkg.com/getting-started/install
@@ -188,7 +149,7 @@ jobs:
- name: Check HTTP request
run: |
# Need to pass environment variables:
docker run --env MAX_TIME_DRIFT=0 --publish 22300:22300 joplin/server:$(dpkg --print-architecture)-0.0.0 node dist/app.js --env dev &
docker run -p 22300:22300 joplin/server:$(dpkg --print-architecture)-0.0.0 node dist/app.js --env dev &
# Wait for server to start
sleep 120
@@ -214,4 +175,5 @@ jobs:
if [[ "$actual_body" != "$expected_body" ]]; then
echo 'Failed while checking the body response after request to /api/ping'
exit 1;
fi
fi

19
.gitignore vendored
View File

@@ -698,8 +698,6 @@ packages/app-mobile/components/SearchInput.js
packages/app-mobile/components/SelectDateTimeDialog.js
packages/app-mobile/components/SideMenu.js
packages/app-mobile/components/SideMenuContentNote.js
packages/app-mobile/components/SyncWizard/JoplinCloudIcon.js
packages/app-mobile/components/SyncWizard/SyncWizard.js
packages/app-mobile/components/TagEditor.test.js
packages/app-mobile/components/TagEditor.js
packages/app-mobile/components/TextInput.js
@@ -716,7 +714,6 @@ packages/app-mobile/components/base-screen.js
packages/app-mobile/components/biometrics/BiometricPopup.js
packages/app-mobile/components/biometrics/biometricAuthenticate.js
packages/app-mobile/components/biometrics/sensorInfo.js
packages/app-mobile/components/buttons/CardButton.js
packages/app-mobile/components/buttons/FloatingActionButton.js
packages/app-mobile/components/buttons/LabelledIconButton.js
packages/app-mobile/components/buttons/MultiTouchableOpacity.js
@@ -817,7 +814,6 @@ packages/app-mobile/components/screens/NoteTagsDialog.js
packages/app-mobile/components/screens/Notes/NewNoteButton.test.js
packages/app-mobile/components/screens/Notes/NewNoteButton.js
packages/app-mobile/components/screens/Notes/Notes.js
packages/app-mobile/components/screens/SearchScreen/SearchResults.test.js
packages/app-mobile/components/screens/SearchScreen/SearchResults.js
packages/app-mobile/components/screens/SearchScreen/index.js
packages/app-mobile/components/screens/ShareManager/AcceptedShareItem.js
@@ -1023,7 +1019,6 @@ packages/editor/CodeMirror/extensions/rendering/types.js
packages/editor/CodeMirror/extensions/rendering/utils/makeBlockReplaceExtension.js
packages/editor/CodeMirror/extensions/rendering/utils/makeInlineReplaceExtension.js
packages/editor/CodeMirror/extensions/rendering/utils/nodeIntersectsSelection.js
packages/editor/CodeMirror/extensions/searchExtension.test.js
packages/editor/CodeMirror/extensions/searchExtension.js
packages/editor/CodeMirror/extensions/selectedNoteIdExtension.js
packages/editor/CodeMirror/getScrollFraction.js
@@ -1070,15 +1065,12 @@ packages/editor/CodeMirror/utils/markdown/renumberSelectedLists.js
packages/editor/CodeMirror/utils/markdown/stripBlockquote.js
packages/editor/CodeMirror/utils/markdown/toggleCheckboxAt.js
packages/editor/CodeMirror/utils/setupVim.js
packages/editor/CodeMirror/vendor/announceSearchMatch.js
packages/editor/ProseMirror/commands.test.js
packages/editor/ProseMirror/commands.js
packages/editor/ProseMirror/createEditor.js
packages/editor/ProseMirror/index.js
packages/editor/ProseMirror/plugins/detailsPlugin.test.js
packages/editor/ProseMirror/plugins/detailsPlugin.js
packages/editor/ProseMirror/plugins/imagePlugin.test.js
packages/editor/ProseMirror/plugins/imagePlugin.js
packages/editor/ProseMirror/plugins/inputRulesPlugin.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin/createEditorDialog.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.test.js
@@ -1090,15 +1082,12 @@ packages/editor/ProseMirror/plugins/linkTooltipPlugin.test.js
packages/editor/ProseMirror/plugins/linkTooltipPlugin.js
packages/editor/ProseMirror/plugins/listPlugin.js
packages/editor/ProseMirror/plugins/originalMarkupPlugin.js
packages/editor/ProseMirror/plugins/resourcePlaceholderPlugin.js
packages/editor/ProseMirror/plugins/searchPlugin.js
packages/editor/ProseMirror/plugins/utils/createExternalEditorPlugin.js
packages/editor/ProseMirror/plugins/utils/createFloatingButtonPlugin.js
packages/editor/ProseMirror/schema.js
packages/editor/ProseMirror/styles.js
packages/editor/ProseMirror/testing/createTestEditor.js
packages/editor/ProseMirror/testing/createTestEditorWithSerializer.js
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/computeSelectionFormatting.js
@@ -1106,7 +1095,6 @@ packages/editor/ProseMirror/utils/dom/createButton.js
packages/editor/ProseMirror/utils/dom/createTextArea.js
packages/editor/ProseMirror/utils/dom/createTextNode.js
packages/editor/ProseMirror/utils/dom/createUniqueId.js
packages/editor/ProseMirror/utils/dom/showModal.js
packages/editor/ProseMirror/utils/extractSelectedLinesTo.test.js
packages/editor/ProseMirror/utils/extractSelectedLinesTo.js
packages/editor/ProseMirror/utils/forEachHeading.js
@@ -1117,7 +1105,6 @@ packages/editor/ProseMirror/utils/postprocessEditorOutput.js
packages/editor/ProseMirror/utils/preprocessEditorInput.test.js
packages/editor/ProseMirror/utils/preprocessEditorInput.js
packages/editor/ProseMirror/utils/sanitizeHtml.js
packages/editor/ProseMirror/utils/selectFirstInstanceOfNode.js
packages/editor/ProseMirror/utils/trimEmptyParagraphs.js
packages/editor/ProseMirror/vendor/changedDescendants.js
packages/editor/ProseMirror/vendor/splitBlockAs.js
@@ -1511,7 +1498,6 @@ packages/lib/services/plugins/utils/createViewHandle.js
packages/lib/services/plugins/utils/executeSandboxCall.js
packages/lib/services/plugins/utils/getActivePluginEditorView.js
packages/lib/services/plugins/utils/getActivePluginEditorViews.js
packages/lib/services/plugins/utils/getPluginHelpUrl.js
packages/lib/services/plugins/utils/getPluginIssueReportUrl.test.js
packages/lib/services/plugins/utils/getPluginIssueReportUrl.js
packages/lib/services/plugins/utils/getPluginNamespacedSettingKey.js
@@ -1716,7 +1702,6 @@ packages/plugin-repo-cli/lib/gitCompareUrl.test.js
packages/plugin-repo-cli/lib/gitCompareUrl.js
packages/plugin-repo-cli/lib/overrideUtils.test.js
packages/plugin-repo-cli/lib/overrideUtils.js
packages/plugin-repo-cli/lib/searchPlugins.js
packages/plugin-repo-cli/lib/types.js
packages/plugin-repo-cli/lib/updateReadme.test.js
packages/plugin-repo-cli/lib/updateReadme.js
@@ -1832,8 +1817,6 @@ packages/tools/updateMarkdownDoc.js
packages/tools/utils/discourse.test.js
packages/tools/utils/discourse.js
packages/tools/utils/loadSponsors.js
packages/tools/utils/parsePluralLocalizationForm.js
packages/tools/utils/parsePlurallLocalizationForm.test.js
packages/tools/utils/translation.js
packages/tools/validateFilenames.js
packages/tools/website/build.js

View File

@@ -1,36 +0,0 @@
# Patch to remove eval. This allows using depd in an environment with
# a strict Content-Security-Policy.
# Ref: https://github.com/dougwilson/nodejs-depd/pull/33
diff --git a/index.js b/index.js
index d758d3c8f58a60bf27ef377ad77639bf10ce7854..2bad40d4eeba553d3bcfb206873eac059067ae3b 100644
--- a/index.js
+++ b/index.js
@@ -399,19 +399,20 @@ function wrapfunction (fn, message) {
throw new TypeError('argument fn must be a function')
}
- var args = createArgumentsString(fn.length)
- var deprecate = this // eslint-disable-line no-unused-vars
var stack = getStack()
var site = callSiteLocation(stack[1])
site.name = fn.name
- // eslint-disable-next-line no-eval
- var deprecatedfn = eval('(function (' + args + ') {\n' +
- '"use strict"\n' +
- 'log.call(deprecate, message, site)\n' +
- 'return fn.apply(this, arguments)\n' +
- '})')
+ var deprecatedfn
+ var self = this
+ deprecatedfn = function () {
+ 'use strict'
+ log.call(self, message, site)
+ return fn.apply(this, arguments)
+ }
+ Object.defineProperty(deprecatedfn, 'length', { value: fn.length })
+ Object.defineProperty(deprecatedfn, 'name', { value: fn.name })
return deprecatedfn
}

View File

@@ -1,35 +0,0 @@
# Patch to remove eval. This allows using depd in an environment with
# a strict Content-Security-Policy.
# Ref: https://github.com/dougwilson/nodejs-depd/pull/33
diff --git a/index.js b/index.js
index 1bf2fcfdeffc984e5ad792eec08744c29d4a4590..1b24aa2414458bc651abfdded81b103c131efeaa 100644
--- a/index.js
+++ b/index.js
@@ -415,19 +415,19 @@ function wrapfunction (fn, message) {
throw new TypeError('argument fn must be a function')
}
- var args = createArgumentsString(fn.length)
var stack = getStack()
var site = callSiteLocation(stack[1])
site.name = fn.name
- // eslint-disable-next-line no-new-func
- var deprecatedfn = new Function('fn', 'log', 'deprecate', 'message', 'site',
- '"use strict"\n' +
- 'return function (' + args + ') {' +
- 'log.call(deprecate, message, site)\n' +
- 'return fn.apply(this, arguments)\n' +
- '}')(fn, log, this, message, site)
+ var self = this
+ var deprecatedfn = function () {
+ 'use strict'
+ log.call(self, message, site)
+ return fn.apply(this, arguments)
+ }
+ Object.defineProperty(deprecatedfn, 'length', { value: fn.length })
+ Object.defineProperty(deprecatedfn, 'name', { value: fn.name })
return deprecatedfn
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

View File

@@ -1,77 +1,4 @@
<?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>
<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>
<img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20250922-mobile-rte.png" width="400" alt="screenshot: Mobile Rich Text Editor editing the welcome notes. Images, headings, etc are rendering."/>
<p>To try it, 1) open a note in the default Markdown editor 2) open the note actions menu (the three vertical dots) for the note and 3) click “Edit as Rich Text”.</p>
<p>Be aware that this editor is still in active development and <a href="https://github.com/laurent22/joplin/issues/12840">has a number of known limitations and issues</a>. The Rich Text editor is based on <a href="https://prosemirror.net/">ProseMirror</a> and will behave differently from the desktop Rich Text Editor in many cases.</p>
<h3>Support for publishing notes with Joplin Cloud and Server<a name="support-for-publishing-notes-with-joplin-cloud-and-server" href="#support-for-publishing-notes-with-joplin-cloud-and-server" class="heading-anchor">🔗</a></h3>
<p>It's now possible to <a href="https://joplinapp.org/help/apps/publish_note">publish notes</a> from the mobile app! To do so, open the “Properties” menu for a note, then click “Publish/unpublish”:</p>
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20250922-publish-notes.png" alt="screenshot: A Publish/unpublish note action is shown in the &quot;Note properties&quot; sidebar, just below a &quot;Previous versions&quot; button"></p>
<p>Next, in the “publish note” dialog, click “Copy shareable link”. Notes can later be unpublished by clicking &quot;Unpublish&quot; in the publication dialog.</p>
<h3>Viewing note history<a name="viewing-note-history" href="#viewing-note-history" class="heading-anchor">🔗</a></h3>
<p>It is now possible to view and restore previous note versions from the mobile app. Like the &quot;publish note&quot; feature, previous note versions can be accessed from the note properties menu.</p>
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20250922-note-history.png" alt="screenshot: The note history page"></p>
<p>As on desktop, the note history feature can be configured from the “Note History” tab in settings.</p>
<h3>Updated tag dialog<a name="updated-tag-dialog" href="#updated-tag-dialog" class="heading-anchor">🔗</a></h3>
<p>The tag dialog has been redesigned, with a new UI for adding, removing, and creating new tags:<br>
<img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20250922-tag-editor.png" width="500" alt="screenshot: Tag dialog now consists of three sections: Added tags, Add new tags, Actions."/></p>
<h3>Android: Improved voice typing<a name="android-improved-voice-typing" href="#android-improved-voice-typing" class="heading-anchor">🔗</a></h3>
<p>The voice typing feature on Android has been updated with <a href="https://github.com/laurent22/joplin/pull/12404">improved silence detection</a> and a new “<a href="https://github.com/laurent22/joplin/pull/12370">custom glossary</a>” setting. Voice typing also now <a href="https://github.com/laurent22/joplin/pull/12352">defaults to a more accurate (but somewhat slower) model</a>.</p>
<h3>Quickly creating a note from multiple photos<a name="quickly-creating-a-note-from-multiple-photos" href="#quickly-creating-a-note-from-multiple-photos" class="heading-anchor">🔗</a></h3>
<p>A “scan notebook” action has been added to the “New note” menu:</p>
<img src="https://raw.githubusercontent.com/laurent22/joplin/dev/Assets/WebsiteAssets/images/news/20250922-scan-notebook.png" width="500"/>
<p>This action allows quickly creating a new note with multiple pictures taken from the camera.</p>
<h2>Desktop<a name="desktop" href="#desktop" class="heading-anchor">🔗</a></h2>
<h3>More Markdown Editor settings<a name="more-markdown-editor-settings" href="#more-markdown-editor-settings" class="heading-anchor">🔗</a></h3>
<p>The &quot;Note&quot; tab in settings now includes new settings for the Markdown editor, including:</p>
<ul>
<li>An option to render headers, lists, and certain other formatting within the editor.</li>
<li>An option to render images in the editor.</li>
</ul>
<p>When enabled, these settings bring the Markdown editor closer to the Rich Text Editor, without <a href="https://joplinapp.org/help/apps/rich_text_editor">some of the Rich Text Editor's limitations</a>.</p>
<p>These settings are also available on mobile.</p>
<h3>Smaller application size and faster startup<a name="smaller-application-size-and-faster-startup" href="#smaller-application-size-and-faster-startup" class="heading-anchor">🔗</a></h3>
<p>We've made the desktop application roughly 33% smaller! In addition to faster application startup, this means that the desktop app should be faster to download take up less space.</p>
<table class="table">
<thead>
<tr>
<th>Joplin version</th>
<th>Previous size (v3.3.13)</th>
<th>New size (v3.4.12)</th>
</tr>
</thead>
<tbody>
<tr>
<td>Joplin for MacOS (ARM)</td>
<td>211 MB</td>
<td>141 MB</td>
</tr>
<tr>
<td>Joplin for Windows (installer)</td>
<td>321 MB</td>
<td>219 MB</td>
</tr>
<tr>
<td>Joplin for Windows (portable)</td>
<td>320 MB</td>
<td>219 MB</td>
</tr>
<tr>
<td>Joplin for Linux (AppImage)</td>
<td>219 MB</td>
<td>147 MB</td>
</tr>
</tbody>
</table>
<h2>Terminal app<a name="terminal-app" href="#terminal-app" class="heading-anchor">🔗</a></h2>
<h3>Collapsible folders<a name="collapsible-folders" href="#collapsible-folders" class="heading-anchor">🔗</a></h3>
<p>The <a href="https://joplinapp.org/help/apps/terminal/">terminal application</a> now supports expanding and collapsing folders by pressing <kbd>z</kbd>. For additional information, see <a href="https://github.com/laurent22/joplin/pull/12718">the original pull request</a>.</p>
<h3>Managing shared notebooks and published notes<a name="managing-shared-notebooks-and-published-notes" href="#managing-shared-notebooks-and-published-notes" class="heading-anchor">🔗</a></h3>
<p>New commands have been added to the terminal app, including <code>publish</code>, <code>unpublish</code>, and <code>share</code>. This allows the terminal app to manage shared folders and published notes.</p>
<h2>Bug fixes<a name="bug-fixes" href="#bug-fixes" class="heading-anchor">🔗</a></h2>
<p>For the full list of changes, see <a href="https://joplinapp.org/help/about/changelog/desktop/">the desktop changelog</a> and <a href="https://joplinapp.org/help/about/changelog/android/">the mobile changelog</a>.</p>
]]></description><link>https://joplinapp.org/news/20250922-release-3-4</link><guid isPermaLink="false">20250922-release-3-4</guid><pubDate>Mon, 22 Sep 2025 00:00:00 GMT</pubDate><twitter-text></twitter-text></item><item><title><![CDATA[What's new in Joplin 3.3]]></title><description><![CDATA[<h2>Desktop application<a name="desktop-application" href="#desktop-application" class="heading-anchor">🔗</a></h2>
<?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, 28 Apr 2025 00:00:00 GMT</lastBuildDate><atom:link href="https://joplinapp.org/rss.xml" rel="self" type="application/rss+xml"/><pubDate>Mon, 28 Apr 2025 00:00:00 GMT</pubDate><item><title><![CDATA[What's new in Joplin 3.3]]></title><description><![CDATA[<h2>Desktop application<a name="desktop-application" href="#desktop-application" class="heading-anchor">🔗</a></h2>
<h3>Accessibility improvements<a name="accessibility-improvements" href="#accessibility-improvements" class="heading-anchor">🔗</a></h3>
<p>The Joplin 3.3 release introduces significant accessibility enhancements designed to make the application more inclusive and user-friendly. Users can now benefit from improved keyboard navigation, thanks to newly added shortcuts and clearer labels that streamline interaction across the interface. We've also added a &quot;go to viewer&quot; menu item that moves focus from the note editor to the note viewer. Focus is moved to the location in the viewer corresponding to the location of the cursor in the editor.</p>
<p>Screen reader support has been bolstered, ensuring elements like the note list and sidebar are easier to toggle and interact with. These updates make the application more usable for individuals relying on assistive technologies.</p>
@@ -519,4 +446,10 @@ sys 0m38.013s</p>
<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&apos;s new in Joplin 2.9</twitter-text></item></channel></rss>
]]></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&apos;s new in Joplin 2.9</twitter-text></item><item><title><![CDATA[Joplin is hiring!]]></title><description><![CDATA[<p>Joplin is an open source note-taking app. Capture your thoughts and securely access them from any device.</p>
<p>We are looking to hire two JavaScript software developers to work on the desktop, mobile, and server applications. All those are built using modern technologies, including React, React Native and Electron with a strong focus on test units.</p>
<p>You need to demonstrate some experience with at least some of these technologies, and willing to learn more and touch various different projects.</p>
<p>You will be part of a small team, so you will have an opportunity for a high-impact role, targeting hundreds of thousands of users.</p>
<p>If you're interested please contact us at job-AT-joplin.cloud</p>
<p>No agencies please.</p>
]]></description><link>https://joplinapp.org/news/20221209-job</link><guid isPermaLink="false">20221209-job</guid><pubDate>Fri, 09 Dec 2022 00:00:00 GMT</pubDate><twitter-text>Joplin is hiring!</twitter-text></item></channel></rss>

View File

@@ -73,10 +73,8 @@ USER $user
COPY --chown=$user:$user --from=builder /build/packages /home/$user/packages
COPY --chown=$user:$user --from=builder /usr/bin/tini /usr/local/bin/tini
# We download a specific version of the plugin to prevent pm2 from fetching the latest, since it may
# not have been properly audited (that fact was used to spread malware at some point). Ref:
# https://github.com/laurent22/joplin/issues/12754
RUN pm2 install https://registry.npmjs.org/pm2-logrotate/-/pm2-logrotate-3.0.0.tgz \
# Install pm2-logrotate and default settings as the runtime user
RUN pm2 install pm2-logrotate \
&& pm2 set pm2-logrotate:max_size 100MB \
&& pm2 set pm2-logrotate:retain 5 \
&& pm2 set pm2-logrotate:compress true

View File

@@ -31,7 +31,7 @@ Please see the [donation page](https://github.com/laurent22/joplin/blob/dev/read
# Sponsors
<!-- SPONSORS-ORG -->
<a href="https://seirei.ne.jp"><img title="Serei Network" width="256" src="https://joplinapp.org/images/sponsors/SeireiNetwork.png"/></a> <a href="https://citricsheep.com"><img title="Citric Sheep" width="256" src="https://joplinapp.org/images/sponsors/CitricSheep.png"/></a> <a href="https://sorted.travel/?utm_source=joplinapp"><img title="Sorted Travel" width="256" src="https://joplinapp.org/images/sponsors/SortedTravel.png"/></a> <a href="https://celebian.com"><img title="Celebian" width="256" src="https://joplinapp.org/images/sponsors/Celebian.png"/></a> <a href="https://bestkru.com"><img title="BestKru" width="256" src="https://joplinapp.org/images/sponsors/BestKru.png"/></a> <a href="https://www.socialfollowers.uk/buy-tiktok-followers/"><img title="Social Followers" width="256" src="https://joplinapp.org/images/sponsors/SocialFollowers.png"/></a> <a href="https://stormlikes.com/"><img title="Stormlikes" width="256" src="https://joplinapp.org/images/sponsors/Stormlikes.png"/></a> <a href="https://route4me.com"><img title="Route4Me" width="256" src="https://joplinapp.org/images/sponsors/Route4Me.png"/></a> <a href="https://topagency.webflow.io"><img title="WebDesignAgency" width="256" src="https://joplinapp.org/images/sponsors/WebDesignAgency.png" alt="topagency"/></a> <a href="https://www.slotozilla.com/nz/no-deposit-bonus"><img title="casino without making any upfront cost" width="256" src="https://joplinapp.org/images/sponsors/Slotozilla.png" alt="casino without making any upfront cost"/></a> <a href="https://writepaper.com/"><img title="best service to write my paper for me" width="256" src="https://joplinapp.org/images/sponsors/WritePaper.png" alt="best service to write my paper for me"/></a> <a href="https://paperwriter.com/"><img title="high-quality paper writing service PaperWriter" width="256" src="https://joplinapp.org/images/sponsors/PaperWriter.png" alt="high-quality paper writing service PaperWriter"/></a> <a href="https://www.bestetf.net/"><img title="BestETF" width="256" src="https://joplinapp.org/images/sponsors/BestEtf.png" alt="BestETF"/></a> <a href="https://freespinny.io/free-spins-no-deposit/"><img title="Freespinny.io Free Spins Bonus site" width="256" src="https://joplinapp.org/images/sponsors/Freespinny.png" alt="Freespinny.io Free Spins Bonus site"/></a> <a href="https://essayshark.com"><img title="EssayShark - essay writers for hire" width="256" src="https://joplinapp.org/images/sponsors/EssayShark.png" alt="EssayShark - essay writers for hire"/></a> <a href="https://pokieslab1.com/real-money-pokies/"><img title="Australian Real Money Pokies" width="256" src="https://joplinapp.org/images/sponsors/PokiesLab.png" alt="Australian Real Money Pokies"/></a> <a href="https://pokiesman1.net/real-money-pokies/"><img title="Australian Real Money Pokies" width="256" src="https://joplinapp.org/images/sponsors/Pokiesman.png" alt="Australian Real Money Pokies"/></a> <a href="https://domyessay.com"><img title="Essay writers DoMyEssay are dedicated to providing top-notch, custom-written papers that meet your academic requirements" width="256" src="https://joplinapp.org/images/sponsors/DoMyEssay.png" alt="DoMyEssay"/></a> <a href="https://essaypro.com/"><img title="best essay writing service" width="256" src="https://joplinapp.org/images/sponsors/EssayPro.png" alt="best essay writing service"/></a> <a href="https://socialkings.online"><img title="Boost your reach and buy real followers" width="256" src="https://joplinapp.org/images/sponsors/SocialKings.png" alt="Boost your reach and buy real followers"/></a> <a href="https://uk.notgamstop.com/bonuses/free-spins-no-deposit-no-gamstop/"><img title="free spins no deposit at NotGamstop" width="256" src="https://joplinapp.org/images/sponsors/NotGamStop.jpg" alt="free spins no deposit at NotGamstop"/></a> <a href="https://www.writemyessay.com/"><img title="writing service for students WriteMyEssay" width="256" src="https://joplinapp.org/images/sponsors/WriteMyEssay.png" alt="writing service for students WriteMyEssay"/></a>
<a href="https://seirei.ne.jp"><img title="Serei Network" width="256" src="https://joplinapp.org/images/sponsors/SeireiNetwork.png"/></a> <a href="https://citricsheep.com"><img title="Citric Sheep" width="256" src="https://joplinapp.org/images/sponsors/CitricSheep.png"/></a> <a href="https://sorted.travel/?utm_source=joplinapp"><img title="Sorted Travel" width="256" src="https://joplinapp.org/images/sponsors/SortedTravel.png"/></a> <a href="https://celebian.com"><img title="Celebian" width="256" src="https://joplinapp.org/images/sponsors/Celebian.png"/></a> <a href="https://bestkru.com"><img title="BestKru" width="256" src="https://joplinapp.org/images/sponsors/BestKru.png"/></a> <a href="https://www.socialfollowers.uk/buy-tiktok-followers/"><img title="Social Followers" width="256" src="https://joplinapp.org/images/sponsors/SocialFollowers.png"/></a> <a href="https://stormlikes.com/"><img title="Stormlikes" width="256" src="https://joplinapp.org/images/sponsors/Stormlikes.png"/></a> <a href="https://route4me.com"><img title="Route4Me" width="256" src="https://joplinapp.org/images/sponsors/Route4Me.png"/></a> <a href="https://topagency.webflow.io"><img title="WebDesignAgency" width="256" src="https://joplinapp.org/images/sponsors/WebDesignAgency.png" alt="topagency"/></a> <a href="https://www.slotozilla.com/nz/no-deposit-bonus"><img title="casino without making any upfront cost" width="256" src="https://joplinapp.org/images/sponsors/Slotozilla.png" alt="casino without making any upfront cost"/></a> <a href="https://writepaper.com/"><img title="best service to write my paper for me" width="256" src="https://joplinapp.org/images/sponsors/WritePaper.png" alt="best service to write my paper for me"/></a> <a href="https://paperwriter.com/"><img title="high-quality paper writing service PaperWriter" width="256" src="https://joplinapp.org/images/sponsors/PaperWriter.png" alt="high-quality paper writing service PaperWriter"/></a> <a href="https://www.bestetf.net/"><img title="BestETF" width="256" src="https://joplinapp.org/images/sponsors/BestEtf.png" alt="BestETF"/></a> <a href="https://freespinny.io/free-spins-no-deposit/"><img title="Freespinny.io Free Spins Bonus site" width="256" src="https://joplinapp.org/images/sponsors/Freespinny.png" alt="Freespinny.io Free Spins Bonus site"/></a> <a href="https://damangameplay.in"><img title="Daman Game" width="256" src="https://joplinapp.org/images/sponsors/DamanGame.png" alt="Daman Game"/></a> <a href="https://essayshark.com"><img title="EssayShark - essay writers for hire" width="256" src="https://joplinapp.org/images/sponsors/EssayShark.png" alt="EssayShark - essay writers for hire"/></a> <a href="https://pokieslab1.com/real-money-pokies/"><img title="Australian Real Money Pokies" width="256" src="https://joplinapp.org/images/sponsors/PokiesLab.png" alt="Australian Real Money Pokies"/></a> <a href="https://pokiesman1.net/real-money-pokies/"><img title="Australian Real Money Pokies" width="256" src="https://joplinapp.org/images/sponsors/Pokiesman.png" alt="Australian Real Money Pokies"/></a> <a href="https://domyessay.com"><img title="Essay writers DoMyEssay are dedicated to providing top-notch, custom-written papers that meet your academic requirements" width="256" src="https://joplinapp.org/images/sponsors/DoMyEssay.png" alt="DoMyEssay"/></a> <a href="https://essaypro.com/"><img title="best essay writing service" width="256" src="https://joplinapp.org/images/sponsors/EssayPro.png" alt="best essay writing service"/></a> <a href="https://socialkings.online"><img title="Boost your reach and buy real followers" width="256" src="https://joplinapp.org/images/sponsors/SocialKings.png" alt="Boost your reach and buy real followers"/></a> <a href="https://uk.notgamstop.com/bonuses/free-spins-no-deposit-no-gamstop/"><img title="free spins no deposit at NotGamstop" width="256" src="https://joplinapp.org/images/sponsors/NotGamStop.jpg" alt="free spins no deposit at NotGamstop"/></a>
<!-- SPONSORS-ORG -->
* * *

View File

@@ -16,6 +16,8 @@
# SLAVE_POSTGRES_PORT=5433
# SLAVE_POSTGRES_HOST=localhost
version: '2'
services:
postgresql-master:

View File

@@ -1,6 +1,8 @@
# This compose file can be used in development to run both the database and app
# within Docker.
version: '3'
services:
app:
build:

View File

@@ -15,6 +15,8 @@
# - This would typically be mapped to port to 443 (TLS) with a reverse proxy.
# - If Joplin Server does not need to be accessible over the internet, the port can be mapped to 22300.
version: '3'
networks:
app-network:
transcribe-network:

View File

@@ -79,7 +79,7 @@
"eslint-plugin-import": "2.31.0",
"eslint-plugin-jest": "27.9.0",
"eslint-plugin-promise": "6.6.0",
"eslint-plugin-react": "7.37.5",
"eslint-plugin-react": "7.37.4",
"execa": "5.1.1",
"fs-extra": "11.2.0",
"glob": "11.0.3",
@@ -89,7 +89,7 @@
"lint-staged": "15.5.2",
"madge": "8.0.0",
"npm-package-json-lint": "8.0.0",
"typescript": "5.8.3"
"typescript": "5.8.2"
},
"dependencies": {
"@types/fs-extra": "11.0.4",
@@ -117,12 +117,6 @@
"pdfjs-dist@*": "patch:pdfjs-dist@npm%3A3.11.174#./.yarn/patches/pdfjs-dist-npm-3.11.174-67f2fee6d6.patch",
"pdfjs-dist@3.11.174": "patch:pdfjs-dist@npm%3A3.11.174#./.yarn/patches/pdfjs-dist-npm-3.11.174-67f2fee6d6.patch",
"canvas@npm:^2.11.2": "link:./.yarn/joplin-empty-package/",
"node-gyp@npm:^9.0.0": "11.2.0",
"depd@npm:^2.0.0": "patch:depd@npm%3A2.0.0#~/.yarn/patches/depd-npm-2.0.0-b6c51a4b43.patch",
"depd@npm:~2.0.0": "patch:depd@npm%3A2.0.0#~/.yarn/patches/depd-npm-2.0.0-b6c51a4b43.patch",
"depd@npm:~1.1.2": "patch:depd@npm%3A2.0.0#~/.yarn/patches/depd-npm-2.0.0-b6c51a4b43.patch",
"depd@npm:2.0.0": "patch:depd@npm%3A2.0.0#~/.yarn/patches/depd-npm-2.0.0-b6c51a4b43.patch",
"depd@npm:^1.1.2": "patch:depd@npm%3A2.0.0#~/.yarn/patches/depd-npm-2.0.0-b6c51a4b43.patch",
"depd@npm:^1.1.0": "patch:depd@npm%3A2.0.0#~/.yarn/patches/depd-npm-2.0.0-b6c51a4b43.patch"
"node-gyp@npm:^9.0.0": "11.2.0"
}
}

View File

@@ -35,15 +35,15 @@
],
"owner": "Laurent Cozic"
},
"version": "3.5.0",
"version": "3.4.1",
"bin": "./main.js",
"engines": {
"node": ">=10.0.0"
},
"dependencies": {
"@joplin/lib": "~3.5",
"@joplin/renderer": "~3.5",
"@joplin/utils": "~3.5",
"@joplin/lib": "~3.4",
"@joplin/renderer": "~3.4",
"@joplin/utils": "~3.4",
"aws-sdk": "2.1340.0",
"chalk": "4.1.2",
"compare-version": "0.1.2",
@@ -57,7 +57,7 @@
"proper-lockfile": "4.1.2",
"redux": "4.2.1",
"server-destroy": "1.0.1",
"sharp": "0.34.3",
"sharp": "0.34.2",
"sprintf-js": "1.1.3",
"sqlite3": "5.1.6",
"string-padding": "1.0.2",
@@ -70,14 +70,14 @@
"yargs-parser": "21.1.1"
},
"devDependencies": {
"@joplin/tools": "~3.5",
"@joplin/tools": "~3.4",
"@types/fs-extra": "11.0.4",
"@types/jest": "29.5.14",
"@types/node": "18.19.119",
"@types/node": "18.19.112",
"@types/proper-lockfile": "^4.1.2",
"gulp": "4.0.2",
"jest": "29.7.0",
"temp": "0.9.4",
"typescript": "5.8.3"
"typescript": "5.8.2"
}
}

View File

@@ -2,7 +2,7 @@ import PluginRunner from '../../../app/services/plugins/PluginRunner';
import PluginService, { PluginSettings, defaultPluginSetting } from '@joplin/lib/services/plugins/PluginService';
import { ContentScriptType } from '@joplin/lib/services/plugins/api/types';
import MdToHtml from '@joplin/renderer/MdToHtml';
import shim, { MobilePlatform } from '@joplin/lib/shim';
import shim from '@joplin/lib/shim';
import Setting from '@joplin/lib/models/Setting';
import * as fs from 'fs-extra';
import Note from '@joplin/lib/models/Note';
@@ -310,7 +310,7 @@ describe('services_PluginService', () => {
let resetPlatformMock = () => {};
if (!isDesktop) {
resetPlatformMock = mockMobilePlatform(MobilePlatform.Android).reset;
resetPlatformMock = mockMobilePlatform('android').reset;
}
try {

View File

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

View File

@@ -407,17 +407,7 @@ export default class ElectronAppWrapper {
isGoingToExit = true;
} else {
event.preventDefault();
const w = this.win_;
if (!w) return;
if (w.isFullScreen()) {
// leave fullscreen, then hide
w.once('leave-full-screen', () => w.hide());
w.setFullScreen(false);
} else {
w.hide();
}
this.hide();
}
} else {
const hasBackgroundWindows = this.secondaryWindows_.size > 0;
@@ -622,11 +612,7 @@ export default class ElectronAppWrapper {
console.warn('The window object was not available during the click event from tray icon');
return;
}
if (!this.mainWindow().isVisible()) {
this.mainWindow().show();
} else {
this.mainWindow().hide();
}
this.mainWindow().show();
});
} catch (error) {
console.error('Cannot create tray', error);

View File

@@ -225,7 +225,7 @@ const Button = React.forwardRef(({
animation={iconAnimation}
mr={iconOnly ? '0' : '6px'}
color={color}
className={`${iconName} icon`}
className={iconName}
role='img'
/>;
}

View File

@@ -8,7 +8,6 @@ import { PluginManifest } from '@joplin/lib/services/plugins/utils/types';
import bridge from '../../../../services/bridge';
import { ItemEvent, PluginItem } from '@joplin/lib/components/shared/config/plugins/types';
import PluginService from '@joplin/lib/services/plugins/PluginService';
import getPluginHelpUrl from '@joplin/lib/services/plugins/utils/getPluginHelpUrl';
export enum InstallState {
NotInstalled = 1,
@@ -151,7 +150,8 @@ export default function(props: Props) {
const onNameClick = useCallback(() => {
const manifest = item.manifest;
void bridge().openExternal(getPluginHelpUrl(manifest.id));
if (!manifest.homepage_url) return;
void bridge().openExternal(manifest.homepage_url);
}, [item]);
const onRecommendedClick = useCallback(() => {

View File

@@ -1387,7 +1387,6 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
async function onCopy(event: any) {
const copiedContent = editor.selection.getContent();
if (!copiedContent) return;
copyHtmlToClipboard(copiedContent);
event.preventDefault();
}
@@ -1396,7 +1395,6 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref<NoteBodyEditorRef>) => {
async function onCut(event: any) {
event.preventDefault();
const selectedContent = editor.selection.getContent();
if (!selectedContent) return;
copyHtmlToClipboard(selectedContent);
editor.insertContent('');
onChangeHandler();

View File

@@ -15,7 +15,6 @@ const joplinRendererUtils = require('@joplin/renderer').utils;
const { clipboard } = require('electron');
import * as mimeUtils from '@joplin/lib/mime-utils';
import bridge from '../../../services/bridge';
import { getCollator, getCollatorLocale } from '@joplin/lib/models/utils/getCollator';
const md5 = require('md5');
const path = require('path');
@@ -44,30 +43,22 @@ export async function commandAttachFileToBody(body: string, filePaths: string[]
if (!filePaths || !filePaths.length) return null;
}
const collatorLocale = getCollatorLocale();
const collator = getCollator(collatorLocale);
filePaths = filePaths.sort((a, b) => {
return collator.compare(a, b);
});
let pos = options.position ?? 0;
for (let i = 0; i < filePaths.length; i++) {
const filePath = filePaths[i];
const beforeLen = body.length;
try {
logger.info(`Attaching ${filePath}`);
const newBody = await shim.attachFileToNoteBody(body, filePath, pos, {
const newBody = await shim.attachFileToNoteBody(body, filePath, options.position, {
createFileURL: options.createFileURL,
resizeLargeImages: Setting.value('imageResizing'),
markupLanguage: options.markupLanguage,
resourcePrefix: i > 0 ? ' ' : '',
resourceSuffix: i > 0 ? ' ' : '',
});
if (!newBody) {
logger.info('File attachment was cancelled');
return null;
}
pos += newBody.length - beforeLen;
body = newBody;
logger.info('File was attached.');
} catch (error) {
@@ -75,6 +66,7 @@ export async function commandAttachFileToBody(body: string, filePaths: string[]
bridge().showErrorMessageBox(error.message);
}
}
return body;
}

View File

@@ -25,13 +25,14 @@ interface Props {
const SidebarComponent = (props: Props) => {
const renderSynchronizeButton = (type: string) => {
const label = type === 'sync' ? _('Synchronise') : _('Cancel');
const iconAnimation = type !== 'sync' ? 'icon-infinite-rotation 1s linear infinite' : '';
return (
<StyledSynchronizeButton
level={ButtonLevel.SidebarSecondary}
className={`sidebar-sync-button ${type === 'sync' ? '' : '-syncing'}`}
iconName="icon-sync"
key="sync_button"
iconAnimation={iconAnimation}
title={label}
onClick={() => {
void CommandService.instance().execute('synchronize', type !== 'sync');

View File

@@ -1,5 +1,5 @@
import * as React from 'react';
import { useCallback, useState } from 'react';
import { useCallback } from 'react';
import { StyledHeader, StyledHeaderIcon, StyledHeaderLabel } from '../styles';
import { HeaderId, HeaderListItem } from '../types';
import bridge from '../../../services/bridge';
@@ -25,8 +25,6 @@ const HeaderItem: React.FC<Props> = props => {
const item = props.item;
const onItemClick = item.onClick;
const itemId = item.id;
const [isHovered, setIsHovered] = useState(false);
const expanded = item.expanded;
const onClick: React.MouseEventHandler<HTMLElement> = useCallback(event => {
if (onItemClick) {
@@ -46,14 +44,6 @@ const HeaderItem: React.FC<Props> = props => {
}
}, [itemId]);
const handleMouseEnter = useCallback(() => {
setIsHovered(true);
}, []);
const handleMouseLeave = useCallback(() => {
setIsHovered(false);
}, []);
return (
<ListItemWrapper
containerRef={props.anchorRef}
@@ -68,12 +58,8 @@ const HeaderItem: React.FC<Props> = props => {
{...item.extraProps}
onDrop={props.onDrop}
>
<StyledHeader
onClick={onClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<StyledHeaderIcon aria-hidden='true' role='img' className={isHovered ? `fas ${expanded ? 'fa-caret-down' : 'fa-caret-right'}` : item.iconName}/>
<StyledHeader onClick={onClick}>
<StyledHeaderIcon aria-hidden='true' role='img' className={item.iconName}/>
<StyledHeaderLabel>{item.label}</StyledHeaderLabel>
</StyledHeader>
</ListItemWrapper>

View File

@@ -5,5 +5,4 @@
@use 'styles/sidebar-expand-link.scss';
@use 'styles/sidebar-header-container.scss';
@use 'styles/sidebar-spacer-item.scss';
@use 'styles/sidebar-header-button.scss';
@use 'styles/sidebar-sync-button.scss';
@use 'styles/sidebar-header-button.scss';

View File

@@ -28,11 +28,6 @@ export const StyledHeader = styled.div`
user-select: none;
text-transform: uppercase;
//cursor: pointer;
cursor: default;
transition: background 0.2s;
&:hover {
background: ${(props: StyleProps) => props.theme.backgroundColorHover2};
}
`;
export const StyledHeaderIcon = styled.i`

View File

@@ -1,16 +0,0 @@
@keyframes icon-infinite-rotation {
to {
transform: rotate(360deg);
}
}
.sidebar-sync-button {
&.-syncing > .icon {
animation: icon-infinite-rotation 1s linear infinite;
@media (prefers-reduced-motion: reduce) {
animation: none;
}
}
}

View File

@@ -143,7 +143,7 @@ export default class NoteListUtils {
menu.append(new MenuItem({ type: 'separator' }));
if ([9, 10, 11].includes(Setting.value('sync.target'))) {
if ([9, 10].includes(Setting.value('sync.target'))) {
menu.append(
new MenuItem(
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied

View File

@@ -2,19 +2,11 @@
<html>
<head>
<meta charset="UTF-8">
<meta
http-equiv="Content-Security-Policy"
content="
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://* ;
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://* ;
font-src 'self' http://* https://* blob: data: joplin-content://* ;
"
/>
<!--
No CPS because we need to allow everything due to some dependencies (eg. depd, which comes from maybe Node or Electron
uses 'eval'.
<meta http-equiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline' 'unsafe-eval'">
-->
<title>Joplin</title>
<!-- Note: Add new dynamic CSS imports to style.scss to allow them to be included in secondary windows. -->
<link rel="stylesheet" href="style.min.css">

View File

@@ -8,7 +8,6 @@ import getMainWindow from './util/getMainWindow';
import setFilePickerResponse from './util/setFilePickerResponse';
import setMessageBoxResponse from './util/setMessageBoxResponse';
import getImageSourceSize from './util/getImageSourceSize';
import setSettingValue from './util/setSettingValue';
test.describe('main', () => {
@@ -20,13 +19,6 @@ test.describe('main', () => {
await mainPage.waitFor();
});
test('app should support French localization', async ({ mainWindow, electronApp }) => {
await setSettingValue(electronApp, mainWindow, 'locale', 'fr_FR');
// The "Notebooks" header should be localized
const localizedText = mainWindow.getByText('Carnets').first();
await expect(localizedText).toBeAttached();
});
test('should be able to create and edit a new note', async ({ mainWindow }) => {
const mainScreen = await new MainScreen(mainWindow).setup();
const editor = await mainScreen.createNewNote('Test note');

View File

@@ -97,6 +97,12 @@ a {
border-radius: 5px;
}
@keyframes icon-infinite-rotation{
to {
transform: rotate(360deg);
}
}
.rdtPicker {
min-width: 250px;
width: auto !important;

View File

@@ -1,6 +1,6 @@
{
"name": "@joplin/app-desktop",
"version": "3.5.4",
"version": "3.4.11",
"description": "Joplin for Desktop",
"main": "main.bundle.js",
"private": true,
@@ -46,7 +46,6 @@
"asar": true,
"asarUnpack": "./node_modules/node-notifier/vendor/**",
"win": {
"sign": "./sign.js",
"rfc3161TimeStampServer": "http://timestamp.digicert.com",
"icon": "../../Assets/ImageSources/Joplin.ico",
"target": [
@@ -137,22 +136,23 @@
"@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.53.2",
"@joplin/default-plugins": "~3.4",
"@joplin/editor": "~3.4",
"@joplin/lib": "~3.4",
"@joplin/renderer": "~3.4",
"@joplin/tools": "~3.4",
"@joplin/utils": "~3.4",
"@playwright/test": "1.52.0",
"@sentry/electron": "4.24.0",
"@testing-library/react-hooks": "8.0.1",
"@types/jest": "29.5.14",
"@types/mustache": "4.2.6",
"@types/node": "18.19.119",
"@types/node": "18.19.112",
"@types/react": "18.3.23",
"@types/react-dom": "18.3.7",
"@types/react-redux": "7.1.33",
"@types/styled-components": "5.1.32",
"@types/tesseract.js": "2.0.0",
"async-mutex": "0.5.0",
"axios": "^1.7.7",
"codemirror": "5.65.9",
@@ -187,7 +187,7 @@
"react": "18.3.1",
"react-dom": "18.3.1",
"react-redux": "8.1.3",
"react-select": "5.10.2",
"react-select": "5.10.1",
"react-test-renderer": "18.3.1",
"react-toggle-button": "2.2.0",
"react-tooltip": "4.5.1",
@@ -199,15 +199,15 @@
"styled-components": "5.3.11",
"styled-system": "5.1.5",
"taboverride": "4.0.3",
"tesseract.js": "6.0.1",
"tesseract.js": "5.1.1",
"tinymce": "6.8.5",
"ts-jest": "29.3.4",
"ts-jest": "29.3.1",
"ts-node": "10.9.2",
"typescript": "5.8.3"
"typescript": "5.8.2"
},
"dependencies": {
"@electron/remote": "2.1.2",
"@joplin/onenote-converter": "~3.5",
"@joplin/onenote-converter": "~3.4",
"fs-extra": "11.2.0",
"keytar": "7.9.0",
"node-fetch": "2.6.7",

View File

@@ -1,109 +0,0 @@
/* eslint-disable no-console */
const { execSync } = require('child_process');
const { chdir, cwd } = require('process');
const { mkdirpSync, moveSync, pathExists } = require('fs-extra');
const { readdirSync, writeFileSync } = require('fs');
const { dirname } = require('path');
const signToolName = 'CodeSignTool.bat';
const getTempDir = () => {
if (process.env.RUNNER_TEMP) return process.env.RUNNER_TEMP;
if (process.env.GITHUB_WORKSPACE) return process.env.GITHUB_WORKSPACE;
const output = `${dirname(dirname(__dirname))}/temp`;
mkdirpSync(output);
return output;
};
const tempDir = getTempDir();
const downloadSignTool = async () => {
const signToolUrl = 'https://www.ssl.com/download/codesigntool-for-windows/';
const downloadDir = `${tempDir}/signToolDownloadTemp`;
const extractDir = `${tempDir}/signToolExtractTemp`;
if (await pathExists(`${extractDir}/${signToolName}`)) {
console.info('sign.js: Sign tool has already been downloaded - skipping');
return extractDir;
}
mkdirpSync(downloadDir);
mkdirpSync(extractDir);
const response = await fetch(signToolUrl);
if (!response.ok) throw new Error(`sign.js: HTTP error ${response.status}: ${response.statusText}`);
const zipPath = `${downloadDir}/codeSignTool.zip`;
const buffer = Buffer.from(await response.arrayBuffer());
writeFileSync(zipPath, buffer);
console.info('sign.js: Downloaded sign tool zip:', readdirSync(downloadDir));
mkdirpSync(extractDir);
execSync(
`powershell -Command "Expand-Archive -Path '${zipPath}' -DestinationPath '${extractDir}' -Force"`,
{ stdio: 'inherit' },
);
console.info('sign.js: Extracted sign tool zip:', readdirSync(extractDir));
return extractDir;
};
exports.default = async (configuration) => {
const inputFilePath = configuration.path;
const {
SSL_ESIGNER_USER_NAME,
SSL_ESIGNER_USER_PASSWORD,
SSL_ESIGNER_CREDENTIAL_ID,
SSL_ESIGNER_USER_TOTP,
SIGN_APPLICATION,
} = process.env;
console.info('sign.js: File to sign:', inputFilePath);
console.info('sign.js: Using temp dir:', tempDir);
if (SIGN_APPLICATION !== '1') {
console.info('sign.js: SIGN_APPLICATION != 1 - not signing application');
return;
}
console.info('sign.js: SIGN_APPLICATION = 1 - signing application');
const signToolDir = await downloadSignTool();
const signToolOutDir = `${tempDir}/signedToolOutDir`;
mkdirpSync(signToolOutDir);
const previousDir = cwd();
chdir(signToolDir);
try {
const cmd = [
`${signToolName} sign`,
`-input_file_path="${inputFilePath}"`,
`-output_dir_path="${signToolOutDir}"`,
`-credential_id="${SSL_ESIGNER_CREDENTIAL_ID}"`,
`-username="${SSL_ESIGNER_USER_NAME}"`,
`-password="${SSL_ESIGNER_USER_PASSWORD}"`,
`-totp_secret="${SSL_ESIGNER_USER_TOTP}"`,
];
execSync(cmd.join(' '));
const createdFiles = readdirSync(signToolOutDir);
console.info('sign.js: Created files:', createdFiles);
moveSync(`${signToolOutDir}/${createdFiles[0]}`, inputFilePath, { overwrite: true });
} catch (error) {
console.error('sign.js: Could not sign file:', error);
process.exit(1);
} finally {
chdir(previousDir);
}
};

View File

@@ -90,7 +90,7 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 2097780
versionName "3.5.0"
versionName "3.4.7"
ndk {
abiFilters "armeabi-v7a", "x86", "arm64-v8a", "x86_64"
}

View File

@@ -4,7 +4,7 @@ import { Platform, ScrollView, StyleSheet, View } from 'react-native';
import { BarcodeScanner } from './utils/useBarcodeScanner';
import { LinkButton, PrimaryButton } from '../buttons';
import { _ } from '@joplin/lib/locale';
import DismissibleDialog, { DialogVariant } from '../DismissibleDialog';
import DismissibleDialog, { DialogSize } from '../DismissibleDialog';
import { Chip, Text } from 'react-native-paper';
import { isCallbackUrl, parseCallbackUrl } from '@joplin/lib/callbackUrlUtils';
import CommandService from '@joplin/lib/services/CommandService';
@@ -84,7 +84,7 @@ const ScannedBarcodes: React.FC<Props> = props => {
visible={dialogVisible}
onDismiss={onHideDialog}
themeId={props.themeId}
size={DialogVariant.Small}
size={DialogSize.Small}
>
<ScrollView>
<Text variant='titleMedium' role='heading'>{_('Scanned code')}</Text>

View File

@@ -6,10 +6,7 @@ import { themeStyle } from './global-style';
import Modal from './Modal';
import { _ } from '@joplin/lib/locale';
export enum DialogVariant {
// Small width, auto-determined height
SmallResize = 'small-resize',
export enum DialogSize {
Small = 'small',
// Ideal for panels and dialogs that should be fullscreen even on large devices
@@ -23,58 +20,34 @@ interface Props {
containerStyle?: ViewStyle;
children: React.ReactNode;
heading?: string;
scrollOverflow?: boolean;
size: DialogVariant;
size: DialogSize;
}
const useStyles = (themeId: number, containerStyle: ViewStyle, size: DialogVariant) => {
const useStyles = (themeId: number, containerStyle: ViewStyle, size: DialogSize) => {
const windowSize = useWindowDimensions();
return useMemo(() => {
const theme = themeStyle(themeId);
const maxWidth = size === DialogVariant.Large ? windowSize.width : 500;
const maxHeight = size === DialogVariant.Large ? windowSize.height : 700;
const dialogSizing: ViewStyle = {
width: '100%',
...(size !== DialogVariant.SmallResize ? {
height: '100%',
} : { }),
};
const maxWidth = size === DialogSize.Large ? windowSize.width : 500;
const maxHeight = size === DialogSize.Large ? windowSize.height : 700;
return StyleSheet.create({
closeButtonRow: {
closeButtonContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
alignContent: 'center',
marginBottom: 8,
},
closeButtonRowWithHeading: {
marginBottom: 16,
},
closeButton: {
margin: 0,
},
// Ensure that the close button is aligned with the center of the header:
// Make its container smaller and center it.
closeButtonWrapper: {
height: 18,
flexDirection: 'column',
justifyContent: 'center',
alignSelf: 'center',
},
heading: {
},
modalBackground: {
justifyContent: 'center',
alignSelf: 'center',
},
dialogContainer: {
maxHeight,
maxWidth,
...dialogSizing,
width: '100%',
height: '100%',
flexShrink: 1,
// Center
marginLeft: 'auto',
@@ -85,11 +58,11 @@ const useStyles = (themeId: number, containerStyle: ViewStyle, size: DialogVaria
...containerStyle,
},
dialogSurface: {
borderRadius: 24,
borderRadius: 12,
backgroundColor: theme.backgroundColor,
paddingHorizontal: 16,
paddingVertical: 24,
...dialogSizing,
padding: 10,
width: '100%',
height: '100%',
},
});
}, [themeId, windowSize.width, windowSize.height, containerStyle, size]);
@@ -103,16 +76,13 @@ const DismissibleDialog: React.FC<Props> = props => {
<Text variant='headlineSmall' role='heading' style={styles.heading}>{props.heading}</Text>
) : null;
const closeButtonRow = (
<View style={[styles.closeButtonRow, !!props.heading && styles.closeButtonRowWithHeading]}>
<View style={styles.closeButtonContainer}>
{heading ?? <View/>}
<View style={styles.closeButtonWrapper}>
<IconButton
icon='close'
accessibilityLabel={_('Close')}
onPress={props.onDismiss}
style={styles.closeButton}
/>
</View>
<IconButton
icon='close'
accessibilityLabel={_('Close')}
onPress={props.onDismiss}
/>
</View>
);
@@ -122,13 +92,9 @@ const DismissibleDialog: React.FC<Props> = props => {
onDismiss={props.onDismiss}
onRequestClose={props.onDismiss}
containerStyle={styles.dialogContainer}
modalBackgroundStyle={styles.modalBackground}
animationType='fade'
backgroundColor={theme.backgroundColorTransparent2}
transparent={true}
scrollOverflow={props.scrollOverflow}
// Allows the modal background to extend under the statusbar
statusBarTranslucent
>
<Surface style={styles.dialogSurface} elevation={1}>
{closeButtonRow}

View File

@@ -12,7 +12,7 @@ import { AppState } from '../../utils/types';
import CommandService from '@joplin/lib/services/CommandService';
import allToolbarCommandNamesFromState from './utils/allToolbarCommandNamesFromState';
import Setting from '@joplin/lib/models/Setting';
import DismissibleDialog, { DialogVariant } from '../DismissibleDialog';
import DismissibleDialog, { DialogSize } from '../DismissibleDialog';
import selectedCommandNamesFromState from './utils/selectedCommandNamesFromState';
import stateToWhenClauseContext from '../../services/commands/stateToWhenClauseContext';
import { DeleteButton } from '../buttons';
@@ -158,7 +158,7 @@ const ToolbarEditorScreen: React.FC<EditorDialogProps> = props => {
return (
<DismissibleDialog
size={DialogVariant.Small}
size={DialogSize.Small}
themeId={props.themeId}
visible={props.visible}
onDismiss={props.onDismiss}

View File

@@ -7,7 +7,6 @@ import createMockReduxStore from '../utils/testing/createMockReduxStore';
import setupGlobalStore from '../utils/testing/setupGlobalStore';
import { act, fireEvent, render, screen } from '@testing-library/react-native';
import FeedbackBanner from './FeedbackBanner';
import { MobilePlatform } from '@joplin/lib/shim';
interface WrapperProps { }
@@ -85,7 +84,7 @@ describe('FeedbackBanner', () => {
setupGlobalStore(store);
jest.useFakeTimers({ advanceTimers: true });
mockMobilePlatform(MobilePlatform.Web);
mockMobilePlatform('web');
});
afterEach(() => {
@@ -94,9 +93,9 @@ describe('FeedbackBanner', () => {
});
test.each([
{ platform: MobilePlatform.Android, shouldShow: false },
{ platform: MobilePlatform.Web, shouldShow: true },
{ platform: MobilePlatform.Ios, shouldShow: false },
{ platform: 'android', shouldShow: false },
{ platform: 'web', shouldShow: true },
{ platform: 'ios', shouldShow: false },
])('should correctly show/hide the feedback banner on %s', ({ platform, shouldShow }) => {
mockMobilePlatform(platform);

View File

@@ -65,7 +65,7 @@ function NoteBodyViewer(props: Props) {
onResourceLongPress,
});
const { api: renderer, pageSetup, webViewEventHandlers, hasPluginScripts } = useWebViewSetup({
const { api: renderer, pageSetup, webViewEventHandlers } = useWebViewSetup({
webviewRef,
onBodyScroll: onScroll,
onPostMessage,
@@ -106,7 +106,6 @@ function NoteBodyViewer(props: Props) {
mixedContentMode="always"
onLoadEnd={onLoadEnd}
onMessage={webViewEventHandlers.onMessage}
hasPluginScripts={hasPluginScripts}
/>
</View>
);

View File

@@ -90,8 +90,6 @@ function editorTheme(themeId: number) {
};
}
const noteEditorSearchChangeSource = 'joplin.noteEditor.setSearchState';
type OnSetVisibleCallback = (visible: boolean)=> void;
type OnSearchStateChangeCallback = (state: SearchState)=> void;
const useEditorControl = (
@@ -106,7 +104,7 @@ const useEditorControl = (
};
const setSearchStateCallback = (state: SearchState) => {
editorRef.current.setSearchState(state, noteEditorSearchChangeSource);
editorRef.current.setSearchState(state);
setSearchState(state);
};
@@ -312,26 +310,15 @@ function NoteEditor(props: Props) {
case EditorEventType.FollowLink:
void CommandService.instance().execute('openItem', event.link);
break;
case EditorEventType.UpdateSearchDialog: {
const hasExternalChange = (
event.changeSources.length !== 1
|| event.changeSources[0] !== noteEditorSearchChangeSource
);
case EditorEventType.UpdateSearchDialog:
setSearchState(event.searchState);
// 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.
if (hasExternalChange) {
setSearchState(event.searchState);
if (event.searchState.dialogVisible) {
editorControl.searchControl.showSearch();
} else {
editorControl.searchControl.hideSearch();
}
if (event.searchState.dialogVisible) {
editorControl.searchControl.showSearch();
} else {
editorControl.searchControl.hideSearch();
}
break;
}
case EditorEventType.Remove:
case EditorEventType.Scroll:
// Not handled

View File

@@ -23,7 +23,6 @@ import { MarkupLanguage } from '@joplin/renderer';
import { NoteEntity } from '@joplin/lib/services/database/types';
import { EditorSettings } from './types';
import { pregQuote } from '@joplin/lib/string-utils';
import { join } from 'path';
interface WrapperProps {
@@ -104,8 +103,8 @@ const mockTyping = (window: EditorWindow, text: string) => {
}
};
const mockSelectionMovement = (window: EditorWindow, from: number, to?: number) => {
getEditorControl(window).select(from, to ?? from);
const mockSelectionMovement = (window: EditorWindow, position: number) => {
getEditorControl(window).select(position, position);
};
const findElement = async function<ElementType extends Element = Element>(selector: string) {
@@ -334,7 +333,7 @@ describe('RichTextEditor', () => {
const editorContent = body.trim();
if (markupLanguage === MarkupLanguage.Html) {
expect(editorContent).toMatch(
new RegExp(`^<p><img[^>]* src=":/${pregQuote(resource.id)}" alt="${pregQuote(renderedImage.alt)}"[^>]*> test</p>$`),
new RegExp(`^<p><img src=":/${pregQuote(resource.id)}" alt="${pregQuote(renderedImage.alt)}"[^>]*> test</p>$`),
);
} else {
expect(editorContent).toBe(`![${renderedImage.alt}](:/${resource.id}) test`);
@@ -342,29 +341,6 @@ describe('RichTextEditor', () => {
});
});
it('should preserve non-image attachments on edit', async () => {
const { note, resource } = await createNoteAndResource({ path: join(supportDir, 'sample.txt') });
let body = note.body;
const resources = await attachedResources(body);
render(<WrappedEditor
noteBody={note.body}
note={note}
onBodyChange={newBody => { body = newBody; }}
noteResources={resources}
/>);
const window = await getEditorWindow();
mockTyping(window, ' test');
await waitFor(async () => {
const editorContent = body.trim();
// TODO: At present, the resource title may be included in the final Markdown
// (e.g. as [sample.txt](:/id-here "sample.txt")).
expect(editorContent).toMatch(new RegExp(`^\\[sample\\.txt\\]\\(:/${pregQuote(resource.id)}.*\\) test$`));
});
});
it.each([
{ useValidSyntax: false },
{ useValidSyntax: true },
@@ -414,18 +390,14 @@ describe('RichTextEditor', () => {
});
});
it('should be possible to show an editor for math blocks', async () => {
it('should be possible show an editor for math blocks', async () => {
let body = 'Test:\n\n$$3^2 + 4^2 = 5^2$$';
render(<WrappedEditor
noteBody={body}
onBodyChange={newBody => { body = newBody; }}
/>);
const window = await getEditorWindow();
// Select the math block to show the "edit" button.
mockSelectionMovement(window, '<Test:>'.length, '<Test:>$'.length);
const editButton = await findElement<HTMLButtonElement>('button.edit-button');
const editButton = await findElement<HTMLButtonElement>('button.edit');
editButton.click();
const editor = await findElement('dialog .cm-editor');
@@ -481,7 +453,7 @@ describe('RichTextEditor', () => {
'<sup>Super</sup>script',
'<sub>Sub</sub>script',
'![image](data:image/svg+xml;utf8,test)',
'<img width="120" src="data:image/svg+xml;utf8,test">',
'<img src="data:image/svg+xml;utf8,test" width="120">',
])('should preserve inline markup on edit (case %#)', async (initialBody) => {
initialBody += 'test'; // Ensure that typing will add new content outside the formatting
let body = initialBody;

View File

@@ -44,13 +44,6 @@ function useCss(themeId: number, editorCss: string): string {
font-size: 13pt;
font-family: ${JSON.stringify(theme.fontFamily)}, sans-serif;
}
.RichTextEditor {
/* Relatively positioning the editor container causes absolutely-positioned
elements to be positioned relative to Rich Text Editor's container,
rather than the body. This fixes an alignment issue involving button overlays. */
position: relative;
}
`;
}, [themeId, editorCss]);
}

View File

@@ -158,7 +158,8 @@ export const SearchPanel = (props: SearchPanelProps) => {
const state = props.searchState;
const control = props.searchControl;
const updateSearchState = (changedData: Partial<SearchState>) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const updateSearchState = (changedData: any) => {
const newState = { ...state, ...changedData };
control.setSearchState(newState);
};

View File

@@ -4,8 +4,8 @@ import { Text } from 'react-native-paper';
import IconButton from '../IconButton';
import { _ } from '@joplin/lib/locale';
import { useCallback, useState } from 'react';
import DismissibleDialog, { DialogVariant } from '../DismissibleDialog';
import { LinkButton, PrimaryButton } from '../buttons';
import DismissibleDialog, { DialogSize } from '../DismissibleDialog';
import { LinkButton } from '../buttons';
import makeDiscourseDebugUrl from '@joplin/lib/makeDiscourseDebugUrl';
import getPackageInfo from '../../utils/getPackageInfo';
import PluginService from '@joplin/lib/services/plugins/PluginService';
@@ -30,10 +30,7 @@ const onReportBug = () => {
const styles = StyleSheet.create({
feedbackContainer: {
flexGrow: 1,
flexDirection: 'row',
gap: 16,
justifyContent: 'flex-end',
flexWrap: 'wrap',
},
paragraph: {
paddingBottom: 7,
@@ -68,7 +65,7 @@ const WebBetaButton: React.FC<Props> = props => {
/>
<DismissibleDialog
heading={_('Beta')}
size={DialogVariant.SmallResize}
size={DialogSize.Small}
themeId={props.themeId}
visible={dialogVisible}
onDismiss={onHideDialog}
@@ -79,7 +76,7 @@ const WebBetaButton: React.FC<Props> = props => {
{renderParagraph('Feel free to use it and let us know if have any questions or notice any issues!')}
<View style={styles.feedbackContainer}>
<LinkButton onPress={onReportBug}>{'Report bug'}</LinkButton>
<PrimaryButton onPress={onLeaveFeedback}>{'Give feedback'}</PrimaryButton>
<LinkButton onPress={onLeaveFeedback}>{'Give feedback'}</LinkButton>
</View>
</DismissibleDialog>
</>

View File

@@ -1,37 +0,0 @@
import * as React from 'react';
import Svg, { SvgProps, G, Path, Defs, LinearGradient, Stop, ClipPath, Rect } from 'react-native-svg';
const JoplinCloudIcon: React.FC<SvgProps> = props => {
return <Svg
viewBox='0 0 84 84'
fill='none'
{...props}
>
<G clipPath='url(#a)'>
<Path fill='url(#b)' d='M0 0h84v84H0z'/>
<Path
fill='#fff'
d='M73.706 49.825c0 3.732-1.534 7.148-4.007 9.592a13.714 13.714 0 0 1-9.675 3.973h-8.199v-7.065h8.2c1.818 0 3.436-.723 4.635-1.904 1.19-1.188 1.92-2.784 1.92-4.596 0-1.804-.73-3.408-1.92-4.597a6.539 6.539 0 0 0-4.636-1.903h-6.933l.386-3.882c.042-.4.059-.79.059-1.197 0-3.3-1.342-6.251-3.513-8.412a11.964 11.964 0 0 0-8.484-3.483c-3.328 0-6.304 1.33-8.484 3.483-2.18 2.16-3.513 5.112-3.513 8.412 0 .399.017.798.06 1.197l.385 3.882h-6.154c-1.819 0-3.437.723-4.636 1.903-1.19 1.189-1.92 2.785-1.92 4.597 0 1.803.73 3.408 1.92 4.596a6.539 6.539 0 0 0 4.636 1.904h9.935a8.854 8.854 0 0 0 4.82-2.452 8.705 8.705 0 0 0 2.59-6.201v-7.523h-7.217v-2.726c0-2.32 1.903-4.215 4.25-4.215h9.968v14.464c0 4.14-1.685 8.187-4.636 11.105-2.6 2.585-6.078 4.19-9.733 4.53l-.923.083h-9.062c-3.764 0-7.21-1.52-9.674-3.973a13.483 13.483 0 0 1 0-19.185 13.673 13.673 0 0 1 8.35-3.906c.452-4.464 2.48-8.487 5.507-11.488a19.154 19.154 0 0 1 13.523-5.552 19.16 19.16 0 0 1 13.522 5.552 18.842 18.842 0 0 1 5.5 11.446c3.554.141 6.782 1.613 9.129 3.948a13.428 13.428 0 0 1 4.024 9.593z'
strokeWidth={1.3}
/>
</G>
<Defs>
<LinearGradient
id='b'
x1={3}
x2={78}
y1={4}
y2={79}
gradientUnits='userSpaceOnUse'
>
<Stop offset={0.14} stopColor='#3873DB'/>
<Stop offset={0.974} stopColor='#163467'/>
</LinearGradient>
<ClipPath id='a'>
<Rect width={84} height={84} fill='#fff' rx={20} />
</ClipPath>
</Defs>
</Svg>;
};
export default JoplinCloudIcon;

View File

@@ -1,138 +0,0 @@
import * as React from 'react';
import DismissibleDialog, { DialogVariant } from '../DismissibleDialog';
import { AppState } from '../../utils/types';
import { connect } from 'react-redux';
import { Dispatch } from 'redux';
import { useCallback } from 'react';
import { Icon, Text } from 'react-native-paper';
import { _ } from '@joplin/lib/locale';
import JoplinCloudIcon from './JoplinCloudIcon';
import NavService from '@joplin/lib/services/NavService';
import { StyleSheet, View } from 'react-native';
import CardButton from '../buttons/CardButton';
interface Props {
dispatch: Dispatch;
visible: boolean;
themeId: number;
}
const iconSize = 24;
const styles = StyleSheet.create({
titleContainer: {
flexDirection: 'row',
gap: 8,
paddingBottom: 6,
alignItems: 'center',
},
subheading: {
marginBottom: 24,
},
cardContent: {
padding: 12,
borderRadius: 14,
},
syncProviderList: {
gap: 8,
},
featuresList: {
marginTop: 4,
},
listItem: {
flexDirection: 'row',
gap: 8,
marginVertical: 6,
verticalAlign: 'middle',
},
});
interface SyncProviderProps {
title: string;
icon: ()=> React.ReactNode;
description: string;
onPress: ()=> void;
featuresList: string[];
disabled: boolean;
}
const SyncProvider: React.FC<SyncProviderProps> = props => {
return <CardButton
disabled={props.disabled}
onPress={props.onPress}
testID='sync-provider-card'
>
<View style={styles.cardContent}>
<View style={styles.titleContainer}>
{props.icon()}
<Text variant='titleMedium'>{props.title}{props.disabled ? ' (Not supported)' : ''}</Text>
</View>
{props.description && <Text variant='bodyMedium'>{props.description}</Text>}
<View style={styles.featuresList}>
{props.featuresList.map((feature, index) => (
<View key={`feature-${index}`} style={styles.listItem}>
<Icon size={14} source='check'/><Text>{feature}</Text>
</View>
))}
</View>
</View>
</CardButton>;
};
const SyncWizard: React.FC<Props> = ({ themeId, visible, dispatch }) => {
const onDismiss = useCallback(() => {
dispatch({
type: 'SYNC_WIZARD_VISIBLE_CHANGE',
visible: false,
});
}, [dispatch]);
const onSelectJoplinCloud = useCallback(async () => {
onDismiss();
await NavService.go('JoplinCloudLogin');
}, [onDismiss]);
const onSelectOtherTarget = useCallback(async () => {
onDismiss();
await NavService.go('Config', { sectionName: 'sync' });
}, [onDismiss]);
return <DismissibleDialog
themeId={themeId}
visible={visible}
onDismiss={onDismiss}
size={DialogVariant.SmallResize}
scrollOverflow={true}
heading={_('Sync')}
>
<Text variant='bodyLarge' role='heading' style={styles.subheading}>{
_('Joplin can synchronise your notes using various providers. Select one from the list below.')
}</Text>
<View style={styles.syncProviderList}>
<SyncProvider
title={_('Joplin Cloud')}
description={_('Joplin\'s own sync service. Also gives access to Joplin-specific features such as publishing notes or collaborating on notebooks with others.')}
featuresList={[
_('Sync your notes'),
_('Publish notes to the internet'),
_('Collaborate on notebooks with others'),
]}
icon={() => <JoplinCloudIcon width={iconSize} height={iconSize}/>}
onPress={onSelectJoplinCloud}
disabled={false}
/>
<SyncProvider
title={_('Other')}
description={_('Select one of the other supported sync targets.')}
icon={() => <Icon size={iconSize} source='dots-horizontal-circle'/>}
featuresList={[]}
onPress={onSelectOtherTarget}
disabled={false}
/>
</View>
</DismissibleDialog>;
};
export default connect((state: AppState) => ({
visible: state.syncWizardVisible,
themeId: state.settings.theme,
}))(SyncWizard);

View File

@@ -1,72 +0,0 @@
import * as React from 'react';
import { Card, TouchableRipple } from 'react-native-paper';
import { useMemo } from 'react';
import { StyleSheet, View, ViewStyle } from 'react-native';
export enum InstallState {
NotInstalled,
Installing,
Installed,
}
interface Props {
onPress: ()=> void;
disabled: boolean;
children: React.ReactNode;
style?: ViewStyle;
testID?: string;
}
const useStyles = (disabled: boolean) => {
return useMemo(() => {
// For the TouchableRipple to work on Android, the card needs a transparent background.
const baseCard = { backgroundColor: 'transparent' };
return StyleSheet.create({
cardOuterWrapper: {
margin: 0,
padding: 0,
borderRadius: 12,
overflow: 'hidden',
},
cardInnerWrapper: {
width: '100%',
},
card: disabled ? {
...baseCard,
opacity: 0.7,
} : baseCard,
content: {
gap: 5,
},
});
}, [disabled]);
};
const CardButton: React.FC<Props> = props => {
const containerIsButton = !!props.onPress;
const styles = useStyles(props.disabled);
const CardWrapper = containerIsButton ? TouchableRipple : View;
return (
<View style={[styles.cardOuterWrapper, props.style]}>
<CardWrapper
accessibilityRole={containerIsButton ? 'button' : null}
accessible={containerIsButton}
onPress={props.onPress}
disabled={props.disabled}
style={styles.cardInnerWrapper}
testID={props.testID}
>
<Card
mode='outlined'
style={styles.card}
>
{props.children}
</Card>
</CardWrapper>
</View>
);
};
export default CardButton;

View File

@@ -12,7 +12,7 @@ import PluginUserWebView from './PluginUserWebView';
import { View, StyleSheet, AccessibilityInfo } from 'react-native';
import { _ } from '@joplin/lib/locale';
import Setting from '@joplin/lib/models/Setting';
import DismissibleDialog, { DialogVariant } from '../../../components/DismissibleDialog';
import DismissibleDialog, { DialogSize } from '../../../components/DismissibleDialog';
import CommandService from '@joplin/lib/services/CommandService';
interface Props {
@@ -164,7 +164,7 @@ const PluginPanelViewer: React.FC<Props> = props => {
<DismissibleDialog
themeId={props.themeId}
visible={props.visible}
size={DialogVariant.Large}
size={DialogSize.Large}
onDismiss={onClose}
>
{renderTabContent()}

View File

@@ -9,7 +9,6 @@ import { reg } from '@joplin/lib/registry';
import { State } from '@joplin/lib/reducer';
import BackButtonService from '../../../services/BackButtonService';
import { connect } from 'react-redux';
import { Dispatch } from 'redux';
import ScreenHeader from '../../ScreenHeader';
import { _ } from '@joplin/lib/locale';
import BaseScreenComponent from '../../base-screen';
@@ -61,7 +60,6 @@ interface ConfigScreenProps {
themeId: number;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
navigation: any;
dispatch: Dispatch;
}
class ConfigScreenComponent extends BaseScreenComponent<ConfigScreenProps, ConfigScreenState> {
@@ -128,13 +126,6 @@ class ConfigScreenComponent extends BaseScreenComponent<ConfigScreenProps, Confi
void NavService.go('EncryptionConfig');
};
private onShowSyncWizard_ = () => {
this.props.dispatch({
type: 'SYNC_WIZARD_VISIBLE_CHANGE',
visible: true,
});
};
private saveButton_press = async () => {
if (this.state.changedSettingKeys.includes('sync.target') && this.state.settings['sync.target'] === SyncTargetRegistry.nameToId('filesystem')) {
if (Platform.OS === 'android') {
@@ -240,11 +231,11 @@ class ConfigScreenComponent extends BaseScreenComponent<ConfigScreenProps, Confi
// Not implemented yet
return true;
}
return await checkPermissions(PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE, { rationale: {
return await checkPermissions(PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE, {
title: _('Information'),
message: _('In order to use file system synchronisation your permission to write to external storage is required.'),
buttonPositive: _('OK'),
} });
});
}
public UNSAFE_componentWillMount() {
@@ -554,7 +545,6 @@ class ConfigScreenComponent extends BaseScreenComponent<ConfigScreenProps, Confi
}
if (section.name === 'sync') {
addSettingButton('sync_wizard_button', _('Open Sync Wizard...'), this.onShowSyncWizard_);
addSettingButton('e2ee_config_button', _('Encryption Config'), this.e2eeConfig_);
}

View File

@@ -1,5 +1,5 @@
import * as React from 'react';
import { Card, Text } from 'react-native-paper';
import { Card, Text, TouchableRipple } from 'react-native-paper';
import { _ } from '@joplin/lib/locale';
import { PluginItem } from '@joplin/lib/components/shared/config/plugins/types';
import ActionButton from '../buttons/ActionButton';
@@ -7,12 +7,11 @@ import { ButtonType } from '../../../../buttons/TextButton';
import PluginChips from './PluginChips';
import { UpdateState } from '../utils/useUpdateState';
import { PluginCallback } from '../utils/usePluginCallbacks';
import { useCallback } from 'react';
import { useCallback, useMemo } from 'react';
import { StyleSheet, View } from 'react-native';
import InstallButton from '../buttons/InstallButton';
import PluginTitle from './PluginTitle';
import RecommendedBadge from './RecommendedBadge';
import CardButton from '../../../../buttons/CardButton';
export enum InstallState {
NotInstalled,
@@ -39,14 +38,28 @@ interface Props {
onShowPluginInfo?: PluginCallback;
}
const styles = StyleSheet.create({
content: {
gap: 5,
},
cardContainer: {
marginTop: 8,
},
});
const useStyles = (compatible: boolean) => {
return useMemo(() => {
// For the TouchableRipple to work on Android, the card needs a transparent background.
const baseCard = { backgroundColor: 'transparent' };
return StyleSheet.create({
cardContainer: {
margin: 0,
marginTop: 8,
padding: 0,
borderRadius: 14,
},
card: !compatible ? {
...baseCard,
opacity: 0.7,
} : baseCard,
content: {
gap: 5,
},
});
}, [compatible]);
};
const PluginBox: React.FC<Props> = props => {
const manifest = props.item.manifest;
@@ -65,36 +78,46 @@ const PluginBox: React.FC<Props> = props => {
props.onShowPluginInfo?.({ item: props.item });
}, [props.onShowPluginInfo, props.item]);
const styles = useStyles(props.isCompatible);
const CardWrapper = props.onShowPluginInfo ? TouchableRipple : View;
const containerIsButton = !!props.onShowPluginInfo;
return (
<CardButton
style={styles.cardContainer}
<CardWrapper
accessibilityRole={containerIsButton ? 'button' : null}
accessible={containerIsButton}
onPress={props.onShowPluginInfo ? onPress : null}
testID='plugin-card'
disabled={!props.isCompatible}
style={styles.cardContainer}
>
<Card.Content style={styles.content}>
<View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
<View style={{ flexShrink: 1 }}>
<PluginTitle manifest={item.manifest} />
<Text numberOfLines={2}>{manifest.description}</Text>
<Card
mode='outlined'
style={styles.card}
testID='plugin-card'
>
<Card.Content style={styles.content}>
<View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
<View style={{ flexShrink: 1 }}>
<PluginTitle manifest={item.manifest} />
<Text numberOfLines={2}>{manifest.description}</Text>
</View>
<RecommendedBadge manifest={item.manifest} isCompatible={props.isCompatible} themeId={props.themeId} />
</View>
<RecommendedBadge manifest={item.manifest} isCompatible={props.isCompatible} themeId={props.themeId} />
</View>
<PluginChips
themeId={props.themeId}
item={props.item}
showInstalledChip={props.showInstalledChip}
hasErrors={props.hasErrors}
canUpdate={props.updateState === UpdateState.CanUpdate}
onShowPluginLog={props.onShowPluginLog}
isCompatible={props.isCompatible}
/>
</Card.Content>
<Card.Actions>
{props.onAboutPress ? aboutButton : null}
{props.onInstall ? installButton : null}
</Card.Actions>
</CardButton>
<PluginChips
themeId={props.themeId}
item={props.item}
showInstalledChip={props.showInstalledChip}
hasErrors={props.hasErrors}
canUpdate={props.updateState === UpdateState.CanUpdate}
onShowPluginLog={props.onShowPluginLog}
isCompatible={props.isCompatible}
/>
</Card.Content>
<Card.Actions>
{props.onAboutPress ? aboutButton : null}
{props.onInstall ? installButton : null}
</Card.Actions>
</Card>
</CardWrapper>
);
};

View File

@@ -5,7 +5,7 @@ import { useCallback, useMemo } from 'react';
import { Card, Divider, List, Portal, Switch, Text } from 'react-native-paper';
import getPluginIssueReportUrl from '@joplin/lib/services/plugins/utils/getPluginIssueReportUrl';
import { Linking, ScrollView, StyleSheet, View, ViewStyle } from 'react-native';
import DismissibleDialog, { DialogVariant } from '../../../DismissibleDialog';
import DismissibleDialog, { DialogSize } from '../../../DismissibleDialog';
import openWebsiteForPlugin from './utils/openWebsiteForPlugin';
import PluginService, { PluginSettings } from '@joplin/lib/services/plugins/PluginService';
import PluginTitle from './PluginBox/PluginTitle';
@@ -253,7 +253,7 @@ const PluginInfoModal: React.FC<Props> = props => {
<DismissibleDialog
themeId={props.themeId}
visible={props.visible}
size={DialogVariant.Small}
size={DialogSize.Small}
onDismiss={props.onModalDismiss}
>
{ props.item ? <PluginInfoModalContent {...props}/> : null }

View File

@@ -6,7 +6,7 @@ import { act, fireEvent, render, screen, userEvent, waitFor } from '../../../../
import PluginService, { PluginSettings, defaultPluginSetting } from '@joplin/lib/services/plugins/PluginService';
import { writeFile } from 'fs-extra';
import { join } from 'path';
import shim, { MobilePlatform } from '@joplin/lib/shim';
import shim from '@joplin/lib/shim';
import { resetRepoApi } from './utils/useRepoApi';
import { Store } from 'redux';
import { AppState } from '../../../../utils/types';
@@ -59,7 +59,7 @@ describe('PluginStates.installed', () => {
mockPluginServiceSetup(reduxStore);
resetRepoApi();
await mockMobilePlatform(MobilePlatform.Android);
await mockMobilePlatform('android');
await mockRepositoryApiConstructor();
// Fake timers are necessary to prevent a warning.
@@ -73,8 +73,8 @@ describe('PluginStates.installed', () => {
});
it.each([
MobilePlatform.Android,
MobilePlatform.Ios,
'android',
'ios',
])('should not allow updating a plugin that is not recommended on iOS, but should on Android (on %s)', async (platform) => {
await mockMobilePlatform(platform);
expect(shim.mobilePlatform()).toBe(platform);

View File

@@ -10,7 +10,6 @@ import { Store } from 'redux';
import mockRepositoryApiConstructor from './testUtils/mockRepositoryApiConstructor';
import { resetRepoApi } from './utils/useRepoApi';
import mockPluginServiceSetup from '../../../../utils/testing/mockPluginServiceSetup';
import { MobilePlatform } from '@joplin/lib/shim';
const expectSearchResultCountToBe = async (count: number) => {
await waitFor(() => {
@@ -39,7 +38,7 @@ describe('PluginStates.search', () => {
await switchClient(0);
reduxStore = createMockReduxStore();
mockPluginServiceSetup(reduxStore);
mockMobilePlatform(MobilePlatform.Android);
mockMobilePlatform('android');
resetRepoApi();
await mockRepositoryApiConstructor();
@@ -71,7 +70,7 @@ describe('PluginStates.search', () => {
it('should only show recommended plugin search results on iOS-like environments', async () => {
// iOS uses restricted install mode
mockMobilePlatform(MobilePlatform.Ios);
mockMobilePlatform('ios');
await mockRepositoryApiConstructor();
const wrapper = render(<WrappedPluginStates initialPluginSettings={{}} store={reduxStore}/>);

View File

@@ -1,9 +1,8 @@
import { ItemEvent } from '@joplin/lib/components/shared/config/plugins/types';
import { Linking } from 'react-native';
import getPluginHelpUrl from '@joplin/lib/services/plugins/utils/getPluginHelpUrl';
const openWebsiteForPlugin = ({ item }: ItemEvent) => {
return Linking.openURL(getPluginHelpUrl(item.manifest.id));
return Linking.openURL(`https://joplinapp.org/plugins/plugin/${item.manifest.id}`);
};
export default openWebsiteForPlugin;

View File

@@ -17,7 +17,6 @@ import { Portal, ProgressBar, Snackbar } from 'react-native-paper';
import useBackHandler from '../../../utils/hooks/useBackHandler';
import Logger from '@joplin/utils/Logger';
import NavService from '@joplin/lib/services/NavService';
import { ResourceOcrDriverId, ResourceOcrStatus } from '@joplin/lib/services/database/types';
const logger = Logger.create('DocumentScanner');
@@ -78,24 +77,13 @@ const DocumentScanner: React.FC<Props> = ({ themeId, dispatch }) => {
const onCreateNote = useCallback(async (event: CreateNoteEvent) => {
setSnackbarMessage(_('Creating note "%s"...', event.title));
setCreatingNote(true);
logger.info('Creating note', event.queueForTranscription ? '(with transcription)' : '');
try {
const resources = [];
for (const image of photos) {
resources.push(await shim.createResourceFromPath(
image.uri,
{
...(event.queueForTranscription ? {
ocr_status: ResourceOcrStatus.Todo,
ocr_driver_id: ResourceOcrDriverId.HandwrittenText,
ocr_details: '',
ocr_error: '',
ocr_text: '',
} : {}),
title: event.title,
mime: image.type,
},
{ title: event.title, mime: image.type },
));
}

View File

@@ -1,7 +1,7 @@
import * as React from 'react';
import { useMemo, useState, useCallback, useEffect } from 'react';
import { themeStyle } from '../../global-style';
import { ScrollView, StyleSheet, TextStyle, View } from 'react-native';
import { ScrollView, StyleSheet, View } from 'react-native';
import { CameraResult } from '../../CameraView/types';
import TextInput from '../../TextInput';
import PhotoPreview from '../../CameraView/PhotoPreview';
@@ -15,14 +15,11 @@ import Folder from '@joplin/lib/models/Folder';
import Setting from '@joplin/lib/models/Setting';
import { formatMsToLocal } from '@joplin/utils/time';
import { PrimaryButton } from '../../buttons';
import { Switch, Text } from 'react-native-paper';
import SyncTargetRegistry from '@joplin/lib/SyncTargetRegistry';
export interface CreateNoteEvent {
title: string;
tags: string[];
parentId: string;
queueForTranscription: boolean;
}
type OnCreateNote = (event: CreateNoteEvent)=> void;
@@ -34,8 +31,6 @@ interface Props {
allTags: TagEntity[];
allFolders: FolderEntity[];
selectedFolderId: string;
isJoplinServer: boolean;
queueForTranscriptionDefault: boolean;
onCreateNote: null|OnCreateNote;
}
@@ -43,11 +38,6 @@ interface Props {
const useStyles = (themeId: number) => {
return useMemo(() => {
const theme = themeStyle(themeId);
const headerStyle: TextStyle = {
...theme.headerStyle,
fontSize: theme.fontSize,
fontWeight: 'normal',
};
return StyleSheet.create({
titleInput: {
color: theme.color,
@@ -68,7 +58,9 @@ const useStyles = (themeId: number) => {
tagEditor: {
marginHorizontal: theme.margin,
},
tagEditorHeader: headerStyle,
tagEditorHeader: {
fontWeight: 'normal',
},
folderPickerLine: {
flexDirection: 'row',
justifyContent: 'space-between',
@@ -83,24 +75,6 @@ const useStyles = (themeId: number) => {
alignSelf: 'flex-end',
margin: theme.margin,
},
transcriptionCheckboxContainer: {
display: 'flex',
flexDirection: 'column',
marginHorizontal: theme.margin,
marginTop: theme.margin * 2,
marginBottom: theme.margin * 2,
gap: 6,
},
transcriptionCheckbox: {
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
},
transcriptionLabel: headerStyle,
transcriptionHelp: {
color: theme.colorFaded,
gap: 8,
},
});
}, [themeId]);
};
@@ -111,13 +85,12 @@ const tagSearchResultsProps = {
};
const NotePreview: React.FC<Props> = ({
themeId, lastImage, imageCount, allTags, onCreateNote, allFolders, selectedFolderId: propsSelectedFolderId, isJoplinServer, queueForTranscriptionDefault,
themeId, lastImage, imageCount, allTags, onCreateNote, allFolders, selectedFolderId: propsSelectedFolderId,
}) => {
const styles = useStyles(themeId);
const [title, setTitle] = useState('');
const [tags, setTags] = useState([]);
const [selectedFolderId, setSelectedFolderId] = useState(propsSelectedFolderId);
const [queueForTranscription, setQueueForTranscription] = useState(queueForTranscriptionDefault);
const realFolders = useMemo(() => {
return Folder.getRealFolders(allFolders);
@@ -142,33 +115,18 @@ const NotePreview: React.FC<Props> = ({
const onNewNote = useCallback(() => {
if (!onCreateNote) return;
Setting.setValue('scanner.requestTranscription', queueForTranscription);
onCreateNote({
tags,
title,
parentId: selectedFolderId ?? '',
queueForTranscription,
});
}, [onCreateNote, tags, title, selectedFolderId, queueForTranscription]);
}, [onCreateNote, tags, title, selectedFolderId]);
const onNewFolder = useCallback(async (title: string) => {
const folder = await Folder.save({ title });
setSelectedFolderId(folder.id);
}, []);
const transcriptionCheckbox = <View style={styles.transcriptionCheckboxContainer}>
<View style={styles.transcriptionCheckbox}>
<Text nativeID='transcriptionLabel' style={styles.transcriptionLabel}>{_('Recognise text:')}</Text>
<Switch accessibilityLabelledBy='transcriptionLabel' value={queueForTranscription} onValueChange={setQueueForTranscription} />
</View>
<View>
<Text style={styles.transcriptionHelp}>{
_('When enabled, requests that the images in the note be transcribed with a higher-quality on-server transcription service. Requires sync with a copy of the desktop app.')
}</Text>
</View>
</View>;
return <ScrollView style={styles.rootScrollView}>
<TextInput
style={styles.titleInput}
@@ -204,7 +162,6 @@ const NotePreview: React.FC<Props> = ({
headerStyle={styles.tagEditorHeader}
searchResultProps={tagSearchResultsProps}
/>
{isJoplinServer ? transcriptionCheckbox : null}
<PrimaryButton
onPress={onNewNote}
style={styles.actionButton}
@@ -217,7 +174,5 @@ export default connect((state: AppState) => ({
allTags: state.tags,
allFolders: state.folders,
selectedFolderId: state.selectedFolderId,
isJoplinServer: SyncTargetRegistry.isJoplinServerOrCloud(state.settings['sync.target']),
themeId: state.settings.theme,
queueForTranscriptionDefault: state.settings['scanner.requestTranscription'],
}))(NotePreview);

View File

@@ -73,9 +73,7 @@ import { defaultWindowId } from '@joplin/lib/reducer';
import useVisiblePluginEditorViewIds from '@joplin/lib/hooks/plugins/useVisiblePluginEditorViewIds';
import { SelectionRange } from '../../../contentScripts/markdownEditorBundle/types';
import { EditorType } from '../../NoteEditor/types';
import { IconButton } from 'react-native-paper';
import { writeTextToCacheFile } from '../../../utils/ShareUtils';
import shareFile from '../../../utils/shareFile';
import IconButton from '../../IconButton';
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const emptyArray: any[] = [];
@@ -532,6 +530,16 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
paddingBottom: 10, // Added for iOS (Not needed for Android??)
};
styles.titleToggleIcon = {
color: theme.colorFaded,
fontSize: 30,
height: 48,
width: 48,
verticalAlign: 'middle',
textAlign: 'center',
alignContent: 'center',
};
this.styles_[cacheKey] = StyleSheet.create(styles);
return this.styles_[cacheKey];
}
@@ -545,17 +553,8 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
if (Platform.OS === 'web') return;
const response = await checkPermissions(PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION, {
onRequestConfirmation: async () => {
const yesIndex = 0;
const result = await shim.showMessageBox(
_('Joplin supports saving the location at which notes are saved or created. Do you want to enable it? This can be changed at any time in settings.'),
{
buttons: [_('Yes'), _('No')],
title: _('Save geolocation?'),
},
);
return result === yesIndex;
},
message: _('In order to associate a geo-location with the note, the app needs your permission to access your location.\n\nYou may turn off this option at any time in the Configuration screen.'),
title: _('Permission needed'),
});
// If the user simply pressed "Deny", we don't automatically switch it off because they might accept
@@ -714,12 +713,7 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
}
private title_changeText(text: string) {
let newText = text;
if (Platform.OS !== 'web') {
// Manipulating the underlying text inside of onChangeText causes issues with the cursor position jumping to the end while typing
// when the Web app is being used on a desktop OS, so providing a toggle to expand the title field can only be done on mobile platforms
newText = text.replace(/(\r\n|\n|\r)/gm, ' ');
}
const newText = text.replace(/(\r\n|\n|\r)/gm, ' ');
shared.noteComponent_change(this, 'title', newText);
this.setState({ newAndNoTitleChangeNoteId: null });
}
@@ -1068,33 +1062,10 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
}
private async share_onPress() {
const shareText = `${this.state.note.title}\n\n${this.state.note.body}`;
const filename = this.state.note.id ?? uuid.create();
if (shareText.length > 100000) {
let fileToShare;
try {
// Using a .txt file extension causes a "No valid provider found from URL" error
// and blank share sheet on iOS for larger log files (around 200 KiB).
fileToShare = await writeTextToCacheFile(shareText, `${filename}.md`);
await shareFile(fileToShare, 'text/plain');
} catch (e) {
logger.error('Unable to share note data:', e);
// Display a message to the user (e.g. in the case where the user is out of disk space).
void shim.showErrorDialog(_('Unable to share note data. Reason: %s', e.toString()));
} finally {
if (fileToShare) {
await shim.fsDriver().remove(fileToShare);
}
}
} else {
// A txt extension is automatically appended to the title when shared to a file via this route
await Share.share({
message: shareText,
title: filename,
});
}
await Share.share({
message: `${this.state.note.title}\n\n${this.state.note.body}`,
title: this.state.note.title,
});
}
private properties_onPress() {
@@ -1690,15 +1661,6 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
const dueDate = Note.dueDateObject(note);
const titleToggleButton = Platform.OS === 'web' ? null :
<IconButton
icon={(!this.state.multiline && 'menu-down') || (this.state.multiline && 'menu-up')}
accessibilityLabel={(!this.state.multiline && _('Expand title')) || (this.state.multiline && _('Collapse title'))}
onPress={() => this.setState({ multiline: !this.state.multiline })}
size={30}
style={{ width: 30, height: 30, alignSelf: 'center' }}
/>;
const titleComp = (
<View style={titleContainerStyle}>
{isTodo && <Checkbox style={this.styles().checkbox} checked={!!Number(note.todo_completed)} onChange={this.todoCheckbox_change} />}
@@ -1717,7 +1679,13 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
multiline={this.state.multiline}
submitBehavior = "blurAndSubmit"
/>
{ titleToggleButton }
<IconButton
iconName={(!this.state.multiline && 'material menu-down') || (this.state.multiline && 'material menu-up')}
onPress={() => this.setState({ multiline: !this.state.multiline })}
description={(!this.state.multiline && _('Expand title')) || (this.state.multiline && _('Collapse title'))}
iconStyle={this.styles().titleToggleIcon}
themeId={this.props.themeId}
/>
</View>
);

View File

@@ -1,6 +1,6 @@
import * as React from 'react';
import { connect } from 'react-redux';
import { View, StyleSheet, TextInput, Platform } from 'react-native';
import { View, StyleSheet, TextInput } from 'react-native';
import { AppState } from '../../utils/types';
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
import Revision from '@joplin/lib/models/Revision';
@@ -213,15 +213,6 @@ const NoteRevisionViewer: React.FC<Props> = props => {
>{restoreButtonTitle}</PrimaryButton>
);
const titleToggleButton = Platform.OS === 'web' ? null :
<IconButton
icon={(!multiline && 'menu-down') || (multiline && 'menu-up')}
accessibilityLabel={(!multiline && _('Expand title')) || (multiline && _('Collapse title'))}
onPress={onToggleTitlePress}
size={30}
style={{ width: 30, height: 30, alignSelf: 'center' }}
/>;
const titleComponent = (
<View style={styles.titleViewContainer}>
<TextInput
@@ -230,7 +221,12 @@ const NoteRevisionViewer: React.FC<Props> = props => {
editable={false}
multiline={multiline}
/>
{ titleToggleButton }
<IconButton
icon={(!multiline && 'menu-down') || (multiline && 'menu-up')}
accessibilityLabel={(!multiline && _('Expand title')) || (multiline && _('Collapse title'))}
onPress={onToggleTitlePress}
size={30}
/>
</View>
);

View File

@@ -1,50 +0,0 @@
import * as React from 'react';
import { AppState } from '../../../utils/types';
import { Store } from 'redux';
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
import createMockReduxStore from '../../../utils/testing/createMockReduxStore';
import setupGlobalStore from '../../../utils/testing/setupGlobalStore';
import Note from '@joplin/lib/models/Note';
import { render, screen } from '../../../utils/testing/testingLibrary';
import SearchResults from './SearchResults';
import SearchEngine from '@joplin/lib/services/search/SearchEngine';
import Folder from '@joplin/lib/models/Folder';
import TestProviderStack from '../../testing/TestProviderStack';
const createNotes = async (count: number) => {
const folder = await Folder.save({ title: 'Test Note' });
for (let i = 0; i < count; i++) {
await Note.save({ title: `abcd ${i}`, body: 'body', parent_id: folder.id });
}
await SearchEngine.instance().syncTables();
};
let store: Store<AppState>;
interface WrapperProps {
query: string;
paused: boolean;
}
const WrappedSearchResults: React.FC<WrapperProps> = props => (
<TestProviderStack store={store}>
<SearchResults paused={props.paused} query={props.query} onHighlightedWordsChange={() => { }} ftsEnabled={1} />
</TestProviderStack>
);
describe('SearchResult', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
store = createMockReduxStore();
setupGlobalStore(store);
});
test('should show results when unpaused', async () => {
const noteCount = 8;
await createNotes(noteCount);
render(<WrappedSearchResults query='abcd' paused={false}/>);
const items = await screen.findAllByText(/abcd \d\d?\d?/);
expect(items.length).toBe(noteCount);
});
});

View File

@@ -13,7 +13,6 @@ import shim from '@joplin/lib/shim';
interface Props {
query: string;
paused: boolean;
onHighlightedWordsChange: (highlightedWords: (ComplexTerm | string)[])=> void;
ftsEnabled: number;
@@ -29,7 +28,7 @@ const useResults = (props: Props) => {
let notes: NoteEntity[] = [];
setIsProcessing(true);
try {
if (query && !props.paused) {
if (query) {
if (ftsEnabled) {
const r = await SearchEngineUtils.notesForQuery(query, true, { appendWildCards: true });
notes = r.notes;
@@ -58,7 +57,7 @@ const useResults = (props: Props) => {
} finally {
setIsProcessing(false);
}
}, [query, props.paused, ftsEnabled], { interval: 200 });
}, [query, ftsEnabled], { interval: 200 });
return {
notes,

View File

@@ -53,36 +53,11 @@ const useStyles = (theme: ThemeStyle, visible: boolean) => {
}, [theme, visible]);
};
// Workaround for https://github.com/laurent22/joplin/issues/12823:
// Disable search-as-you-type for short 0-2 character searches that
// are likely to match the start of a large number of words.
const useSearchPaused = (query: string) => {
const [pauseDisabled, setPauseDisabled] = useState(false);
// Only disable search-as-you-type for a subset of all characters.
// This is, for example, to ensure that search-as-you-type remains
// enabled for CJK characters (e.g. U+6570 has length 1).
const paused = query.match(/^[a-z0-9]{0,2}$/i);
const onOverridePause = useCallback(() => {
setPauseDisabled(true);
}, []);
useEffect(() => {
setPauseDisabled(false);
}, [query]);
return {
paused: paused && !pauseDisabled,
onOverridePause,
};
};
const SearchScreenComponent: React.FC<Props> = props => {
const theme = themeStyle(props.themeId);
const styles = useStyles(theme, props.visible);
const [query, setQuery] = useState(props.query);
const { paused, onOverridePause } = useSearchPaused(query);
const globalQueryRef = useRef(props.query);
globalQueryRef.current = props.query;
@@ -124,7 +99,6 @@ const SearchScreenComponent: React.FC<Props> = props => {
autoFocus={props.visible}
underlineColorAndroid="#ffffff00"
onChangeText={setQuery}
onSubmitEditing={onOverridePause}
value={query}
selectionColor={theme.textSelectionColor}
keyboardAppearance={theme.keyboardAppearance}
@@ -140,7 +114,6 @@ const SearchScreenComponent: React.FC<Props> = props => {
<SearchResults
query={query}
paused={paused}
ftsEnabled={props.ftsEnabled}
onHighlightedWordsChange={onHighlightedWordsChange}
/>

View File

@@ -15,7 +15,7 @@ import useEncryptionWarningMessage from '@joplin/lib/components/shared/ShareNote
import { SharingStatus } from '@joplin/lib/components/shared/ShareNoteDialog/types';
import { AppState } from '../../utils/types';
import { connect } from 'react-redux';
import DismissibleDialog, { DialogVariant } from '../DismissibleDialog';
import DismissibleDialog, { DialogSize } from '../DismissibleDialog';
import { _, _n } from '@joplin/lib/locale';
import { LinkButton, PrimaryButton } from '../buttons';
import { themeStyle } from '../global-style';
@@ -191,7 +191,7 @@ const ShareNoteDialog: React.FC<Props> = props => {
themeId={props.themeId}
visible={props.visible}
onDismiss={props.onClose}
size={DialogVariant.Small}
size={DialogSize.Small}
heading={_('Publish Note')}
>
{props.visible ? <ShareNoteDialogContent {...props}/> : null}

View File

@@ -96,10 +96,6 @@ const useStyles = (themeId: number) => {
...buttonStyle,
flex: 0,
};
const folderButtonTextStyle: ViewStyle = {
...buttonTextStyle,
paddingLeft: 0,
};
const styles = StyleSheet.create({
menu: {
@@ -117,10 +113,9 @@ const useStyles = (themeId: number) => {
},
sidebarIcon: sidebarIconStyle,
folderButton: folderButtonStyle,
folderButtonText: folderButtonTextStyle,
conflictFolderButtonText: {
...folderButtonTextStyle,
color: theme.colorError,
folderButtonText: {
...buttonTextStyle,
paddingLeft: 0,
},
folderButtonSelected: {
...folderButtonStyle,
@@ -269,7 +264,6 @@ const FolderItem: React.FC<FolderItemProps> = props => {
// depth is specified with an accessibilityLabel:
const folderDepthDescription = props.depth > 0 ? _('(level %d)', props.depth) : '';
const accessibilityLabel = `${folderTitle} ${folderDepthDescription}`.trim();
const folderButtonTextStyle = props.folder.id === Folder.conflictFolderId() ? baseStyles.conflictFolderButtonText : baseStyles.folderButtonText;
return (
<View key={props.folder.id} style={styles.buttonWrapper}>
<TouchableRipple
@@ -285,7 +279,7 @@ const FolderItem: React.FC<FolderItemProps> = props => {
{renderFolderIcon(props.folder.id, folderIcon)}
<Text
numberOfLines={1}
style={folderButtonTextStyle}
style={baseStyles.folderButtonText}
accessibilityLabel={accessibilityLabel}
>
{folderTitle}
@@ -508,8 +502,9 @@ const SideMenuContentComponent = (props: Props) => {
});
props.dispatch({
type: 'SYNC_WIZARD_VISIBLE_CHANGE',
visible: true,
type: 'NAV_GO',
routeName: 'Config',
sectionName: 'sync',
});
return 'init';

View File

@@ -56,11 +56,13 @@ const useWebViewSetup = ({
})})
` : '';
const afterLoadFinishedJs = useRef((): string => '');
// Store as a callback to avoid rebuilding the string on each content change.
// Since the editor content is included in editorOptions, for large documents,
// creating the initial injected JS is potentially expensive.
afterLoadFinishedJs.current = () => `
const injectedJavaScript = useMemo(() => `
if (typeof markdownEditorBundle === 'undefined') {
${shim.injectedJs('markdownEditorBundle')};
window.markdownEditorBundle = markdownEditorBundle;
markdownEditorBundle.setUpLogger();
}
if (!window.cm) {
const parentClassName = ${JSON.stringify(editorOptions?.parentElementOrClassName)};
const foundParent = !!parentClassName && document.getElementsByClassName(parentClassName).length > 0;
@@ -72,7 +74,6 @@ const useWebViewSetup = ({
window.cm = markdownEditorBundle.createMainEditor(${JSON.stringify(editorOptions)});
${jumpToHashJs}
// Set the initial selection after jumping to the header -- the initial selection,
// if specified, should take precedence.
${setInitialSelectionJs}
@@ -85,15 +86,7 @@ const useWebViewSetup = ({
console.log('No parent element found with class name ', parentClassName);
}
}
`;
const injectedJavaScript = useMemo(() => `
if (typeof markdownEditorBundle === 'undefined') {
${shim.injectedJs('markdownEditorBundle')};
window.markdownEditorBundle = markdownEditorBundle;
markdownEditorBundle.setUpLogger();
}
`, []);
`, [jumpToHashJs, setInitialSearchJs, setInitialSelectionJs, editorOptions]);
// Scroll to the new hash, if it changes.
const isFirstScrollRef = useRef(true);
@@ -165,14 +158,13 @@ const useWebViewSetup = ({
const webViewEventHandlers = useMemo(() => {
return {
onLoadEnd: () => {
webviewRef.current?.injectJS(afterLoadFinishedJs.current());
editorMessenger.onWebViewLoaded();
},
onMessage: (event: OnMessageEvent) => {
editorMessenger.onWebViewMessage(event);
},
};
}, [editorMessenger, webviewRef]);
}, [editorMessenger]);
const api = useMemo(() => {
return editorMessenger.remoteApi;

View File

@@ -137,11 +137,7 @@ const useTempDirPath = () => {
return tempDirPath;
};
type Result = SetUpResult<RendererControl> & {
hasPluginScripts: boolean;
};
const useWebViewSetup = (props: Props): Result => {
const useWebViewSetup = (props: Props): SetUpResult<RendererControl> => {
const tempDirPath = useTempDirPath();
const { css, injectedJs } = useSource(tempDirPath);
const { editPopupCss, createEditPopupSyntax, destroyEditPopupSyntax } = useEditPopup(props.themeId);
@@ -273,7 +269,6 @@ const useWebViewSetup = (props: Props): Result => {
};
}, [createEditPopupSyntax, destroyEditPopupSyntax, messenger]);
const hasPluginScripts = contentScripts.length > 0;
return useMemo(() => {
return {
api: rendererControl,
@@ -285,9 +280,8 @@ const useWebViewSetup = (props: Props): Result => {
onLoadEnd: messenger.onWebViewLoaded,
onMessage: messenger.onWebViewMessage,
},
hasPluginScripts,
};
}, [css, injectedJs, messenger, editPopupCss, rendererControl, hasPluginScripts]);
}, [css, injectedJs, messenger, editPopupCss, rendererControl]);
};
export default useWebViewSetup;

View File

@@ -98,7 +98,7 @@ export const initialize = async (
},
});
});
editor.setSearchState(initialSearch, 'initialSearch');
editor.setSearchState(initialSearch);
messenger.setLocalInterface({
editor,

View File

@@ -341,7 +341,6 @@
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/RCT-Folly/RCT-Folly_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/RNDeviceInfo/RNDeviceInfoPrivacyInfo.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/RNSVG/RNSVGFilters.bundle",
"${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/AntDesign.ttf",
"${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/Entypo.ttf",
"${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/EvilIcons.ttf",
@@ -364,6 +363,7 @@
"${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",
);
name = "[CP] Copy Pods Resources";
@@ -373,7 +373,6 @@
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoFileSystem_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RCT-Folly_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNDeviceInfoPrivacyInfo.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNSVGFilters.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/AntDesign.ttf",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Entypo.ttf",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EvilIcons.ttf",
@@ -396,6 +395,7 @@
"${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",
);
runOnlyForDeploymentPostprocessing = 0;
@@ -544,7 +544,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 13.5.0;
MARKETING_VERSION = 13.4.3;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@@ -578,7 +578,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 13.5.0;
MARKETING_VERSION = 13.4.3;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@@ -782,7 +782,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 13.5.0;
MARKETING_VERSION = 13.4.3;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
OTHER_LDFLAGS = (
@@ -825,7 +825,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 13.5.0;
MARKETING_VERSION = 13.4.3;
MTL_FAST_MATH = YES;
OTHER_LDFLAGS = (
"$(inherited)",

View File

@@ -1406,7 +1406,7 @@ PODS:
- React-jsiexecutor
- React-RCTFBReactNativeSpec
- ReactCommon/turbomodule/core
- react-native-alarm-notification (3.5.0):
- react-native-alarm-notification (3.4.0):
- React
- react-native-document-picker (10.1.3):
- DoubleConversion
@@ -1458,7 +1458,7 @@ PODS:
- Yoga
- react-native-get-random-values (1.11.0):
- React-Core
- react-native-image-picker (8.2.1):
- react-native-image-picker (8.0.0):
- DoubleConversion
- glog
- hermes-engine
@@ -1486,7 +1486,7 @@ PODS:
- React-Core
- react-native-netinfo (11.4.1):
- React-Core
- react-native-quick-crypto (0.7.17):
- react-native-quick-crypto (0.7.13):
- DoubleConversion
- glog
- hermes-engine
@@ -1514,7 +1514,7 @@ PODS:
- Yoga
- react-native-rsa-native (2.0.5):
- React
- react-native-saf-x (3.5.0):
- react-native-saf-x (3.4.1):
- React-Core
- react-native-safe-area-context (5.4.1):
- React-Core
@@ -1522,7 +1522,7 @@ PODS:
- React-Core
- react-native-version-info (1.1.1):
- React-Core
- react-native-webview (13.14.2):
- react-native-webview (13.13.5):
- DoubleConversion
- glog
- hermes-engine
@@ -1870,11 +1870,11 @@ PODS:
- React-utils (= 0.79.2)
- rn-fetch-blob (0.12.0):
- React-Core
- RNCClipboard (1.16.3):
- RNCClipboard (1.16.2):
- React-Core
- RNCPushNotificationIOS (1.11.0):
- React-Core
- RNDateTimePicker (8.4.2):
- RNDateTimePicker (8.3.0):
- React-Core
- RNDeviceInfo (14.0.4):
- React-Core
@@ -1884,7 +1884,7 @@ PODS:
- React-Core
- RNFS (2.20.0):
- React-Core
- RNLocalize (3.4.2):
- RNLocalize (3.4.1):
- React-Core
- RNQuickAction (0.3.13):
- React
@@ -1914,8 +1914,6 @@ PODS:
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Yoga
- RNSVG (15.13.0):
- React-Core
- RNVectorIcons (10.2.0):
- DoubleConversion
- glog
@@ -2057,7 +2055,6 @@ DEPENDENCIES:
- RNQuickAction (from `../node_modules/react-native-quick-actions`)
- RNSecureRandom (from `../node_modules/react-native-securerandom`)
- RNShare (from `../node_modules/react-native-share`)
- RNSVG (from `../node_modules/react-native-svg`)
- RNVectorIcons (from `../node_modules/react-native-vector-icons`)
- Yoga (from `../node_modules/react-native/ReactCommon/yoga`)
@@ -2281,8 +2278,6 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native-securerandom"
RNShare:
:path: "../node_modules/react-native-share"
RNSVG:
:path: "../node_modules/react-native-svg"
RNVectorIcons:
:path: "../node_modules/react-native-vector-icons"
Yoga:
@@ -2290,7 +2285,7 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90
DoubleConversion: 76ab83afb40bddeeee456813d9c04f67f78771b5
DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb
EXAV: ae28256069c4cdde93d185c007d8f68d92902c2e
EXConstants: 98bcf0f22b820f9b28f9fee55ff2daededadd2f8
Expo: 4b1c6de7c441e1caa1918671ae0aa34d51f019a5
@@ -2303,7 +2298,7 @@ SPEC CHECKSUMS:
fast_float: 06eeec4fe712a76acc9376682e4808b05ce978b6
FBLazyVector: 84b955f7b4da8b895faf5946f73748267347c975
fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd
glog: c5d68082e772fa1c511173d6b30a9de2c05a69a2
glog: 5683914934d5b6e4240e497e0f4a3b42d1854183
hermes-engine: 314be5250afa5692b57b4dd1705959e1973a8ebe
JoplinCommonShareExtension: a8b60b02704d85a7305627912c0240e94af78db7
JoplinRNShareExtension: e158a4b53ee0aa9cd3037a16221dc8adbd6f7860
@@ -2339,20 +2334,20 @@ SPEC CHECKSUMS:
React-logger: 8edfcedc100544791cd82692ca5a574240a16219
React-Mapbuffer: c3f4b608e4a59dd2f6a416ef4d47a14400194468
React-microtasksnativemodule: 054f34e9b82f02bd40f09cebd4083828b5b2beb6
react-native-alarm-notification: a4326a743df72a94d361a4c3a21515556f650341
react-native-alarm-notification: fd7c73a3dc15ce2d5bd9b28dfaa5aa2e25850c7b
react-native-document-picker: da39c5e4f279d39c0356dca157b98f9dc349e5bb
react-native-geolocation: ec15ffebc53790314885eb9e5f2132132fbc2600
react-native-get-random-values: d16467cf726c618e9c7a8c3c39c31faa2244bbba
react-native-image-picker: 7babe45e727db306b3f00d08c72eda3586d6e9c1
react-native-image-picker: 922b9ba90f144b5866d07d04b0fb2b4e9ab0ed75
react-native-image-resizer: 24c5d06fae2176dc0caed4b6396e02befb44064a
react-native-netinfo: cec9c4e86083cb5b6aba0e0711f563e2fbbff187
react-native-quick-crypto: b475b71e7fa4dbf3446be55e8ad4ef2c58ac4f7f
react-native-quick-crypto: 988d8d57cd720dbe218272b60775a8e0210d0b80
react-native-rsa-native: a7931cdda1f73a8576a46d7f431378c5550f0c38
react-native-saf-x: 8a349c8348f43ff7c14770da4b0d618d62593346
react-native-saf-x: 3f8b52fb8160d7322161dec02a564271cc8f4138
react-native-safe-area-context: dde2052b903c11d677c320b599c3244021c34ce8
react-native-sqlite-storage: 0c84826214baaa498796c7e46a5ccc9a82e114ed
react-native-version-info: f0b04e16111c4016749235ff6d9a757039189141
react-native-webview: 2d9ffd72b87cf905cdf8821d7d27d551188bac70
react-native-webview: 1b5778b306d4ed09d13829a6e7a6550e3c1a644a
React-NativeModulesApple: 2c4377e139522c3d73f5df582e4f051a838ff25e
React-oscompat: ef5df1c734f19b8003e149317d041b8ce1f7d29c
React-perflogger: 9a151e0b4c933c9205fd648c246506a83f31395d
@@ -2385,18 +2380,17 @@ SPEC CHECKSUMS:
ReactCodegen: c63eda03ba1d94353fb97b031fc84f75a0d125ba
ReactCommon: 76d2dc87136d0a667678668b86f0fca0c16fdeb0
rn-fetch-blob: 25612b6d6f6e980c6f17ed98ba2f58f5696a51ca
RNCClipboard: f6679d470d0da2bce2a37b0af7b9e0bf369ecda5
RNCClipboard: e1d17c9d093d8129ef50b39b63a17a0e8ccd0ade
RNCPushNotificationIOS: 6c4ca3388c7434e4a662b92e4dfeeee858e6f440
RNDateTimePicker: 392bdc0d6863b5de2fe9b957c82c25b6a038db29
RNDateTimePicker: 29264364ea7b8cc0fb355b3843cf276a4ff78966
RNDeviceInfo: d863506092aef7e7af3a1c350c913d867d795047
RNExitApp: 4432b9b7cc5ccec9f91c94e507849891282befd4
RNFileViewer: 4b5d83358214347e4ab2d4ca8d5c1c90d869e251
RNFS: 89de7d7f4c0f6bafa05343c578f61118c8282ed8
RNLocalize: 6a87f0490f1793d7a70042e4c55eb9a1ba6dd5b4
RNLocalize: 15463c4d79c7da45230064b4adcf5e9bb984667e
RNQuickAction: c2c8f379e614428be0babe4d53a575739667744d
RNSecureRandom: b64d263529492a6897e236a22a2c4249aa1b53dc
RNShare: 675e8e4a84f0137baf33057cac8f7334b0bb4b98
RNSVG: 295a96bc43f2baa5958d64aeec9847a1d8ca7a3d
RNVectorIcons: d53917643fddb261b22bd6d889776f336893622b
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748
Yoga: c758bfb934100bb4bf9cbaccb52557cee35e8bdf

View File

@@ -105,13 +105,6 @@ jest.mock('react-native-zip-archive', () => {
jest.mock('@react-native-documents/picker', () => ({ default: { } }));
// This is one of the icon libraries that react-native-paper attempts to use.
// Throwing an Error causes react-native-paper to select a different icon library
// that better supports our automated testing environment.
jest.doMock('@expo/vector-icons/MaterialCommunityIcons', () => {
throw new Error('Not supported in testing environments.');
});
// Used by the renderer
jest.doMock('react-native-vector-icons/Ionicons', () => {
return {

View File

@@ -2,7 +2,7 @@
"name": "@joplin/app-mobile",
"description": "Joplin for Mobile",
"license": "AGPL-3.0-or-later",
"version": "3.5.0",
"version": "3.4.0",
"private": true,
"scripts": {
"start": "BROWSERSLIST_IGNORE_OLD_DATA=true react-native start --reset-cache",
@@ -22,18 +22,18 @@
},
"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",
"@react-native-clipboard/clipboard": "1.16.3",
"@react-native-community/datetimepicker": "8.4.2",
"@joplin/editor": "~3.4",
"@joplin/lib": "~3.4",
"@joplin/react-native-alarm-notification": "~3.4",
"@joplin/react-native-saf-x": "~3.4",
"@joplin/renderer": "~3.4",
"@joplin/utils": "~3.4",
"@react-native-clipboard/clipboard": "1.16.2",
"@react-native-community/datetimepicker": "8.3.0",
"@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.5",
"@react-native-documents/picker": "10.1.3",
"assert-browserify": "2.0.0",
"buffer": "6.0.3",
"color": "3.2.1",
@@ -41,9 +41,9 @@
"crypto-browserify": "3.12.1",
"deprecated-react-native-prop-types": "5.0.0",
"events": "3.3.0",
"expo": "53.0.20",
"expo": "53.0.19",
"expo-av": "15.1.7",
"expo-camera": "16.1.11",
"expo-camera": "16.1.10",
"expo-local-authentication": "16.0.5",
"lodash": "4.17.21",
"md5": "2.3.0",
@@ -59,9 +59,9 @@
"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.4.2",
"react-native-localize": "3.4.1",
"react-native-modal-datetime-picker": "18.0.0",
"react-native-paper": "5.14.5",
"react-native-paper": "5.13.5",
"react-native-popup-menu": "0.17.0",
"react-native-quick-actions": "0.3.13",
"react-native-quick-crypto": "0.7.17",
@@ -70,11 +70,10 @@
"react-native-securerandom": "1.0.1",
"react-native-share": "12.0.11",
"react-native-sqlite-storage": "6.0.1",
"react-native-svg": "15.13.0",
"react-native-url-polyfill": "2.0.0",
"react-native-vector-icons": "10.2.0",
"react-native-version-info": "1.1.1",
"react-native-webview": "13.15.0",
"react-native-webview": "13.13.5",
"react-native-zip-archive": "7.0.2",
"react-redux": "8.1.3",
"redux": "4.2.1",
@@ -91,7 +90,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.4",
"@joplin/turndown": "~4.0.80",
"@joplin/turndown-plugin-gfm": "~1.0.62",
"@js-draw/material-icons": "1.30.1",
@@ -99,23 +98,23 @@
"@react-native-community/cli": "16.0.3",
"@react-native-community/cli-platform-android": "16.0.3",
"@react-native-community/cli-platform-ios": "16.0.3",
"@react-native/babel-preset": "0.80.1",
"@react-native/metro-config": "0.79.5",
"@react-native/typescript-config": "0.79.5",
"@react-native/babel-preset": "0.79.4",
"@react-native/metro-config": "0.79.4",
"@react-native/typescript-config": "0.79.2",
"@sqlite.org/sqlite-wasm": "3.46.0-build2",
"@testing-library/react-native": "13.2.0",
"@types/fs-extra": "11.0.4",
"@types/jest": "29.5.14",
"@types/node": "18.19.119",
"@types/node": "18.19.112",
"@types/react": "19.0.14",
"@types/react-redux": "7.1.33",
"@types/serviceworker": "0.0.142",
"@types/serviceworker": "0.0.139",
"@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.20.0",
"esbuild": "0.25.6",
"esbuild": "0.25.5",
"fast-deep-equal": "3.1.3",
"fs-extra": "11.2.0",
"gulp": "4.0.2",
@@ -130,10 +129,10 @@
"react-native-web": "0.20.0",
"react-refresh": "0.17.0",
"react-test-renderer": "19.0.0",
"sharp": "0.34.3",
"sharp": "0.34.2",
"sqlite3": "5.1.6",
"timers-browserify": "2.0.12",
"ts-jest": "29.3.4",
"ts-jest": "29.3.1",
"ts-loader": "9.5.2",
"ts-node": "10.9.2",
"typescript": "5.8.3",

View File

@@ -107,7 +107,6 @@ import DocumentScanner from './components/screens/DocumentScanner/DocumentScanne
import buildStartupTasks from './utils/buildStartupTasks';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import appReducer from './utils/appReducer';
import SyncWizard from './components/SyncWizard/SyncWizard';
const logger = Logger.create('root');
const perfLogger = PerformanceLogger.create();
@@ -144,7 +143,7 @@ const generalMiddleware = (store: any) => (next: any) => async (action: any) =>
if (action.type === 'NAV_GO') Keyboard.dismiss();
if (['NOTE_UPDATE_ONE', 'NOTE_DELETE', 'FOLDER_UPDATE_ONE', 'FOLDER_DELETE'].indexOf(action.type) >= 0) {
if (!await reg.syncTarget().syncStarted()) void reg.scheduleSync(reg.syncAsYouTypeInterval(), { syncSteps: ['update_remote', 'delete_remote'] }, true);
if (!await reg.syncTarget().syncStarted()) void reg.scheduleSync(1000, { syncSteps: ['update_remote', 'delete_remote'] }, true);
SearchEngine.instance().scheduleSyncTables();
}
@@ -763,7 +762,6 @@ class AppComponent extends React.Component<AppComponentProps, AppComponentState>
</View>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied */}
<DropdownAlert alert={(func: any) => (this.dropdownAlert_ = func)} />
<SyncWizard/>
</SafeAreaView>
</View>
</SideMenu>

View File

@@ -17,7 +17,6 @@ const appDefaultState: AppState = {
disableSideMenuGestures: false,
showPanelsDialog: false,
noteEditorVisible: false,
syncWizardVisible: false,
...defaultState,
// On mobile, it's possible to select notes that aren't in the selected folder/tag/etc.

View File

@@ -195,10 +195,6 @@ const appReducer = (state = appDefaultState, action: any) => {
case 'NOTE_EDITOR_VISIBLE_CHANGE':
newState = { ...state, noteEditorVisible: action.visible };
break;
case 'SYNC_WIZARD_VISIBLE_CHANGE':
newState = { ...state, syncWizardVisible: action.visible };
break;
}
} catch (error) {
error.message = `In reducer: ${error.message} Action: ${JSON.stringify(action)}`;

View File

@@ -1,36 +1,25 @@
import Logger from '@joplin/utils/Logger';
import { Platform, PermissionsAndroid, Permission } from 'react-native';
const { Platform, PermissionsAndroid } = require('react-native');
const logger = Logger.create('checkPermissions');
type Rationale = {
type rationale = {
title: string;
message: string;
buttonPositive: string;
buttonPositive?: string;
buttonNegative?: string;
buttonNeutral?: string;
};
interface Options {
rationale?: Rationale;
onRequestConfirmation?: ()=> Promise<boolean>;
}
export default async (permissions: Permission, { rationale, onRequestConfirmation }: Options = {}) => {
export default async (permissions: string, rationale?: rationale) => {
// On iOS, permissions are prompted for by the system, so here we assume it's granted.
if (Platform.OS !== 'android') return PermissionsAndroid.RESULTS.GRANTED;
const granted = await PermissionsAndroid.check(permissions);
logger.info('Checked permission:', granted);
if (granted) {
return PermissionsAndroid.RESULTS.GRANTED;
} else {
if (onRequestConfirmation && !await onRequestConfirmation()) {
return PermissionsAndroid.RESULTS.NEVER_ASK_AGAIN;
}
const result = await PermissionsAndroid.request(permissions, rationale);
let result = await PermissionsAndroid.check(permissions);
logger.info('Checked permission:', result);
if (result !== PermissionsAndroid.RESULTS.GRANTED) {
result = await PermissionsAndroid.request(permissions, rationale);
logger.info('Requested permission:', result);
return result;
}
return result;
};

View File

@@ -1,6 +1,6 @@
import shimInitShared from './shimInitShared';
import shim, { MobilePlatform } from '@joplin/lib/shim';
import shim from '@joplin/lib/shim';
const { GeolocationReact } = require('../geolocation-react.js');
import RNFetchBlob from 'rn-fetch-blob';
import { generateSecureRandom } from 'react-native-securerandom';
@@ -165,7 +165,7 @@ export default function shimInit() {
};
shim.mobilePlatform = () => {
return Platform.OS as MobilePlatform;
return Platform.OS;
};
shim.isAppleSilicon = () => {

View File

@@ -9,7 +9,7 @@ import * as mimeUtils from '@joplin/lib/mime-utils';
import Resource from '@joplin/lib/models/Resource';
import { getLocales } from 'react-native-localize';
import type Setting from '@joplin/lib/models/Setting';
import shim, { MobilePlatform } from '@joplin/lib/shim';
import shim from '@joplin/lib/shim';
import { closestSupportedLocale, defaultLocale, setLocale } from '@joplin/lib/locale';
const shimInitShared = () => {
@@ -76,7 +76,7 @@ const shimInitShared = () => {
};
shim.mobilePlatform = () => {
return Platform.OS as MobilePlatform;
return Platform.OS;
};
shim.platformArch = () => {
@@ -116,7 +116,8 @@ const shimInitShared = () => {
const resourceId = defaultProps.id ? defaultProps.id : uuid.create();
const ext = fileExtension(filePath);
const mimeType = defaultProps.mime ?? mimeUtils.fromFileExtension(ext) ?? 'image/jpeg';
let mimeType = mimeUtils.fromFileExtension(ext);
if (!mimeType) mimeType = 'image/jpeg';
let resource = Resource.new();
resource.id = resourceId;

View File

@@ -11,5 +11,4 @@ export interface AppState extends State {
noteSideMenuOptions: any;
disableSideMenuGestures: boolean;
noteEditorVisible: boolean;
syncWizardVisible: boolean;
}

View File

@@ -1 +0,0 @@
throw new Error('Failed to load');

View File

@@ -45,7 +45,6 @@ const buildSharedConfig = (hotReload: boolean): webpack.Configuration => {
};
const emptyLibraryMock = path.resolve(__dirname, 'mocks/empty.js');
const throwOnLoadLibraryMock = path.resolve(__dirname, 'mocks/throwOnLoad.js');
return {
output: {
@@ -79,8 +78,6 @@ const buildSharedConfig = (hotReload: boolean): webpack.Configuration => {
'@react-native-documents/picker': emptyLibraryMock,
'react-native-exit-app': emptyLibraryMock,
'expo-camera': emptyLibraryMock,
// Remove this after upgrading react-native-vector-icons.
'@react-native-vector-icons/material-design-icons': throwOnLoadLibraryMock,
// Workaround for applying serviceworker types to a single file.
// See https://joshuatz.com/posts/2021/strongly-typed-service-workers/.

View File

@@ -1,6 +1,6 @@
{
"name": "@joplin/default-plugins",
"version": "3.5.0",
"version": "3.4.0",
"description": "Default plugins bundler",
"private": true,
"scripts": {
@@ -15,10 +15,10 @@
"devDependencies": {
"@types/yargs": "17.0.33",
"ts-node": "10.9.2",
"typescript": "5.8.3"
"typescript": "5.8.2"
},
"dependencies": {
"@joplin/utils": "~3.5",
"@joplin/utils": "~3.4",
"fs-extra": "11.2.0",
"yargs": "17.7.2"
}

View File

@@ -15,7 +15,6 @@ import { noteIdFacet, setNoteIdEffect } from './extensions/selectedNoteIdExtensi
import jumpToHash from './editorCommands/jumpToHash';
import { resetImageResourceEffect } from './extensions/rendering/renderBlockImages';
import Logger from '@joplin/utils/Logger';
import { searchChangeSourceEffect } from './extensions/searchExtension';
const logger = Logger.create('CodeMirrorControl');
@@ -182,7 +181,7 @@ export default class CodeMirrorControl extends CodeMirror5Emulation implements E
return getSearchState(this.editor.state);
}
public setSearchState(newState: SearchState, changeSource = 'setSearchState') {
public setSearchState(newState: SearchState) {
if (newState.dialogVisible !== searchPanelOpen(this.editor.state)) {
this.execCommand(newState.dialogVisible ? EditorCommandType.ShowSearch : EditorCommandType.HideSearch);
}
@@ -195,7 +194,6 @@ export default class CodeMirrorControl extends CodeMirror5Emulation implements E
});
this.editor.dispatch({
effects: [
searchChangeSourceEffect.of(changeSource),
setSearchQuery.of(query),
],
});

View File

@@ -1,99 +0,0 @@
import { EditorSelection } from '@codemirror/state';
import createTestEditor from '../testing/createTestEditor';
import { getSearchQuery, openSearchPanel, SearchQuery, setSearchQuery } from '@codemirror/search';
import { EditorView } from '@codemirror/view';
import searchExtension from './searchExtension';
import createEditorSettings from '../../testing/createEditorSettings';
import { Second } from '@joplin/utils/time';
const setSearchText = (text: string, view: EditorView) => {
const oldQuery = getSearchQuery(view.state);
const query = new SearchQuery({
search: text,
caseSensitive: oldQuery.caseSensitive,
regexp: oldQuery.regexp,
replace: oldQuery.replace,
});
view.dispatch({
effects: [
setSearchQuery.of(query),
],
});
};
const setSearchTextAndWait = async (text: string, view: EditorView) => {
setSearchText(text, view);
await jest.advanceTimersByTimeAsync(Second);
};
const createEditor = async (initialText: string, cursorPosition: number) => {
const view = await createTestEditor(initialText, EditorSelection.cursor(cursorPosition), [], [
searchExtension(()=>{}, createEditorSettings(1)),
]);
openSearchPanel(view);
return view;
};
const getSelectionFrom = (view: EditorView) => {
return view.state.selection.main.from;
};
describe('searchExtension', () => {
beforeEach(() => {
jest.useFakeTimers();
});
test('should auto-scroll to a match when the search changes', async () => {
const view = await createEditor('Line 1\n\nLine 3\nLine 4', 0);
const docText = view.state.doc.toString();
await setSearchTextAndWait('Line 3', view);
expect(getSelectionFrom(view)).toBe(docText.indexOf('Line 3'));
await setSearchTextAndWait('Line 4', view);
expect(getSelectionFrom(view)).toBe(docText.indexOf('Line 4'));
});
test('should advance to the next match on change', async () => {
const view = await createEditor('Match\nMatch2\nMatch23', 0);
const docText = view.state.doc.toString();
await setSearchTextAndWait('Match', view);
expect(getSelectionFrom(view)).toBe(0);
await setSearchTextAndWait('Match2', view);
expect(getSelectionFrom(view)).toBe(docText.indexOf('Match2'));
await setSearchTextAndWait('Match23', view);
expect(getSelectionFrom(view)).toBe(docText.indexOf('Match23'));
});
test('should preserve auto-match start position until selection is manually changed', async () => {
const view = await createEditor('Match1\nMatch2\nMatch23', 0);
const docText = view.state.doc.toString();
await setSearchTextAndWait('Match2', view);
expect(getSelectionFrom(view)).toBe(docText.indexOf('Match2'));
await setSearchTextAndWait('Match', view);
expect(getSelectionFrom(view)).toBe(docText.indexOf('Match'));
await setSearchTextAndWait('Match23', view);
expect(getSelectionFrom(view)).toBe(docText.indexOf('Match23'));
// Manually setting the selection should change the match start location
view.dispatch({
selection: EditorSelection.single('Match1\n'.length),
});
await setSearchTextAndWait('Match2', view);
expect(getSelectionFrom(view)).toBe(docText.indexOf('Match2'));
});
test('search should wrap to the top when not found after the cursor', async () => {
const view = await createEditor('Before\nOther\nOther 2', 5);
await setSearchTextAndWait('Before', view);
expect(getSelectionFrom(view)).toBe(0);
});
});

View File

@@ -1,163 +1,17 @@
import { EditorSelection, EditorState, Extension, StateEffect, StateField } from '@codemirror/state';
import { EditorView, ViewPlugin, ViewUpdate } from '@codemirror/view';
import { EditorState, Extension } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
import { EditorSettings, OnEventCallback } from '../../types';
import getSearchState from '../utils/getSearchState';
import { EditorEventType } from '../../events';
import { search, searchPanelOpen, SearchQuery, setSearchQuery } from '@codemirror/search';
import announceSearchMatch from '../vendor/announceSearchMatch';
type CancelEvent = { cancelled: boolean };
const scanForFirstMatch = async (
state: EditorState,
query: SearchQuery,
startPosition: number,
delayFunction: ()=> Promise<void>,
cancelEvent: CancelEvent,
) => {
if (cancelEvent.cancelled) return null;
const pageSizeChars = 40_000;
let nextStartPosition = startPosition;
const nextCursor = () => {
if (nextStartPosition >= state.doc.length) {
return null;
}
let endPosition = Math.min(nextStartPosition + pageSizeChars, state.doc.length);
const endPositionLine = state.doc.lineAt(endPosition);
// Always search up to the end of the current line to avoid getting partial matches.
endPosition = endPositionLine.to;
const cursor = query.getCursor(state, nextStartPosition, endPosition);
nextStartPosition = endPosition;
return cursor;
};
const nextCursorAndWait = async () => {
const result = nextCursor();
await delayFunction();
return result;
};
for (let cursor = nextCursor(); !!cursor && !cancelEvent.cancelled; cursor = await nextCursorAndWait()) {
const match = cursor.next();
if (match?.value && match.value.to && match.value.from !== match.value.to) {
return match.value;
}
}
return null;
};
// Included in a transaction if it was caused by the auto-scroll-to-next-match logic
const autoMatchAnnotation = StateEffect.define<boolean>();
const autoMatchSearchStartField = StateField.define<number>({
create: (state) => state.selection.main.from,
update: (lastValue, viewUpdate) => {
const changedByAutoMatch = viewUpdate.effects.some(effect => effect.is(autoMatchAnnotation));
const sameSelection = viewUpdate.startState.selection.eq(viewUpdate.newSelection);
const noSignificantChanges = sameSelection && !viewUpdate.docChanged;
if (changedByAutoMatch || noSignificantChanges) {
return lastValue;
}
return viewUpdate.newSelection.main.from;
},
});
const autoScrollToMatchPlugin = ViewPlugin.fromClass(class {
private _lastCancelEvent: CancelEvent = { cancelled: false };
public constructor(private _view: EditorView) { }
private async handleScrollOnQueryChange_(
query: SearchQuery,
state: EditorState,
startState: EditorState,
cancelEvent: CancelEvent,
) {
const isOpenSearchPanelEvent = () => searchPanelOpen(startState) && !searchPanelOpen(state);
if (
!query || query.search.length === 0
// Avoid auto-scrolling to the search result when first opening the search panel
|| isOpenSearchPanelEvent()
) {
return;
}
const getFirstMatchAfter = async (pos: number) => {
const delayFunction = () => {
return new Promise<void>(resolve => {
requestAnimationFrame(() => resolve());
});
};
return await scanForFirstMatch(
state, query, pos, delayFunction, cancelEvent,
);
};
const searchStart = state.field(autoMatchSearchStartField);
const firstMatchAfterSelection = await getFirstMatchAfter(searchStart);
const targetMatch = firstMatchAfterSelection ?? await getFirstMatchAfter(0);
if (targetMatch && targetMatch.from >= 0 && !cancelEvent.cancelled) {
this._view.dispatch({
selection: EditorSelection.single(targetMatch.from, targetMatch.to),
effects: [
// Mark this transaction as an auto-match. This allows listeners to
// process the transaction differently.
autoMatchAnnotation.of(true),
EditorView.scrollIntoView(targetMatch.from),
announceSearchMatch(state, targetMatch),
],
userEvent: 'select.search',
});
}
}
public async update(update: ViewUpdate) {
let lastQueryUpdate: StateEffect<SearchQuery>|null = null;
for (const tr of update.transactions) {
const queryUpdate = tr.effects.find(e => e.is(setSearchQuery));
if (queryUpdate) {
lastQueryUpdate = queryUpdate;
}
}
const cancelOngoingSearch = () => {
this._lastCancelEvent.cancelled = true;
};
const newCancelEvent = () => {
cancelOngoingSearch();
const cancelEvent = { cancelled: false };
this._lastCancelEvent = cancelEvent;
return cancelEvent;
};
if (!searchPanelOpen(update.state)) {
cancelOngoingSearch();
} else if (lastQueryUpdate) {
void this.handleScrollOnQueryChange_(
lastQueryUpdate.value, update.state, update.startState, newCancelEvent(),
);
}
}
}, {
provide: () => autoMatchSearchStartField,
});
export const searchChangeSourceEffect = StateEffect.define<string>();
import { search, searchPanelOpen, setSearchQuery } from '@codemirror/search';
const searchExtension = (onEvent: OnEventCallback, settings: EditorSettings): Extension => {
const onSearchDialogUpdate = (state: EditorState, changeSources: string[]) => {
const onSearchDialogUpdate = (state: EditorState) => {
const newSearchState = getSearchState(state);
onEvent({
kind: EditorEventType.UpdateSearchDialog,
searchState: newSearchState,
changeSources,
});
};
@@ -174,14 +28,9 @@ const searchExtension = (onEvent: OnEventCallback, settings: EditorSettings): Ex
},
} : undefined),
autoScrollToMatchPlugin,
EditorState.transactionExtender.of((tr) => {
if (tr.effects.some(e => e.is(setSearchQuery)) || searchPanelOpen(tr.state) !== searchPanelOpen(tr.startState)) {
const changeSourceEffects = tr.effects.filter(effect => effect.is(searchChangeSourceEffect));
const changeSources = changeSourceEffects.map(effect => effect.value);
onSearchDialogUpdate(tr.state, changeSources);
onSearchDialogUpdate(tr.state);
}
return null;
}),

View File

@@ -1,53 +0,0 @@
import { EditorState } from "@codemirror/state"
import { EditorView } from "@codemirror/view"
// From https://github.com/codemirror/search/blob/3fd68b965a1a149bb65a268ef52c10b36c080538/src/search.ts
// Modified to accept an EditorState instead of an EditorView.
//!
//! MIT License
//!
//! Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
//!
//! Permission is hereby granted, free of charge, to any person obtaining a copy
//! of this software and associated documentation files (the "Software"), to deal
//! in the Software without restriction, including without limitation the rights
//! to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
//! copies of the Software, and to permit persons to whom the Software is
//! furnished to do so, subject to the following conditions:
//!
//! The above copyright notice and this permission notice shall be included in
//! all copies or substantial portions of the Software.
//!
//! THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
//! IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
//! FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
//! AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
//! LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
//! OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
//! THE SOFTWARE.
//!
const AnnounceMargin = 30
const Break = /[\s\.,:;?!]/
export default function announceSearchMatch(state: EditorState, {from, to}: {from: number, to: number}) {
let line = state.doc.lineAt(from), lineEnd = state.doc.lineAt(to).to
let start = Math.max(line.from, from - AnnounceMargin), end = Math.min(lineEnd, to + AnnounceMargin)
let text = state.sliceDoc(start, end)
if (start != line.from) {
for (let i = 0; i < AnnounceMargin; i++) if (!Break.test(text[i + 1]) && Break.test(text[i])) {
text = text.slice(i)
break
}
}
if (end != lineEnd) {
for (let i = text.length - 1; i > text.length - AnnounceMargin; i--) if (!Break.test(text[i - 1]) && Break.test(text[i])) {
text = text.slice(0, i)
break
}
}
return EditorView.announce.of(
`${state.phrase("current match")}. ${text} ${state.phrase("on line")} ${line.number}.`)
}

View File

@@ -23,7 +23,7 @@ import searchExtension from './plugins/searchPlugin';
import joplinEditorApiPlugin, { setEditorApi } from './plugins/joplinEditorApiPlugin';
import linkTooltipPlugin from './plugins/linkTooltipPlugin';
import { OnCreateCodeEditor as OnCreateCodeEditor, RendererControl } from './types';
import imagePlugin, { onResourceDownloaded } from './plugins/imagePlugin';
import resourcePlaceholderPlugin, { onResourceDownloaded } from './plugins/resourcePlaceholderPlugin';
import getFileFromPasteEvent from '../utils/getFileFromPasteEvent';
import { RenderResult } from '../../renderer/types';
import postprocessEditorOutput from './utils/postprocessEditorOutput';
@@ -92,7 +92,7 @@ const createEditor = async (
linkTooltipPlugin,
tableEditing({ allowTableNodeSelection: true }),
joplinEditorApiPlugin,
imagePlugin,
resourcePlaceholderPlugin,
].flat(),
});
@@ -265,8 +265,8 @@ const createEditor = async (
view.dispatch(transaction);
},
setSearchState: (newState: SearchState, changeSource = 'setSearchState') => {
view.dispatch(updateSearchState(view.state, newState, changeSource));
setSearchState: (newState: SearchState) => {
view.dispatch(updateSearchState(view.state, newState));
},
setContentScripts: (_plugins: ContentScriptData[]) => {
throw new Error('setContentScripts not implemented.');

View File

@@ -1,23 +1,33 @@
import createTestEditor from '../testing/createTestEditor';
import createTestEditorWithSerializer from '../testing/createTestEditorWithSerializer';
import detailsPlugin from './detailsPlugin';
import originalMarkupPlugin from './originalMarkupPlugin';
describe('detailsPlugin', () => {
it('should add jop-noMdConv attributes to <details> and <summary>', () => {
const { toHtml, normalizeHtml } = createTestEditorWithSerializer({
const serializer = new XMLSerializer();
const markupToHtml = originalMarkupPlugin(node => serializer.serializeToString(node));
const view = createTestEditor({
html: `
<details><summary>Test</summary>
<p>Test...</p>
</details>
`,
plugins: [detailsPlugin],
plugins: [detailsPlugin, markupToHtml.plugin],
});
expect(toHtml()).toBe(normalizeHtml([
'<details class="jop-noMdConv"><summary class="jop-noMdConv">Test</summary>',
'<p>Test...</p>',
'</details>',
].join('')));
// Serialize, then parse to normalize the HTML (for comparison
// with the HTML serialized by markupToHtml).
const expectedState = serializer.serializeToString(
new DOMParser().parseFromString([
'<details class="jop-noMdConv"><summary class="jop-noMdConv">Test</summary>',
'<p>Test...</p>',
'</details>',
].join(''), 'text/html').querySelector('details'),
);
expect(
markupToHtml.stateToMarkup(view.state).trim(),
).toBe(expectedState);
});
it.each([

View File

@@ -1,16 +0,0 @@
import createTestEditorWithSerializer from '../testing/createTestEditorWithSerializer';
const testImageUrl = 'data:image/svg+xml;utf8,some-icon-here';
describe('imagePlugin', () => {
test('should preserve image ALT text on save', () => {
const { toHtml, normalizeHtml } = createTestEditorWithSerializer({
html: `
<img src="${testImageUrl}" alt="Test"/>
`,
plugins: [],
});
expect(toHtml()).toBe(normalizeHtml(`<p><img src="${testImageUrl}" alt="Test"/></p>`));
});
});

View File

@@ -1,269 +0,0 @@
import { Plugin } from 'prosemirror-state';
import { AttributeSpec, Node, NodeSpec } from 'prosemirror-model';
import { EditorView } from 'prosemirror-view';
import SelectableNodeView, { GetPosition } from '../utils/SelectableNodeView';
import { getEditorApi } from './joplinEditorApiPlugin';
import showModal from '../utils/dom/showModal';
import createTextArea from '../utils/dom/createTextArea';
import createExternalEditorPlugin, { OnHide } from './utils/createExternalEditorPlugin';
import createFloatingButtonPlugin, { ToolbarPosition } from './utils/createFloatingButtonPlugin';
// See the fold example for more information about
// writing similar ProseMirror plugins:
// https://prosemirror.net/examples/fold/
type NodeAttrs = Readonly<{
// Placeholder attributes (e.g. if the src is not
// yet valid).
isPlaceholder: boolean;
placeholderSrc: string;
placeholderAlt: string;
notDownloaded: boolean;
isImage: boolean;
resourceId: string;
src: string;
fromMd: boolean;
alt: string;
title: string;
width: string;
height: string;
}>;
const attrsSpec = {
isPlaceholder: { default: '', validate: 'boolean' },
placeholderSrc: { default: '', validate: 'string' },
placeholderAlt: { default: '', validate: 'string' },
notDownloaded: { validate: 'boolean' },
isImage: { validate: 'boolean' },
width: { validate: 'string', default: '' },
height: { validate: 'string', default: '' },
resourceId: { default: null as string|null, validate: 'string|null' },
src: { default: '', validate: 'string' },
fromMd: { default: false, validate: 'boolean' },
alt: { default: '', validate: 'string' },
title: { default: '', validate: 'string' },
} satisfies Record<keyof NodeAttrs, AttributeSpec>;
const imageSpec: NodeSpec = {
group: 'inline',
inline: true,
attrs: attrsSpec,
draggable: true,
parseDOM: [
{
// Images
tag: 'img[src]',
getAttrs: (node): NodeAttrs => ({
src: node.getAttribute('src'),
alt: node.getAttribute('alt'),
title: node.getAttribute('title'),
width: node.getAttribute('width') ?? '',
height: node.getAttribute('height') ?? '',
fromMd: node.hasAttribute('data-from-md'),
resourceId: node.getAttribute('data-resource-id') || null,
isPlaceholder: false,
placeholderSrc: '',
placeholderAlt: '',
notDownloaded: false,
isImage: true,
}),
},
{
// Placeholders
tag: 'span[data-resource-id].not-loaded-resource',
getAttrs: (node): NodeAttrs => {
return {
isPlaceholder: true,
resourceId: node.getAttribute('data-resource-id'),
placeholderSrc: node.querySelector('img')?.src,
src: '',
width: null,
height: null,
placeholderAlt: node.querySelector('img')?.alt,
fromMd: false,
alt: node.getAttribute('data-original-alt'),
title: node.getAttribute('data-original-title'),
isImage: node.classList.contains('not-loaded-image-resource'),
notDownloaded: node.classList.contains('resource-status-notDownloaded'),
};
},
},
],
toDOM: (node) => {
const attrs = node.attrs as NodeAttrs;
// Continue to render non-images as placeholders for now, even after downloading:
return (attrs.isPlaceholder || !attrs.isImage) ? [
'span',
{
'data-resource-id': attrs.resourceId,
'data-original-alt': attrs.alt,
'data-original-title': attrs.title,
class: [
'not-loaded-resource',
attrs.isImage ? 'not-loaded-image-resource' : null,
attrs.notDownloaded ? 'resource-status-notDownloaded' : null,
].filter(item => !!item).join(' '),
},
['img', { src: attrs.placeholderSrc, alt: attrs.placeholderAlt }],
] : [
'img',
{
...(attrs.fromMd ? {
'data-from-md': true,
} : {}),
...(attrs.resourceId ? {
'data-resource-id': attrs.resourceId,
} : {}),
...(attrs.width ? {
width: attrs.width,
} : {}),
...(attrs.height ? {
height: attrs.height,
} : {}),
src: attrs.src,
alt: attrs.alt,
title: attrs.title,
},
];
},
};
export const nodeSpecs = {
image: imageSpec,
};
const createAltTextDialog = (nodePosition: number, view: EditorView, onHide: ()=> void) => {
const node = view.state.doc.nodeAt(nodePosition);
const attrs = node.attrs as NodeAttrs;
const { localize: _ } = getEditorApi(view.state);
const content = document.createElement('div');
content.classList.add('alt-text-editor');
const input = createTextArea({
label: _('A brief description of the image:'),
initialContent: attrs.alt,
onChange: (newContent) => {
view.dispatch(
view.state.tr.setNodeAttribute(nodePosition, 'alt', newContent.replace(/[\n]+/g, '\n')),
);
},
});
input.textArea.setAttribute('autofocus', 'true');
content.appendChild(input.label);
content.appendChild(input.textArea);
const modal = showModal({
content,
doneLabel: _('Done'),
onDismiss: ()=>{
onHide();
},
});
return {
onPositionChange: (newPosition: number) => {
nodePosition = newPosition;
},
dismiss: () => modal.dismiss(),
};
};
const { plugin: altTextEditorPlugin, editAt: editAltTextAt } = createExternalEditorPlugin({
canEdit: (node: Node) => {
return node.type.name === 'image';
},
showEditor: (pos: number, view: EditorView, onHide: OnHide) => {
return createAltTextDialog(pos, view, onHide);
},
});
class ImageView extends SelectableNodeView {
public constructor(node: Node, view: EditorView, getPosition: GetPosition) {
super(true);
this.dom.classList.add('joplin-image-view');
this.dom.appendChild(this.createDom_(node));
this.dom.ondblclick = () => {
editAltTextAt(getPosition())(view.state, view.dispatch, view);
};
}
private createDom_(node: Node) {
const attrs = node.attrs as NodeAttrs;
const createDom = (imageSrc: string, imageAlt: string, loaded: boolean) => {
const image = document.createElement('img');
image.src = imageSrc;
image.alt = imageAlt;
image.setAttribute('width', attrs.width);
image.setAttribute('height', attrs.height);
let dom;
if (!loaded) {
dom = document.createElement('span');
dom.classList.add('not-loaded-resource');
dom.appendChild(image);
} else {
dom = image;
dom.classList.add('late-loaded-resource');
}
// For testing
dom.setAttribute('data-resource-id', attrs.resourceId);
return dom;
};
let imageSrc = attrs.placeholderSrc;
let imageAlt = attrs.placeholderAlt;
let loaded = false;
if (!attrs.isPlaceholder) {
loaded = true;
imageSrc = attrs.src;
imageAlt = attrs.alt;
}
return createDom(imageSrc, imageAlt, loaded);
}
}
export const onResourceDownloaded = (view: EditorView, resourceId: string, newSrc: string) => {
let tr = view.state.tr;
view.state.doc.descendants((node, pos) => {
if (node.type.name === 'image') {
const attrs = node.attrs as NodeAttrs;
const itemId = attrs.resourceId;
if (itemId === resourceId && attrs.isPlaceholder) {
tr = tr.setNodeAttribute(pos, 'isPlaceholder', false)
.setNodeAttribute(pos, 'notDownloaded', false)
.setNodeAttribute(pos, 'src', newSrc)
.setMeta('addToHistory', false);
}
}
});
view.dispatch(tr);
};
const imagePlugin = [
altTextEditorPlugin,
new Plugin({
props: {
nodeViews: {
image: (node, view, getPosition) => {
return new ImageView(node, view, getPosition);
},
},
},
}),
createFloatingButtonPlugin('image', [
{ label: _ => _('Label'), command: (_node, offset) => editAltTextAt(offset) },
], ToolbarPosition.TopRightInside),
];
export default imagePlugin;

View File

@@ -1,6 +1,7 @@
import { focus } from '@joplin/lib/utils/focusHandler';
import createTextNode from '../../utils/dom/createTextNode';
import { EditorApi } from '../joplinEditorApiPlugin';
import { EditorLanguageType } from '../../../types';
import showModal from '../../utils/dom/showModal';
interface SourceBlockData {
start: string;
@@ -18,12 +19,18 @@ interface Options {
}
const createEditorDialog = ({ editorApi, doneLabel, block, onSave, onDismiss }: Options) => {
const content = document.createElement('div');
content.classList.add('editor-dialog-content');
document.body.appendChild(content);
const dialog = document.createElement('dialog');
dialog.classList.add('editor-dialog', '-visible');
document.body.appendChild(dialog);
dialog.onclose = () => {
onDismiss();
dialog.remove();
editor.remove();
};
const editor = editorApi.createCodeEditor(
content,
dialog,
EditorLanguageType.Markdown,
(newContent) => {
block = {
@@ -41,14 +48,35 @@ const createEditorDialog = ({ editorApi, doneLabel, block, onSave, onDismiss }:
block.end,
].join(''));
return showModal({
content,
doneLabel,
onDismiss: () => {
onDismiss();
editor.remove();
},
});
const onClose = () => {
if (dialog.close) {
dialog.close();
} else {
// Handle the case where the dialog element is not supported by the
// browser/testing environment.
dialog.onclose(new Event('close'));
}
};
const submitButton = document.createElement('button');
submitButton.appendChild(createTextNode(doneLabel));
submitButton.classList.add('submit');
submitButton.onclick = onClose;
dialog.appendChild(submitButton);
// .showModal is not defined in JSDOM and some older (pre-2022) browsers
if (dialog.showModal) {
dialog.showModal();
} else {
dialog.classList.add('-fake-modal');
focus('createEditorDialog/legacy', editor);
}
return {
dismiss: onClose,
};
};
export default createEditorDialog;

View File

@@ -4,8 +4,6 @@ import createTestEditor from '../../testing/createTestEditor';
import joplinEditorApiPlugin, { getEditorApi, setEditorApi } from '../joplinEditorApiPlugin';
import joplinEditablePlugin, { editSourceBlockAt, hideSourceBlockEditor } from './joplinEditablePlugin';
import { Second } from '@joplin/utils/time';
import { EditorView } from 'prosemirror-view';
import selectFirstInstanceOfNode from '../../utils/selectFirstInstanceOfNode';
const createEditor = (html: string) => {
return createTestEditor({
@@ -14,12 +12,12 @@ const createEditor = (html: string) => {
});
};
const findEditButton = (editor: EditorView): HTMLButtonElement => {
return editor.dom.parentElement.querySelector('.floating-button-bar:not(.-hidden) > .edit-button');
const findEditButton = (ancestor: Element): HTMLButtonElement => {
return ancestor.querySelector('.joplin-editable > button.edit');
};
const findEditorDialog = () => {
const dialog = document.querySelector('dialog.joplin-dialog');
const dialog = document.querySelector('dialog.editor-dialog');
if (!dialog) {
return null;
}
@@ -49,19 +47,13 @@ describe('joplinEditablePlugin', () => {
'<p>Test: <mark><span class="joplin-editable"><pre class="joplin-source">test</pre>rendered</span></mark></p>',
])('should show an edit button on source blocks (case %#)', (htmlSource) => {
const editor = createEditor(htmlSource);
selectFirstInstanceOfNode(editor, 'joplinEditableInline');
selectFirstInstanceOfNode(editor, 'joplinEditableBlock');
const editButton = findEditButton(editor);
const editButton = findEditButton(editor.dom);
expect(editButton.textContent).toBe('Edit');
});
test('clicking the edit button should show an editor dialog', () => {
const editor = createEditor('<span class="joplin-editable"><pre class="joplin-source">test source</pre>rendered</span>');
selectFirstInstanceOfNode(editor, 'joplinEditableInline');
const editButton = findEditButton(editor);
const editButton = findEditButton(editor.dom);
editButton.click();
// Should show the dialog
@@ -84,7 +76,7 @@ describe('joplinEditablePlugin', () => {
},
}));
const editButton = findEditButton(editor);
const editButton = findEditButton(editor.dom);
editButton.click();
const dialog = findEditorDialog();

View File

@@ -1,22 +1,64 @@
import { Plugin } from 'prosemirror-state';
import { Command, EditorState, Plugin } from 'prosemirror-state';
import { Node, NodeSpec, TagParseRule } from 'prosemirror-model';
import { EditorView } from 'prosemirror-view';
import { EditorView, NodeView } from 'prosemirror-view';
import sanitizeHtml from '../../utils/sanitizeHtml';
import createEditorDialog from './createEditorDialog';
import { getEditorApi } from '../joplinEditorApiPlugin';
import { msleep } from '@joplin/utils/time';
import postProcessRenderedHtml from './postProcessRenderedHtml';
import createButton from '../../utils/dom/createButton';
import makeLinksClickableInElement from '../../utils/makeLinksClickableInElement';
import SelectableNodeView from '../../utils/SelectableNodeView';
import createExternalEditorPlugin, { OnHide } from '../utils/createExternalEditorPlugin';
import createFloatingButtonPlugin, { ToolbarPosition } from '../utils/createFloatingButtonPlugin';
// See the fold example for more information about
// writing similar ProseMirror plugins:
// https://prosemirror.net/examples/fold/
type EditRequest = {
nodeStart: number;
showEditor: true;
} | {
nodeStart?: undefined;
showEditor: false;
};
const createEditorDialogForNode = (nodePosition: number, view: EditorView, onHide: OnHide) => {
export const editSourceBlockAt = (nodeStart: number): Command => (state, dispatch) => {
const node = state.doc.nodeAt(nodeStart);
if (node.type.name !== 'joplinEditableInline' && node.type.name !== 'joplinEditableBlock') {
return false;
}
if (dispatch) {
const editRequest: EditRequest = {
nodeStart,
showEditor: true,
};
dispatch(state.tr.setMeta(joplinEditablePlugin, editRequest));
}
return true;
};
const isSourceBlockEditorVisible = (state: EditorState) => {
return joplinEditablePlugin.getState(state).editingNodeAt !== null;
};
export const hideSourceBlockEditor: Command = (state, dispatch) => {
const isEditing = isSourceBlockEditorVisible(state);
if (!isEditing) {
return false;
}
if (dispatch) {
const editRequest: EditRequest = {
showEditor: false,
};
dispatch(state.tr.setMeta(joplinEditablePlugin, editRequest));
}
return true;
};
const createDialogForNode = (nodePosition: number, view: EditorView) => {
let saveCounter = 0;
const getNode = () => (
@@ -66,7 +108,7 @@ const createEditorDialogForNode = (nodePosition: number, view: EditorView, onHid
);
},
onDismiss: () => {
onHide();
hideSourceBlockEditor(view.state, view.dispatch, view);
},
});
@@ -78,6 +120,8 @@ const createEditorDialogForNode = (nodePosition: number, view: EditorView, onHid
};
};
type DialogHandle = ReturnType<typeof createDialogForNode>;
interface JoplinEditableAttributes {
contentHtml: string;
@@ -180,14 +224,16 @@ export const nodeSpecs = {
]),
};
class EditableSourceBlockView extends SelectableNodeView {
public constructor(private node: Node, inline: boolean, view: EditorView) {
type GetPosition = ()=> number;
class EditableSourceBlockView implements NodeView {
public readonly dom: HTMLElement;
public constructor(private node: Node, inline: boolean, private view: EditorView, private getPosition: GetPosition) {
if ((node.attrs.contentHtml ?? undefined) === undefined) {
throw new Error(`Unable to create a SourceBlockView for a node lacking contentHtml. Node: ${node}.`);
}
super(inline);
this.dom = document.createElement(inline ? 'span' : 'div');
this.dom.classList.add('joplin-editable');
// The link tooltip used for other in-editor links won't be shown for links within a
@@ -197,14 +243,43 @@ class EditableSourceBlockView extends SelectableNodeView {
this.updateContent_();
}
private showEditDialog_() {
editSourceBlockAt(this.getPosition())(this.view.state, this.view.dispatch, this.view);
}
private updateContent_() {
const setDomContentSafe = (html: string) => {
this.dom.innerHTML = sanitizeHtml(html);
};
const attrs = this.node.attrs as JoplinEditableAttributes;
const addEditButton = () => {
const { localize: _ } = getEditorApi(this.view.state);
const editButton = createButton(_('Edit'), () => this.showEditDialog_());
editButton.classList.add('edit');
if (!attrs.readOnly) {
this.dom.appendChild(editButton);
}
};
setDomContentSafe(attrs.contentHtml);
postProcessRenderedHtml(this.dom, this.node.isInline);
addEditButton();
}
public selectNode() {
this.dom.classList.add('-selected');
}
public deselectNode() {
this.dom.classList.remove('-selected');
}
public stopEvent(event: Event) {
// Allow using the keyboard to activate the "edit" button:
return event.target === this.dom.querySelector('button.edit');
}
public update(node: Node) {
@@ -219,32 +294,64 @@ class EditableSourceBlockView extends SelectableNodeView {
}
}
const { plugin: externalEditorPlugin, hideEditor, editAt } = createExternalEditorPlugin({
canEdit: (node: Node) => {
return (node.type.name === 'joplinEditableInline' || node.type.name === 'joplinEditableBlock') && !node.attrs.readOnly;
interface PluginState {
editingNodeAt: number|null;
}
const joplinEditablePlugin = new Plugin<PluginState>({
state: {
init: () => ({
editingNodeAt: null,
}),
apply: (tr, oldValue) => {
let editingAt = oldValue.editingNodeAt;
const editRequest: EditRequest|null = tr.getMeta(joplinEditablePlugin);
if (editRequest) {
if (editRequest.showEditor) {
editingAt = editRequest.nodeStart;
} else {
editingAt = null;
}
}
if (editingAt) {
editingAt = tr.mapping.map(editingAt, 1);
}
return { editingNodeAt: editingAt };
},
},
props: {
nodeViews: {
joplinEditableInline: (node, view, getPos) => new EditableSourceBlockView(node, true, view, getPos),
joplinEditableBlock: (node, view, getPos) => new EditableSourceBlockView(node, false, view, getPos),
},
},
view: () => {
let dialog: DialogHandle|null = null;
return {
update(view, prevState) {
const oldState = joplinEditablePlugin.getState(prevState);
const newState = joplinEditablePlugin.getState(view.state);
if (newState.editingNodeAt !== null) {
if (oldState.editingNodeAt === null) {
dialog = createDialogForNode(newState.editingNodeAt, view);
}
dialog?.onPositionChange(newState.editingNodeAt);
} else if (dialog) {
const lastDialog = dialog;
// Set dialog to null before dismissing to prevent infinite recursion.
// Dismissing the dialog can cause the editor state to update, which can
// result in this callback being re-run.
dialog = null;
lastDialog.dismiss();
}
},
};
},
showEditor: createEditorDialogForNode,
});
export { hideEditor as hideSourceBlockEditor, editAt as editSourceBlockAt };
export default [
externalEditorPlugin,
new Plugin({
props: {
nodeViews: {
joplinEditableInline: (node, view) => new EditableSourceBlockView(node, true, view),
joplinEditableBlock: (node, view) => new EditableSourceBlockView(node, false, view),
},
},
}),
...['joplinEditableInline', 'joplinEditableBlock'].map(nodeName => (
createFloatingButtonPlugin(nodeName, [
{
label: _ => _('Edit'),
className: 'edit-button',
command: (_node, offset) => editAt(offset),
},
], ToolbarPosition.TopRightInside)
)),
];
export default joplinEditablePlugin;

View File

@@ -0,0 +1,211 @@
import { Plugin } from 'prosemirror-state';
import { AttributeSpec, Node, NodeSpec } from 'prosemirror-model';
import { Decoration, DecorationSet, EditorView, NodeView } from 'prosemirror-view';
import changedDescendants from '../vendor/changedDescendants';
// See the fold example for more information about
// writing similar ProseMirror plugins:
// https://prosemirror.net/examples/fold/
type NodeAttrs = Readonly<{
placeholderSrc: string;
placeholderAlt: string;
itemId: string;
alt: string;
title: string;
isImage: boolean;
notDownloaded: boolean;
}>;
const attrsSpec = {
placeholderSrc: { default: '', validate: 'string' },
placeholderAlt: { default: '', validate: 'string' },
itemId: { validate: 'string' },
alt: { default: '', validate: 'string' },
title: { default: '', validate: 'string' },
isImage: { validate: 'boolean' },
notDownloaded: { validate: 'boolean' },
} satisfies Record<keyof NodeAttrs, AttributeSpec>;
const placeholderSpec: NodeSpec = {
group: 'inline',
inline: true,
attrs: attrsSpec,
parseDOM: [
{
tag: 'span[data-resource-id].not-loaded-resource',
getAttrs: (node): NodeAttrs => {
return {
itemId: node.getAttribute('data-resource-id'),
alt: node.getAttribute('data-original-alt'),
title: node.getAttribute('data-original-title'),
isImage: node.classList.contains('not-loaded-image-resource'),
notDownloaded: node.classList.contains('resource-status-notDownloaded'),
placeholderSrc: node.querySelector('img')?.src,
placeholderAlt: node.querySelector('img')?.alt,
};
},
},
],
toDOM: (node) => [
'span',
{
'data-resource-id': node.attrs.itemId,
'data-original-alt': node.attrs.alt,
'data-original-title': node.attrs.title,
class: [
'not-loaded-resource',
node.attrs.isImage ? 'not-loaded-image-resource' : null,
node.attrs.notDownloaded ? 'resource-status-notDownloaded' : null,
].filter(item => !!item).join(' '),
},
['img', { src: node.attrs.placeholderSrc, alt: node.attrs.placeholderAlt }],
],
};
export const nodeSpecs = {
resourcePlaceholder: placeholderSpec,
};
class ResourcePlaceholderView implements NodeView {
public readonly dom: HTMLElement;
private resourceId_: string;
public constructor(node: Node, decorations: readonly Decoration[]) {
this.resourceId_ = node.attrs.itemId;
this.dom = this.createDom_(node, decorations);
}
private createDom_(node: Node, decorations: readonly Decoration[]) {
const createDom = (imageSrc: string, imageAlt: string, loaded: boolean) => {
const image = document.createElement('img');
image.src = imageSrc;
image.alt = imageAlt;
let dom;
if (!loaded) {
dom = document.createElement('span');
dom.classList.add('not-loaded-resource');
dom.appendChild(image);
} else {
dom = image;
dom.classList.add('late-loaded-resource');
}
// For testing
dom.setAttribute('data-resource-id', this.resourceId_);
return dom;
};
const attrs = node.attrs as NodeAttrs;
let imageSrc = attrs.placeholderSrc;
let imageAlt = attrs.placeholderAlt;
let loaded = false;
for (const decoration of decorations) {
if (decoration.spec.resourceId === this.resourceId_) {
imageSrc = (decoration.spec as PluginMeta).newSrc;
imageAlt = attrs.alt;
loaded = true;
}
}
return createDom(imageSrc, imageAlt, loaded);
}
public update(node: Node, decorations: readonly Decoration[]) {
if (node.type.spec !== placeholderSpec) return false;
for (const decoration of decorations) {
if (decoration.spec.resourceId === this.resourceId_) {
this.dom.replaceWith(this.createDom_(node, decorations));
}
}
return true;
}
}
interface PluginMeta {
resourceId: string;
newSrc: string;
}
export const onResourceDownloaded = (view: EditorView, resourceId: string, newSrc: string) => {
const meta: PluginMeta = {
resourceId,
newSrc,
};
view.dispatch(
view.state.tr.setMeta(resourcePlaceholderPlugin, meta),
);
};
interface PluginState {
decorations: DecorationSet;
idToSrc: Record<string, string>;
}
const resourcePlaceholderPlugin: Plugin<PluginState> = new Plugin({
state: {
init: (): PluginState => ({
decorations: DecorationSet.empty,
idToSrc: Object.create(null),
}),
apply: (tr, oldValue, oldState, newState) => {
let decorations = oldValue.decorations.map(tr.mapping, tr.doc);
let idToSrc = oldValue.idToSrc;
const tryAddDecoration = (node: Node, pos: number) => {
if (node.type.spec === placeholderSpec && decorations.find(pos, pos + node.nodeSize).length === 0) {
const attrs = node.attrs as NodeAttrs;
const itemId = attrs.itemId;
if (Object.hasOwnProperty.call(idToSrc, itemId)) {
const spec: PluginMeta = {
newSrc: idToSrc[attrs.itemId],
resourceId: attrs.itemId,
};
decorations = decorations.add(tr.doc, [
Decoration.node(pos, pos + node.nodeSize, {}, spec),
]);
}
}
};
const meta: PluginMeta|undefined = tr.getMeta(resourcePlaceholderPlugin);
if (meta) {
const { resourceId, newSrc } = meta;
if (!resourceId || !newSrc) {
throw new Error('Invalid .setMeta for resourcePlaceholderPlugin');
}
idToSrc = { ...idToSrc, [resourceId]: newSrc };
tr.doc.descendants((node, pos) => {
tryAddDecoration(node, pos);
});
}
changedDescendants(oldState.doc, newState.doc, 0, (node, pos) => {
tryAddDecoration(node, pos);
});
return {
decorations,
idToSrc,
};
},
},
props: {
nodeViews: {
resourcePlaceholder: (node, _view, _getPos, decorations) => {
return new ResourcePlaceholderView(node, decorations);
},
},
decorations(state) {
return this.getState(state).decorations;
},
},
});
export default resourcePlaceholderPlugin;

View File

@@ -1,6 +1,6 @@
import { getSearchState, search, SearchQuery, setSearchState } from 'prosemirror-search';
import { SearchState } from '../../types';
import { Plugin, EditorState, Command, Transaction } from 'prosemirror-state';
import { Plugin, EditorState, Command } from 'prosemirror-state';
import { EditorEvent, EditorEventType } from '../../events';
const visiblePlugin = new Plugin({
@@ -32,7 +32,7 @@ export const setSearchVisible = (visible: boolean): Command => (state, dispatch)
const searchExtension = (onEditorEvent: (event: EditorEvent)=> void) => {
let lastState: SearchState|null = null;
const checkSearchStateChange = (state: EditorState, transaction: Transaction) => {
const checkSearchStateChange = (state: EditorState) => {
const currentQuery = getSearchState(state).query;
const currentVisible = getSearchVisible(state);
@@ -58,7 +58,6 @@ const searchExtension = (onEditorEvent: (event: EditorEvent)=> void) => {
onEditorEvent({
kind: EditorEventType.UpdateSearchDialog,
searchState: currentState,
changeSources: [transaction.getMeta(visiblePlugin)?.changeSource ?? 'unknown'],
});
}
};
@@ -66,8 +65,8 @@ const searchExtension = (onEditorEvent: (event: EditorEvent)=> void) => {
const checkStateChangePlugin = new Plugin<null>({
state: {
init: ()=>null,
apply: (transaction, oldValue, _oldState, state) => {
checkSearchStateChange(state, transaction);
apply: (_transaction, oldValue, _oldState, state) => {
checkSearchStateChange(state);
return oldValue;
},
},
@@ -79,7 +78,7 @@ const searchExtension = (onEditorEvent: (event: EditorEvent)=> void) => {
visiblePlugin,
checkStateChangePlugin,
],
updateState: (editorState: EditorState, searchState: SearchState, changeSource: string) => {
updateState: (editorState: EditorState, searchState: SearchState) => {
let transaction = editorState.tr;
setSearchVisible(searchState.dialogVisible)(editorState, (newTransaction) => {
transaction = newTransaction;
@@ -90,7 +89,6 @@ const searchExtension = (onEditorEvent: (event: EditorEvent)=> void) => {
regexp: searchState.useRegex,
replace: searchState.replaceText,
}));
transaction.setMeta(visiblePlugin, { changeSource });
lastState = { ...searchState };
return transaction;

View File

@@ -1,126 +0,0 @@
import { Command, EditorState, Plugin } from 'prosemirror-state';
import { EditorView } from 'prosemirror-view';
import { Node } from 'prosemirror-model';
// See the fold example for more information about
// writing similar ProseMirror plugins:
// https://prosemirror.net/examples/fold/
type EditRequest = {
nodeStart: number;
showEditor: true;
} | {
nodeStart?: undefined;
showEditor: false;
};
interface PluginState {
editingNodeAt: number|null;
}
interface EditorDialog {
onPositionChange: (position: number)=> void;
dismiss: ()=> void;
}
export type OnHide = ()=> void;
interface Options {
canEdit: (node: Node, pos: number)=> boolean;
showEditor: (pos: number, view: EditorView, onHide: OnHide)=> EditorDialog;
}
const createExternalEditorPlugin = (options: Options) => {
const plugin = new Plugin<PluginState>({
state: {
init: () => ({
editingNodeAt: null,
}),
apply: (tr, oldValue) => {
let editingAt = oldValue.editingNodeAt;
const editRequest: EditRequest|null = tr.getMeta(plugin);
if (editRequest) {
if (editRequest.showEditor) {
editingAt = editRequest.nodeStart;
} else {
editingAt = null;
}
}
if (editingAt) {
editingAt = tr.mapping.map(editingAt, 1);
}
return { editingNodeAt: editingAt };
},
},
view: () => {
let dialog: EditorDialog|null = null;
return {
update(view, prevState) {
const oldState = plugin.getState(prevState);
const newState = plugin.getState(view.state);
if (newState.editingNodeAt !== null) {
if (oldState.editingNodeAt === null) {
const onHide = () => {
hideEditor(view.state, view.dispatch, view);
};
dialog = options.showEditor(newState.editingNodeAt, view, onHide);
}
dialog?.onPositionChange(newState.editingNodeAt);
} else if (dialog) {
const lastDialog = dialog;
// Set dialog to null before dismissing to prevent infinite recursion.
// Dismissing the dialog can cause the editor state to update, which can
// result in this callback being re-run.
dialog = null;
lastDialog.dismiss();
}
},
};
},
});
const editAt = (nodeStart: number): Command => (state, dispatch) => {
const node = state.doc.nodeAt(nodeStart);
if (!options.canEdit(node, nodeStart)) {
return false;
}
if (dispatch) {
const editRequest: EditRequest = {
nodeStart,
showEditor: true,
};
dispatch(state.tr.setMeta(plugin, editRequest));
}
return true;
};
const isEditorVisible = (state: EditorState) => {
return plugin.getState(state).editingNodeAt !== null;
};
const hideEditor: Command = (state, dispatch) => {
const isEditing = isEditorVisible(state);
if (!isEditing) {
return false;
}
if (dispatch) {
const editRequest: EditRequest = {
showEditor: false,
};
dispatch(state.tr.setMeta(plugin, editRequest));
}
return true;
};
return { plugin, hideEditor, editAt, isEditorVisible };
};
export default createExternalEditorPlugin;

View File

@@ -1,130 +0,0 @@
import { Command, EditorState, Plugin } from 'prosemirror-state';
import { LocalizationResult, OnLocalize } from '../../../types';
import { EditorView } from 'prosemirror-view';
import createButton from '../../utils/dom/createButton';
import { getEditorApi } from '../joplinEditorApiPlugin';
import { Node } from 'prosemirror-model';
type LocalizeFunction = (_: OnLocalize)=> LocalizationResult;
interface ButtonSpec {
label: LocalizeFunction;
command: (node: Node, offset: number)=> Command;
showForNode?: (node: Node)=> boolean;
className?: string;
}
export enum ToolbarPosition {
TopLeftOutside,
TopRightInside,
}
class FloatingButtonBar {
private container_: HTMLElement;
public constructor(
view: EditorView, private targetNode_: string, private buttons_: ButtonSpec[], private position_: ToolbarPosition,
) {
this.container_ = document.createElement('div');
this.container_.classList.add('floating-button-bar');
// Prevent other elements (e.g. checkboxes, links) from being between the toolbar button and the
// target element. If the toolbar is instead included **after** the Rich Text Editor's main content,
// then all items included directly within the Rich Text Editor come before the toolbar in the focus
// order.
view.dom.parentElement.prepend(this.container_);
this.update(view, null);
}
public update(view: EditorView, lastState: EditorState|null) {
const state = view.state;
const sameSelection = lastState && state.selection.eq(lastState.selection);
const sameDoc = lastState && state.doc.eq(lastState.doc);
if (sameSelection && sameDoc) {
return;
}
const findTargetNode = () => {
type TargetNode = { offset: number; node: Node };
let target: TargetNode = null;
state.doc.nodesBetween(state.selection.from, state.selection.to, (node, offset) => {
if (node.type.name === this.targetNode_) {
target = { node, offset };
return false;
}
return true;
});
return target;
};
const target = findTargetNode();
if (!target) {
this.container_.classList.add('-hidden');
} else {
this.container_.classList.remove('-hidden');
const hasCreatedButtons = this.container_.children.length === this.buttons_.length;
if (!hasCreatedButtons) {
const { localize } = getEditorApi(view.state);
this.container_.replaceChildren(...this.buttons_.map(buttonSpec => {
const button = createButton(
buttonSpec.label(localize),
() => { },
);
button.classList.add('action');
if (buttonSpec.className) {
button.classList.add(buttonSpec.className);
}
return button;
}));
}
for (let i = 0; i < this.buttons_.length; i++) {
const button = this.container_.children[i] as HTMLButtonElement;
const buttonSpec = this.buttons_[i];
const command = buttonSpec.command(target.node, target.offset);
button.onclick = () => {
command(view.state, view.dispatch, view);
};
button.disabled = !command(view.state);
}
const position = view.coordsAtPos(target.offset);
// Fall back to document.body to support testing environments:
const parentBox = (this.container_.offsetParent ?? document.body).getBoundingClientRect();
const tooltipBox = this.container_.getBoundingClientRect();
this.container_.style.left = '';
this.container_.style.right = '';
const nodeElement = view.nodeDOM(target.offset);
const nodeBbox = nodeElement instanceof HTMLElement ? nodeElement.getBoundingClientRect() : {
...position,
width: 0,
height: 0,
};
let top = nodeBbox.top - parentBox.top;
if (this.position_ === ToolbarPosition.TopLeftOutside) {
top -= tooltipBox.height;
this.container_.style.left = `${Math.max(nodeBbox.left - parentBox.left, 0)}px`;
} else if (this.position_ === ToolbarPosition.TopRightInside) {
this.container_.style.right = `${parentBox.width - nodeBbox.width - (nodeBbox.left - parentBox.left)}px`;
}
this.container_.style.top = `${top}px`;
}
}
}
const createFloatingButtonPlugin = (nodeName: string, actions: ButtonSpec[], position: ToolbarPosition) => {
return new Plugin({
view: (view) => new FloatingButtonBar(view, nodeName, actions, position),
});
};
export default createFloatingButtonPlugin;

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