You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-12-08 23:07:32 +02:00
Compare commits
137 Commits
server-v3.
...
server-v3.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e8500c148 | ||
|
|
4f1999f921 | ||
|
|
6ee9571069 | ||
|
|
10663b1494 | ||
|
|
f25db9bbd7 | ||
|
|
44ac261304 | ||
|
|
eac995a209 | ||
|
|
15c973e885 | ||
|
|
1762f9485f | ||
|
|
7777f8428f | ||
|
|
948aa9db4f | ||
|
|
fdde04ee85 | ||
|
|
f77a20f5d5 | ||
|
|
d43aa2a3e6 | ||
|
|
04d5ce13c2 | ||
|
|
3b764ba06a | ||
|
|
5492ce55fa | ||
|
|
f6b3f9860c | ||
|
|
88f687ba6a | ||
|
|
1f0a98999f | ||
|
|
69135c3bea | ||
|
|
c27d542a4b | ||
|
|
bd1c2534c5 | ||
|
|
72513b520c | ||
|
|
ec0f9ef9bc | ||
|
|
818bc3218a | ||
|
|
82760a5b6a | ||
|
|
5ba9a16cfd | ||
|
|
68fc91fdc7 | ||
|
|
bdc4687327 | ||
|
|
3a9f57e13f | ||
|
|
b72c48c693 | ||
|
|
f1e42f3bac | ||
|
|
93c908286d | ||
|
|
4eb8777ed0 | ||
|
|
5e1909cee0 | ||
|
|
2e7b312415 | ||
|
|
7735a59fc1 | ||
|
|
41d6e912a7 | ||
|
|
4c2fae8423 | ||
|
|
b72c134890 | ||
|
|
58a9c229bb | ||
|
|
d8c203bb8a | ||
|
|
9020c07825 | ||
|
|
e884da8312 | ||
|
|
d134ea8bfe | ||
|
|
faa44468f3 | ||
|
|
85585d16d2 | ||
|
|
b9c5b8f187 | ||
|
|
da8e638359 | ||
|
|
6482ab5a4e | ||
|
|
ec74abe754 | ||
|
|
859bc8d88e | ||
|
|
56ed471a2f | ||
|
|
650594ecea | ||
|
|
3e9bb914e5 | ||
|
|
f75e911a4e | ||
|
|
78fb07d4c7 | ||
|
|
6390ef43ed | ||
|
|
78c5c4d7c3 | ||
|
|
0d1d50768b | ||
|
|
57093b35ea | ||
|
|
cba5cf660b | ||
|
|
0024722c79 | ||
|
|
bc2832e78f | ||
|
|
424cc96d36 | ||
|
|
56fd5d828f | ||
|
|
03843b087a | ||
|
|
b179509dd3 | ||
|
|
f6851314d2 | ||
|
|
eaec45cb3f | ||
|
|
9be954496c | ||
|
|
ac289c5198 | ||
|
|
98ef5e619b | ||
|
|
62faa48aac | ||
|
|
5daa7a1f4c | ||
|
|
32be071601 | ||
|
|
0dc63dd306 | ||
|
|
78ed58187a | ||
|
|
b8b8dd8011 | ||
|
|
0bc72b45be | ||
|
|
c52523134d | ||
|
|
aff871eee6 | ||
|
|
a5a68a2238 | ||
|
|
e066b8f9bc | ||
|
|
e7827a3a64 | ||
|
|
4ceca647dc | ||
|
|
4185afebdb | ||
|
|
c530b07f45 | ||
|
|
0ed7daaed8 | ||
|
|
2eb107c716 | ||
|
|
c99780db1b | ||
|
|
ac05b7d389 | ||
|
|
9719d82c47 | ||
|
|
48694a585f | ||
|
|
b577a27887 | ||
|
|
9f649c9fc2 | ||
|
|
8c9c5d13bd | ||
|
|
96692de93c | ||
|
|
3d8e1dd146 | ||
|
|
227e41b69a | ||
|
|
a616e26a0f | ||
|
|
ba0e7e2226 | ||
|
|
b5a4ba554d | ||
|
|
9037da8f2d | ||
|
|
6998606ec9 | ||
|
|
66d52c90a3 | ||
|
|
f6fb1f7fbf | ||
|
|
3aac6043da | ||
|
|
ae170e0aa0 | ||
|
|
371f027a24 | ||
|
|
37422f316e | ||
|
|
a9f284ae45 | ||
|
|
fd2f69cc73 | ||
|
|
c4eab3c79c | ||
|
|
a0b9c6376e | ||
|
|
e2fc056369 | ||
|
|
453b4705b1 | ||
|
|
4128061e40 | ||
|
|
432b0ca870 | ||
|
|
c484cd2e48 | ||
|
|
58f0725c6b | ||
|
|
bf8fbec0cd | ||
|
|
f1d452f130 | ||
|
|
26012cd7d5 | ||
|
|
a414241541 | ||
|
|
0f13bf9d51 | ||
|
|
c142c5c5c0 | ||
|
|
af5c0135dc | ||
|
|
8a811b9e78 | ||
|
|
602484f143 | ||
|
|
dc84db1657 | ||
|
|
f5882ecfcc | ||
|
|
30000c34ec | ||
|
|
6e3df1bd90 | ||
|
|
67196ac0b2 | ||
|
|
69646b5522 |
@@ -117,6 +117,8 @@ packages/app-cli/app/command-ls.js
|
||||
packages/app-cli/app/command-mkbook.test.js
|
||||
packages/app-cli/app/command-mkbook.js
|
||||
packages/app-cli/app/command-mv.js
|
||||
packages/app-cli/app/command-publish.test.js
|
||||
packages/app-cli/app/command-publish.js
|
||||
packages/app-cli/app/command-ren.js
|
||||
packages/app-cli/app/command-restore.js
|
||||
packages/app-cli/app/command-rmbook.test.js
|
||||
@@ -129,6 +131,8 @@ packages/app-cli/app/command-share.test.js
|
||||
packages/app-cli/app/command-share.js
|
||||
packages/app-cli/app/command-sync.js
|
||||
packages/app-cli/app/command-testing.js
|
||||
packages/app-cli/app/command-unpublish.test.js
|
||||
packages/app-cli/app/command-unpublish.js
|
||||
packages/app-cli/app/command-use.js
|
||||
packages/app-cli/app/command-version.js
|
||||
packages/app-cli/app/gui/FolderListWidget.js
|
||||
@@ -672,6 +676,8 @@ packages/app-mobile/components/ExtendedWebView/index.js
|
||||
packages/app-mobile/components/ExtendedWebView/index.web.js
|
||||
packages/app-mobile/components/ExtendedWebView/types.js
|
||||
packages/app-mobile/components/ExtendedWebView/utils/useCss.js
|
||||
packages/app-mobile/components/FeedbackBanner.test.js
|
||||
packages/app-mobile/components/FeedbackBanner.js
|
||||
packages/app-mobile/components/FolderPicker.js
|
||||
packages/app-mobile/components/Icon.js
|
||||
packages/app-mobile/components/IconButton.js
|
||||
@@ -698,7 +704,6 @@ packages/app-mobile/components/NoteEditor/RichTextEditor.js
|
||||
packages/app-mobile/components/NoteEditor/SearchPanel.js
|
||||
packages/app-mobile/components/NoteEditor/WarningBanner.js
|
||||
packages/app-mobile/components/NoteEditor/commandDeclarations.js
|
||||
packages/app-mobile/components/NoteEditor/hooks/useCodeMirrorPlugins.js
|
||||
packages/app-mobile/components/NoteEditor/hooks/useEditorCommandHandler.test.js
|
||||
packages/app-mobile/components/NoteEditor/hooks/useEditorCommandHandler.js
|
||||
packages/app-mobile/components/NoteEditor/testing/createTestEditorProps.js
|
||||
@@ -810,7 +815,6 @@ packages/app-mobile/components/screens/ConfigScreen/plugins/buttons/InstallButto
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/WrappedPluginStates.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/mockRepositoryApiConstructor.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/newRepoApi.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/pluginServiceSetup.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/utils/openWebsiteForPlugin.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/utils/usePluginCallbacks.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/utils/usePluginItem.js
|
||||
@@ -868,6 +872,7 @@ packages/app-mobile/contentScripts/imageEditorBundle/utils/useEditorMessenger.js
|
||||
packages/app-mobile/contentScripts/markdownEditorBundle/contentScript.js
|
||||
packages/app-mobile/contentScripts/markdownEditorBundle/types.js
|
||||
packages/app-mobile/contentScripts/markdownEditorBundle/useWebViewSetup.js
|
||||
packages/app-mobile/contentScripts/markdownEditorBundle/utils/useCodeMirrorPlugins.js
|
||||
packages/app-mobile/contentScripts/rendererBundle/contentScript/Renderer.test.js
|
||||
packages/app-mobile/contentScripts/rendererBundle/contentScript/Renderer.js
|
||||
packages/app-mobile/contentScripts/rendererBundle/contentScript/index.js
|
||||
@@ -897,6 +902,7 @@ packages/app-mobile/services/AlarmServiceDriver.web.js
|
||||
packages/app-mobile/services/BackButtonService.js
|
||||
packages/app-mobile/services/commands/stateToWhenClauseContext.js
|
||||
packages/app-mobile/services/e2ee/RSA.react-native.js
|
||||
packages/app-mobile/services/e2ee/RSA.react-native.web.js
|
||||
packages/app-mobile/services/e2ee/crypto.js
|
||||
packages/app-mobile/services/plugins/PlatformImplementation.js
|
||||
packages/app-mobile/services/profiles/index.js
|
||||
@@ -918,6 +924,7 @@ packages/app-mobile/utils/ShareUtils.test.js
|
||||
packages/app-mobile/utils/ShareUtils.js
|
||||
packages/app-mobile/utils/TlsUtils.js
|
||||
packages/app-mobile/utils/appDefaultState.js
|
||||
packages/app-mobile/utils/appReducer.js
|
||||
packages/app-mobile/utils/autodetectTheme.js
|
||||
packages/app-mobile/utils/buildStartupTasks.js
|
||||
packages/app-mobile/utils/checkPermissions.js
|
||||
@@ -939,6 +946,7 @@ packages/app-mobile/utils/fs-driver/testUtil/verifyDirectoryMatches.js
|
||||
packages/app-mobile/utils/getPackageInfo.js
|
||||
packages/app-mobile/utils/getVersionInfoText.js
|
||||
packages/app-mobile/utils/hooks/useBackHandler.js
|
||||
packages/app-mobile/utils/hooks/useIsScreenReaderEnabled.js
|
||||
packages/app-mobile/utils/hooks/useKeyboardState.js
|
||||
packages/app-mobile/utils/hooks/useOnLongPressProps.js
|
||||
packages/app-mobile/utils/hooks/useReduceMotionEnabled.js
|
||||
@@ -956,6 +964,7 @@ packages/app-mobile/utils/pickDocument.js
|
||||
packages/app-mobile/utils/polyfills/bufferPolyfill.js
|
||||
packages/app-mobile/utils/polyfills/crypto-polyfill/index.js
|
||||
packages/app-mobile/utils/polyfills/index.js
|
||||
packages/app-mobile/utils/polyfills/index.web.js
|
||||
packages/app-mobile/utils/setupNotifications.js
|
||||
packages/app-mobile/utils/shareFile.js
|
||||
packages/app-mobile/utils/shareHandler.js
|
||||
@@ -966,6 +975,7 @@ packages/app-mobile/utils/shim-init-react/shimInitShared.js
|
||||
packages/app-mobile/utils/testing/createMockReduxStore.js
|
||||
packages/app-mobile/utils/testing/getWebViewDomById.js
|
||||
packages/app-mobile/utils/testing/getWebViewWindowById.js
|
||||
packages/app-mobile/utils/testing/mockPluginServiceSetup.js
|
||||
packages/app-mobile/utils/testing/setupGlobalStore.js
|
||||
packages/app-mobile/utils/testing/testingLibrary.js
|
||||
packages/app-mobile/utils/types.js
|
||||
@@ -1004,6 +1014,9 @@ packages/editor/CodeMirror/editorCommands/sortSelectedLines.test.js
|
||||
packages/editor/CodeMirror/editorCommands/sortSelectedLines.js
|
||||
packages/editor/CodeMirror/editorCommands/supportsCommand.js
|
||||
packages/editor/CodeMirror/extensions/biDirectionalTextExtension.js
|
||||
packages/editor/CodeMirror/extensions/ctrlClickActionExtension.js
|
||||
packages/editor/CodeMirror/extensions/ctrlClickCheckboxExtension.js
|
||||
packages/editor/CodeMirror/extensions/highlightActiveLineExtension.js
|
||||
packages/editor/CodeMirror/extensions/keyUpHandlerExtension.js
|
||||
packages/editor/CodeMirror/extensions/links/ctrlClickLinksExtension.js
|
||||
packages/editor/CodeMirror/extensions/links/followLinkTooltipExtension.test.js
|
||||
@@ -1074,9 +1087,11 @@ packages/editor/CodeMirror/utils/isInSyntaxNode.js
|
||||
packages/editor/CodeMirror/utils/markdown/codeBlockLanguages/allLanguages.js
|
||||
packages/editor/CodeMirror/utils/markdown/codeBlockLanguages/defaultLanguage.js
|
||||
packages/editor/CodeMirror/utils/markdown/codeBlockLanguages/lookUpLanguage.js
|
||||
packages/editor/CodeMirror/utils/markdown/getCheckboxAtPosition.js
|
||||
packages/editor/CodeMirror/utils/markdown/renumberSelectedLists.test.js
|
||||
packages/editor/CodeMirror/utils/markdown/renumberSelectedLists.js
|
||||
packages/editor/CodeMirror/utils/markdown/stripBlockquote.js
|
||||
packages/editor/CodeMirror/utils/markdown/toggleCheckboxAt.js
|
||||
packages/editor/CodeMirror/utils/setupVim.js
|
||||
packages/editor/ProseMirror/commands.test.js
|
||||
packages/editor/ProseMirror/commands.js
|
||||
@@ -1113,6 +1128,8 @@ packages/editor/ProseMirror/utils/extractSelectedLinesTo.js
|
||||
packages/editor/ProseMirror/utils/forEachHeading.js
|
||||
packages/editor/ProseMirror/utils/jumpToHash.js
|
||||
packages/editor/ProseMirror/utils/makeLinksClickableInElement.js
|
||||
packages/editor/ProseMirror/utils/postprocessEditorOutput.test.js
|
||||
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
|
||||
@@ -1392,14 +1409,19 @@ packages/lib/services/database/types.js
|
||||
packages/lib/services/debug/populateDatabase.js
|
||||
packages/lib/services/e2ee/EncryptionService.test.js
|
||||
packages/lib/services/e2ee/EncryptionService.js
|
||||
packages/lib/services/e2ee/RSA.node.js
|
||||
packages/lib/services/e2ee/crypto.test.js
|
||||
packages/lib/services/e2ee/crypto.js
|
||||
packages/lib/services/e2ee/cryptoShared.js
|
||||
packages/lib/services/e2ee/cryptoTestUtils.js
|
||||
packages/lib/services/e2ee/ppk.test.js
|
||||
packages/lib/services/e2ee/ppk.js
|
||||
packages/lib/services/e2ee/ppkTestUtils.js
|
||||
packages/lib/services/e2ee/ppk/RSA.node.js
|
||||
packages/lib/services/e2ee/ppk/ppk.test.js
|
||||
packages/lib/services/e2ee/ppk/ppk.js
|
||||
packages/lib/services/e2ee/ppk/ppkTestUtils.js
|
||||
packages/lib/services/e2ee/ppk/webCrypto/LongDataWrapper.js
|
||||
packages/lib/services/e2ee/ppk/webCrypto/StringToBufferWrapper.js
|
||||
packages/lib/services/e2ee/ppk/webCrypto/WebCryptoRsa.js
|
||||
packages/lib/services/e2ee/ppk/webCrypto/buildRsaCryptoProvider.test.js
|
||||
packages/lib/services/e2ee/ppk/webCrypto/buildRsaCryptoProvider.js
|
||||
packages/lib/services/e2ee/types.js
|
||||
packages/lib/services/e2ee/utils.test.js
|
||||
packages/lib/services/e2ee/utils.js
|
||||
@@ -1770,6 +1792,7 @@ packages/tools/fuzzer/Client.js
|
||||
packages/tools/fuzzer/ClientPool.js
|
||||
packages/tools/fuzzer/Server.js
|
||||
packages/tools/fuzzer/constants.js
|
||||
packages/tools/fuzzer/model/FolderRecord.js
|
||||
packages/tools/fuzzer/sync-fuzzer.js
|
||||
packages/tools/fuzzer/types.js
|
||||
packages/tools/fuzzer/utils/SeededRandom.js
|
||||
|
||||
27
.github/workflows/build-android.yml
vendored
27
.github/workflows/build-android.yml
vendored
@@ -40,4 +40,29 @@ jobs:
|
||||
cd packages/app-mobile/android
|
||||
sed -i -- 's/signingConfig signingConfigs.release/signingConfig signingConfigs.debug/' app/build.gradle
|
||||
./gradlew assembleRelease
|
||||
|
||||
|
||||
- name: Verify alignment
|
||||
run: |
|
||||
cd packages/app-mobile/android/app
|
||||
APK_FILE="./build/outputs/apk/release/app-release.apk"
|
||||
if test ! -f "$APK_FILE" ; then
|
||||
echo "APK file not found."
|
||||
exit 1
|
||||
else
|
||||
echo "APK file found at: $APK_FILE"
|
||||
fi
|
||||
|
||||
BUILD_TOOLS_PATH="$ANDROID_HOME/build-tools/"
|
||||
if test ! -d "$BUILD_TOOLS_PATH" ; then
|
||||
echo "Build tools not found at $BUILD_TOOLS_PATH ($ANDROID_HOME, $BUILD_TOOLS_VERSION)"
|
||||
exit 1
|
||||
fi
|
||||
# The build-tools/ directory contains different subdirectories
|
||||
# for each build tools version. As a result, there may be multiple
|
||||
# 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"
|
||||
35
.gitignore
vendored
35
.gitignore
vendored
@@ -90,6 +90,8 @@ packages/app-cli/app/command-ls.js
|
||||
packages/app-cli/app/command-mkbook.test.js
|
||||
packages/app-cli/app/command-mkbook.js
|
||||
packages/app-cli/app/command-mv.js
|
||||
packages/app-cli/app/command-publish.test.js
|
||||
packages/app-cli/app/command-publish.js
|
||||
packages/app-cli/app/command-ren.js
|
||||
packages/app-cli/app/command-restore.js
|
||||
packages/app-cli/app/command-rmbook.test.js
|
||||
@@ -102,6 +104,8 @@ packages/app-cli/app/command-share.test.js
|
||||
packages/app-cli/app/command-share.js
|
||||
packages/app-cli/app/command-sync.js
|
||||
packages/app-cli/app/command-testing.js
|
||||
packages/app-cli/app/command-unpublish.test.js
|
||||
packages/app-cli/app/command-unpublish.js
|
||||
packages/app-cli/app/command-use.js
|
||||
packages/app-cli/app/command-version.js
|
||||
packages/app-cli/app/gui/FolderListWidget.js
|
||||
@@ -645,6 +649,8 @@ packages/app-mobile/components/ExtendedWebView/index.js
|
||||
packages/app-mobile/components/ExtendedWebView/index.web.js
|
||||
packages/app-mobile/components/ExtendedWebView/types.js
|
||||
packages/app-mobile/components/ExtendedWebView/utils/useCss.js
|
||||
packages/app-mobile/components/FeedbackBanner.test.js
|
||||
packages/app-mobile/components/FeedbackBanner.js
|
||||
packages/app-mobile/components/FolderPicker.js
|
||||
packages/app-mobile/components/Icon.js
|
||||
packages/app-mobile/components/IconButton.js
|
||||
@@ -671,7 +677,6 @@ packages/app-mobile/components/NoteEditor/RichTextEditor.js
|
||||
packages/app-mobile/components/NoteEditor/SearchPanel.js
|
||||
packages/app-mobile/components/NoteEditor/WarningBanner.js
|
||||
packages/app-mobile/components/NoteEditor/commandDeclarations.js
|
||||
packages/app-mobile/components/NoteEditor/hooks/useCodeMirrorPlugins.js
|
||||
packages/app-mobile/components/NoteEditor/hooks/useEditorCommandHandler.test.js
|
||||
packages/app-mobile/components/NoteEditor/hooks/useEditorCommandHandler.js
|
||||
packages/app-mobile/components/NoteEditor/testing/createTestEditorProps.js
|
||||
@@ -783,7 +788,6 @@ packages/app-mobile/components/screens/ConfigScreen/plugins/buttons/InstallButto
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/WrappedPluginStates.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/mockRepositoryApiConstructor.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/newRepoApi.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/pluginServiceSetup.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/utils/openWebsiteForPlugin.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/utils/usePluginCallbacks.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/plugins/utils/usePluginItem.js
|
||||
@@ -841,6 +845,7 @@ packages/app-mobile/contentScripts/imageEditorBundle/utils/useEditorMessenger.js
|
||||
packages/app-mobile/contentScripts/markdownEditorBundle/contentScript.js
|
||||
packages/app-mobile/contentScripts/markdownEditorBundle/types.js
|
||||
packages/app-mobile/contentScripts/markdownEditorBundle/useWebViewSetup.js
|
||||
packages/app-mobile/contentScripts/markdownEditorBundle/utils/useCodeMirrorPlugins.js
|
||||
packages/app-mobile/contentScripts/rendererBundle/contentScript/Renderer.test.js
|
||||
packages/app-mobile/contentScripts/rendererBundle/contentScript/Renderer.js
|
||||
packages/app-mobile/contentScripts/rendererBundle/contentScript/index.js
|
||||
@@ -870,6 +875,7 @@ packages/app-mobile/services/AlarmServiceDriver.web.js
|
||||
packages/app-mobile/services/BackButtonService.js
|
||||
packages/app-mobile/services/commands/stateToWhenClauseContext.js
|
||||
packages/app-mobile/services/e2ee/RSA.react-native.js
|
||||
packages/app-mobile/services/e2ee/RSA.react-native.web.js
|
||||
packages/app-mobile/services/e2ee/crypto.js
|
||||
packages/app-mobile/services/plugins/PlatformImplementation.js
|
||||
packages/app-mobile/services/profiles/index.js
|
||||
@@ -891,6 +897,7 @@ packages/app-mobile/utils/ShareUtils.test.js
|
||||
packages/app-mobile/utils/ShareUtils.js
|
||||
packages/app-mobile/utils/TlsUtils.js
|
||||
packages/app-mobile/utils/appDefaultState.js
|
||||
packages/app-mobile/utils/appReducer.js
|
||||
packages/app-mobile/utils/autodetectTheme.js
|
||||
packages/app-mobile/utils/buildStartupTasks.js
|
||||
packages/app-mobile/utils/checkPermissions.js
|
||||
@@ -912,6 +919,7 @@ packages/app-mobile/utils/fs-driver/testUtil/verifyDirectoryMatches.js
|
||||
packages/app-mobile/utils/getPackageInfo.js
|
||||
packages/app-mobile/utils/getVersionInfoText.js
|
||||
packages/app-mobile/utils/hooks/useBackHandler.js
|
||||
packages/app-mobile/utils/hooks/useIsScreenReaderEnabled.js
|
||||
packages/app-mobile/utils/hooks/useKeyboardState.js
|
||||
packages/app-mobile/utils/hooks/useOnLongPressProps.js
|
||||
packages/app-mobile/utils/hooks/useReduceMotionEnabled.js
|
||||
@@ -929,6 +937,7 @@ packages/app-mobile/utils/pickDocument.js
|
||||
packages/app-mobile/utils/polyfills/bufferPolyfill.js
|
||||
packages/app-mobile/utils/polyfills/crypto-polyfill/index.js
|
||||
packages/app-mobile/utils/polyfills/index.js
|
||||
packages/app-mobile/utils/polyfills/index.web.js
|
||||
packages/app-mobile/utils/setupNotifications.js
|
||||
packages/app-mobile/utils/shareFile.js
|
||||
packages/app-mobile/utils/shareHandler.js
|
||||
@@ -939,6 +948,7 @@ packages/app-mobile/utils/shim-init-react/shimInitShared.js
|
||||
packages/app-mobile/utils/testing/createMockReduxStore.js
|
||||
packages/app-mobile/utils/testing/getWebViewDomById.js
|
||||
packages/app-mobile/utils/testing/getWebViewWindowById.js
|
||||
packages/app-mobile/utils/testing/mockPluginServiceSetup.js
|
||||
packages/app-mobile/utils/testing/setupGlobalStore.js
|
||||
packages/app-mobile/utils/testing/testingLibrary.js
|
||||
packages/app-mobile/utils/types.js
|
||||
@@ -977,6 +987,9 @@ packages/editor/CodeMirror/editorCommands/sortSelectedLines.test.js
|
||||
packages/editor/CodeMirror/editorCommands/sortSelectedLines.js
|
||||
packages/editor/CodeMirror/editorCommands/supportsCommand.js
|
||||
packages/editor/CodeMirror/extensions/biDirectionalTextExtension.js
|
||||
packages/editor/CodeMirror/extensions/ctrlClickActionExtension.js
|
||||
packages/editor/CodeMirror/extensions/ctrlClickCheckboxExtension.js
|
||||
packages/editor/CodeMirror/extensions/highlightActiveLineExtension.js
|
||||
packages/editor/CodeMirror/extensions/keyUpHandlerExtension.js
|
||||
packages/editor/CodeMirror/extensions/links/ctrlClickLinksExtension.js
|
||||
packages/editor/CodeMirror/extensions/links/followLinkTooltipExtension.test.js
|
||||
@@ -1047,9 +1060,11 @@ packages/editor/CodeMirror/utils/isInSyntaxNode.js
|
||||
packages/editor/CodeMirror/utils/markdown/codeBlockLanguages/allLanguages.js
|
||||
packages/editor/CodeMirror/utils/markdown/codeBlockLanguages/defaultLanguage.js
|
||||
packages/editor/CodeMirror/utils/markdown/codeBlockLanguages/lookUpLanguage.js
|
||||
packages/editor/CodeMirror/utils/markdown/getCheckboxAtPosition.js
|
||||
packages/editor/CodeMirror/utils/markdown/renumberSelectedLists.test.js
|
||||
packages/editor/CodeMirror/utils/markdown/renumberSelectedLists.js
|
||||
packages/editor/CodeMirror/utils/markdown/stripBlockquote.js
|
||||
packages/editor/CodeMirror/utils/markdown/toggleCheckboxAt.js
|
||||
packages/editor/CodeMirror/utils/setupVim.js
|
||||
packages/editor/ProseMirror/commands.test.js
|
||||
packages/editor/ProseMirror/commands.js
|
||||
@@ -1086,6 +1101,8 @@ packages/editor/ProseMirror/utils/extractSelectedLinesTo.js
|
||||
packages/editor/ProseMirror/utils/forEachHeading.js
|
||||
packages/editor/ProseMirror/utils/jumpToHash.js
|
||||
packages/editor/ProseMirror/utils/makeLinksClickableInElement.js
|
||||
packages/editor/ProseMirror/utils/postprocessEditorOutput.test.js
|
||||
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
|
||||
@@ -1365,14 +1382,19 @@ packages/lib/services/database/types.js
|
||||
packages/lib/services/debug/populateDatabase.js
|
||||
packages/lib/services/e2ee/EncryptionService.test.js
|
||||
packages/lib/services/e2ee/EncryptionService.js
|
||||
packages/lib/services/e2ee/RSA.node.js
|
||||
packages/lib/services/e2ee/crypto.test.js
|
||||
packages/lib/services/e2ee/crypto.js
|
||||
packages/lib/services/e2ee/cryptoShared.js
|
||||
packages/lib/services/e2ee/cryptoTestUtils.js
|
||||
packages/lib/services/e2ee/ppk.test.js
|
||||
packages/lib/services/e2ee/ppk.js
|
||||
packages/lib/services/e2ee/ppkTestUtils.js
|
||||
packages/lib/services/e2ee/ppk/RSA.node.js
|
||||
packages/lib/services/e2ee/ppk/ppk.test.js
|
||||
packages/lib/services/e2ee/ppk/ppk.js
|
||||
packages/lib/services/e2ee/ppk/ppkTestUtils.js
|
||||
packages/lib/services/e2ee/ppk/webCrypto/LongDataWrapper.js
|
||||
packages/lib/services/e2ee/ppk/webCrypto/StringToBufferWrapper.js
|
||||
packages/lib/services/e2ee/ppk/webCrypto/WebCryptoRsa.js
|
||||
packages/lib/services/e2ee/ppk/webCrypto/buildRsaCryptoProvider.test.js
|
||||
packages/lib/services/e2ee/ppk/webCrypto/buildRsaCryptoProvider.js
|
||||
packages/lib/services/e2ee/types.js
|
||||
packages/lib/services/e2ee/utils.test.js
|
||||
packages/lib/services/e2ee/utils.js
|
||||
@@ -1743,6 +1765,7 @@ packages/tools/fuzzer/Client.js
|
||||
packages/tools/fuzzer/ClientPool.js
|
||||
packages/tools/fuzzer/Server.js
|
||||
packages/tools/fuzzer/constants.js
|
||||
packages/tools/fuzzer/model/FolderRecord.js
|
||||
packages/tools/fuzzer/sync-fuzzer.js
|
||||
packages/tools/fuzzer/types.js
|
||||
packages/tools/fuzzer/utils/SeededRandom.js
|
||||
|
||||
BIN
Assets/WebsiteAssets/images/sponsors/DamanGame.png
Normal file
BIN
Assets/WebsiteAssets/images/sponsors/DamanGame.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
BIN
Assets/WebsiteAssets/images/sponsors/DoMyEssay.png
Normal file
BIN
Assets/WebsiteAssets/images/sponsors/DoMyEssay.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
BIN
Assets/WebsiteAssets/images/sponsors/EssayShark.png
Normal file
BIN
Assets/WebsiteAssets/images/sponsors/EssayShark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
BIN
Assets/WebsiteAssets/images/sponsors/PokiesLab.png
Normal file
BIN
Assets/WebsiteAssets/images/sponsors/PokiesLab.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 378 KiB |
BIN
Assets/WebsiteAssets/images/sponsors/Pokiesman.png
Normal file
BIN
Assets/WebsiteAssets/images/sponsors/Pokiesman.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 295 KiB |
BIN
Assets/WebsiteAssets/images/sponsors/SocialKings.png
Normal file
BIN
Assets/WebsiteAssets/images/sponsors/SocialKings.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
@@ -63,11 +63,22 @@ FROM node:18-slim
|
||||
ARG user=joplin
|
||||
RUN useradd --create-home --shell /bin/bash $user
|
||||
|
||||
# Install PM2 and set home directory. Setting the PM2 data dir so modules/config persist regardless
|
||||
# of user home.
|
||||
RUN npm i -g pm2@5.4.3 && mkdir -p /opt/pm2 && chown -R $user:$user /opt/pm2
|
||||
ENV PM2_HOME=/opt/pm2
|
||||
|
||||
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
|
||||
|
||||
# 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
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV RUNNING_IN_DOCKER=1
|
||||
EXPOSE ${APP_PORT}
|
||||
|
||||
@@ -31,7 +31,7 @@ Please see the [donation page](https://github.com/laurent22/joplin/blob/dev/read
|
||||
# Sponsors
|
||||
|
||||
<!-- SPONSORS-ORG -->
|
||||
<a href="https://seirei.ne.jp"><img title="Serei Network" width="256" src="https://joplinapp.org/images/sponsors/SeireiNetwork.png"/></a> <a href="https://www.hosting.de/nextcloud/?mtm_campaign=managed-nextcloud&mtm_kwd=joplinapp&mtm_source=joplinapp-webseite&mtm_medium=banner"><img title="Hosting.de" width="256" src="https://joplinapp.org/images/sponsors/HostingDe.png"/></a> <a href="https://citricsheep.com"><img title="Citric Sheep" width="256" src="https://joplinapp.org/images/sponsors/CitricSheep.png"/></a> <a href="https://sorted.travel/?utm_source=joplinapp"><img title="Sorted Travel" width="256" src="https://joplinapp.org/images/sponsors/SortedTravel.png"/></a> <a href="https://celebian.com"><img title="Celebian" width="256" src="https://joplinapp.org/images/sponsors/Celebian.png"/></a> <a href="https://bestkru.com"><img title="BestKru" width="256" src="https://joplinapp.org/images/sponsors/BestKru.png"/></a> <a href="https://www.socialfollowers.uk/buy-tiktok-followers/"><img title="Social Followers" width="256" src="https://joplinapp.org/images/sponsors/SocialFollowers.png"/></a> <a href="https://stormlikes.com/"><img title="Stormlikes" width="256" src="https://joplinapp.org/images/sponsors/Stormlikes.png"/></a> <a href="https://route4me.com"><img title="Route4Me" width="256" src="https://joplinapp.org/images/sponsors/Route4Me.png"/></a> <a href="https://topagency.webflow.io"><img title="WebDesignAgency" width="256" src="https://joplinapp.org/images/sponsors/WebDesignAgency.png" alt="topagency"/></a> <a href="https://realgambling.ca/"><img title="RealGambling.ca" width="256" src="https://joplinapp.org/images/sponsors/RealGambling.png" alt="RealGambling.ca"/></a> <a href="https://essaypro.com/"><img title="write an essay online with EssayPro" width="256" src="https://joplinapp.org/images/sponsors/EssayPro.png" alt="write an essay online with EssayPro"/></a> <a href="https://www.slotozilla.com/nz/no-deposit-bonus"><img title="casino without making any upfront cost" width="256" src="https://joplinapp.org/images/sponsors/Slotozilla.png" alt="casino without making any upfront cost"/></a> <a href="https://essaywriter.pro"><img title="write my essay services by EssayWriter" width="256" src="https://joplinapp.org/images/sponsors/EssayWriterPro.png" alt="write my essay services by EssayWriter"/></a> <a href="https://essayservice.com"><img title="quick and reliable service to write my paper for me" width="256" src="https://joplinapp.org/images/sponsors/EssayService.png" alt="quick and reliable service to write my paper for me"/></a> <a href="https://writepaper.com/"><img title="best service to write my paper for me" width="256" src="https://joplinapp.org/images/sponsors/WritePaper.png" alt="best service to write my paper for me"/></a> <a href="https://paperwriter.com/"><img title="high-quality paper writing service PaperWriter" width="256" src="https://joplinapp.org/images/sponsors/PaperWriter.png" alt="high-quality paper writing service PaperWriter"/></a> <a href="https://homeworkguy.org/someone-to-take-my-online-class"><img title="someone to take my online class" width="256" src="https://joplinapp.org/images/sponsors/HomeworkGuy.png" alt="someone to take my online class"/></a> <a href="https://www.bestetf.net/"><img title="BestETF" width="256" src="https://joplinapp.org/images/sponsors/BestEtf.png" alt="BestETF"/></a> <a href="https://freespinny.io/free-spins-no-deposit/"><img title="Freespinny.io Free Spins Bonus site" width="256" src="https://joplinapp.org/images/sponsors/Freespinny.png" alt="Freespinny.io Free Spins Bonus site"/></a>
|
||||
<a href="https://seirei.ne.jp"><img title="Serei Network" width="256" src="https://joplinapp.org/images/sponsors/SeireiNetwork.png"/></a> <a href="https://citricsheep.com"><img title="Citric Sheep" width="256" src="https://joplinapp.org/images/sponsors/CitricSheep.png"/></a> <a href="https://sorted.travel/?utm_source=joplinapp"><img title="Sorted Travel" width="256" src="https://joplinapp.org/images/sponsors/SortedTravel.png"/></a> <a href="https://celebian.com"><img title="Celebian" width="256" src="https://joplinapp.org/images/sponsors/Celebian.png"/></a> <a href="https://bestkru.com"><img title="BestKru" width="256" src="https://joplinapp.org/images/sponsors/BestKru.png"/></a> <a href="https://www.socialfollowers.uk/buy-tiktok-followers/"><img title="Social Followers" width="256" src="https://joplinapp.org/images/sponsors/SocialFollowers.png"/></a> <a href="https://stormlikes.com/"><img title="Stormlikes" width="256" src="https://joplinapp.org/images/sponsors/Stormlikes.png"/></a> <a href="https://route4me.com"><img title="Route4Me" width="256" src="https://joplinapp.org/images/sponsors/Route4Me.png"/></a> <a href="https://topagency.webflow.io"><img title="WebDesignAgency" width="256" src="https://joplinapp.org/images/sponsors/WebDesignAgency.png" alt="topagency"/></a> <a href="https://www.slotozilla.com/nz/no-deposit-bonus"><img title="casino without making any upfront cost" width="256" src="https://joplinapp.org/images/sponsors/Slotozilla.png" alt="casino without making any upfront cost"/></a> <a href="https://essayservice.com"><img title="quick and reliable service to write my paper for me" width="256" src="https://joplinapp.org/images/sponsors/EssayService.png" alt="quick and reliable service to write my paper for me"/></a> <a href="https://writepaper.com/"><img title="best service to write my paper for me" width="256" src="https://joplinapp.org/images/sponsors/WritePaper.png" alt="best service to write my paper for me"/></a> <a href="https://paperwriter.com/"><img title="high-quality paper writing service PaperWriter" width="256" src="https://joplinapp.org/images/sponsors/PaperWriter.png" alt="high-quality paper writing service PaperWriter"/></a> <a href="https://www.bestetf.net/"><img title="BestETF" width="256" src="https://joplinapp.org/images/sponsors/BestEtf.png" alt="BestETF"/></a> <a href="https://freespinny.io/free-spins-no-deposit/"><img title="Freespinny.io Free Spins Bonus site" width="256" src="https://joplinapp.org/images/sponsors/Freespinny.png" alt="Freespinny.io Free Spins Bonus site"/></a> <a href="https://damangameplay.in"><img title="Daman Game" width="256" src="https://joplinapp.org/images/sponsors/DamanGame.png" alt="Daman Game"/></a> <a href="https://essayshark.com"><img title="EssayShark - essay writers for hire" width="256" src="https://joplinapp.org/images/sponsors/EssayShark.png" alt="EssayShark - essay writers for hire"/></a> <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>
|
||||
<!-- SPONSORS-ORG -->
|
||||
|
||||
* * *
|
||||
@@ -40,9 +40,8 @@ Please see the [donation page](https://github.com/laurent22/joplin/blob/dev/read
|
||||
| | | | |
|
||||
| :---: | :---: | :---: | :---: |
|
||||
| <img width="50" src="https://avatars2.githubusercontent.com/u/97193607?s=96&v=4"/></br>[Akhil-CM](https://github.com/Akhil-CM) | <img width="50" src="https://avatars2.githubusercontent.com/u/552452?s=96&v=4"/></br>[andypiper](https://github.com/andypiper) | <img width="50" src="https://avatars2.githubusercontent.com/u/215668?s=96&v=4"/></br>[avanderberg](https://github.com/avanderberg) | <img width="50" src="https://avatars2.githubusercontent.com/u/67130?s=96&v=4"/></br>[chr15m](https://github.com/chr15m) |
|
||||
| <img width="50" src="https://avatars2.githubusercontent.com/u/1177810?s=96&v=4"/></br>[felixstorm](https://github.com/felixstorm) | <img width="50" src="https://avatars2.githubusercontent.com/u/8030470?s=96&v=4"/></br>[Galliver7](https://github.com/Galliver7) | <img width="50" src="https://avatars2.githubusercontent.com/u/64712218?s=96&v=4"/></br>[Hegghammer](https://github.com/Hegghammer) | <img width="50" src="https://avatars2.githubusercontent.com/u/11947658?s=96&v=4"/></br>[KentBrockman](https://github.com/KentBrockman) |
|
||||
| <img width="50" src="https://avatars2.githubusercontent.com/u/42319182?s=96&v=4"/></br>[marcdw1289](https://github.com/marcdw1289) | <img width="50" src="https://avatars2.githubusercontent.com/u/1788010?s=96&v=4"/></br>[maxtruxa](https://github.com/maxtruxa) | <img width="50" src="https://avatars2.githubusercontent.com/u/327998?s=96&v=4"/></br>[sif](https://github.com/sif) | <img width="50" src="https://avatars2.githubusercontent.com/u/765564?s=96&v=4"/></br>[taskcruncher](https://github.com/taskcruncher) |
|
||||
| <img width="50" src="https://avatars2.githubusercontent.com/u/668977?s=96&v=4"/></br>[ugoertz](https://github.com/ugoertz) | | | |
|
||||
| <img width="50" src="https://avatars2.githubusercontent.com/u/1177810?s=96&v=4"/></br>[felixstorm](https://github.com/felixstorm) | <img width="50" src="https://avatars2.githubusercontent.com/u/11947658?s=96&v=4"/></br>[KentBrockman](https://github.com/KentBrockman) | <img width="50" src="https://avatars2.githubusercontent.com/u/42319182?s=96&v=4"/></br>[marcdw1289](https://github.com/marcdw1289) | <img width="50" src="https://avatars2.githubusercontent.com/u/668977?s=96&v=4"/></br>[ugoertz](https://github.com/ugoertz) |
|
||||
| | | | |
|
||||
<!-- SPONSORS-GITHUB -->
|
||||
|
||||
# Community
|
||||
|
||||
@@ -417,8 +417,10 @@ class Application extends BaseApplication {
|
||||
if (argv.length) {
|
||||
this.gui_ = this.dummyGui();
|
||||
|
||||
const initialFolder = await Folder.load(Setting.value('activeFolderId'));
|
||||
await this.switchCurrentFolder(initialFolder);
|
||||
await this.applySettingsSideEffects();
|
||||
await this.refreshCurrentFolder();
|
||||
|
||||
try {
|
||||
await this.execCommand(argv);
|
||||
} catch (error) {
|
||||
@@ -432,6 +434,7 @@ class Application extends BaseApplication {
|
||||
}
|
||||
|
||||
await Setting.saveAll();
|
||||
await this.database_.close();
|
||||
|
||||
// Need to call exit() explicitly, otherwise Node wait for any timeout to complete
|
||||
// https://stackoverflow.com/questions/18050095
|
||||
|
||||
@@ -6,6 +6,7 @@ import app from './app';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { ImportOptions } from '@joplin/lib/services/interop/types';
|
||||
import { unique } from '@joplin/lib/array';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
|
||||
class Command extends BaseCommand {
|
||||
public override usage() {
|
||||
@@ -32,14 +33,16 @@ class Command extends BaseCommand {
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
public override async action(args: any) {
|
||||
const folder = await app().loadItem(BaseModel.TYPE_FOLDER, args.notebook);
|
||||
let destinationFolder = await app().loadItem(BaseModel.TYPE_FOLDER, args.notebook);
|
||||
|
||||
if (args.notebook && !folder) throw new Error(_('Cannot find "%s".', args.notebook));
|
||||
if (args.notebook && !destinationFolder) throw new Error(_('Cannot find "%s".', args.notebook));
|
||||
|
||||
if (!destinationFolder) destinationFolder = await Folder.defaultFolder();
|
||||
|
||||
const importOptions: ImportOptions = {};
|
||||
importOptions.path = args.path;
|
||||
importOptions.format = args.options.format ? args.options.format : 'auto';
|
||||
importOptions.destinationFolderId = folder ? folder.id : null;
|
||||
importOptions.destinationFolderId = destinationFolder ? destinationFolder.id : null;
|
||||
|
||||
let lastProgress = '';
|
||||
|
||||
|
||||
104
packages/app-cli/app/command-publish.test.ts
Normal file
104
packages/app-cli/app/command-publish.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import ShareService from '@joplin/lib/services/share/ShareService';
|
||||
import mockShareService from '@joplin/lib/testing/share/mockShareService';
|
||||
import { createFolderTree, setupDatabaseAndSynchronizer, switchClient, waitFor } from '@joplin/lib/testing/test-utils';
|
||||
import { setupApplication, setupCommandForTesting } from './utils/testUtils';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
const Command = require('./command-publish');
|
||||
|
||||
const setUpCommand = () => {
|
||||
const onStdout = jest.fn();
|
||||
const command = setupCommandForTesting(Command, onStdout);
|
||||
|
||||
return { command, onStdout };
|
||||
};
|
||||
|
||||
describe('command-publish', () => {
|
||||
beforeEach(async () => {
|
||||
await setupDatabaseAndSynchronizer(1);
|
||||
await switchClient(1);
|
||||
await setupApplication();
|
||||
|
||||
mockShareService({
|
||||
getShares: async () => {
|
||||
return { items: [] };
|
||||
},
|
||||
postShares: async () => ({ id: 'test-id' }),
|
||||
getShareInvitations: async () => null,
|
||||
}, ShareService.instance());
|
||||
});
|
||||
|
||||
test('should publish a note', async () => {
|
||||
const { command, onStdout } = setUpCommand();
|
||||
|
||||
const testFolder = await Folder.save({ title: 'Test' });
|
||||
const testNote = await Note.save({ title: 'test', parent_id: testFolder.id });
|
||||
|
||||
await command.action({
|
||||
note: testNote.id,
|
||||
options: {
|
||||
force: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Should be shared
|
||||
await waitFor(async () => {
|
||||
expect(await Note.load(testNote.id)).toMatchObject({
|
||||
is_shared: 1,
|
||||
});
|
||||
});
|
||||
|
||||
// Should have logged the publication URL
|
||||
expect(onStdout).toHaveBeenCalled();
|
||||
expect(onStdout.mock.lastCall[0]).toMatch(/Published at URL:/);
|
||||
});
|
||||
|
||||
test('should be enabled for Joplin Server and Cloud sync targets', () => {
|
||||
const { command } = setUpCommand();
|
||||
|
||||
Setting.setValue('sync.target', 1);
|
||||
expect(command.enabled()).toBe(false);
|
||||
|
||||
const supportedSyncTargets = [9, 10, 11];
|
||||
for (const id of supportedSyncTargets) {
|
||||
Setting.setValue('sync.target', id);
|
||||
expect(command.enabled()).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('should not ask for confirmation if a note is already published', async () => {
|
||||
const { command } = setUpCommand();
|
||||
|
||||
const promptMock = jest.fn(() => true);
|
||||
command.setPrompt(promptMock);
|
||||
|
||||
await createFolderTree('', [
|
||||
{
|
||||
title: 'folder 1',
|
||||
children: [
|
||||
{
|
||||
title: 'note 1',
|
||||
body: 'test',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
const noteId = (await Note.loadByTitle('note 1')).id;
|
||||
|
||||
// Should ask for confirmation when first sharing
|
||||
await command.action({
|
||||
note: noteId,
|
||||
options: { },
|
||||
});
|
||||
expect(promptMock).toHaveBeenCalledTimes(1);
|
||||
expect(await Note.load(noteId)).toMatchObject({ is_shared: 1 });
|
||||
|
||||
// Should not ask for confirmation if called again for the same note
|
||||
await command.action({
|
||||
note: noteId,
|
||||
options: { },
|
||||
});
|
||||
expect(promptMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
64
packages/app-cli/app/command-publish.ts
Normal file
64
packages/app-cli/app/command-publish.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import BaseCommand from './base-command';
|
||||
import app from './app';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import ShareService from '@joplin/lib/services/share/ShareService';
|
||||
import { ModelType } from '@joplin/lib/BaseModel';
|
||||
import SyncTargetRegistry from '@joplin/lib/SyncTargetRegistry';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { reg } from '@joplin/lib/registry';
|
||||
|
||||
const logger = Logger.create('command-publish');
|
||||
|
||||
type Args = {
|
||||
note: string;
|
||||
options: {
|
||||
force?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
class Command extends BaseCommand {
|
||||
public usage() {
|
||||
return 'publish [note]';
|
||||
}
|
||||
|
||||
public description() {
|
||||
return _('Publishes a note to Joplin Server or Joplin Cloud');
|
||||
}
|
||||
|
||||
public options() {
|
||||
return [
|
||||
['-f, --force', _('Do not ask for user confirmation.')],
|
||||
];
|
||||
}
|
||||
|
||||
public enabled() {
|
||||
return SyncTargetRegistry.isJoplinServerOrCloud(Setting.value('sync.target'));
|
||||
}
|
||||
|
||||
public async action(args: Args) {
|
||||
const targetNote = await app().loadItemOrFail(ModelType.Note, args.note);
|
||||
const parent = await app().loadItem(ModelType.Folder, targetNote.parent_id);
|
||||
|
||||
const force = args.options.force;
|
||||
const alreadyShared = !!targetNote.is_shared;
|
||||
const ok = force || alreadyShared ? true : await this.prompt(
|
||||
_('Publish note "%s" (in notebook "%s")?', targetNote.title, parent.title ?? '<root>'),
|
||||
{ booleanAnswerDefault: 'n' },
|
||||
);
|
||||
if (!ok) return;
|
||||
|
||||
logger.info('Share note: ', targetNote.id);
|
||||
const share = await ShareService.instance().shareNote(targetNote.id, false);
|
||||
|
||||
this.stdout(_('Synchronising...'));
|
||||
await reg.waitForSyncFinishedThenSync();
|
||||
|
||||
const userId = ShareService.instance().userId;
|
||||
const shareUrl = ShareService.instance().shareUrl(userId, share);
|
||||
this.stdout(_('Published at URL: %s', shareUrl));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Command;
|
||||
@@ -149,6 +149,7 @@ class Command extends BaseCommand {
|
||||
waiting: invitation.status === ShareUserStatus.Waiting,
|
||||
rejected: invitation.status === ShareUserStatus.Rejected,
|
||||
folderId: invitation.share.folder_id,
|
||||
canWrite: !!invitation.can_write,
|
||||
fromUser: {
|
||||
email: invitation.share.user?.email,
|
||||
},
|
||||
|
||||
43
packages/app-cli/app/command-unpublish.test.ts
Normal file
43
packages/app-cli/app/command-unpublish.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import ShareService from '@joplin/lib/services/share/ShareService';
|
||||
import mockShareService from '@joplin/lib/testing/share/mockShareService';
|
||||
import { setupDatabaseAndSynchronizer, switchClient, waitFor } from '@joplin/lib/testing/test-utils';
|
||||
import { setupApplication, setupCommandForTesting } from './utils/testUtils';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
const Command = require('./command-unpublish');
|
||||
|
||||
|
||||
describe('command-unpublish', () => {
|
||||
beforeEach(async () => {
|
||||
await setupDatabaseAndSynchronizer(1);
|
||||
await switchClient(1);
|
||||
await setupApplication();
|
||||
|
||||
mockShareService({
|
||||
getShares: async () => {
|
||||
return { items: [{ id: 'test-id' }] };
|
||||
},
|
||||
postShares: async () => {
|
||||
throw new Error('Unexpected call to postShares');
|
||||
},
|
||||
getShareInvitations: async () => null,
|
||||
}, ShareService.instance());
|
||||
});
|
||||
|
||||
test('should unpublish a note', async () => {
|
||||
const command = setupCommandForTesting(Command, ()=>{});
|
||||
|
||||
const testFolder = await Folder.save({ title: 'Test' });
|
||||
const testNote = await Note.save({ title: 'test', parent_id: testFolder.id, is_shared: 1 });
|
||||
|
||||
await command.action({
|
||||
note: testNote.id,
|
||||
});
|
||||
|
||||
await waitFor(async () => {
|
||||
expect(await Note.load(testNote.id)).toMatchObject({
|
||||
is_shared: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
57
packages/app-cli/app/command-unpublish.ts
Normal file
57
packages/app-cli/app/command-unpublish.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import BaseCommand from './base-command';
|
||||
import app from './app';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import ShareService from '@joplin/lib/services/share/ShareService';
|
||||
import { ModelType } from '@joplin/lib/BaseModel';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import SyncTargetRegistry from '@joplin/lib/SyncTargetRegistry';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { reg } from '@joplin/lib/registry';
|
||||
|
||||
const logger = Logger.create('command-unpublish');
|
||||
|
||||
type Args = {
|
||||
note: string;
|
||||
};
|
||||
|
||||
class Command extends BaseCommand {
|
||||
public usage() {
|
||||
return 'publish [note]';
|
||||
}
|
||||
|
||||
public description() {
|
||||
return _('Publishes a note to Joplin Server or Joplin Cloud');
|
||||
}
|
||||
|
||||
public options() {
|
||||
return [
|
||||
['-f, --force', _('Do not ask for user confirmation.')],
|
||||
];
|
||||
}
|
||||
|
||||
public enabled() {
|
||||
return SyncTargetRegistry.isJoplinServerOrCloud(Setting.value('sync.target'));
|
||||
}
|
||||
|
||||
public async action(args: Args) {
|
||||
const targetNote = await app().loadItemOrFail(ModelType.Note, args.note);
|
||||
|
||||
if (!targetNote.is_shared) {
|
||||
throw new Error(_('Note not published: %s', targetNote.title));
|
||||
}
|
||||
|
||||
logger.info('Unshare note: ', targetNote.id);
|
||||
await ShareService.instance().unshareNote(targetNote.id);
|
||||
|
||||
const note = await Note.load(targetNote.id);
|
||||
if (note.is_shared) {
|
||||
throw new Error('Assertion failure: The note is still shared.');
|
||||
}
|
||||
|
||||
this.stdout(_('Synchronising...'));
|
||||
await reg.waitForSyncFinishedThenSync();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Command;
|
||||
@@ -35,7 +35,7 @@
|
||||
],
|
||||
"owner": "Laurent Cozic"
|
||||
},
|
||||
"version": "3.4.0",
|
||||
"version": "3.4.1",
|
||||
"bin": "./main.js",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
@@ -57,7 +57,7 @@
|
||||
"proper-lockfile": "4.1.2",
|
||||
"redux": "4.2.1",
|
||||
"server-destroy": "1.0.1",
|
||||
"sharp": "0.34.1",
|
||||
"sharp": "0.34.2",
|
||||
"sprintf-js": "1.1.3",
|
||||
"sqlite3": "5.1.6",
|
||||
"string-padding": "1.0.2",
|
||||
@@ -73,7 +73,7 @@
|
||||
"@joplin/tools": "~3.4",
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/node": "18.19.101",
|
||||
"@types/node": "18.19.103",
|
||||
"@types/proper-lockfile": "^4.1.2",
|
||||
"gulp": "4.0.2",
|
||||
"jest": "29.7.0",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
content
|
||||
@@ -0,0 +1 @@
|
||||
content
|
||||
@@ -0,0 +1 @@
|
||||
content
|
||||
@@ -0,0 +1,3 @@
|
||||
1. File without extension and leading `./`: [file1](./file1). Gets imported, but filename is converted to extension, like `<internal_id>.file1`
|
||||
2. File without extension: [file2](file2). Not imported at all.
|
||||
3. File with extension: [file3](file3.text). Gets imported properly.
|
||||
@@ -86,8 +86,14 @@ export default class InteropServiceHelper {
|
||||
// pdfs.
|
||||
// https://github.com/laurent22/joplin/issues/6254.
|
||||
await win.webContents.executeJavaScript('document.querySelectorAll(\'details\').forEach(el=>el.setAttribute(\'open\',\'\'))');
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const data = await win.webContents.printToPDF(options as any);
|
||||
const data = await win.webContents.printToPDF({
|
||||
...options,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Partially refactored old code before rule was applied
|
||||
pageSize: options.pageSize as any,
|
||||
// Allows users to override the CSS page size.
|
||||
// See https://github.com/laurent22/joplin/issues/13096
|
||||
preferCSSPageSize: true,
|
||||
});
|
||||
resolve(data);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
|
||||
@@ -63,6 +63,8 @@ import { refreshFolders } from '@joplin/lib/folders-screen-utils';
|
||||
import initializeCommandService from './utils/initializeCommandService';
|
||||
import OcrDriverBase from '@joplin/lib/services/ocr/OcrDriverBase';
|
||||
import PerformanceLogger from '@joplin/lib/PerformanceLogger';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import Resource from '@joplin/lib/models/Resource';
|
||||
|
||||
const perfLogger = PerformanceLogger.create();
|
||||
|
||||
@@ -683,6 +685,11 @@ class Application extends BaseApplication {
|
||||
debug: new DebugService(reg.db()),
|
||||
resourceService: ResourceService.instance(),
|
||||
searchEngine: SearchEngine.instance(),
|
||||
shim,
|
||||
Note,
|
||||
Folder,
|
||||
Resource,
|
||||
Setting,
|
||||
ocrService: () => this.ocrService_,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@ import { connect } from 'react-redux';
|
||||
import { AppState } from '../../app.reducer';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import { PublicPrivateKeyPair } from '@joplin/lib/services/e2ee/ppk';
|
||||
import { PublicPrivateKeyPair } from '@joplin/lib/services/e2ee/ppk/ppk';
|
||||
import ToggleAdvancedSettingsButton from '../ConfigScreen/controls/ToggleAdvancedSettingsButton';
|
||||
import MacOSMissingPasswordHelpLink from '../ConfigScreen/controls/MissingPasswordHelpLink';
|
||||
|
||||
|
||||
@@ -67,6 +67,11 @@ import 'codemirror/mode/diff/diff';
|
||||
import 'codemirror/mode/erlang/erlang';
|
||||
import 'codemirror/mode/sql/sql';
|
||||
|
||||
interface ExtendedWindow {
|
||||
CodeMirror?: unknown;
|
||||
}
|
||||
declare const window: ExtendedWindow;
|
||||
|
||||
|
||||
export interface EditorProps {
|
||||
value: string;
|
||||
@@ -100,6 +105,14 @@ function Editor(props: EditorProps, ref: any) {
|
||||
const editorParent = useRef(null);
|
||||
const lastEditTime = useRef(NaN);
|
||||
|
||||
useEffect(() => {
|
||||
window.CodeMirror = CodeMirror;
|
||||
|
||||
return () => {
|
||||
window.CodeMirror = undefined;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Codemirror plugins add new commands to codemirror (or change it's behavior)
|
||||
// This command adds the smartListIndent function which will be bound to tab
|
||||
useListIdent(CodeMirror);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
|
||||
import bridge from '../../../../../../services/bridge';
|
||||
import { contentScriptsToCodeMirrorPlugin } from '@joplin/lib/services/plugins/utils/loadContentScripts';
|
||||
import { extname } from 'path';
|
||||
import shim from '@joplin/lib/shim';
|
||||
@@ -7,6 +8,18 @@ import uuid from '@joplin/lib/uuid';
|
||||
|
||||
import { reg } from '@joplin/lib/registry';
|
||||
|
||||
const addPluginDependency = (path: string) => {
|
||||
const id = `content-script-${encodeURIComponent(path)}`;
|
||||
if (document.getElementById(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const element = document.createElement('script');
|
||||
element.setAttribute('id', id);
|
||||
element.setAttribute('src', path);
|
||||
document.head.appendChild(element);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
export default function useExternalPlugins(CodeMirror: any, plugins: PluginStates) {
|
||||
const [options, setOptions] = useState({});
|
||||
@@ -23,7 +36,14 @@ export default function useExternalPlugins(CodeMirror: any, plugins: PluginState
|
||||
if (mod.codeMirrorResources) {
|
||||
for (const asset of mod.codeMirrorResources) {
|
||||
try {
|
||||
require(`codemirror/${asset}`);
|
||||
let assetPath = shim.fsDriver().resolveRelativePathWithinDir(`${bridge().vendorDir()}/lib/codemirror/`, asset);
|
||||
|
||||
// Compatibility with old versions of Joplin, where the file extension was automatically added by require().
|
||||
if (extname(assetPath) === '') {
|
||||
assetPath += '.js';
|
||||
}
|
||||
|
||||
addPluginDependency(assetPath);
|
||||
} catch (error) {
|
||||
error.message = `${asset} is not a valid CodeMirror asset, keymap or mode. You can find a list of valid assets here: https://codemirror.net/doc/manual.html#addons`;
|
||||
throw error;
|
||||
|
||||
@@ -30,6 +30,7 @@ import useEditorSearchHandler from '../utils/useEditorSearchHandler';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import useRefocusOnVisiblePaneChange from './utils/useRefocusOnVisiblePaneChange';
|
||||
import { WindowIdContext } from '../../../../NewWindowOrIFrame';
|
||||
import eventManager, { EventName, ResourceChangeEvent } from '@joplin/lib/eventManager';
|
||||
|
||||
const logger = Logger.create('CodeMirror6');
|
||||
const logDebug = (message: string) => logger.debug(message);
|
||||
@@ -272,6 +273,17 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
|
||||
props.noteId, props.useCustomPdfViewer,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const listener = (event: ResourceChangeEvent) => {
|
||||
editorRef.current?.onResourceChanged(event.id);
|
||||
};
|
||||
|
||||
eventManager.on(EventName.ResourceChange, listener);
|
||||
return () => {
|
||||
eventManager.off(EventName.ResourceChange, listener);
|
||||
};
|
||||
}, [props.resourceInfos]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!webviewReady) return;
|
||||
|
||||
@@ -366,6 +378,7 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
|
||||
katexEnabled: Setting.value('markdown.plugin.katex'),
|
||||
inlineRenderingEnabled: Setting.value('editor.inlineRendering'),
|
||||
imageRenderingEnabled: Setting.value('editor.imageRendering'),
|
||||
highlightActiveLine: Setting.value('editor.highlightActiveLine'),
|
||||
themeData: {
|
||||
...styles.globalTheme,
|
||||
marginLeft: 0,
|
||||
|
||||
@@ -110,11 +110,12 @@ const Editor = (props: Props, ref: ForwardedRef<CodeMirrorControl>) => {
|
||||
|
||||
const editor = createEditor(editorContainerRef.current, {
|
||||
...editorProps,
|
||||
resolveImageSrc: async src => {
|
||||
resolveImageSrc: async (src, reloadCounter) => {
|
||||
const url = parseResourceUrl(src);
|
||||
if (!url.itemId) return null;
|
||||
const item = await Resource.load(url.itemId);
|
||||
return `${getResourceBaseUrl()}/${resourceFilename(item)}`;
|
||||
if (!item) return null;
|
||||
return `${getResourceBaseUrl()}/${resourceFilename(item)}${reloadCounter ? `?r=${reloadCounter}` : ''}`;
|
||||
},
|
||||
});
|
||||
editor.addStyles({
|
||||
|
||||
@@ -13,6 +13,7 @@ import { MarkupToHtmlOptions } from '../../hooks/useMarkupToHtml';
|
||||
import { ScrollbarSize } from '@joplin/lib/models/settings/builtInMetadata';
|
||||
import { RefObject, SetStateAction } from 'react';
|
||||
import * as React from 'react';
|
||||
import { ResourceEntity, ResourceLocalStateEntity } from '@joplin/lib/services/database/types';
|
||||
|
||||
export interface AllAssetsOptions {
|
||||
contentMaxWidthTarget?: string;
|
||||
@@ -214,10 +215,8 @@ export function defaultFormNote(): FormNote {
|
||||
}
|
||||
|
||||
export interface ResourceInfo {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
localState: any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
item: any;
|
||||
localState: ResourceLocalStateEntity;
|
||||
item: ResourceEntity;
|
||||
}
|
||||
|
||||
export interface ResourceInfos {
|
||||
|
||||
@@ -251,8 +251,6 @@ export default class PromptDialog extends React.Component<Props, any> {
|
||||
} else {
|
||||
onClose(true);
|
||||
}
|
||||
} else if (event.key === 'Escape') {
|
||||
onClose(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -309,7 +307,7 @@ export default class PromptDialog extends React.Component<Props, any> {
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog className='prompt-dialog' contentStyle={styles.dialog}>
|
||||
<Dialog className='prompt-dialog' contentStyle={styles.dialog} onCancel={() => onClose(false, 'cancel')}>
|
||||
<label style={styles.label}>{this.props.label ? this.props.label : ''}</label>
|
||||
<div style={{ display: 'inline-block', color: 'black', backgroundColor: theme.backgroundColor }}>
|
||||
{inputComp}
|
||||
|
||||
@@ -72,4 +72,10 @@ export default class MainScreen {
|
||||
await setFilePickerResponse(electronApp, [path]);
|
||||
await activateMainMenuItem(electronApp, 'HTML - HTML document (Directory)', 'Import');
|
||||
}
|
||||
|
||||
public async pluginPanelLocator(pluginId: string) {
|
||||
return this.page.locator(
|
||||
`iframe[id^=${JSON.stringify(`plugin-view-${pluginId}`)}]`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,41 @@ test.describe('pluginApi', () => {
|
||||
}));
|
||||
});
|
||||
|
||||
test('should report the correct visibility state for dialogs', async ({ startAppWithPlugins }) => {
|
||||
const { app, mainWindow } = await startAppWithPlugins(['resources/test-plugins/dialogs.js']);
|
||||
const mainScreen = await new MainScreen(mainWindow).setup();
|
||||
await mainScreen.createNewNote('Dialog test note');
|
||||
|
||||
const editor = mainScreen.noteEditor;
|
||||
const expectVisible = async (visible: boolean) => {
|
||||
// Check UI visibility
|
||||
if (visible) {
|
||||
await expect(mainScreen.dialog).toBeVisible();
|
||||
} else {
|
||||
await expect(mainScreen.dialog).not.toBeVisible();
|
||||
}
|
||||
|
||||
// Check visibility reported through the plugin API
|
||||
await expect.poll(async () => {
|
||||
await mainScreen.goToAnything.runCommand(app, 'getTestDialogVisibility');
|
||||
|
||||
const editorContent = await editor.contentLocator();
|
||||
return editorContent.textContent();
|
||||
}).toBe(JSON.stringify({
|
||||
visible: visible,
|
||||
active: visible,
|
||||
}));
|
||||
};
|
||||
await expectVisible(false);
|
||||
|
||||
await mainScreen.goToAnything.runCommand(app, 'showTestDialog');
|
||||
await expectVisible(true);
|
||||
|
||||
// Submitting the dialog should include form data in the output
|
||||
await mainScreen.dialog.getByRole('button', { name: 'Okay' }).click();
|
||||
await expectVisible(false);
|
||||
});
|
||||
|
||||
test('should be possible to create multiple toasts with the same text from a plugin', async ({ startAppWithPlugins }) => {
|
||||
const { app, mainWindow } = await startAppWithPlugins(['resources/test-plugins/showToast.js']);
|
||||
const mainScreen = await new MainScreen(mainWindow).setup();
|
||||
@@ -122,5 +157,30 @@ test.describe('pluginApi', () => {
|
||||
await msleep(Second);
|
||||
await expect(noteEditor.codeMirrorEditor).toHaveText(expectedUpdatedText);
|
||||
});
|
||||
|
||||
test('should support hiding and showing panels', async ({ startAppWithPlugins }) => {
|
||||
const { mainWindow, app } = await startAppWithPlugins(['resources/test-plugins/panels.js']);
|
||||
const mainScreen = await new MainScreen(mainWindow).setup();
|
||||
await mainScreen.createNewNote('Test note (panels)');
|
||||
|
||||
const panelLocator = await mainScreen.pluginPanelLocator('org.joplinapp.plugins.example.panels');
|
||||
|
||||
const noteEditor = mainScreen.noteEditor;
|
||||
await mainScreen.goToAnything.runCommand(app, 'testShowPanel');
|
||||
await expect(noteEditor.codeMirrorEditor).toHaveText('visible');
|
||||
|
||||
// Panel should be visible
|
||||
await expect(panelLocator).toBeVisible();
|
||||
// The panel should have the expected content
|
||||
const panelContent = panelLocator.contentFrame();
|
||||
await expect(
|
||||
panelContent.getByRole('heading', { name: 'Panel content' }),
|
||||
).toBeAttached();
|
||||
|
||||
await mainScreen.goToAnything.runCommand(app, 'testHidePanel');
|
||||
await expect(noteEditor.codeMirrorEditor).toHaveText('hidden');
|
||||
|
||||
await expect(panelLocator).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -47,5 +47,22 @@ joplin.plugins.register({
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
await joplin.commands.register({
|
||||
name: 'getTestDialogVisibility',
|
||||
label: 'Returns the dialog visibility state',
|
||||
execute: async () => {
|
||||
// panels.visible should also work for dialogs.
|
||||
const visible = await joplin.views.panels.visible(dialogHandle);
|
||||
// For dialogs, isActive should return the visibility.
|
||||
// (Prefer panels.visible for dialogs).
|
||||
const active = await joplin.views.panels.isActive(dialogHandle);
|
||||
|
||||
await joplin.commands.execute('editor.setText', JSON.stringify({
|
||||
visible,
|
||||
active,
|
||||
}));
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// Allows referencing the Joplin global:
|
||||
/* eslint-disable no-undef */
|
||||
|
||||
// Allows the `joplin-manifest` block comment:
|
||||
/* eslint-disable multiline-comment-style */
|
||||
|
||||
/* joplin-manifest:
|
||||
{
|
||||
"id": "org.joplinapp.plugins.example.panels",
|
||||
"manifest_version": 1,
|
||||
"app_min_version": "3.1",
|
||||
"name": "JS Bundle test",
|
||||
"description": "JS Bundle Test plugin",
|
||||
"version": "1.0.0",
|
||||
"author": "",
|
||||
"homepage_url": "https://joplinapp.org"
|
||||
}
|
||||
*/
|
||||
|
||||
const waitFor = async (condition) => {
|
||||
const wait = () => {
|
||||
return new Promise(resolve => {
|
||||
setTimeout(() => resolve(), 100);
|
||||
});
|
||||
};
|
||||
for (let i = 0; i < 100; i++) {
|
||||
if (await condition()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Pause for a brief delay
|
||||
await wait();
|
||||
}
|
||||
|
||||
throw new Error('Condition was never true');
|
||||
};
|
||||
|
||||
joplin.plugins.register({
|
||||
onStart: async function() {
|
||||
const panels = joplin.views.panels;
|
||||
const view = await panels.create('panelTestView');
|
||||
await panels.setHtml(view, '<h1>Panel content</h1><p>Test</p>');
|
||||
await panels.hide(view);
|
||||
|
||||
|
||||
await joplin.commands.register({
|
||||
name: 'testShowPanel',
|
||||
label: 'Test panel visibility',
|
||||
execute: async () => {
|
||||
await panels.show(view);
|
||||
await waitFor(async () => {
|
||||
return await panels.visible(view);
|
||||
});
|
||||
await joplin.commands.execute('editor.setText', 'visible');
|
||||
},
|
||||
});
|
||||
|
||||
await joplin.commands.register({
|
||||
name: 'testHidePanel',
|
||||
label: 'Test: Hide the panel',
|
||||
execute: async () => {
|
||||
await panels.hide(view);
|
||||
await waitFor(async () => {
|
||||
return !await panels.visible(view);
|
||||
});
|
||||
|
||||
await joplin.commands.execute('editor.setText', 'hidden');
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -53,6 +53,10 @@ const { rootProfileDir } = determineBaseAppDirs(profileFromArgs, appName, altIns
|
||||
// various places early in the initialisation code.
|
||||
mkdirpSync(rootProfileDir);
|
||||
|
||||
// Required for correct display of Windows notifications. Should be done near the beginning of startup. See
|
||||
// https://www.electron.build/nsis.html#guid-vs-application-name
|
||||
electronApp.setAppUserModelId(appId);
|
||||
|
||||
const settingsPath = `${rootProfileDir}/settings.json`;
|
||||
let autoUploadCrashDumps = false;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/app-desktop",
|
||||
"version": "3.4.5",
|
||||
"version": "3.4.11",
|
||||
"description": "Joplin for Desktop",
|
||||
"main": "main.bundle.js",
|
||||
"private": true,
|
||||
@@ -147,8 +147,8 @@
|
||||
"@testing-library/react-hooks": "8.0.1",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/mustache": "4.2.6",
|
||||
"@types/node": "18.19.101",
|
||||
"@types/react": "18.3.21",
|
||||
"@types/node": "18.19.103",
|
||||
"@types/react": "18.3.23",
|
||||
"@types/react-dom": "18.3.7",
|
||||
"@types/react-redux": "7.1.33",
|
||||
"@types/styled-components": "5.1.32",
|
||||
@@ -160,7 +160,7 @@
|
||||
"compare-versions": "6.1.1",
|
||||
"countable": "3.0.1",
|
||||
"debounce": "1.2.1",
|
||||
"electron": "35.5.1",
|
||||
"electron": "37.4.0",
|
||||
"electron-builder": "24.13.3",
|
||||
"electron-updater": "6.6.2",
|
||||
"electron-window-state": "5.0.3",
|
||||
@@ -179,7 +179,6 @@
|
||||
"moment": "2.30.1",
|
||||
"mustache": "4.2.0",
|
||||
"nan": "2.22.2",
|
||||
"node-fetch": "2.6.7",
|
||||
"node-notifier": "10.0.1",
|
||||
"node-rsa": "1.1.1",
|
||||
"pdfjs-dist": "3.11.174",
|
||||
@@ -211,6 +210,7 @@
|
||||
"@joplin/onenote-converter": "~3.4",
|
||||
"fs-extra": "11.2.0",
|
||||
"keytar": "7.9.0",
|
||||
"node-fetch": "2.6.7",
|
||||
"sqlite3": "5.1.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ const makeBuildContext = (entryPoint: string, renderer: boolean, computeFileSize
|
||||
// in the final bundle.
|
||||
name: 'joplin--relative-imports-for-externals',
|
||||
setup: build => {
|
||||
const externalRegex = /^(.*\.node|sqlite3|electron|@electron\/remote\/.*|electron\/.*|@mapbox\/node-pre-gyp|jsdom)$/;
|
||||
const externalRegex = /^(.*\.node|sqlite3|node-fetch|electron|@electron\/remote\/.*|electron\/.*|@mapbox\/node-pre-gyp|jsdom)$/;
|
||||
build.onResolve({ filter: externalRegex }, args => {
|
||||
// Electron packages don't need relative requires
|
||||
if (args.path === 'electron' || args.path.startsWith('electron/')) {
|
||||
|
||||
@@ -82,7 +82,7 @@ async function main() {
|
||||
const files = [
|
||||
'@fortawesome/fontawesome-free/css/all.min.css',
|
||||
'@joeattardi/emoji-button/dist/index.js',
|
||||
'codemirror/addon/dialog/dialog.css',
|
||||
'codemirror/addon/',
|
||||
'codemirror/lib/codemirror.css',
|
||||
'mark.js/dist/mark.min.js',
|
||||
'roboto-fontface/css/roboto/roboto-fontface.css',
|
||||
|
||||
@@ -25,7 +25,7 @@ async function main() {
|
||||
// wrong one. However it means it will have to be manually upgraded for each
|
||||
// new Electron release. Some ABI map there:
|
||||
// https://github.com/electron/node-abi/tree/master/test
|
||||
const forceAbiArgs = '--force-abi 134';
|
||||
const forceAbiArgs = '--force-abi 138';
|
||||
|
||||
if (isWindows()) {
|
||||
// Cannot run this in parallel, or the 64-bit version might end up
|
||||
|
||||
@@ -89,8 +89,8 @@ android {
|
||||
applicationId "net.cozic.joplin"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 2097777
|
||||
versionName "3.4.4"
|
||||
versionCode 2097780
|
||||
versionName "3.4.7"
|
||||
ndk {
|
||||
abiFilters "armeabi-v7a", "x86", "arm64-v8a", "x86_64"
|
||||
}
|
||||
@@ -100,6 +100,8 @@ android {
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
cppFlags '-DCMAKE_BUILD_TYPE=Release'
|
||||
// For 16 KB pages. This should be removable after upgrading to NDK r28
|
||||
arguments "-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,8 +38,9 @@ add_library(${CMAKE_PROJECT_NAME} SHARED
|
||||
set(WHISPER_LIB_DIR ${CMAKE_SOURCE_DIR}/../../../../vendor/whisper.cpp)
|
||||
|
||||
# Based on the Whisper.cpp Android example:
|
||||
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -O3 ")
|
||||
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -O3 -fvisibility=hidden -fvisibility-inlines-hidden -ffunction-sections -fdata-sections")
|
||||
set(SHARED_FLAGS "-O3 ")
|
||||
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} ${SHARED_FLAGS} ")
|
||||
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} ${SHARED_FLAGS} -fvisibility=hidden -fvisibility-inlines-hidden -ffunction-sections -fdata-sections")
|
||||
|
||||
# Whisper: See https://stackoverflow.com/a/76290722
|
||||
add_subdirectory(${WHISPER_LIB_DIR} ./whisper)
|
||||
|
||||
@@ -54,6 +54,14 @@ const Camera = (props: Props, ref: ForwardedRef<CameraRef>) => {
|
||||
logger.error(message);
|
||||
}, []);
|
||||
|
||||
const isReadyRef = useRef(false);
|
||||
const onCameraReady = useCallback(() => {
|
||||
if (isReadyRef.current) return; // Already emitted
|
||||
|
||||
isReadyRef.current = true;
|
||||
props.onCameraReady();
|
||||
}, [props.onCameraReady]);
|
||||
|
||||
useAsyncEffect(async (event) => {
|
||||
// iOS issue workaround: Since upgrading to Expo SDK 52, closing and reopening the camera on iOS
|
||||
// never emits onCameraReady. As a workaround, call .resumePreview and wait for it to resolve,
|
||||
@@ -63,16 +71,16 @@ const Camera = (props: Props, ref: ForwardedRef<CameraRef>) => {
|
||||
// Instead, wait for the preview to start using resumePreview:
|
||||
await camera.resumePreview();
|
||||
if (event.cancelled) return;
|
||||
props.onCameraReady();
|
||||
onCameraReady();
|
||||
}
|
||||
}, [camera, props.onCameraReady]);
|
||||
}, [camera, onCameraReady]);
|
||||
|
||||
return hasPermission?.granted ? <CameraView
|
||||
ref={setCamera}
|
||||
style={props.style}
|
||||
facing={props.cameraType === CameraDirection.Front ? 'front' : 'back'}
|
||||
ratio={props.ratio as CameraRatio}
|
||||
onCameraReady={Platform.OS === 'android' ? props.onCameraReady : undefined}
|
||||
onCameraReady={onCameraReady}
|
||||
onMountError={onMountError}
|
||||
animateShutter={false}
|
||||
barcodeScannerSettings={barcodeScannerSettings}
|
||||
|
||||
@@ -11,6 +11,7 @@ import SearchInput from './SearchInput';
|
||||
import focusView from '../utils/focusView';
|
||||
import AsyncActionQueue from '@joplin/lib/AsyncActionQueue';
|
||||
import NestableFlatList, { NestableFlatListControl } from './NestableFlatList';
|
||||
import useKeyboardState from '../utils/hooks/useKeyboardState';
|
||||
const naturalCompare = require('string-natural-compare');
|
||||
|
||||
|
||||
@@ -65,7 +66,7 @@ const useSearchResults = ({
|
||||
}: UseSearchResultsOptions) => {
|
||||
const results = useMemo(() => {
|
||||
return options
|
||||
.filter(option => option.title.startsWith(search))
|
||||
.filter(option => option.title.toLowerCase().includes(search))
|
||||
.sort((a, b) => {
|
||||
if (a.title === b.title) return 0;
|
||||
// Full matches should go first
|
||||
@@ -151,7 +152,12 @@ const useSelectedIndex = (search: string, searchResults: Option[]) => {
|
||||
};
|
||||
|
||||
const useStyles = (themeId: number, showSearchResults: boolean) => {
|
||||
const { fontScale } = useWindowDimensions();
|
||||
const { fontScale, height: screenHeight } = useWindowDimensions();
|
||||
const { dockedKeyboardHeight: keyboardHeight } = useKeyboardState();
|
||||
|
||||
// Allow the search results size to decrease when the keyboard is visible.
|
||||
const searchResultsHeight = Math.max(128, Math.min(200, (screenHeight - keyboardHeight) / 3));
|
||||
|
||||
const menuItemHeight = 40 * fontScale;
|
||||
const theme = themeStyle(themeId);
|
||||
|
||||
@@ -187,7 +193,7 @@ const useStyles = (themeId: number, showSearchResults: boolean) => {
|
||||
minHeight: 32,
|
||||
},
|
||||
searchResults: {
|
||||
height: 200,
|
||||
height: searchResultsHeight,
|
||||
flexGrow: 1,
|
||||
flexShrink: 1,
|
||||
...(showSearchResults ? {} : {
|
||||
@@ -220,7 +226,7 @@ const useStyles = (themeId: number, showSearchResults: boolean) => {
|
||||
backgroundColor: theme.selectedColor,
|
||||
},
|
||||
});
|
||||
}, [theme, menuItemHeight, showSearchResults]);
|
||||
}, [theme, menuItemHeight, searchResultsHeight, showSearchResults]);
|
||||
|
||||
return { menuItemHeight, styles };
|
||||
};
|
||||
@@ -248,6 +254,8 @@ const SearchResult: React.FC<SearchResultProps> = ({
|
||||
<View style={[styles.optionContent, selected && styles.optionContentSelected]}>
|
||||
{icon}
|
||||
<Text
|
||||
ellipsizeMode='tail'
|
||||
numberOfLines={1}
|
||||
style={styles.optionLabel}
|
||||
>{text}</Text>
|
||||
</View>
|
||||
@@ -452,10 +460,11 @@ const useInputEventHandlers = ({
|
||||
} else if (key === 'ArrowUp') {
|
||||
selectedIndexControl.onPreviousResult();
|
||||
event.preventDefault();
|
||||
} else if (key === 'Enter') {
|
||||
} else if (key === 'Enter' && Platform.OS === 'web') {
|
||||
// This case is necessary on web to prevent the
|
||||
// search input from becoming defocused after
|
||||
// pressing "enter".
|
||||
// pressing "enter". Enter key behavior is handled
|
||||
// elsewhere for other platforms.
|
||||
event.preventDefault();
|
||||
onSubmit();
|
||||
setSearch('');
|
||||
@@ -540,6 +549,7 @@ const ComboBox: React.FC<Props> = ({
|
||||
};
|
||||
const activeId = `${baseId}-${selectedIndex}`;
|
||||
const searchResults = <NestableFlatList
|
||||
keyboardShouldPersistTaps="handled"
|
||||
ref={listRef}
|
||||
data={results}
|
||||
{...searchResultProps}
|
||||
@@ -577,6 +587,7 @@ const ComboBox: React.FC<Props> = ({
|
||||
onChangeText={setSearch}
|
||||
onKeyPress={onKeyPress}
|
||||
onSubmitEditing={onSubmit}
|
||||
submitBehavior='submit'
|
||||
placeholder={placeholder}
|
||||
aria-activedescendant={showSearchResults ? activeId : undefined}
|
||||
aria-controls={`menuBox-${baseId}`}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import * as React from 'react';
|
||||
import { TouchableOpacity, TouchableWithoutFeedback, Dimensions, Text, Modal, View, LayoutRectangle, ViewStyle, TextStyle, FlatList } from 'react-native';
|
||||
import { TouchableOpacity, TouchableWithoutFeedback, Dimensions, Text, Modal, View, LayoutRectangle, ViewStyle, TextStyle, FlatList, Platform } from 'react-native';
|
||||
import { Component, ReactElement } from 'react';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { EdgeInsets, SafeAreaInsetsContext } from 'react-native-safe-area-context';
|
||||
|
||||
type ValueType = string;
|
||||
export interface DropdownListItem {
|
||||
@@ -56,25 +57,43 @@ class Dropdown extends Component<DropdownProps, DropdownState> {
|
||||
};
|
||||
}
|
||||
|
||||
private updateHeaderCoordinates = () => {
|
||||
private updateHeaderCoordinates = (insets: EdgeInsets) => {
|
||||
if (!this.headerRef) return;
|
||||
|
||||
// https://stackoverflow.com/questions/30096038/react-native-getting-the-position-of-an-element
|
||||
this.headerRef.measure((_fx, _fy, width, height, px, py) => {
|
||||
const lastLayout = this.state.headerSize;
|
||||
let offsetX = 0;
|
||||
let offsetY = 0;
|
||||
|
||||
// The opening position of the dropdown must be offset to cater for insets, on newer versions of Android which use edge to edge by default
|
||||
// If the dropdown fills the full height of the screen, the offset gets ignored and does not cause anything to be truncated
|
||||
if (Platform.OS === 'android' && Platform.Version >= 35) {
|
||||
const windowHeight = Dimensions.get('window').height;
|
||||
const windowWidth = Dimensions.get('window').width;
|
||||
const isLandscape = windowWidth > windowHeight;
|
||||
|
||||
if (isLandscape) {
|
||||
offsetX = insets.left;
|
||||
offsetY = insets.top;
|
||||
} else {
|
||||
offsetY = insets.top;
|
||||
}
|
||||
}
|
||||
|
||||
if (px !== lastLayout.x || py !== lastLayout.y || width !== lastLayout.width || height !== lastLayout.height) {
|
||||
this.setState({
|
||||
headerSize: { x: px, y: py, width: width, height: height },
|
||||
headerSize: { x: px - offsetX, y: py - offsetY, width: width, height: height },
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
private onOpenList = () => {
|
||||
private onOpenList = (insets: EdgeInsets) => {
|
||||
// On iOS, we need to re-measure just before opening the list. Measurements from just after
|
||||
// onLayout can be inaccurate in some cases (in the past, this had caused the menu to be
|
||||
// drawn far offscreen).
|
||||
this.updateHeaderCoordinates();
|
||||
this.updateHeaderCoordinates(insets);
|
||||
this.setState({ listVisible: true });
|
||||
};
|
||||
private onCloseList = () => {
|
||||
@@ -92,10 +111,16 @@ class Dropdown extends Component<DropdownProps, DropdownState> {
|
||||
}
|
||||
};
|
||||
|
||||
public render() {
|
||||
private renderWithInsets(insets: EdgeInsets) {
|
||||
let offsetHeight = 0;
|
||||
|
||||
if (Platform.OS === 'android' && Platform.Version >= 35) {
|
||||
offsetHeight = insets.bottom;
|
||||
}
|
||||
|
||||
const items = this.props.items;
|
||||
const itemHeight = 60;
|
||||
const windowHeight = Dimensions.get('window').height - 50;
|
||||
const windowHeight = Dimensions.get('window').height - 50 - offsetHeight;
|
||||
const windowWidth = Dimensions.get('window').width;
|
||||
|
||||
// Dimensions doesn't return quite the right dimensions so leave an extra gap to make
|
||||
@@ -205,13 +230,13 @@ class Dropdown extends Component<DropdownProps, DropdownState> {
|
||||
<View style={{ flex: 1, flexDirection: 'column' }}>
|
||||
<View
|
||||
style={{ flexDirection: 'row', flex: 1, alignItems: 'center' }}
|
||||
onLayout={this.updateHeaderCoordinates}
|
||||
onLayout={() => this.updateHeaderCoordinates(insets)}
|
||||
ref={ref => { this.headerRef = ref; } }
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={headerWrapperStyle}
|
||||
disabled={this.props.disabled}
|
||||
onPress={this.onOpenList}
|
||||
onPress={() => this.onOpenList(insets)}
|
||||
accessibilityRole='button'
|
||||
accessibilityHint={[this.props.accessibilityHint, _('Opens dropdown')].join(' ')}
|
||||
>
|
||||
@@ -268,6 +293,14 @@ class Dropdown extends Component<DropdownProps, DropdownState> {
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<SafeAreaInsetsContext.Consumer>
|
||||
{(insets) => this.renderWithInsets(insets)}
|
||||
</SafeAreaInsetsContext.Consumer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Dropdown;
|
||||
|
||||
132
packages/app-mobile/components/FeedbackBanner.test.tsx
Normal file
132
packages/app-mobile/components/FeedbackBanner.test.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import * as React from 'react';
|
||||
import { Store } from 'redux';
|
||||
import { AppState } from '../utils/types';
|
||||
import TestProviderStack from './testing/TestProviderStack';
|
||||
import { switchClient, setupDatabase, mockMobilePlatform, mockFetch, waitFor } from '@joplin/lib/testing/test-utils';
|
||||
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';
|
||||
|
||||
interface WrapperProps { }
|
||||
|
||||
let store: Store<AppState>;
|
||||
const WrappedFeedbackBanner: React.FC<WrapperProps> = () => {
|
||||
return <TestProviderStack store={store}>
|
||||
<FeedbackBanner/>
|
||||
</TestProviderStack>;
|
||||
};
|
||||
|
||||
const getFeedbackButton = (positive: boolean) => {
|
||||
return screen.getByRole('button', { name: positive ? 'Useful' : 'Not useful' });
|
||||
};
|
||||
|
||||
const getSurveyLink = () => {
|
||||
return screen.getByRole('button', { name: 'Take survey' });
|
||||
};
|
||||
|
||||
const mockFeedbackServer = (surveyName = 'web-app-test') => {
|
||||
let helpfulCount = 0;
|
||||
let unhelpfulCount = 0;
|
||||
|
||||
const { reset } = mockFetch((request) => {
|
||||
const surveyBaseUrls = [
|
||||
'https://objects.joplinusercontent.com/',
|
||||
'http://localhost:3430/',
|
||||
];
|
||||
const isSurveyRequest = surveyBaseUrls.some(url => request.url.startsWith(url));
|
||||
if (!isSurveyRequest) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (url.pathname === `/r/survey--${surveyName}--helpful`) {
|
||||
helpfulCount ++;
|
||||
} else if (url.pathname === `/r/survey--${surveyName}--unhelpful`) {
|
||||
unhelpfulCount ++;
|
||||
} else {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
// The feedback server always redirects to another URL after a
|
||||
// successful request. Mock this by always redirecting to the
|
||||
// same URL.
|
||||
return new Response('', {
|
||||
// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/302
|
||||
status: 302,
|
||||
statusText: 'Found',
|
||||
headers: [
|
||||
['location', 'https://joplinapp.org'],
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
reset,
|
||||
get helpfulCount() {
|
||||
return helpfulCount;
|
||||
},
|
||||
get unhelpfulCount() {
|
||||
return unhelpfulCount;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
describe('FeedbackBanner', () => {
|
||||
const resetMobilePlatform = ()=>{};
|
||||
|
||||
beforeEach(async () => {
|
||||
await setupDatabase(0);
|
||||
await switchClient(0);
|
||||
|
||||
store = createMockReduxStore();
|
||||
setupGlobalStore(store);
|
||||
|
||||
jest.useFakeTimers({ advanceTimers: true });
|
||||
mockMobilePlatform('web');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
screen.unmount();
|
||||
resetMobilePlatform();
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ 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);
|
||||
|
||||
render(<WrappedFeedbackBanner />);
|
||||
|
||||
const header = screen.queryByRole('header', { name: 'Feedback' });
|
||||
if (shouldShow) {
|
||||
expect(header).toBeVisible();
|
||||
} else {
|
||||
expect(header).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test('clicking the "Useful" button should submit the response and show the "take survey" link', async () => {
|
||||
const feedbackServerMock = mockFeedbackServer();
|
||||
render(<WrappedFeedbackBanner />);
|
||||
|
||||
try {
|
||||
const usefulButton = getFeedbackButton(true);
|
||||
fireEvent.press(usefulButton);
|
||||
|
||||
await act(() => waitFor(async () => {
|
||||
expect(getSurveyLink()).toBeVisible();
|
||||
}));
|
||||
|
||||
expect(feedbackServerMock).toMatchObject({
|
||||
helpfulCount: 1,
|
||||
unhelpfulCount: 0,
|
||||
});
|
||||
} finally {
|
||||
feedbackServerMock.reset();
|
||||
}
|
||||
});
|
||||
});
|
||||
216
packages/app-mobile/components/FeedbackBanner.tsx
Normal file
216
packages/app-mobile/components/FeedbackBanner.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import * as React from 'react';
|
||||
import { View, StyleSheet, useWindowDimensions, TextStyle, Linking } from 'react-native';
|
||||
import { Portal, Text } from 'react-native-paper';
|
||||
import IconButton from './IconButton';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import { Dispatch } from 'redux';
|
||||
import { themeStyle } from './global-style';
|
||||
import { AppState } from '../utils/types';
|
||||
import { connect } from 'react-redux';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { LinkButton } from './buttons';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import { SurveyProgress } from '@joplin/lib/models/settings/builtInMetadata';
|
||||
|
||||
const logger = Logger.create('FeedbackBanner');
|
||||
|
||||
interface Props {
|
||||
dispatch: Dispatch;
|
||||
progress: SurveyProgress;
|
||||
surveyKey: string;
|
||||
themeId: number;
|
||||
}
|
||||
|
||||
const useStyles = (themeId: number, sentFeedback: boolean) => {
|
||||
const { width: windowWidth } = useWindowDimensions();
|
||||
return useMemo(() => {
|
||||
const theme = themeStyle(themeId);
|
||||
|
||||
const iconBaseStyle: TextStyle = {
|
||||
fontSize: 24,
|
||||
color: theme.color3,
|
||||
};
|
||||
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor: theme.backgroundColor3,
|
||||
borderTopRightRadius: 16,
|
||||
display: 'flex',
|
||||
flexGrow: 1,
|
||||
flexWrap: 'wrap',
|
||||
flexDirection: 'row',
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
maxWidth: windowWidth - 50,
|
||||
gap: 18,
|
||||
padding: 12,
|
||||
},
|
||||
contentRight: {
|
||||
display: sentFeedback ? 'none' : 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
},
|
||||
header: {
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
iconUseful: {
|
||||
...iconBaseStyle,
|
||||
color: theme.colorCorrect,
|
||||
},
|
||||
iconNotUseful: {
|
||||
...iconBaseStyle,
|
||||
color: theme.colorWarn,
|
||||
},
|
||||
dismissButtonIcon: {
|
||||
fontSize: 16,
|
||||
color: theme.color2,
|
||||
marginLeft: 'auto',
|
||||
marginRight: 'auto',
|
||||
},
|
||||
dismissButton: {
|
||||
backgroundColor: theme.backgroundColor2,
|
||||
borderColor: theme.backgroundColor,
|
||||
borderWidth: 2,
|
||||
width: 29,
|
||||
height: 29,
|
||||
borderRadius: 14,
|
||||
position: 'absolute',
|
||||
top: -16,
|
||||
right: -16,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
dismissButtonContent: {
|
||||
flexShrink: 1,
|
||||
},
|
||||
});
|
||||
}, [themeId, windowWidth, sentFeedback]);
|
||||
};
|
||||
|
||||
const useSurveyUrl = (surveyKey: string) => {
|
||||
return useMemo(() => {
|
||||
let baseUrl = 'https://objects.joplinusercontent.com/';
|
||||
|
||||
// For testing with a locally-hosted server:
|
||||
const useLocalServer = false;
|
||||
if (Setting.value('env') === 'dev' && useLocalServer) {
|
||||
baseUrl = 'http://localhost:3430/';
|
||||
}
|
||||
|
||||
return `${baseUrl}r/survey--${encodeURIComponent(surveyKey)}`;
|
||||
}, [surveyKey]);
|
||||
};
|
||||
|
||||
const setProgress = (progress: SurveyProgress) => {
|
||||
Setting.setValue('survey.webClientEval2025.progress', progress);
|
||||
};
|
||||
|
||||
const onDismiss = () => {
|
||||
setProgress(SurveyProgress.Dismissed);
|
||||
};
|
||||
|
||||
const FeedbackBanner: React.FC<Props> = props => {
|
||||
const surveyUrl = useSurveyUrl(props.surveyKey);
|
||||
const sentFeedback = props.progress === SurveyProgress.Started;
|
||||
|
||||
const sendSurveyResponse = useCallback(async (surveyResponse: string) => {
|
||||
const fetchUrl = `${surveyUrl}--${encodeURIComponent(surveyResponse)}`;
|
||||
logger.debug('sending response to', fetchUrl);
|
||||
const showError = (message: string) => {
|
||||
logger.error('Error', message);
|
||||
void shim.showErrorDialog(
|
||||
_('An error occurred while sending the response. This can happen if the app is offline or cannot connect to the server.\nError: %s', message),
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await shim.fetch(fetchUrl);
|
||||
// The server currently redirects (status 302) in response
|
||||
// to many survey-related requests. This may be returned by
|
||||
// the web app service worker as a 200 OK response, however. Support both:
|
||||
if (response.ok || response.status === 302) {
|
||||
setProgress(SurveyProgress.Started);
|
||||
} else {
|
||||
const body = await response.text();
|
||||
showError(`Server error: ${response.status} ${body}`);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
}
|
||||
}, [surveyUrl]);
|
||||
|
||||
const onSurveyLinkClick = useCallback(() => {
|
||||
void Linking.openURL(surveyUrl);
|
||||
onDismiss();
|
||||
}, [surveyUrl]);
|
||||
|
||||
const onNotUsefulClick = useCallback(() => {
|
||||
void sendSurveyResponse('unhelpful');
|
||||
}, [sendSurveyResponse]);
|
||||
|
||||
const onUsefulClick = useCallback(() => {
|
||||
void sendSurveyResponse('helpful');
|
||||
}, [sendSurveyResponse]);
|
||||
|
||||
const styles = useStyles(props.themeId, sentFeedback);
|
||||
|
||||
const renderStatusMessage = () => {
|
||||
if (sentFeedback) {
|
||||
return <View>
|
||||
<Text>{_('Thank you for the feedback!\nDo you have time to complete a short survey?')}</Text>
|
||||
<LinkButton onPress={onSurveyLinkClick}>{_('Take survey')}</LinkButton>
|
||||
</View>;
|
||||
} else {
|
||||
return <Text>{_('Do you find the Joplin web app useful?')}</Text>;
|
||||
}
|
||||
};
|
||||
|
||||
if (shim.mobilePlatform() !== 'web' || props.progress === SurveyProgress.Dismissed) return null;
|
||||
|
||||
return <Portal>
|
||||
<View style={styles.container} role='complementary'>
|
||||
<View>
|
||||
<Text
|
||||
accessibilityRole='header'
|
||||
variant='titleMedium'
|
||||
style={styles.header}
|
||||
>{_('Feedback')}</Text>
|
||||
<Text>{renderStatusMessage()}</Text>
|
||||
</View>
|
||||
<View style={styles.contentRight}>
|
||||
<IconButton
|
||||
iconName='fas times'
|
||||
themeId={props.themeId}
|
||||
onPress={onNotUsefulClick}
|
||||
description={_('Not useful')}
|
||||
iconStyle={styles.iconNotUseful}
|
||||
/>
|
||||
<IconButton
|
||||
iconName='fas check'
|
||||
themeId={props.themeId}
|
||||
onPress={onUsefulClick}
|
||||
description={_('Useful')}
|
||||
iconStyle={styles.iconUseful}
|
||||
/>
|
||||
</View>
|
||||
<IconButton
|
||||
iconName='fas times'
|
||||
themeId={props.themeId}
|
||||
onPress={onDismiss}
|
||||
description={_('Dismiss')}
|
||||
iconStyle={styles.dismissButtonIcon}
|
||||
contentWrapperStyle={styles.dismissButtonContent}
|
||||
containerStyle={styles.dismissButton}
|
||||
/>
|
||||
</View>
|
||||
</Portal>;
|
||||
};
|
||||
|
||||
export default connect((state: AppState) => ({
|
||||
themeId: state.settings.theme,
|
||||
surveyKey: 'web-app-test',
|
||||
progress: state.settings['survey.webClientEval2025.progress'],
|
||||
}))(FeedbackBanner);
|
||||
@@ -87,6 +87,14 @@ const IconButton = (props: ButtonProps) => {
|
||||
props.preventKeyboardDismiss, props.onPress, props.disabled,
|
||||
);
|
||||
|
||||
let icon = <Icon
|
||||
name={props.iconName}
|
||||
style={props.iconStyle}
|
||||
accessibilityLabel={null}
|
||||
/>;
|
||||
// Include browser-provided tooltips on web.
|
||||
icon = Platform.OS === 'web' ? <span title={props.description}>{icon}</span> : icon;
|
||||
|
||||
const button = (
|
||||
<Pressable
|
||||
ref={props.pressableRef}
|
||||
@@ -115,11 +123,7 @@ const IconButton = (props: ButtonProps) => {
|
||||
opacity: fadeAnim,
|
||||
...props.contentWrapperStyle,
|
||||
}}>
|
||||
<Icon
|
||||
name={props.iconName}
|
||||
style={props.iconStyle}
|
||||
accessibilityLabel={null}
|
||||
/>
|
||||
{icon}
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from 'react';
|
||||
import { RefObject, useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { GestureResponderEvent, Modal, ModalProps, Platform, Pressable, ScrollView, ScrollViewProps, StyleSheet, View, ViewStyle } from 'react-native';
|
||||
import { GestureResponderEvent, KeyboardAvoidingView, Modal, ModalProps, Platform, Pressable, ScrollView, ScrollViewProps, StyleSheet, View, ViewStyle } from 'react-native';
|
||||
import FocusControl from './accessibility/FocusControl/FocusControl';
|
||||
import { msleep, Second } from '@joplin/utils/time';
|
||||
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
|
||||
@@ -8,7 +8,7 @@ import { ModalState } from './accessibility/FocusControl/types';
|
||||
import useSafeAreaPadding from '../utils/hooks/useSafeAreaPadding';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
|
||||
interface ModalElementProps extends ModalProps {
|
||||
export interface ModalElementProps extends ModalProps {
|
||||
children: React.ReactNode;
|
||||
containerStyle?: ViewStyle;
|
||||
backgroundColor?: string;
|
||||
@@ -27,11 +27,23 @@ interface ModalElementProps extends ModalProps {
|
||||
const useStyles = (hasScrollView: boolean, backgroundColor: string|undefined) => {
|
||||
const safeAreaPadding = useSafeAreaPadding();
|
||||
return useMemo(() => {
|
||||
// On Android, the top-level container seems to need to be absolutely positioned
|
||||
// to prevent it from being larger than the screen size:
|
||||
const absoluteFill = {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
} satisfies ViewStyle;
|
||||
|
||||
return StyleSheet.create({
|
||||
modalBackground: {
|
||||
...safeAreaPadding,
|
||||
flexGrow: 1,
|
||||
flexShrink: 1,
|
||||
...(hasScrollView ? {
|
||||
flexGrow: 1,
|
||||
flexShrink: 1,
|
||||
} : absoluteFill),
|
||||
|
||||
// When hasScrollView, the modal background is wrapped in a ScrollView. In this case, it's
|
||||
// possible to scroll content outside the background into view. To prevent the edge of the
|
||||
@@ -39,6 +51,10 @@ const useStyles = (hasScrollView: boolean, backgroundColor: string|undefined) =>
|
||||
// instead:
|
||||
backgroundColor: hasScrollView ? null : backgroundColor,
|
||||
},
|
||||
keyboardAvoidingView: {
|
||||
...absoluteFill,
|
||||
flex: 1,
|
||||
},
|
||||
modalScrollView: {
|
||||
backgroundColor,
|
||||
flexGrow: 1,
|
||||
@@ -159,11 +175,13 @@ const ModalElement: React.FC<ModalElementProps> = ({
|
||||
{...modalProps}
|
||||
>
|
||||
{scrollOverflow ? (
|
||||
<ScrollView
|
||||
{...extraScrollViewProps}
|
||||
style={[styles.modalScrollView, extraScrollViewProps.style]}
|
||||
contentContainerStyle={[styles.modalScrollViewContent, extraScrollViewProps.contentContainerStyle]}
|
||||
>{contentAndBackdrop}</ScrollView>
|
||||
<KeyboardAvoidingView behavior='padding' style={styles.keyboardAvoidingView}>
|
||||
<ScrollView
|
||||
{...extraScrollViewProps}
|
||||
style={[styles.modalScrollView, extraScrollViewProps.style]}
|
||||
contentContainerStyle={[styles.modalScrollViewContent, extraScrollViewProps.contentContainerStyle]}
|
||||
>{contentAndBackdrop}</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
) : contentAndBackdrop}
|
||||
</Modal>
|
||||
</FocusControl.ModalWrapper>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useMemo } from 'react';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
import { themeStyle } from './global-style';
|
||||
|
||||
import Modal from './Modal';
|
||||
import Modal, { ModalElementProps } from './Modal';
|
||||
import { PrimaryButton } from './buttons';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { Button } from 'react-native-paper';
|
||||
@@ -11,6 +11,7 @@ import { Button } from 'react-native-paper';
|
||||
interface Props {
|
||||
themeId: number;
|
||||
children: React.ReactNode;
|
||||
modalProps: Partial<ModalElementProps>;
|
||||
|
||||
buttonBarEnabled: boolean;
|
||||
okTitle: string;
|
||||
@@ -27,19 +28,15 @@ const useStyles = (themeId: number) => {
|
||||
borderRadius: 4,
|
||||
backgroundColor: theme.backgroundColor,
|
||||
maxWidth: 600,
|
||||
maxHeight: 500,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
alignSelf: 'center',
|
||||
marginVertical: 'auto',
|
||||
flexGrow: 1,
|
||||
flexShrink: 1,
|
||||
padding: theme.margin,
|
||||
},
|
||||
title: theme.headerStyle,
|
||||
contentWrapper: {
|
||||
flexGrow: 1,
|
||||
flexShrink: 1,
|
||||
},
|
||||
buttonRow: {
|
||||
flexDirection: 'row',
|
||||
@@ -66,6 +63,7 @@ const ModalDialog: React.FC<Props> = props => {
|
||||
onRequestClose={null}
|
||||
containerStyle={styles.container}
|
||||
backgroundColor={theme.backgroundColorTransparent2}
|
||||
{...props.modalProps}
|
||||
>
|
||||
<View style={styles.contentWrapper}>{props.children}</View>
|
||||
<View style={styles.buttonRow}>
|
||||
|
||||
@@ -84,7 +84,7 @@ const NestableFlatList = function<T>({
|
||||
}, []);
|
||||
|
||||
const bufferSize = 10;
|
||||
const visibleStartIndex = Math.floor(scroll / itemHeight);
|
||||
const visibleStartIndex = Math.min(Math.floor(scroll / itemHeight), data.length);
|
||||
const visibleEndIndex = Math.ceil((scroll + listHeight) / itemHeight);
|
||||
const startIndex = Math.max(0, visibleStartIndex - bufferSize);
|
||||
const maximumIndex = data.length - 1;
|
||||
|
||||
@@ -16,6 +16,12 @@ import { ResourceInfo } from './hooks/useRerenderHandler';
|
||||
import getWebViewDomById from '../../utils/testing/getWebViewDomById';
|
||||
import TestProviderStack from '../testing/TestProviderStack';
|
||||
import createMockReduxStore from '../../utils/testing/createMockReduxStore';
|
||||
import Plugin from '@joplin/lib/services/plugins/Plugin';
|
||||
import { Store } from 'redux';
|
||||
import { ContentScriptType } from '@joplin/lib/services/plugins/api/types';
|
||||
import { basename, dirname, join } from 'path';
|
||||
import PluginService from '@joplin/lib/services/plugins/PluginService';
|
||||
import mockPluginServiceSetup from '../../utils/testing/mockPluginServiceSetup';
|
||||
|
||||
interface WrapperProps {
|
||||
noteBody: string;
|
||||
@@ -28,7 +34,7 @@ interface WrapperProps {
|
||||
const emptyObject = {};
|
||||
const emptyArray: string[] = [];
|
||||
const noOpFunction = () => {};
|
||||
const testStore = createMockReduxStore();
|
||||
let testStore: Store;
|
||||
const WrappedNoteViewer: React.FC<WrapperProps> = (
|
||||
{
|
||||
noteBody,
|
||||
@@ -58,10 +64,34 @@ const getNoteViewerDom = async () => {
|
||||
return await getWebViewDomById('NoteBodyViewer');
|
||||
};
|
||||
|
||||
const loadTestContentScript = async (path: string, pluginId: string) => {
|
||||
const plugin = new Plugin(
|
||||
dirname(path),
|
||||
{
|
||||
manifest_version: 1,
|
||||
id: pluginId,
|
||||
name: 'Test plugin',
|
||||
version: '1',
|
||||
app_min_version: '1',
|
||||
},
|
||||
'',
|
||||
testStore.dispatch,
|
||||
'',
|
||||
);
|
||||
await PluginService.instance().runPlugin(plugin);
|
||||
await plugin.registerContentScript(
|
||||
ContentScriptType.MarkdownItPlugin,
|
||||
`${pluginId}-markdown-it`,
|
||||
basename(path),
|
||||
);
|
||||
};
|
||||
|
||||
describe('NoteBodyViewer', () => {
|
||||
beforeEach(async () => {
|
||||
await setupDatabaseAndSynchronizer(0);
|
||||
await switchClient(0);
|
||||
testStore = createMockReduxStore();
|
||||
mockPluginServiceSetup(testStore);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -85,6 +115,17 @@ describe('NoteBodyViewer', () => {
|
||||
await expectHeaderToBe('Test 3');
|
||||
});
|
||||
|
||||
it('should support basic renderer plugins', async () => {
|
||||
await loadTestContentScript(join(supportDir, 'plugins', 'markdownItTestPlugin.js'), 'test-plugin');
|
||||
|
||||
render(<WrappedNoteViewer noteBody={'```justtesting\ntest\n```\n'}/>);
|
||||
|
||||
const noteViewer = await getNoteViewerDom();
|
||||
await waitFor(async () => {
|
||||
expect(noteViewer.querySelector('div.just-testing')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ keywords: ['match'], body: 'A match and another match. Both should be highlighted.', expectedMatchCount: 2 },
|
||||
{ keywords: ['test'], body: 'No match.', expectedMatchCount: 0 },
|
||||
|
||||
@@ -3,13 +3,11 @@ import themeToCss from '@joplin/lib/services/style/themeToCss';
|
||||
import ExtendedWebView from '../ExtendedWebView';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import { NativeSyntheticEvent } from 'react-native';
|
||||
|
||||
import { EditorProps } from './types';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import useCodeMirrorPlugins from './hooks/useCodeMirrorPlugins';
|
||||
import { WebViewErrorEvent } from 'react-native-webview/lib/RNCWebViewNativeComponent';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import { OnMessageEvent } from '../ExtendedWebView/types';
|
||||
@@ -117,16 +115,16 @@ const MarkdownEditor: React.FC<EditorProps> = props => {
|
||||
onEditorEvent: props.onEditorEvent,
|
||||
onAttachFile: props.onAttach,
|
||||
editorOptions: {
|
||||
parentElementClassName: 'CodeMirror',
|
||||
parentElementOrClassName: 'CodeMirror',
|
||||
initialText: props.initialText,
|
||||
initialNoteId: props.noteId,
|
||||
settings: props.editorSettings,
|
||||
onLocalize: _,
|
||||
},
|
||||
webviewRef,
|
||||
pluginStates: props.plugins,
|
||||
});
|
||||
|
||||
props.editorRef.current = editorWebViewSetup.api.editor;
|
||||
props.editorRef.current = editorWebViewSetup.api.mainEditor;
|
||||
|
||||
const injectedJavaScript = `
|
||||
window.onerror = (message, source, lineno) => {
|
||||
@@ -154,11 +152,6 @@ const MarkdownEditor: React.FC<EditorProps> = props => {
|
||||
const css = useCss(props.themeId);
|
||||
const html = useHtml();
|
||||
|
||||
const codeMirrorPlugins = useCodeMirrorPlugins(props.plugins);
|
||||
useEffect(() => {
|
||||
void editorWebViewSetup.api.editor.setContentScripts(codeMirrorPlugins);
|
||||
}, [codeMirrorPlugins, editorWebViewSetup]);
|
||||
|
||||
const onMessage = useCallback((event: OnMessageEvent) => {
|
||||
const data = event.nativeEvent.data;
|
||||
|
||||
@@ -183,7 +176,7 @@ const MarkdownEditor: React.FC<EditorProps> = props => {
|
||||
html={html}
|
||||
injectedJavaScript={injectedJavaScript}
|
||||
css={css}
|
||||
hasPluginScripts={codeMirrorPlugins.length > 0}
|
||||
hasPluginScripts={editorWebViewSetup.hasPlugins}
|
||||
onMessage={onMessage}
|
||||
onLoadEnd={editorWebViewSetup.webViewEventHandlers.onLoadEnd}
|
||||
onError={onError}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { WebViewControl } from '../ExtendedWebView/types';
|
||||
import * as React from 'react';
|
||||
import { Ref, RefObject, useEffect, useImperativeHandle } from 'react';
|
||||
import { useMemo, useState, useCallback, useRef } from 'react';
|
||||
import { LayoutChangeEvent, View, ViewStyle } from 'react-native';
|
||||
import { LayoutChangeEvent, Platform, View, ViewStyle } from 'react-native';
|
||||
import { editorFont } from '../global-style';
|
||||
|
||||
import { EditorControl as EditorBodyControl, ContentScriptData } from '@joplin/editor/types';
|
||||
@@ -32,6 +32,7 @@ import { dirname } from '@joplin/utils/path';
|
||||
import { toFileExtension } from '@joplin/lib/mime-utils';
|
||||
import { MarkupLanguage } from '@joplin/renderer';
|
||||
import WarningBanner from './WarningBanner';
|
||||
import useIsScreenReaderEnabled from '../../utils/hooks/useIsScreenReaderEnabled';
|
||||
|
||||
type ChangeEventHandler = (event: ChangeEvent)=> void;
|
||||
type UndoRedoDepthChangeHandler = (event: UndoRedoDepthChangeEvent)=> void;
|
||||
@@ -229,8 +230,12 @@ const useEditorControl = (
|
||||
setSearchState: setSearchStateCallback,
|
||||
},
|
||||
|
||||
onResourceDownloaded: (id: string) => {
|
||||
editorRef.current.onResourceDownloaded(id);
|
||||
onResourceChanged: (id: string) => {
|
||||
editorRef.current.onResourceChanged(id);
|
||||
},
|
||||
|
||||
remove: () => {
|
||||
editorRef.current.remove();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -238,9 +243,18 @@ const useEditorControl = (
|
||||
}, [webviewRef, editorRef, setLinkDialogVisible, setSearchState]);
|
||||
};
|
||||
|
||||
const useHighlightActiveLine = () => {
|
||||
const screenReaderEnabled = useIsScreenReaderEnabled();
|
||||
// Guess whether highlighting the active line can be enabled without triggering
|
||||
// https://github.com/codemirror/dev/issues/1559.
|
||||
const canHighlight = Platform.OS !== 'ios' || !screenReaderEnabled;
|
||||
return canHighlight && Setting.value('editor.highlightActiveLine');
|
||||
};
|
||||
|
||||
function NoteEditor(props: Props) {
|
||||
const webviewRef = useRef<WebViewControl>(null);
|
||||
|
||||
const highlightActiveLine = useHighlightActiveLine();
|
||||
const editorSettings: EditorSettings = useMemo(() => ({
|
||||
themeData: editorTheme(props.themeId),
|
||||
markdownMarkEnabled: Setting.value('markdown.plugin.mark'),
|
||||
@@ -251,6 +265,7 @@ function NoteEditor(props: Props) {
|
||||
language: props.markupLanguage === MarkupLanguage.Html ? EditorLanguageType.Html : EditorLanguageType.Markdown,
|
||||
useExternalSearch: true,
|
||||
readOnly: props.readOnly,
|
||||
highlightActiveLine,
|
||||
|
||||
keymap: EditorKeymap.Default,
|
||||
|
||||
@@ -263,7 +278,7 @@ function NoteEditor(props: Props) {
|
||||
indentWithTabs: true,
|
||||
|
||||
editorLabel: _('Markdown editor'),
|
||||
}), [props.themeId, props.readOnly, props.markupLanguage]);
|
||||
}), [props.themeId, props.readOnly, props.markupLanguage, highlightActiveLine]);
|
||||
|
||||
const [selectionState, setSelectionState] = useState<SelectionFormatting>(defaultSelectionFormatting);
|
||||
const [linkDialogVisible, setLinkDialogVisible] = useState(false);
|
||||
@@ -300,6 +315,7 @@ function NoteEditor(props: Props) {
|
||||
editorControl.searchControl.hideSearch();
|
||||
}
|
||||
break;
|
||||
case EditorEventType.Remove:
|
||||
case EditorEventType.Scroll:
|
||||
// Not handled
|
||||
break;
|
||||
@@ -326,10 +342,18 @@ function NoteEditor(props: Props) {
|
||||
const isDownloaded = (resourceInfos: ResourceInfos, resourceId: string) => {
|
||||
return resourceInfos[resourceId]?.localState?.fetch_status === Resource.FETCH_STATUS_DONE;
|
||||
};
|
||||
const isEncrypted = (resourceInfos: ResourceInfos, resourceId: string) => {
|
||||
return resourceInfos[resourceId]?.item?.encryption_blob_encrypted === 1;
|
||||
};
|
||||
for (const key in props.noteResources) {
|
||||
const wasDownloaded = isDownloaded(lastNoteResources.current, key);
|
||||
if (!wasDownloaded && isDownloaded(props.noteResources, key)) {
|
||||
editorControl.onResourceDownloaded(key);
|
||||
const hasDownloaded = !wasDownloaded && isDownloaded(props.noteResources, key);
|
||||
|
||||
const wasEncrypted = isEncrypted(lastNoteResources.current, key);
|
||||
const hasDecrypted = wasEncrypted && !isEncrypted(props.noteResources, key);
|
||||
|
||||
if (hasDownloaded || hasDecrypted) {
|
||||
editorControl.onResourceChanged(key);
|
||||
}
|
||||
}
|
||||
}, [props.noteResources, editorControl]);
|
||||
|
||||
@@ -257,7 +257,7 @@ describe('RichTextEditor', () => {
|
||||
ref={editorRef}
|
||||
/>,
|
||||
);
|
||||
editorRef.current.onResourceDownloaded(localResource.id);
|
||||
editorRef.current.onResourceChanged(localResource.id);
|
||||
|
||||
expect(
|
||||
await findElement(`img[data-resource-id=${JSON.stringify(localResource.id)}]`),
|
||||
@@ -288,6 +288,26 @@ describe('RichTextEditor', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should avoid rendering URLs with unknown protocols', async () => {
|
||||
let body = '[link](unknown://test)';
|
||||
|
||||
render(<WrappedEditor
|
||||
noteBody={body}
|
||||
onBodyChange={newBody => { body = newBody; }}
|
||||
/>);
|
||||
|
||||
const renderedLink = await findElement<HTMLAnchorElement>('a[href][data-original-href]');
|
||||
expect(renderedLink.getAttribute('href')).toBe('#');
|
||||
expect(renderedLink.getAttribute('data-original-href')).toBe('unknown://test');
|
||||
|
||||
const window = await getEditorWindow();
|
||||
mockTyping(window, ' testing');
|
||||
|
||||
await waitFor(async () => {
|
||||
expect(body.trim()).toBe('[link](unknown://test) testing');
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
MarkupLanguage.Markdown, MarkupLanguage.Html,
|
||||
])('should preserve image attachments on edit (case %#)', async (markupLanguage) => {
|
||||
@@ -370,6 +390,37 @@ describe('RichTextEditor', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should be possible show an editor for math blocks', async () => {
|
||||
let body = 'Test:\n\n$$3^2 + 4^2 = 5^2$$';
|
||||
render(<WrappedEditor
|
||||
noteBody={body}
|
||||
onBodyChange={newBody => { body = newBody; }}
|
||||
/>);
|
||||
|
||||
const editButton = await findElement<HTMLButtonElement>('button.edit');
|
||||
editButton.click();
|
||||
|
||||
const editor = await findElement('dialog .cm-editor');
|
||||
expect(editor).toBeTruthy();
|
||||
expect(editor.textContent).toContain('3^2 + 4^2 = 5^2');
|
||||
});
|
||||
|
||||
it('should save lists as single-spaced', async () => {
|
||||
let body = 'Test:\n\n- this\n- is\n- a\n- test.';
|
||||
|
||||
render(<WrappedEditor
|
||||
noteBody={body}
|
||||
onBodyChange={newBody => { body = newBody; }}
|
||||
/>);
|
||||
|
||||
const window = await getEditorWindow();
|
||||
mockTyping(window, ' Testing');
|
||||
|
||||
await waitFor(async () => {
|
||||
expect(body.trim()).toBe('Test:\n\n- this\n- is\n- a\n- test. Testing');
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve table of contents blocks on edit', async () => {
|
||||
let body = '# Heading\n\n# Heading 2\n\n[toc]\n\nTest.';
|
||||
|
||||
@@ -391,4 +442,32 @@ describe('RichTextEditor', () => {
|
||||
expect(body.trim()).toBe('# Heading\n\n# Heading 2\n\n[toc]\n\nTest. testing');
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
'**bold**',
|
||||
'*italic*',
|
||||
'$\\text{math}$',
|
||||
'<span style="color: red;">test</span>',
|
||||
'`code`',
|
||||
'==highlight==ed',
|
||||
'<sup>Super</sup>script',
|
||||
'<sub>Sub</sub>script',
|
||||
])('should preserve inline markup on edit (case %#)', async (initialBody) => {
|
||||
initialBody += 'test'; // Ensure that typing will add new content outside the formatting
|
||||
let body = initialBody;
|
||||
|
||||
render(<WrappedEditor
|
||||
noteBody={body}
|
||||
onBodyChange={newBody => { body = newBody; }}
|
||||
/>);
|
||||
|
||||
await findElement<HTMLElement>('div.prosemirror-editor');
|
||||
|
||||
const window = await getEditorWindow();
|
||||
mockTyping(window, ' testing');
|
||||
|
||||
await waitFor(async () => {
|
||||
expect(body.trim()).toBe(`${initialBody} testing`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,6 +37,7 @@ const useStyles = (themeId: number) => {
|
||||
|
||||
const listItemPressable: ViewStyle = {
|
||||
flexGrow: 1,
|
||||
flexShrink: 1,
|
||||
alignSelf: 'stretch',
|
||||
};
|
||||
const listItemPressableWithCheckbox: ViewStyle = {
|
||||
|
||||
@@ -11,7 +11,7 @@ import { saveProfileConfig, switchProfile } from '../../services/profiles';
|
||||
import { themeStyle } from '../global-style';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import { DialogContext } from '../DialogManager';
|
||||
import { FAB, List, Portal } from 'react-native-paper';
|
||||
import { FAB, List } from 'react-native-paper';
|
||||
import { TextStyle } from 'react-native';
|
||||
import useOnLongPressProps from '../../utils/hooks/useOnLongPressProps';
|
||||
import { Dispatch } from 'redux';
|
||||
@@ -206,19 +206,17 @@ export default (props: Props) => {
|
||||
extraData={extraListItemData}
|
||||
/>
|
||||
</View>
|
||||
<Portal>
|
||||
<FAB
|
||||
icon="plus"
|
||||
accessibilityLabel={_('New profile')}
|
||||
style={style.fab}
|
||||
onPress={() => {
|
||||
props.dispatch({
|
||||
type: 'NAV_GO',
|
||||
routeName: 'ProfileEditor',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Portal>
|
||||
<FAB
|
||||
icon="plus"
|
||||
accessibilityLabel={_('New profile')}
|
||||
style={style.fab}
|
||||
onPress={() => {
|
||||
props.dispatch({
|
||||
type: 'NAV_GO',
|
||||
routeName: 'ProfileEditor',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,6 +7,8 @@ import AccessibleView from '../accessibility/AccessibleView';
|
||||
import debounce from '../../utils/debounce';
|
||||
import FocusControl from '../accessibility/FocusControl/FocusControl';
|
||||
import { ModalState } from '../accessibility/FocusControl/types';
|
||||
import useKeyboardState from '../../utils/hooks/useKeyboardState';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
interface MenuOptionDivider {
|
||||
isDivider: true;
|
||||
@@ -29,7 +31,9 @@ interface Props {
|
||||
}
|
||||
|
||||
const useStyles = (themeId: number) => {
|
||||
const { height: windowHeight } = useWindowDimensions();
|
||||
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
||||
const safeAreaInsets = useSafeAreaInsets();
|
||||
const { dockedKeyboardHeight: keyboardHeight } = useKeyboardState();
|
||||
|
||||
return useMemo(() => {
|
||||
const theme = themeStyle(themeId);
|
||||
@@ -46,6 +50,20 @@ const useStyles = (themeId: number) => {
|
||||
fontSize: theme.fontSize,
|
||||
};
|
||||
|
||||
const isLandscape = windowWidth > windowHeight;
|
||||
const extraPadding = isLandscape ? 25 : 50;
|
||||
|
||||
// When a docked on-screen keyboard is showing, we want to maximise the height of the menu as much as possible, due to the limited available space.
|
||||
// However, when the on-screen keyboard is hidden or floating in either portrait or landscape orientation, it is less of an issue to have excess in the amount
|
||||
// of padding, to ensure nothing is cut off on all varieties of supported mobile platforms with different input and navigation bar settings. In particular,
|
||||
// on Android it is not possible to distinguish between a floating keyboard and a horizontal input bar which is docked, but the latter requires a larger
|
||||
// reduction in height. For this reason we use a fixed value for insetOrExtraFullscreenPadding when the keyboard height is zero. However, Android versions
|
||||
// earlier than 15 have an IME toolbar in addition to the input toolbar when using an external keyboard, so to cater for this scenario, we can use the fixed
|
||||
// value if the keyboardHeight is <= 25 (as any proper on-screen keyboard would be much taller than this). If the keyboard height is larger than this, we can assume
|
||||
// a docked keyboard is visible, so we only need cater for the insets in addition to the fixed extraPadding required for compatibility across Android versions
|
||||
const insetOrExtraFullscreenPadding = keyboardHeight <= 25 ? 70 : safeAreaInsets.top + safeAreaInsets.bottom;
|
||||
const maxMenuHeight = windowHeight - keyboardHeight - extraPadding - insetOrExtraFullscreenPadding;
|
||||
|
||||
return StyleSheet.create({
|
||||
divider: {
|
||||
borderBottomWidth: 1,
|
||||
@@ -66,13 +84,13 @@ const useStyles = (themeId: number) => {
|
||||
opacity: 0.5,
|
||||
},
|
||||
menuContentScroller: {
|
||||
maxHeight: windowHeight - 50,
|
||||
maxHeight: maxMenuHeight,
|
||||
},
|
||||
contextMenuButton: {
|
||||
padding: 0,
|
||||
},
|
||||
});
|
||||
}, [themeId, windowHeight]);
|
||||
}, [themeId, windowWidth, windowHeight, safeAreaInsets, keyboardHeight]);
|
||||
};
|
||||
|
||||
const MenuComponent: React.FC<Props> = props => {
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import * as React from 'react';
|
||||
import { Linking, TextStyle, View, ViewStyle } from 'react-native';
|
||||
import { Linking, StyleSheet, TextStyle, View, ViewStyle } from 'react-native';
|
||||
import { Text } from 'react-native-paper';
|
||||
import IconButton from '../IconButton';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { useCallback, useState } from 'react';
|
||||
import DismissibleDialog, { DialogSize } from '../DismissibleDialog';
|
||||
import { LinkButton } from '../buttons';
|
||||
import NavService from '@joplin/lib/services/NavService';
|
||||
import makeDiscourseDebugUrl from '@joplin/lib/makeDiscourseDebugUrl';
|
||||
import getPackageInfo from '../../utils/getPackageInfo';
|
||||
import PluginService from '@joplin/lib/services/plugins/PluginService';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
|
||||
interface Props {
|
||||
wrapperStyle: ViewStyle;
|
||||
@@ -15,10 +18,24 @@ interface Props {
|
||||
}
|
||||
|
||||
const onLeaveFeedback = () => {
|
||||
void Linking.openURL('https://discourse.joplinapp.org/t/web-client-running-joplin-mobile-in-a-web-browser-with-react-native-web/38749');
|
||||
void Linking.openURL('https://forms.gle/B5YGDNzsUYBnoPx19');
|
||||
};
|
||||
|
||||
const feedbackContainerStyles: ViewStyle = { flexGrow: 1, justifyContent: 'flex-end' };
|
||||
const onReportBug = () => {
|
||||
void Linking.openURL(
|
||||
makeDiscourseDebugUrl('', '', [], getPackageInfo(), PluginService.instance(), Setting.value('plugins.states')),
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
feedbackContainer: {
|
||||
flexGrow: 1,
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
paragraph: {
|
||||
paddingBottom: 7,
|
||||
},
|
||||
});
|
||||
|
||||
const WebBetaButton: React.FC<Props> = props => {
|
||||
const [dialogVisible, setDialogVisible] = useState(false);
|
||||
@@ -31,6 +48,10 @@ const WebBetaButton: React.FC<Props> = props => {
|
||||
setDialogVisible(false);
|
||||
}, []);
|
||||
|
||||
const renderParagraph = (content: string) => {
|
||||
return <Text variant='bodyLarge' style={styles.paragraph}>{content}</Text>;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
@@ -49,10 +70,13 @@ const WebBetaButton: React.FC<Props> = props => {
|
||||
visible={dialogVisible}
|
||||
onDismiss={onHideDialog}
|
||||
>
|
||||
<Text>{'At present, the web client is in beta. In the future, it may change significantly, or be removed.'}</Text>
|
||||
<View style={feedbackContainerStyles}>
|
||||
{renderParagraph('Welcome to the beta version of the Joplin Web App!')}
|
||||
{renderParagraph('Thank you for participating in the beta version of the Joplin Web App.')}
|
||||
{renderParagraph('The Joplin Web App is available for a limited time in open beta and may later join the Joplin Cloud plans.')}
|
||||
{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>
|
||||
<LinkButton onPress={onLeaveFeedback}>{'Give feedback'}</LinkButton>
|
||||
<LinkButton onPress={() => NavService.go('DocumentScanner')}>{'Test work-in-progress feature: Document scanner'}</LinkButton>
|
||||
</View>
|
||||
</DismissibleDialog>
|
||||
</>
|
||||
|
||||
@@ -101,12 +101,24 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const styleObject: any = {
|
||||
container: {
|
||||
outerContainer: {
|
||||
flexDirection: 'column',
|
||||
},
|
||||
innerContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: theme.backgroundColor2,
|
||||
shadowColor: '#000000',
|
||||
elevation: 5,
|
||||
},
|
||||
// A small border above the header: Covers the part of the shadow that would otherwise
|
||||
// be shown above the header on Android.
|
||||
aboveHeader: {
|
||||
backgroundColor: theme.backgroundColor2,
|
||||
paddingBottom: 6,
|
||||
marginTop: -6,
|
||||
zIndex: 2,
|
||||
},
|
||||
sideMenuButton: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
@@ -678,8 +690,9 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={this.styles().container}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
<View style={this.styles().outerContainer}>
|
||||
<View style={this.styles().aboveHeader}/>
|
||||
<View style={this.styles().innerContainer}>
|
||||
{sideMenuComp}
|
||||
{backButtonComp}
|
||||
{renderUndoButton()}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import * as React from 'react';
|
||||
import TextInput from './TextInput';
|
||||
import { View, StyleSheet, TextInputProps, ViewStyle, TextInput as ReactNativeTextInput } from 'react-native';
|
||||
import { View, StyleSheet, TextInputProps, ViewStyle, TextInput as ReactNativeTextInput, Keyboard } from 'react-native';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { Ref, useCallback, useMemo } from 'react';
|
||||
import { themeStyle } from './global-style';
|
||||
import IconButton from './IconButton';
|
||||
import Icon from './Icon';
|
||||
|
||||
|
||||
interface SearchInputProps extends TextInputProps {
|
||||
@@ -58,11 +57,12 @@ const SearchInput: React.FC<SearchInputProps> = ({ inputRef, themeId, value, con
|
||||
}, [onChangeText]);
|
||||
|
||||
return <View style={[styles.root, containerStyle]}>
|
||||
<Icon
|
||||
aria-hidden={true}
|
||||
name='material magnify'
|
||||
accessibilityLabel={null}
|
||||
style={styles.icon}
|
||||
<IconButton
|
||||
iconName='material magnify'
|
||||
onPress={() => Keyboard.dismiss()}
|
||||
description={_('Hide keyboard')}
|
||||
iconStyle={styles.icon}
|
||||
themeId={themeId}
|
||||
/>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
|
||||
@@ -38,11 +38,13 @@ const useStyles = (themeId: number, headerStyle: TextStyle|undefined) => {
|
||||
color: theme.color3,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
maxWidth: '100%',
|
||||
gap: 4,
|
||||
},
|
||||
tagText: {
|
||||
color: theme.color3,
|
||||
fontSize: theme.fontSize,
|
||||
flexShrink: 1,
|
||||
},
|
||||
removeTagButton: {
|
||||
color: theme.color3,
|
||||
@@ -51,7 +53,7 @@ const useStyles = (themeId: number, headerStyle: TextStyle|undefined) => {
|
||||
},
|
||||
tagBoxRoot: {
|
||||
flexDirection: 'column',
|
||||
flexGrow: 1,
|
||||
flexGrow: 0.5,
|
||||
flexShrink: 1,
|
||||
},
|
||||
tagBoxScrollView: {
|
||||
@@ -122,7 +124,11 @@ const TagCard: React.FC<TagChipProps> = props => {
|
||||
style={props.styles.tag}
|
||||
role='listitem'
|
||||
>
|
||||
<Text style={props.styles.tagText}>{props.title}</Text>
|
||||
<Text
|
||||
ellipsizeMode='tail'
|
||||
numberOfLines={1}
|
||||
style={props.styles.tagText}
|
||||
>{props.title}</Text>
|
||||
<IconButton
|
||||
pressableRef={removeButtonRef}
|
||||
themeId={props.themeId}
|
||||
@@ -171,6 +177,7 @@ const TagsBox: React.FC<TagsBoxProps> = props => {
|
||||
return <View style={props.styles.tagBoxRoot}>
|
||||
<Text style={props.styles.header} role='heading'>{_('Associated tags:')}</Text>
|
||||
<ScrollView
|
||||
keyboardShouldPersistTaps="handled"
|
||||
style={props.styles.tagBoxScrollView}
|
||||
// On web, specifying aria-live here announces changes to the associated tags.
|
||||
// However, on Android (and possibly iOS), this breaks focus behavior:
|
||||
|
||||
@@ -8,6 +8,7 @@ import { themeStyle } from './global-style';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import useKeyboardState from '../utils/hooks/useKeyboardState';
|
||||
import usePrevious from '@joplin/lib/hooks/usePrevious';
|
||||
import FeedbackBanner from './FeedbackBanner';
|
||||
|
||||
interface Props {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
@@ -67,6 +68,7 @@ const AppNavComponent: React.FC<Props> = (props) => {
|
||||
<NotesScreen visible={notesScreenVisible} />
|
||||
{searchScreenLoaded && <SearchScreen visible={searchScreenVisible} />}
|
||||
{!notesScreenVisible && !searchScreenVisible && <Screen navigation={{ state: route }} themeId={props.themeId} dispatch={props.dispatch} />}
|
||||
{notesScreenVisible ? <FeedbackBanner/> : null}
|
||||
<View style={{ height: autocompletionBarPadding }} />
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
|
||||
@@ -6,11 +6,12 @@ import createMockReduxStore from '../../utils/testing/createMockReduxStore';
|
||||
import setupGlobalStore from '../../utils/testing/setupGlobalStore';
|
||||
import PluginRunnerWebView from './PluginRunnerWebView';
|
||||
import TestProviderStack from '../testing/TestProviderStack';
|
||||
import { render, waitFor } from '../../utils/testing/testingLibrary';
|
||||
import { act, render, screen, waitFor } from '../../utils/testing/testingLibrary';
|
||||
import createTestPlugin from '@joplin/lib/testing/plugins/createTestPlugin';
|
||||
import getWebViewDomById from '../../utils/testing/getWebViewDomById';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import PluginService from '@joplin/lib/services/plugins/PluginService';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
|
||||
let store: Store<AppState>;
|
||||
|
||||
@@ -30,6 +31,16 @@ const defaultManifestProperties = {
|
||||
name: 'Some plugin name',
|
||||
};
|
||||
|
||||
type PluginSlice = { manifest: { id: string } };
|
||||
const waitForPluginToLoad = (plugin: PluginSlice) => {
|
||||
return waitFor(async () => {
|
||||
expect(PluginService.instance().pluginById(plugin.manifest.id)).toBeTruthy();
|
||||
});
|
||||
};
|
||||
|
||||
const webViewId = 'joplin__PluginDialogWebView';
|
||||
const getUserWebViewDom = () => getWebViewDomById(webViewId);
|
||||
|
||||
describe('PluginRunnerWebView', () => {
|
||||
beforeEach(async () => {
|
||||
await setupDatabaseAndSynchronizer(0);
|
||||
@@ -56,16 +67,68 @@ describe('PluginRunnerWebView', () => {
|
||||
`,
|
||||
});
|
||||
render(<WrappedPluginRunnerWebView/>);
|
||||
|
||||
// Should load the plugin
|
||||
await waitFor(async () => {
|
||||
expect(PluginService.instance().pluginById(testPlugin.manifest.id)).toBeTruthy();
|
||||
});
|
||||
await waitForPluginToLoad(testPlugin);
|
||||
|
||||
// Should show the dialog
|
||||
await waitFor(async () => {
|
||||
const dom = await getWebViewDomById('joplin__PluginDialogWebView');
|
||||
const dom = await getUserWebViewDom();
|
||||
expect(dom.querySelector('h1').textContent).toBe('Test!');
|
||||
});
|
||||
});
|
||||
|
||||
test('should load a plugin that adds a panel', async () => {
|
||||
const testPlugin = await createTestPlugin({
|
||||
...defaultManifestProperties,
|
||||
id: 'org.joplinapp.panel-test',
|
||||
}, {
|
||||
onStart: `
|
||||
const panels = joplin.views.panels;
|
||||
const handle = await panels.create('test-panel');
|
||||
await panels.setHtml(
|
||||
handle,
|
||||
'<h1>Panel content</h1><p>Test</p>',
|
||||
);
|
||||
|
||||
const commands = joplin.commands;
|
||||
await commands.register({
|
||||
name: 'hideTestPanel',
|
||||
label: 'Hide the test plugin panel',
|
||||
execute: async () => {
|
||||
await panels.hide(handle);
|
||||
},
|
||||
});
|
||||
|
||||
await commands.register({
|
||||
name: 'showTestPanel',
|
||||
execute: async () => {
|
||||
await panels.show(handle);
|
||||
},
|
||||
});
|
||||
`,
|
||||
});
|
||||
render(<WrappedPluginRunnerWebView/>);
|
||||
await waitForPluginToLoad(testPlugin);
|
||||
|
||||
act(() => {
|
||||
store.dispatch({ type: 'SET_PLUGIN_PANELS_DIALOG_VISIBLE', visible: true });
|
||||
});
|
||||
|
||||
const expectPanelVisible = async () => {
|
||||
const dom = await getUserWebViewDom();
|
||||
await waitFor(async () => {
|
||||
expect(dom.querySelector('h1').textContent).toBe('Panel content');
|
||||
});
|
||||
};
|
||||
await expectPanelVisible();
|
||||
|
||||
// Should hide the panel
|
||||
await act(() => CommandService.instance().execute('hideTestPanel'));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('webViewId')).toBeNull();
|
||||
});
|
||||
|
||||
// Should show the panel again
|
||||
await act(() => CommandService.instance().execute('showTestPanel'));
|
||||
await expectPanelVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -120,7 +120,7 @@ const PluginPanelViewer: React.FC<Props> = props => {
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.webViewContainer}>
|
||||
<View style={styles.webViewContainer} testID='plugin-tab-content'>
|
||||
<PluginUserWebView
|
||||
key={selectedTabId}
|
||||
themeId={props.themeId}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import * as React from 'react';
|
||||
import { ConfigScreenStyles } from '../configScreenStyles';
|
||||
import Icon from '../../../Icon';
|
||||
import BetaChip from '../../../BetaChip';
|
||||
import { TouchableRipple, Text } from 'react-native-paper';
|
||||
import { View } from 'react-native';
|
||||
import Setting, { AppType, SettingMetadataSection } from '@joplin/lib/models/Setting';
|
||||
@@ -21,9 +20,6 @@ const SectionTab: React.FC<Props> = ({ styles, onPress, selected, section }) =>
|
||||
const styleSheet = styles.styleSheet;
|
||||
const titleStyle = selected ? styleSheet.sidebarSelectedButtonText : styleSheet.sidebarButtonMainText;
|
||||
|
||||
const isBeta = section.name === 'plugins';
|
||||
const betaChip = isBeta ? <BetaChip size={10}/> : null;
|
||||
|
||||
return (
|
||||
<TouchableRipple
|
||||
key={section.name}
|
||||
@@ -47,8 +43,6 @@ const SectionTab: React.FC<Props> = ({ styles, onPress, selected, section }) =>
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
|
||||
{betaChip}
|
||||
</View>
|
||||
<Text
|
||||
style={styleSheet.sidebarButtonDescriptionText}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { createTempDir, mockMobilePlatform, setupDatabaseAndSynchronizer, switch
|
||||
import { act, fireEvent, render, screen, userEvent, waitFor } from '../../../../utils/testing/testingLibrary';
|
||||
|
||||
import PluginService, { PluginSettings, defaultPluginSetting } from '@joplin/lib/services/plugins/PluginService';
|
||||
import pluginServiceSetup from './testUtils/pluginServiceSetup';
|
||||
import { writeFile } from 'fs-extra';
|
||||
import { join } from 'path';
|
||||
import shim from '@joplin/lib/shim';
|
||||
@@ -15,6 +14,7 @@ import createMockReduxStore from '../../../../utils/testing/createMockReduxStore
|
||||
import WrappedPluginStates from './testUtils/WrappedPluginStates';
|
||||
import mockRepositoryApiConstructor from './testUtils/mockRepositoryApiConstructor';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import mockPluginServiceSetup from '../../../../utils/testing/mockPluginServiceSetup';
|
||||
|
||||
|
||||
let reduxStore: Store<AppState> = null;
|
||||
@@ -56,7 +56,7 @@ describe('PluginStates.installed', () => {
|
||||
await setupDatabaseAndSynchronizer(0);
|
||||
await switchClient(0);
|
||||
reduxStore = createMockReduxStore();
|
||||
pluginServiceSetup(reduxStore);
|
||||
mockPluginServiceSetup(reduxStore);
|
||||
resetRepoApi();
|
||||
|
||||
await mockMobilePlatform('android');
|
||||
|
||||
@@ -3,13 +3,13 @@ import { mockMobilePlatform, setupDatabaseAndSynchronizer, switchClient } from '
|
||||
|
||||
import { render, screen, userEvent, waitFor } from '../../../../utils/testing/testingLibrary';
|
||||
|
||||
import pluginServiceSetup from './testUtils/pluginServiceSetup';
|
||||
import createMockReduxStore from '../../../../utils/testing/createMockReduxStore';
|
||||
import WrappedPluginStates from './testUtils/WrappedPluginStates';
|
||||
import { AppState } from '../../../../utils/types';
|
||||
import { Store } from 'redux';
|
||||
import mockRepositoryApiConstructor from './testUtils/mockRepositoryApiConstructor';
|
||||
import { resetRepoApi } from './utils/useRepoApi';
|
||||
import mockPluginServiceSetup from '../../../../utils/testing/mockPluginServiceSetup';
|
||||
|
||||
const expectSearchResultCountToBe = async (count: number) => {
|
||||
await waitFor(() => {
|
||||
@@ -37,7 +37,7 @@ describe('PluginStates.search', () => {
|
||||
await setupDatabaseAndSynchronizer(0);
|
||||
await switchClient(0);
|
||||
reduxStore = createMockReduxStore();
|
||||
pluginServiceSetup(reduxStore);
|
||||
mockPluginServiceSetup(reduxStore);
|
||||
mockMobilePlatform('android');
|
||||
resetRepoApi();
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import useRepoApi from './utils/useRepoApi';
|
||||
import RepositoryApi from '@joplin/lib/services/plugins/RepositoryApi';
|
||||
import PluginInfoModal from './PluginInfoModal';
|
||||
import usePluginCallbacks from './utils/usePluginCallbacks';
|
||||
import BetaChip from '../../../BetaChip';
|
||||
import SectionLabel from './SectionLabel';
|
||||
|
||||
interface Props {
|
||||
@@ -191,10 +190,6 @@ const PluginStates: React.FC<Props> = props => {
|
||||
return (
|
||||
<View>
|
||||
{renderRepoApiStatus()}
|
||||
<Banner visible={true} elevation={0} icon={() => <BetaChip size={13}/>}>
|
||||
<Text>Plugin support on mobile is still in beta. Plugins may cause performance issues. Some have only partial support for Joplin mobile.</Text>
|
||||
</Banner>
|
||||
<Divider/>
|
||||
|
||||
{showSearch ? searchSection : null}
|
||||
<View style={styles.installedPluginsContainer}>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
'use strict';
|
||||
Object.defineProperty(exports, '__esModule', { value: true });
|
||||
const PluginService_1 = require('@joplin/lib/services/plugins/PluginService');
|
||||
const BasePluginRunner_1 = require('@joplin/lib/services/plugins/BasePluginRunner');
|
||||
class MockPluginRunner extends BasePluginRunner_1.default {
|
||||
async run() { }
|
||||
async stop() { }
|
||||
}
|
||||
const pluginServiceSetup = (store) => {
|
||||
const runner = new MockPluginRunner();
|
||||
PluginService_1.default.instance().initialize('2.14.0', { joplin: {} }, runner, store);
|
||||
};
|
||||
exports.default = pluginServiceSetup;
|
||||
// # sourceMappingURL=pluginServiceSetup.js.map
|
||||
@@ -17,6 +17,13 @@ interface Props {
|
||||
tags: TagEntity[];
|
||||
}
|
||||
|
||||
const modalPropOverrides = {
|
||||
scrollOverflow: {
|
||||
// Prevent the keyboard from auto-dismissing when tapping outside the search input
|
||||
keyboardShouldPersistTaps: true,
|
||||
},
|
||||
};
|
||||
|
||||
const NoteTagsDialogComponent: React.FC<Props> = props => {
|
||||
const [noteId, setNoteId] = useState(props.noteId);
|
||||
const [savingTags, setSavingTags] = useState(false);
|
||||
@@ -57,6 +64,7 @@ const NoteTagsDialogComponent: React.FC<Props> = props => {
|
||||
buttonBarEnabled={!savingTags}
|
||||
okTitle={_('Apply')}
|
||||
cancelTitle={_('Cancel')}
|
||||
modalProps={modalPropOverrides}
|
||||
>
|
||||
<TagEditor
|
||||
themeId={props.themeId}
|
||||
@@ -64,6 +72,7 @@ const NoteTagsDialogComponent: React.FC<Props> = props => {
|
||||
allTags={props.tags}
|
||||
onTagsChange={setNoteTags}
|
||||
mode={TagEditorMode.Large}
|
||||
searchResultProps={{ nestedScrollEnabled: true }}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</ModalDialog>;
|
||||
|
||||
@@ -1,30 +1,62 @@
|
||||
import { createEditor } from '@joplin/editor/CodeMirror';
|
||||
import { focus } from '@joplin/lib/utils/focusHandler';
|
||||
import WebViewToRNMessenger from '../../utils/ipc/WebViewToRNMessenger';
|
||||
import { EditorProcessApi, EditorProps, MainProcessApi } from './types';
|
||||
import { EditorProcessApi, EditorProps, EditorWithParentProps, ExportedWebViewGlobals, MainProcessApi } from './types';
|
||||
import readFileToBase64 from '../utils/readFileToBase64';
|
||||
import { EditorControl } from '@joplin/editor/types';
|
||||
import { EditorEventType } from '@joplin/editor/events';
|
||||
import InMemoryCache from '@joplin/renderer/InMemoryCache';
|
||||
|
||||
export { default as setUpLogger } from '../utils/setUpLogger';
|
||||
|
||||
export const initializeEditor = ({
|
||||
parentElementClassName,
|
||||
interface ExtendedWindow extends ExportedWebViewGlobals, Window { }
|
||||
declare const window: ExtendedWindow;
|
||||
|
||||
let mainEditor: EditorControl|null = null;
|
||||
let allEditors: EditorControl[] = [];
|
||||
const messenger = new WebViewToRNMessenger<EditorProcessApi, MainProcessApi>('markdownEditor', {
|
||||
get mainEditor() {
|
||||
return mainEditor;
|
||||
},
|
||||
updatePlugins(contentScripts) {
|
||||
for (const editor of allEditors) {
|
||||
void editor.setContentScripts(contentScripts);
|
||||
}
|
||||
},
|
||||
updateSettings(settings) {
|
||||
for (const editor of allEditors) {
|
||||
editor.updateSettings(settings);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
export const createEditorWithParent = ({
|
||||
parentElementOrClassName,
|
||||
initialText,
|
||||
initialNoteId,
|
||||
settings,
|
||||
onLocalize,
|
||||
}: EditorProps) => {
|
||||
const messenger = new WebViewToRNMessenger<EditorProcessApi, MainProcessApi>('markdownEditor', null);
|
||||
|
||||
const parentElement = document.getElementsByClassName(parentElementClassName)[0] as HTMLElement;
|
||||
onEvent,
|
||||
}: EditorWithParentProps) => {
|
||||
const parentElement = (() => {
|
||||
if (parentElementOrClassName instanceof HTMLElement) {
|
||||
return parentElementOrClassName;
|
||||
}
|
||||
return document.getElementsByClassName(parentElementOrClassName)[0] as HTMLElement;
|
||||
})();
|
||||
if (!parentElement) {
|
||||
throw new Error(`Unable to find parent element for editor (class name: ${JSON.stringify(parentElementClassName)})`);
|
||||
throw new Error(`Unable to find parent element for editor (class name: ${JSON.stringify(parentElementOrClassName)})`);
|
||||
}
|
||||
|
||||
// resolveImageSrc can be called frequently for the same image. To avoid unnecessary IPC,
|
||||
// use an InMemoryCache.
|
||||
const resolvedImageSrcCache = new InMemoryCache();
|
||||
|
||||
const control = createEditor(parentElement, {
|
||||
initialText,
|
||||
initialNoteId,
|
||||
settings,
|
||||
onLocalize,
|
||||
onLocalize: messenger.remoteApi.onLocalize,
|
||||
|
||||
onPasteFile: async (data) => {
|
||||
const base64 = await readFileToBase64(data);
|
||||
@@ -34,11 +66,37 @@ export const initializeEditor = ({
|
||||
onLogMessage: message => {
|
||||
void messenger.remoteApi.logMessage(message);
|
||||
},
|
||||
onEvent: (event): void => {
|
||||
void messenger.remoteApi.onEditorEvent(event);
|
||||
onEvent: (event) => {
|
||||
onEvent(event);
|
||||
|
||||
if (event.kind === EditorEventType.Remove) {
|
||||
allEditors = allEditors.filter(other => other !== control);
|
||||
}
|
||||
},
|
||||
resolveImageSrc: (src) => {
|
||||
return messenger.remoteApi.onResolveImageSrc(src);
|
||||
resolveImageSrc: async (src, reloadCounter) => {
|
||||
const cacheKey = `cachedImage.${reloadCounter}.${src}`;
|
||||
const cachedValue = resolvedImageSrcCache.value(cacheKey);
|
||||
if (cachedValue) {
|
||||
return cachedValue;
|
||||
}
|
||||
|
||||
const result = messenger.remoteApi.onResolveImageSrc(src, reloadCounter);
|
||||
resolvedImageSrcCache.setValue(cacheKey, result);
|
||||
return result;
|
||||
},
|
||||
});
|
||||
|
||||
allEditors.push(control);
|
||||
void messenger.remoteApi.onEditorAdded();
|
||||
|
||||
return control;
|
||||
};
|
||||
|
||||
export const createMainEditor = (props: EditorProps) => {
|
||||
const control = createEditorWithParent({
|
||||
...props,
|
||||
onEvent: (event) => {
|
||||
void messenger.remoteApi.onEditorEvent(event);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -57,6 +115,7 @@ export const initializeEditor = ({
|
||||
|
||||
// Note: Just adding an onclick listener seems sufficient to focus the editor when its background
|
||||
// is tapped.
|
||||
const parentElement = control.editor.dom.parentElement;
|
||||
parentElement.addEventListener('click', (event) => {
|
||||
const activeElement = document.querySelector(':focus');
|
||||
if (!parentElement.contains(activeElement) && event.target === parentElement) {
|
||||
@@ -64,8 +123,9 @@ export const initializeEditor = ({
|
||||
}
|
||||
});
|
||||
|
||||
messenger.setLocalInterface({
|
||||
editor: control,
|
||||
});
|
||||
mainEditor = control;
|
||||
return control;
|
||||
};
|
||||
|
||||
window.createEditorWithParent = createEditorWithParent;
|
||||
window.createMainEditor = createMainEditor;
|
||||
|
||||
@@ -1,8 +1,29 @@
|
||||
import { EditorEvent } from '@joplin/editor/events';
|
||||
import { EditorControl, EditorSettings, OnLocalize } from '@joplin/editor/types';
|
||||
import { ContentScriptData, EditorControl, EditorSettings, LocalizationResult } from '@joplin/editor/types';
|
||||
|
||||
|
||||
export interface EditorProps {
|
||||
parentElementOrClassName: HTMLElement|string;
|
||||
initialText: string;
|
||||
initialNoteId: string|null;
|
||||
settings: EditorSettings;
|
||||
}
|
||||
|
||||
export interface EditorWithParentProps extends EditorProps {
|
||||
onEvent: (editorEvent: EditorEvent)=> void;
|
||||
}
|
||||
|
||||
// The Markdown editor exposes global functions within its <WebView>.
|
||||
// These functions can be used externally.
|
||||
export interface ExportedWebViewGlobals {
|
||||
createEditorWithParent: (options: EditorWithParentProps)=> EditorControl;
|
||||
createMainEditor: (props: EditorProps)=> EditorControl;
|
||||
}
|
||||
|
||||
export interface EditorProcessApi {
|
||||
editor: EditorControl;
|
||||
mainEditor: EditorControl;
|
||||
updateSettings: (settings: EditorSettings)=> void;
|
||||
updatePlugins: (contentScripts: ContentScriptData[])=> void;
|
||||
}
|
||||
|
||||
export interface SelectionRange {
|
||||
@@ -10,17 +31,11 @@ export interface SelectionRange {
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface EditorProps {
|
||||
parentElementClassName: string;
|
||||
initialText: string;
|
||||
initialNoteId: string;
|
||||
onLocalize: OnLocalize;
|
||||
settings: EditorSettings;
|
||||
}
|
||||
|
||||
export interface MainProcessApi {
|
||||
onLocalize(text: string): LocalizationResult;
|
||||
onEditorEvent(event: EditorEvent): Promise<void>;
|
||||
onEditorAdded(): Promise<void>;
|
||||
logMessage(message: string): Promise<void>;
|
||||
onPasteFile(type: string, dataBase64: string): Promise<void>;
|
||||
onResolveImageSrc(src: string): Promise<string|null>;
|
||||
onResolveImageSrc(src: string, reloadCounter: number): Promise<string|null>;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ import { OnMessageEvent, WebViewControl } from '../../components/ExtendedWebView
|
||||
import { EditorEvent } from '@joplin/editor/events';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import RNToWebViewMessenger from '../../utils/ipc/RNToWebViewMessenger';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
|
||||
import useCodeMirrorPlugins from './utils/useCodeMirrorPlugins';
|
||||
import Resource from '@joplin/lib/models/Resource';
|
||||
import { parseResourceUrl } from '@joplin/lib/urlUtils';
|
||||
const { isImageMimeType } = require('@joplin/lib/resourceUtils');
|
||||
@@ -15,9 +18,10 @@ const logger = Logger.create('markdownEditor');
|
||||
|
||||
interface Props {
|
||||
editorOptions: EditorOptions;
|
||||
initialSelection: SelectionRange;
|
||||
initialSelection: SelectionRange|null;
|
||||
noteHash: string;
|
||||
globalSearch: string;
|
||||
pluginStates: PluginStates;
|
||||
onEditorEvent: (event: EditorEvent)=> void;
|
||||
onAttachFile: (mime: string, base64: string)=> void;
|
||||
|
||||
@@ -33,9 +37,11 @@ const defaultSearchState: SearchState = {
|
||||
dialogVisible: false,
|
||||
};
|
||||
|
||||
type Result = SetUpResult<EditorProcessApi> & { hasPlugins: boolean };
|
||||
|
||||
const useWebViewSetup = ({
|
||||
editorOptions, initialSelection, noteHash, globalSearch, webviewRef, onEditorEvent, onAttachFile,
|
||||
}: Props): SetUpResult<EditorProcessApi> => {
|
||||
editorOptions, pluginStates, initialSelection, noteHash, globalSearch, webviewRef, onEditorEvent, onAttachFile,
|
||||
}: Props): Result => {
|
||||
const setInitialSelectionJs = initialSelection ? `
|
||||
cm.select(${initialSelection.start}, ${initialSelection.end});
|
||||
cm.execCommand('scrollSelectionIntoView');
|
||||
@@ -51,20 +57,21 @@ const useWebViewSetup = ({
|
||||
` : '';
|
||||
|
||||
const injectedJavaScript = useMemo(() => `
|
||||
if (typeof markdownEditorBundle === 'undefined') {
|
||||
${shim.injectedJs('markdownEditorBundle')};
|
||||
window.markdownEditorBundle = markdownEditorBundle;
|
||||
markdownEditorBundle.setUpLogger();
|
||||
}
|
||||
|
||||
if (!window.cm) {
|
||||
const parentClassName = ${JSON.stringify(editorOptions.parentElementClassName)};
|
||||
const foundParent = document.getElementsByClassName(parentClassName).length > 0;
|
||||
const parentClassName = ${JSON.stringify(editorOptions?.parentElementOrClassName)};
|
||||
const foundParent = !!parentClassName && document.getElementsByClassName(parentClassName).length > 0;
|
||||
|
||||
// On Android, injectedJavaScript can be run multiple times, including once before the
|
||||
// document has loaded. To avoid logging an error each time the editor starts, don't throw
|
||||
// if the parent element can't be found:
|
||||
if (foundParent) {
|
||||
${shim.injectedJs('markdownEditorBundle')};
|
||||
markdownEditorBundle.setUpLogger();
|
||||
|
||||
window.cm = markdownEditorBundle.initializeEditor(
|
||||
${JSON.stringify(editorOptions)}
|
||||
);
|
||||
window.cm = markdownEditorBundle.createMainEditor(${JSON.stringify(editorOptions)});
|
||||
|
||||
${jumpToHashJs}
|
||||
// Set the initial selection after jumping to the header -- the initial selection,
|
||||
@@ -75,7 +82,7 @@ const useWebViewSetup = ({
|
||||
window.onresize = () => {
|
||||
cm.execCommand('scrollSelectionIntoView');
|
||||
};
|
||||
} else {
|
||||
} else if (parentClassName) {
|
||||
console.log('No parent element found with class name ', parentClassName);
|
||||
}
|
||||
}
|
||||
@@ -101,6 +108,10 @@ const useWebViewSetup = ({
|
||||
const onAttachRef = useRef(onAttachFile);
|
||||
onAttachRef.current = onAttachFile;
|
||||
|
||||
const codeMirrorPlugins = useCodeMirrorPlugins(pluginStates);
|
||||
const codeMirrorPluginsRef = useRef(codeMirrorPlugins);
|
||||
codeMirrorPluginsRef.current = codeMirrorPlugins;
|
||||
|
||||
const editorMessenger = useMemo(() => {
|
||||
const localApi: MainProcessApi = {
|
||||
async onEditorEvent(event) {
|
||||
@@ -112,7 +123,14 @@ const useWebViewSetup = ({
|
||||
async onPasteFile(type, data) {
|
||||
onAttachRef.current(type, data);
|
||||
},
|
||||
async onResolveImageSrc(src) {
|
||||
async onLocalize(text) {
|
||||
const localizationFunction = _;
|
||||
return localizationFunction(text);
|
||||
},
|
||||
async onEditorAdded() {
|
||||
messenger.remoteApi.updatePlugins(codeMirrorPluginsRef.current);
|
||||
},
|
||||
async onResolveImageSrc(src, reloadCounter) {
|
||||
const url = parseResourceUrl(src);
|
||||
if (!url.itemId) return null;
|
||||
const item = await Resource.load(url.itemId);
|
||||
@@ -126,7 +144,8 @@ const useWebViewSetup = ({
|
||||
}
|
||||
return null;
|
||||
} else {
|
||||
return Resource.fullPath(item);
|
||||
const path = Resource.fullPath(item);
|
||||
return reloadCounter ? `${path}?r=${reloadCounter}` : path;
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -153,17 +172,22 @@ const useWebViewSetup = ({
|
||||
|
||||
const editorSettings = editorOptions.settings;
|
||||
useEffect(() => {
|
||||
api.editor.updateSettings(editorSettings);
|
||||
api.updateSettings(editorSettings);
|
||||
}, [api, editorSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
api.updatePlugins(codeMirrorPlugins);
|
||||
}, [codeMirrorPlugins, api]);
|
||||
|
||||
return useMemo(() => ({
|
||||
pageSetup: {
|
||||
js: injectedJavaScript,
|
||||
css: '',
|
||||
},
|
||||
hasPlugins: codeMirrorPlugins.length > 0,
|
||||
api,
|
||||
webViewEventHandlers,
|
||||
}), [injectedJavaScript, api, webViewEventHandlers]);
|
||||
}), [injectedJavaScript, api, webViewEventHandlers, codeMirrorPlugins]);
|
||||
};
|
||||
|
||||
export default useWebViewSetup;
|
||||
|
||||
@@ -17,6 +17,7 @@ import Resource from '@joplin/lib/models/Resource';
|
||||
import { ResourceInfos } from '@joplin/renderer/types';
|
||||
import useContentScripts from './utils/useContentScripts';
|
||||
import uuid from '@joplin/lib/uuid';
|
||||
import AsyncActionQueue from '@joplin/lib/AsyncActionQueue';
|
||||
|
||||
const logger = Logger.create('renderer/useWebViewSetup');
|
||||
|
||||
@@ -149,6 +150,8 @@ const useWebViewSetup = (props: Props): SetUpResult<RendererControl> => {
|
||||
void messenger.remoteApi.renderer.setExtraContentScriptsAndRerender(contentScripts);
|
||||
}, [messenger, contentScripts]);
|
||||
|
||||
const onRerenderRequestRef = useRef(()=>{});
|
||||
|
||||
const rendererControl = useMemo((): RendererControl => {
|
||||
const renderer = messenger.remoteApi.renderer;
|
||||
|
||||
@@ -185,7 +188,7 @@ const useWebViewSetup = (props: Props): SetUpResult<RendererControl> => {
|
||||
};
|
||||
|
||||
let settingsChanged = false;
|
||||
const settings: RenderSettings = {
|
||||
const getSettings = (): RenderSettings => ({
|
||||
...options,
|
||||
codeTheme: theme.codeThemeCss,
|
||||
// We .stringify the theme to avoid a JSON serialization error involving
|
||||
@@ -201,6 +204,7 @@ const useWebViewSetup = (props: Props): SetUpResult<RendererControl> => {
|
||||
const key = `${pluginId}.${settingKey}`;
|
||||
if (!pluginSettingKeysRef.current.has(key)) {
|
||||
pluginSettingKeysRef.current.add(key);
|
||||
onRerenderRequestRef.current();
|
||||
settingsChanged = true;
|
||||
}
|
||||
},
|
||||
@@ -220,12 +224,12 @@ const useWebViewSetup = (props: Props): SetUpResult<RendererControl> => {
|
||||
return shim.fsDriver().fileAtPath(resolvedPath);
|
||||
},
|
||||
removeUnusedPluginAssets: options.removeUnusedPluginAssets,
|
||||
};
|
||||
});
|
||||
|
||||
await transferResources(options.resources);
|
||||
|
||||
return {
|
||||
settings,
|
||||
getSettings,
|
||||
getSettingsChanged() {
|
||||
return settingsChanged;
|
||||
},
|
||||
@@ -234,23 +238,28 @@ const useWebViewSetup = (props: Props): SetUpResult<RendererControl> => {
|
||||
|
||||
return {
|
||||
rerenderToBody: async (markup, options, cancelEvent) => {
|
||||
const { settings, getSettingsChanged } = await prepareRenderer(options);
|
||||
const { getSettings } = await prepareRenderer(options);
|
||||
if (cancelEvent?.cancelled) return null;
|
||||
|
||||
const output = await renderer.rerenderToBody(markup, settings);
|
||||
if (cancelEvent?.cancelled) return null;
|
||||
const render = async () => {
|
||||
if (cancelEvent?.cancelled) return;
|
||||
|
||||
if (getSettingsChanged()) {
|
||||
return await renderer.rerenderToBody(markup, settings);
|
||||
}
|
||||
return output;
|
||||
await renderer.rerenderToBody(markup, getSettings());
|
||||
};
|
||||
|
||||
const queue = new AsyncActionQueue();
|
||||
onRerenderRequestRef.current = async () => {
|
||||
queue.push(render);
|
||||
};
|
||||
|
||||
return await render();
|
||||
},
|
||||
render: async (markup, options) => {
|
||||
const { settings, getSettingsChanged } = await prepareRenderer(options);
|
||||
const output = await renderer.render(markup, settings);
|
||||
const { getSettings, getSettingsChanged } = await prepareRenderer(options);
|
||||
const output = await renderer.render(markup, getSettings());
|
||||
|
||||
if (getSettingsChanged()) {
|
||||
return await renderer.render(markup, settings);
|
||||
return await renderer.render(markup, getSettings());
|
||||
}
|
||||
return output;
|
||||
},
|
||||
|
||||
@@ -7,17 +7,8 @@ import '@joplin/editor/ProseMirror/styles';
|
||||
import readFileToBase64 from '../../utils/readFileToBase64';
|
||||
import { EditorLanguageType } from '@joplin/editor/types';
|
||||
import convertHtmlToMarkdown from './convertHtmlToMarkdown';
|
||||
|
||||
const postprocessHtml = (html: HTMLElement) => {
|
||||
// Fix resource URLs
|
||||
const resources = html.querySelectorAll<HTMLImageElement>('img[data-resource-id]');
|
||||
for (const resource of resources) {
|
||||
const resourceId = resource.getAttribute('data-resource-id');
|
||||
resource.src = `:/${resourceId}`;
|
||||
}
|
||||
|
||||
return html;
|
||||
};
|
||||
import { ExportedWebViewGlobals as MarkdownEditorWebViewGlobals } from '../../markdownEditorBundle/types';
|
||||
import { EditorEventType } from '@joplin/editor/events';
|
||||
|
||||
const wrapHtmlForMarkdownConversion = (html: HTMLElement) => {
|
||||
// Add a container element -- when converting to HTML, Turndown
|
||||
@@ -30,18 +21,19 @@ const wrapHtmlForMarkdownConversion = (html: HTMLElement) => {
|
||||
|
||||
|
||||
const htmlToMarkdown = (html: HTMLElement): string => {
|
||||
html = postprocessHtml(html);
|
||||
|
||||
return convertHtmlToMarkdown(html);
|
||||
};
|
||||
|
||||
export const initialize = async ({
|
||||
settings,
|
||||
initialText,
|
||||
initialNoteId,
|
||||
parentElementClassName,
|
||||
initialSearch,
|
||||
}: EditorProps) => {
|
||||
export const initialize = async (
|
||||
{
|
||||
settings,
|
||||
initialText,
|
||||
initialNoteId,
|
||||
parentElementClassName,
|
||||
initialSearch,
|
||||
}: EditorProps,
|
||||
markdownEditorApi: MarkdownEditorWebViewGlobals,
|
||||
) => {
|
||||
const messenger = new WebViewToRNMessenger<EditorProcessApi, MainProcessApi>('rich-text-editor', null);
|
||||
const parentElement = document.getElementsByClassName(parentElementClassName)[0];
|
||||
if (!parentElement) throw new Error('Parent element not found');
|
||||
@@ -86,29 +78,25 @@ export const initialize = async ({
|
||||
removeUnusedPluginAssets: options.isFullPageRender,
|
||||
});
|
||||
},
|
||||
renderHtmlToMarkup: (node) => {
|
||||
// By default, if `src` is specified on an image, the browser will try to load the image, even if it isn't added
|
||||
// to the DOM. (A similar problem is described here: https://stackoverflow.com/q/62019538).
|
||||
// Since :/resourceId isn't a valid image URI, this results in a large number of warnings. As a workaround,
|
||||
// move the element to a temporary document before processing:
|
||||
const dom = document.implementation.createHTMLDocument();
|
||||
node = dom.importNode(node, true);
|
||||
|
||||
let html: HTMLElement;
|
||||
if ((node instanceof HTMLElement)) {
|
||||
html = node;
|
||||
} else {
|
||||
const container = document.createElement('div');
|
||||
container.appendChild(html);
|
||||
html = container;
|
||||
}
|
||||
|
||||
renderHtmlToMarkup: (html) => {
|
||||
if (settings.language === EditorLanguageType.Markdown) {
|
||||
return htmlToMarkdown(wrapHtmlForMarkdownConversion(html));
|
||||
} else {
|
||||
return postprocessHtml(html).outerHTML;
|
||||
return html.outerHTML;
|
||||
}
|
||||
},
|
||||
}, (parent, language, onChange) => {
|
||||
return markdownEditorApi.createEditorWithParent({
|
||||
initialText: '',
|
||||
initialNoteId: '',
|
||||
parentElementOrClassName: parent,
|
||||
settings: { ...editor.getSettings(), language },
|
||||
onEvent: (event) => {
|
||||
if (event.kind === EditorEventType.Change) {
|
||||
onChange(event.value);
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
editor.setSearchState(initialSearch);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { SetUpResult } from '../types';
|
||||
import { EditorControl, EditorSettings } from '@joplin/editor/types';
|
||||
import RNToWebViewMessenger from '../../utils/ipc/RNToWebViewMessenger';
|
||||
import { EditorProcessApi, EditorProps, MainProcessApi } from './types';
|
||||
import useMarkdownEditorSetup from '../markdownEditorBundle/useWebViewSetup';
|
||||
import useRendererSetup from '../rendererBundle/useWebViewSetup';
|
||||
import { EditorEvent } from '@joplin/editor/events';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
@@ -92,7 +93,10 @@ const useMessenger = (props: UseMessengerProps) => {
|
||||
}, [props.webviewRef]);
|
||||
};
|
||||
|
||||
type UseSourceProps = Props & { renderer: SetUpResult<RendererControl> };
|
||||
type UseSourceProps = Props & {
|
||||
renderer: SetUpResult<RendererControl>;
|
||||
markdownEditor: SetUpResult<unknown>;
|
||||
};
|
||||
|
||||
const useSource = (props: UseSourceProps) => {
|
||||
const propsRef = useRef(props);
|
||||
@@ -100,6 +104,8 @@ const useSource = (props: UseSourceProps) => {
|
||||
|
||||
const rendererJs = props.renderer.pageSetup.js;
|
||||
const rendererCss = props.renderer.pageSetup.css;
|
||||
const markdownEditorJs = props.markdownEditor.pageSetup.js;
|
||||
const markdownEditorCss = props.markdownEditor.pageSetup.css;
|
||||
|
||||
return useMemo(() => {
|
||||
const editorOptions: EditorProps = {
|
||||
@@ -117,6 +123,7 @@ const useSource = (props: UseSourceProps) => {
|
||||
css: `
|
||||
${shim.injectedCss('richTextEditorBundle')}
|
||||
${rendererCss}
|
||||
${markdownEditorCss}
|
||||
|
||||
/* Increase the size of the editor to make it easier to focus the editor. */
|
||||
.prosemirror-editor {
|
||||
@@ -125,19 +132,23 @@ const useSource = (props: UseSourceProps) => {
|
||||
`,
|
||||
js: `
|
||||
${rendererJs}
|
||||
${markdownEditorJs}
|
||||
|
||||
if (!window.richTextEditorCreated) {
|
||||
window.richTextEditorCreated = true;
|
||||
${shim.injectedJs('richTextEditorBundle')}
|
||||
richTextEditorBundle.setUpLogger();
|
||||
richTextEditorBundle.initialize(${JSON.stringify(editorOptions)}).then(function(editor) {
|
||||
richTextEditorBundle.initialize(
|
||||
${JSON.stringify(editorOptions)},
|
||||
window,
|
||||
).then(function(editor) {
|
||||
/* For testing */
|
||||
window.joplinRichTextEditor_ = editor;
|
||||
});
|
||||
}
|
||||
`,
|
||||
};
|
||||
}, [rendererJs, rendererCss]);
|
||||
}, [rendererJs, rendererCss, markdownEditorCss, markdownEditorJs]);
|
||||
};
|
||||
|
||||
const useWebViewSetup = (props: Props): SetUpResult<EditorControl> => {
|
||||
@@ -148,8 +159,23 @@ const useWebViewSetup = (props: Props): SetUpResult<EditorControl> => {
|
||||
pluginStates: props.pluginStates,
|
||||
themeId: props.themeId,
|
||||
});
|
||||
const markdownEditor = useMarkdownEditorSetup({
|
||||
webviewRef: props.webviewRef,
|
||||
onAttachFile: props.onAttachFile,
|
||||
initialSelection: null,
|
||||
noteHash: '',
|
||||
globalSearch: props.globalSearch,
|
||||
editorOptions: {
|
||||
settings: props.settings,
|
||||
initialNoteId: null,
|
||||
parentElementOrClassName: '',
|
||||
initialText: '',
|
||||
},
|
||||
onEditorEvent: (_event)=>{},
|
||||
pluginStates: props.pluginStates,
|
||||
});
|
||||
const messenger = useMessenger({ ...props, renderer });
|
||||
const pageSetup = useSource({ ...props, renderer });
|
||||
const pageSetup = useSource({ ...props, renderer, markdownEditor });
|
||||
|
||||
useEffect(() => {
|
||||
void messenger.remoteApi.editor.updateSettings(props.settings);
|
||||
@@ -163,14 +189,16 @@ const useWebViewSetup = (props: Props): SetUpResult<EditorControl> => {
|
||||
onLoadEnd: () => {
|
||||
messenger.onWebViewLoaded();
|
||||
renderer.webViewEventHandlers.onLoadEnd();
|
||||
markdownEditor.webViewEventHandlers.onLoadEnd();
|
||||
},
|
||||
onMessage: (event) => {
|
||||
messenger.onWebViewMessage(event);
|
||||
renderer.webViewEventHandlers.onMessage(event);
|
||||
markdownEditor.webViewEventHandlers.onMessage(event);
|
||||
},
|
||||
},
|
||||
};
|
||||
}, [messenger, pageSetup, renderer.webViewEventHandlers]);
|
||||
}, [messenger, pageSetup, renderer.webViewEventHandlers, markdownEditor.webViewEventHandlers]);
|
||||
};
|
||||
|
||||
export default useWebViewSetup;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import './utils/polyfills';
|
||||
import { AppRegistry } from 'react-native';
|
||||
import Root from './root';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
|
||||
@@ -363,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";
|
||||
@@ -394,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;
|
||||
@@ -533,7 +535,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 143;
|
||||
CURRENT_PROJECT_VERSION = 145;
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Joplin/Info.plist;
|
||||
@@ -542,7 +544,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 13.4.1;
|
||||
MARKETING_VERSION = 13.4.3;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
@@ -568,7 +570,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 143;
|
||||
CURRENT_PROJECT_VERSION = 145;
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
INFOPLIST_FILE = Joplin/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
|
||||
@@ -576,7 +578,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 13.4.1;
|
||||
MARKETING_VERSION = 13.4.3;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
@@ -769,7 +771,7 @@
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 143;
|
||||
CURRENT_PROJECT_VERSION = 145;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
@@ -780,7 +782,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 13.4.1;
|
||||
MARKETING_VERSION = 13.4.3;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
OTHER_LDFLAGS = (
|
||||
@@ -812,7 +814,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 143;
|
||||
CURRENT_PROJECT_VERSION = 145;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
@@ -823,7 +825,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 13.4.1;
|
||||
MARKETING_VERSION = 13.4.3;
|
||||
MTL_FAST_MATH = YES;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
|
||||
@@ -1458,7 +1458,7 @@ PODS:
|
||||
- Yoga
|
||||
- react-native-get-random-values (1.11.0):
|
||||
- React-Core
|
||||
- react-native-image-picker (7.2.3):
|
||||
- react-native-image-picker (8.0.0):
|
||||
- DoubleConversion
|
||||
- glog
|
||||
- hermes-engine
|
||||
@@ -1514,9 +1514,9 @@ PODS:
|
||||
- Yoga
|
||||
- react-native-rsa-native (2.0.5):
|
||||
- React
|
||||
- react-native-saf-x (3.4.0):
|
||||
- react-native-saf-x (3.4.1):
|
||||
- React-Core
|
||||
- react-native-safe-area-context (5.4.0):
|
||||
- react-native-safe-area-context (5.4.1):
|
||||
- React-Core
|
||||
- react-native-sqlite-storage (6.0.1):
|
||||
- React-Core
|
||||
@@ -2285,7 +2285,7 @@ EXTERNAL SOURCES:
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90
|
||||
DoubleConversion: 76ab83afb40bddeeee456813d9c04f67f78771b5
|
||||
DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb
|
||||
EXAV: ae28256069c4cdde93d185c007d8f68d92902c2e
|
||||
EXConstants: 98bcf0f22b820f9b28f9fee55ff2daededadd2f8
|
||||
Expo: 4b1c6de7c441e1caa1918671ae0aa34d51f019a5
|
||||
@@ -2298,7 +2298,7 @@ SPEC CHECKSUMS:
|
||||
fast_float: 06eeec4fe712a76acc9376682e4808b05ce978b6
|
||||
FBLazyVector: 84b955f7b4da8b895faf5946f73748267347c975
|
||||
fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd
|
||||
glog: c5d68082e772fa1c511173d6b30a9de2c05a69a2
|
||||
glog: 5683914934d5b6e4240e497e0f4a3b42d1854183
|
||||
hermes-engine: 314be5250afa5692b57b4dd1705959e1973a8ebe
|
||||
JoplinCommonShareExtension: a8b60b02704d85a7305627912c0240e94af78db7
|
||||
JoplinRNShareExtension: e158a4b53ee0aa9cd3037a16221dc8adbd6f7860
|
||||
@@ -2338,13 +2338,13 @@ SPEC CHECKSUMS:
|
||||
react-native-document-picker: da39c5e4f279d39c0356dca157b98f9dc349e5bb
|
||||
react-native-geolocation: ec15ffebc53790314885eb9e5f2132132fbc2600
|
||||
react-native-get-random-values: d16467cf726c618e9c7a8c3c39c31faa2244bbba
|
||||
react-native-image-picker: 99fbcec11cf4679170a7cfba4e4d9f598297448c
|
||||
react-native-image-picker: 922b9ba90f144b5866d07d04b0fb2b4e9ab0ed75
|
||||
react-native-image-resizer: 24c5d06fae2176dc0caed4b6396e02befb44064a
|
||||
react-native-netinfo: cec9c4e86083cb5b6aba0e0711f563e2fbbff187
|
||||
react-native-quick-crypto: 988d8d57cd720dbe218272b60775a8e0210d0b80
|
||||
react-native-rsa-native: a7931cdda1f73a8576a46d7f431378c5550f0c38
|
||||
react-native-saf-x: 24ebe9aa153f82ec6726de459ae77508d68d5599
|
||||
react-native-safe-area-context: 9d72abf6d8473da73033b597090a80b709c0b2f1
|
||||
react-native-saf-x: 3f8b52fb8160d7322161dec02a564271cc8f4138
|
||||
react-native-safe-area-context: dde2052b903c11d677c320b599c3244021c34ce8
|
||||
react-native-sqlite-storage: 0c84826214baaa498796c7e46a5ccc9a82e114ed
|
||||
react-native-version-info: f0b04e16111c4016749235ff6d9a757039189141
|
||||
react-native-webview: 1b5778b306d4ed09d13829a6e7a6550e3c1a644a
|
||||
|
||||
@@ -58,15 +58,15 @@
|
||||
"react-native-file-viewer": "2.1.5",
|
||||
"react-native-fs": "2.20.0",
|
||||
"react-native-get-random-values": "1.11.0",
|
||||
"react-native-image-picker": "7.2.3",
|
||||
"react-native-image-picker": "8.0.0",
|
||||
"react-native-localize": "3.4.1",
|
||||
"react-native-modal-datetime-picker": "18.0.0",
|
||||
"react-native-paper": "5.13.5",
|
||||
"react-native-popup-menu": "0.17.0",
|
||||
"react-native-quick-actions": "0.3.13",
|
||||
"react-native-quick-crypto": "0.7.13",
|
||||
"react-native-quick-crypto": "0.7.17",
|
||||
"react-native-rsa-native": "2.0.5",
|
||||
"react-native-safe-area-context": "5.4.0",
|
||||
"react-native-safe-area-context": "5.4.1",
|
||||
"react-native-securerandom": "1.0.1",
|
||||
"react-native-share": "12.0.11",
|
||||
"react-native-sqlite-storage": "6.0.1",
|
||||
@@ -106,7 +106,7 @@
|
||||
"@testing-library/react-native": "13.2.0",
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/node": "18.19.101",
|
||||
"@types/node": "18.19.103",
|
||||
"@types/react": "19.0.14",
|
||||
"@types/react-redux": "7.1.33",
|
||||
"@types/serviceworker": "0.0.135",
|
||||
@@ -115,7 +115,7 @@
|
||||
"babel-loader": "9.1.3",
|
||||
"babel-plugin-module-resolver": "4.1.0",
|
||||
"babel-plugin-react-native-web": "0.20.0",
|
||||
"esbuild": "0.25.4",
|
||||
"esbuild": "0.25.5",
|
||||
"fast-deep-equal": "3.1.3",
|
||||
"fs-extra": "11.2.0",
|
||||
"gulp": "4.0.2",
|
||||
@@ -130,7 +130,7 @@
|
||||
"react-native-web": "0.20.0",
|
||||
"react-refresh": "0.17.0",
|
||||
"react-test-renderer": "19.0.0",
|
||||
"sharp": "0.34.1",
|
||||
"sharp": "0.34.2",
|
||||
"sqlite3": "5.1.6",
|
||||
"timers-browserify": "2.0.12",
|
||||
"ts-jest": "29.3.1",
|
||||
|
||||
@@ -14,7 +14,7 @@ import NoteScreen from './components/screens/Note/Note';
|
||||
import UpgradeSyncTargetScreen from './components/screens/UpgradeSyncTargetScreen';
|
||||
import Setting, { } from '@joplin/lib/models/Setting';
|
||||
import PoorManIntervals from '@joplin/lib/PoorManIntervals';
|
||||
import reducer, { NotesParent, serializeNotesParent } from '@joplin/lib/reducer';
|
||||
import { NotesParent, serializeNotesParent } from '@joplin/lib/reducer';
|
||||
import ShareExtension, { UnsubscribeShareListener } from './utils/ShareExtension';
|
||||
import handleShared from './utils/shareHandler';
|
||||
import { _, setLocale } from '@joplin/lib/locale';
|
||||
@@ -28,7 +28,6 @@ import NetInfo, { NetInfoSubscription } from '@react-native-community/netinfo';
|
||||
const DropdownAlert = require('react-native-dropdownalert').default;
|
||||
import SafeAreaView from './components/SafeAreaView';
|
||||
const { connect, Provider } = require('react-redux');
|
||||
import fastDeepEqual = require('fast-deep-equal');
|
||||
import { Provider as PaperProvider, MD3DarkTheme, MD3LightTheme } from 'react-native-paper';
|
||||
import BackButtonService, { BackButtonHandler } from './services/BackButtonService';
|
||||
import NavService from '@joplin/lib/services/NavService';
|
||||
@@ -95,7 +94,6 @@ import autodetectTheme, { onSystemColorSchemeChange } from './utils/autodetectTh
|
||||
import PluginRunnerWebView from './components/plugins/PluginRunnerWebView';
|
||||
import { refreshFolders, scheduleRefreshFolders } from '@joplin/lib/folders-screen-utils';
|
||||
import ShareManager from './components/screens/ShareManager';
|
||||
import appDefaultState from './utils/appDefaultState';
|
||||
import { setDateFormat, setTimeFormat, setTimeLocale } from '@joplin/utils/time';
|
||||
import DialogManager from './components/DialogManager';
|
||||
import { AppState } from './utils/types';
|
||||
@@ -108,6 +106,7 @@ import NoteRevisionViewer from './components/screens/NoteRevisionViewer';
|
||||
import DocumentScanner from './components/screens/DocumentScanner/DocumentScanner';
|
||||
import buildStartupTasks from './utils/buildStartupTasks';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
import appReducer from './utils/appReducer';
|
||||
|
||||
const logger = Logger.create('root');
|
||||
const perfLogger = PerformanceLogger.create();
|
||||
@@ -235,204 +234,6 @@ const generalMiddleware = (store: any) => (next: any) => async (action: any) =>
|
||||
return result;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const navHistory: any[] = [];
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
function historyCanGoBackTo(route: any) {
|
||||
if (route.routeName === 'Folder') return false;
|
||||
|
||||
// This is an intermediate screen that acts more like a modal -- it should be skipped in the
|
||||
// navigation history.
|
||||
if (route.routeName === 'DocumentScanner') return false;
|
||||
|
||||
// There's no point going back to these screens in general and, at least in OneDrive case,
|
||||
// it can be buggy to do so, due to incorrectly relying on global state (reg.syncTarget...)
|
||||
if (route.routeName === 'OneDriveLogin') return false;
|
||||
if (route.routeName === 'DropboxLogin') return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const appReducer = (state = appDefaultState, action: any) => {
|
||||
let newState = state;
|
||||
let historyGoingBack = false;
|
||||
|
||||
try {
|
||||
switch (action.type) {
|
||||
|
||||
case 'NAV_BACK':
|
||||
case 'NAV_GO':
|
||||
|
||||
if (action.type === 'NAV_BACK') {
|
||||
if (!navHistory.length) break;
|
||||
|
||||
const newAction = navHistory.pop();
|
||||
action = newAction ? newAction : navHistory.pop();
|
||||
|
||||
historyGoingBack = true;
|
||||
}
|
||||
|
||||
{
|
||||
const currentRoute = state.route;
|
||||
|
||||
if (!historyGoingBack && historyCanGoBackTo(currentRoute)) {
|
||||
const previousRoute = navHistory.length && navHistory[navHistory.length - 1];
|
||||
const isDifferentRoute = !previousRoute || !fastDeepEqual(navHistory[navHistory.length - 1], currentRoute);
|
||||
|
||||
// Avoid multiple consecutive duplicate screens in the navigation history -- these can make
|
||||
// pressing "back" seem to have no effect.
|
||||
if (isDifferentRoute) {
|
||||
navHistory.push(currentRoute);
|
||||
}
|
||||
}
|
||||
|
||||
if (action.clearHistory) {
|
||||
navHistory.splice(0, navHistory.length);
|
||||
}
|
||||
|
||||
newState = { ...state };
|
||||
|
||||
newState.selectedNoteHash = '';
|
||||
|
||||
if (action.routeName === 'Search') {
|
||||
newState.notesParentType = 'Search';
|
||||
}
|
||||
|
||||
if ('noteId' in action) {
|
||||
newState.selectedNoteIds = action.noteId ? [action.noteId] : [];
|
||||
}
|
||||
|
||||
if ('folderId' in action) {
|
||||
newState.selectedFolderId = action.folderId;
|
||||
newState.notesParentType = 'Folder';
|
||||
}
|
||||
|
||||
if ('tagId' in action) {
|
||||
newState.selectedTagId = action.tagId;
|
||||
newState.notesParentType = 'Tag';
|
||||
}
|
||||
|
||||
if ('smartFilterId' in action) {
|
||||
newState.smartFilterId = action.smartFilterId;
|
||||
newState.selectedSmartFilterId = action.smartFilterId;
|
||||
newState.notesParentType = 'SmartFilter';
|
||||
}
|
||||
|
||||
if ('itemType' in action) {
|
||||
newState.selectedItemType = action.itemType;
|
||||
}
|
||||
|
||||
if ('noteHash' in action) {
|
||||
newState.selectedNoteHash = action.noteHash;
|
||||
}
|
||||
|
||||
if ('sharedData' in action) {
|
||||
newState.sharedData = action.sharedData;
|
||||
} else {
|
||||
newState.sharedData = null;
|
||||
}
|
||||
|
||||
newState.route = action;
|
||||
newState.historyCanGoBack = !!navHistory.length;
|
||||
|
||||
logger.debug('Navigated to route:', newState.route?.routeName, 'with notesParentType:', newState.notesParentType);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'SIDE_MENU_TOGGLE':
|
||||
|
||||
newState = { ...state };
|
||||
newState.showSideMenu = !newState.showSideMenu;
|
||||
break;
|
||||
|
||||
case 'SIDE_MENU_OPEN':
|
||||
|
||||
newState = { ...state };
|
||||
newState.showSideMenu = true;
|
||||
break;
|
||||
|
||||
case 'SIDE_MENU_CLOSE':
|
||||
|
||||
newState = { ...state };
|
||||
newState.showSideMenu = false;
|
||||
break;
|
||||
|
||||
case 'SET_PLUGIN_PANELS_DIALOG_VISIBLE':
|
||||
newState = { ...state };
|
||||
newState.showPanelsDialog = action.visible;
|
||||
break;
|
||||
|
||||
case 'NOTE_SELECTION_TOGGLE':
|
||||
|
||||
{
|
||||
newState = { ...state };
|
||||
|
||||
const noteId = action.id;
|
||||
const newSelectedNoteIds = state.selectedNoteIds.slice();
|
||||
const existingIndex = state.selectedNoteIds.indexOf(noteId);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
newSelectedNoteIds.splice(existingIndex, 1);
|
||||
} else {
|
||||
newSelectedNoteIds.push(noteId);
|
||||
}
|
||||
|
||||
newState.selectedNoteIds = newSelectedNoteIds;
|
||||
newState.noteSelectionEnabled = !!newSelectedNoteIds.length;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'NOTE_SELECTION_START':
|
||||
|
||||
if (!state.noteSelectionEnabled) {
|
||||
newState = { ...state };
|
||||
newState.noteSelectionEnabled = true;
|
||||
newState.selectedNoteIds = [action.id];
|
||||
}
|
||||
break;
|
||||
|
||||
case 'NOTE_SELECTION_END':
|
||||
|
||||
newState = { ...state };
|
||||
newState.noteSelectionEnabled = false;
|
||||
newState.selectedNoteIds = [];
|
||||
break;
|
||||
|
||||
case 'NOTE_SIDE_MENU_OPTIONS_SET':
|
||||
|
||||
newState = { ...state };
|
||||
newState.noteSideMenuOptions = action.options;
|
||||
break;
|
||||
|
||||
case 'SET_SIDE_MENU_TOUCH_GESTURES_DISABLED':
|
||||
newState = { ...state };
|
||||
newState.disableSideMenuGestures = action.disableSideMenuGestures;
|
||||
break;
|
||||
|
||||
case 'MOBILE_DATA_WARNING_UPDATE':
|
||||
|
||||
newState = { ...state };
|
||||
newState.isOnMobileData = action.isOnMobileData;
|
||||
break;
|
||||
|
||||
case 'KEYBOARD_VISIBLE_CHANGE':
|
||||
newState = { ...state, keyboardVisible: action.visible };
|
||||
break;
|
||||
|
||||
case 'NOTE_EDITOR_VISIBLE_CHANGE':
|
||||
newState = { ...state, noteEditorVisible: action.visible };
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
error.message = `In reducer: ${error.message} Action: ${JSON.stringify(action)}`;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return reducer(newState, action) as AppState;
|
||||
};
|
||||
|
||||
const store = createStore(appReducer, applyMiddleware(generalMiddleware));
|
||||
storeDispatch = store.dispatch;
|
||||
|
||||
@@ -975,46 +776,46 @@ class AppComponent extends React.Component<AppComponentProps, AppComponentState>
|
||||
// Wrap everything in a PaperProvider -- this allows using components from react-native-paper
|
||||
return (
|
||||
<FocusControl.Provider>
|
||||
<PaperProvider theme={{
|
||||
...paperTheme,
|
||||
version: 3,
|
||||
colors: {
|
||||
...paperTheme.colors,
|
||||
onPrimaryContainer: theme.color5,
|
||||
primaryContainer: theme.backgroundColor5,
|
||||
<MenuProvider
|
||||
style={{ flex: 1 }}
|
||||
closeButtonLabel={_('Dismiss')}
|
||||
>
|
||||
<PaperProvider theme={{
|
||||
...paperTheme,
|
||||
version: 3,
|
||||
colors: {
|
||||
...paperTheme.colors,
|
||||
onPrimaryContainer: theme.color5,
|
||||
primaryContainer: theme.backgroundColor5,
|
||||
|
||||
outline: theme.codeBorderColor,
|
||||
outline: theme.codeBorderColor,
|
||||
|
||||
primary: theme.color4,
|
||||
onPrimary: theme.backgroundColor4,
|
||||
primary: theme.color4,
|
||||
onPrimary: theme.backgroundColor4,
|
||||
|
||||
background: theme.backgroundColor,
|
||||
background: theme.backgroundColor,
|
||||
|
||||
surface: theme.backgroundColor,
|
||||
onSurface: theme.color,
|
||||
surface: theme.backgroundColor,
|
||||
onSurface: theme.color,
|
||||
|
||||
secondaryContainer: theme.raisedBackgroundColor,
|
||||
onSecondaryContainer: theme.raisedColor,
|
||||
secondaryContainer: theme.raisedBackgroundColor,
|
||||
onSecondaryContainer: theme.raisedColor,
|
||||
|
||||
surfaceVariant: theme.backgroundColor3,
|
||||
onSurfaceVariant: theme.color3,
|
||||
surfaceVariant: theme.backgroundColor3,
|
||||
onSurfaceVariant: theme.color3,
|
||||
|
||||
elevation: {
|
||||
level0: 'transparent',
|
||||
level1: theme.oddBackgroundColor,
|
||||
level2: theme.raisedBackgroundColor,
|
||||
level3: theme.raisedBackgroundColor,
|
||||
level4: theme.raisedBackgroundColor,
|
||||
level5: theme.raisedBackgroundColor,
|
||||
elevation: {
|
||||
level0: 'transparent',
|
||||
level1: theme.oddBackgroundColor,
|
||||
level2: theme.raisedBackgroundColor,
|
||||
level3: theme.raisedBackgroundColor,
|
||||
level4: theme.raisedBackgroundColor,
|
||||
level5: theme.raisedBackgroundColor,
|
||||
},
|
||||
},
|
||||
},
|
||||
}}>
|
||||
<DialogManager themeId={this.props.themeId}>
|
||||
<StatusBar barStyle={statusBarStyle} />
|
||||
<MenuProvider
|
||||
style={{ flex: 1 }}
|
||||
closeButtonLabel={_('Dismiss')}
|
||||
>
|
||||
}}>
|
||||
<DialogManager themeId={this.props.themeId}>
|
||||
<StatusBar barStyle={statusBarStyle} />
|
||||
<SafeAreaProvider>
|
||||
<FocusControl.MainAppContent style={{ flex: 1 }}>
|
||||
{shouldShowMainContent ? mainContent : (
|
||||
@@ -1028,9 +829,9 @@ class AppComponent extends React.Component<AppComponentProps, AppComponentState>
|
||||
)}
|
||||
</FocusControl.MainAppContent>
|
||||
</SafeAreaProvider>
|
||||
</MenuProvider>
|
||||
</DialogManager>
|
||||
</PaperProvider>
|
||||
</DialogManager>
|
||||
</PaperProvider>
|
||||
</MenuProvider>
|
||||
</FocusControl.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,11 +7,17 @@ import libStateToWhenClauseContext, { WhenClauseContextOptions } from '@joplin/l
|
||||
import { AppState } from '../../utils/types';
|
||||
|
||||
const stateToWhenClauseContext = (state: AppState, options: WhenClauseContextOptions = null) => {
|
||||
const markdownEditorVisible = state.noteEditorVisible && state.settings['editor.codeView'];
|
||||
const richTextEditorVisible = state.noteEditorVisible && !state.settings['editor.codeView'];
|
||||
return {
|
||||
...libStateToWhenClauseContext(state, options),
|
||||
keyboardVisible: state.keyboardVisible,
|
||||
markdownEditorVisible: state.noteEditorVisible && state.settings['editor.codeView'],
|
||||
richTextEditorVisible: state.noteEditorVisible && !state.settings['editor.codeView'],
|
||||
|
||||
// Provide both markdownEditorPaneVisible and markdownEditorVisible for compatibility
|
||||
// with the desktop app.
|
||||
markdownEditorPaneVisible: markdownEditorVisible,
|
||||
markdownEditorVisible: markdownEditorVisible,
|
||||
richTextEditorVisible: richTextEditorVisible,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,46 +1,43 @@
|
||||
import { RSA } from '@joplin/lib/services/e2ee/types';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import buildRsaCryptoProvider from '@joplin/lib/services/e2ee/ppk/webCrypto/buildRsaCryptoProvider';
|
||||
import { WebCryptoSlice } from '@joplin/lib/services/e2ee/ppk/webCrypto/WebCryptoRsa';
|
||||
import { CiphertextBuffer, PublicKeyAlgorithm, PublicKeyCrypto, PublicKeyCryptoProvider } from '@joplin/lib/services/e2ee/types';
|
||||
import QuickCrypto from 'react-native-quick-crypto';
|
||||
const RnRSA = require('react-native-rsa-native').RSA;
|
||||
|
||||
interface RSAKeyPair {
|
||||
interface LegacyRsaKeyPair {
|
||||
public: string;
|
||||
private: string;
|
||||
keySizeBits: number;
|
||||
}
|
||||
|
||||
const logger = Logger.create('RSA');
|
||||
|
||||
const rsa: RSA = {
|
||||
|
||||
generateKeyPair: async (keySize: number): Promise<RSAKeyPair> => {
|
||||
if (shim.mobilePlatform() === 'web') {
|
||||
// TODO: Try to implement with SubtleCrypto. May require migrating the RSA algorithm used on
|
||||
// desktop and mobile (which is not supported on web). See commit 12adcd9dbc3f723bac36ff4447701573084c4694.
|
||||
logger.warn('RSA on web is not yet supported.');
|
||||
return null;
|
||||
}
|
||||
|
||||
const keys: RSAKeyPair = await RnRSA.generateKeys(keySize);
|
||||
const legacyRsa: PublicKeyCrypto = {
|
||||
generateKeyPair: async () => {
|
||||
const keySize = 2048;
|
||||
const keys: LegacyRsaKeyPair = await RnRSA.generateKeys(keySize);
|
||||
|
||||
// Sanity check
|
||||
if (!keys.private) throw new Error('No private key was generated');
|
||||
if (!keys.public) throw new Error('No public key was generated');
|
||||
|
||||
return rsa.loadKeys(keys.public, keys.private, keySize);
|
||||
const keyPair = await legacyRsa.loadKeys(keys.public, keys.private, keySize);
|
||||
return {
|
||||
keyPair,
|
||||
keySize,
|
||||
};
|
||||
},
|
||||
|
||||
loadKeys: async (publicKey: string, privateKey: string, keySizeBits: number): Promise<RSAKeyPair> => {
|
||||
maximumPlaintextLengthBytes: 190,
|
||||
|
||||
loadKeys: async (publicKey: string, privateKey: string, keySizeBits: number): Promise<LegacyRsaKeyPair> => {
|
||||
return { public: publicKey, private: privateKey, keySizeBits };
|
||||
},
|
||||
|
||||
encrypt: async (plaintextUtf8: string, rsaKeyPair: RSAKeyPair): Promise<string> => {
|
||||
encrypt: async (plaintextUtf8: string, rsaKeyPair: LegacyRsaKeyPair) => {
|
||||
// TODO: Support long-data encryption in a way compatible with node-rsa.
|
||||
return RnRSA.encrypt(plaintextUtf8, rsaKeyPair.public);
|
||||
return Buffer.from(await RnRSA.encrypt(plaintextUtf8, rsaKeyPair.public), 'base64');
|
||||
},
|
||||
|
||||
decrypt: async (ciphertextBase64: string, rsaKeyPair: RSAKeyPair): Promise<string> => {
|
||||
const ciphertextBuffer = Buffer.from(ciphertextBase64, 'base64');
|
||||
decrypt: async (ciphertextBuffer: CiphertextBuffer, rsaKeyPair: LegacyRsaKeyPair): Promise<string> => {
|
||||
const maximumEncryptedSize = Math.floor(rsaKeyPair.keySizeBits / 8); // Usually 256
|
||||
|
||||
// On iOS, .decrypt fails without throwing or rejecting.
|
||||
@@ -75,20 +72,26 @@ const rsa: RSA = {
|
||||
}
|
||||
return result.join('');
|
||||
} else {
|
||||
const plainText = await RnRSA.decrypt(ciphertextBase64, rsaKeyPair.private);
|
||||
const plainText = await RnRSA.decrypt(ciphertextBuffer.toString('base64'), rsaKeyPair.private);
|
||||
handleError(plainText);
|
||||
return plainText;
|
||||
}
|
||||
},
|
||||
|
||||
publicKey: (rsaKeyPair: RSAKeyPair): string => {
|
||||
publicKey: async (rsaKeyPair: LegacyRsaKeyPair) => {
|
||||
return rsaKeyPair.public;
|
||||
},
|
||||
|
||||
privateKey: (rsaKeyPair: RSAKeyPair): string => {
|
||||
privateKey: async (rsaKeyPair: LegacyRsaKeyPair) => {
|
||||
return rsaKeyPair.private;
|
||||
},
|
||||
};
|
||||
|
||||
const rsa: PublicKeyCryptoProvider = {
|
||||
[PublicKeyAlgorithm.Unknown]: null,
|
||||
[PublicKeyAlgorithm.RsaV1]: legacyRsa,
|
||||
[PublicKeyAlgorithm.RsaV2]: buildRsaCryptoProvider(PublicKeyAlgorithm.RsaV2, QuickCrypto as WebCryptoSlice),
|
||||
[PublicKeyAlgorithm.RsaV3]: buildRsaCryptoProvider(PublicKeyAlgorithm.RsaV3, QuickCrypto as WebCryptoSlice),
|
||||
};
|
||||
|
||||
export default rsa;
|
||||
|
||||
11
packages/app-mobile/services/e2ee/RSA.react-native.web.ts
Normal file
11
packages/app-mobile/services/e2ee/RSA.react-native.web.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import buildRsaCryptoProvider from '@joplin/lib/services/e2ee/ppk/webCrypto/buildRsaCryptoProvider';
|
||||
import { PublicKeyAlgorithm, PublicKeyCryptoProvider } from '@joplin/lib/services/e2ee/types';
|
||||
|
||||
const rsa: PublicKeyCryptoProvider = {
|
||||
[PublicKeyAlgorithm.Unknown]: null,
|
||||
[PublicKeyAlgorithm.RsaV1]: null, // Unsupported on web
|
||||
[PublicKeyAlgorithm.RsaV2]: buildRsaCryptoProvider(PublicKeyAlgorithm.RsaV2, crypto),
|
||||
[PublicKeyAlgorithm.RsaV3]: buildRsaCryptoProvider(PublicKeyAlgorithm.RsaV3, crypto),
|
||||
};
|
||||
|
||||
export default rsa;
|
||||
207
packages/app-mobile/utils/appReducer.ts
Normal file
207
packages/app-mobile/utils/appReducer.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import reducer from '@joplin/lib/reducer';
|
||||
import { AppState } from './types';
|
||||
import appDefaultState from './appDefaultState';
|
||||
import fastDeepEqual = require('fast-deep-equal');
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
|
||||
const logger = Logger.create('appReducer');
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const navHistory: any[] = [];
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
function historyCanGoBackTo(route: any) {
|
||||
if (route.routeName === 'Folder') return false;
|
||||
|
||||
// This is an intermediate screen that acts more like a modal -- it should be skipped in the
|
||||
// navigation history.
|
||||
if (route.routeName === 'DocumentScanner') return false;
|
||||
|
||||
// There's no point going back to these screens in general and, at least in OneDrive case,
|
||||
// it can be buggy to do so, due to incorrectly relying on global state (reg.syncTarget...)
|
||||
if (route.routeName === 'OneDriveLogin') return false;
|
||||
if (route.routeName === 'DropboxLogin') return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const appReducer = (state = appDefaultState, action: any) => {
|
||||
let newState = state;
|
||||
let historyGoingBack = false;
|
||||
|
||||
try {
|
||||
switch (action.type) {
|
||||
|
||||
case 'NAV_BACK':
|
||||
case 'NAV_GO':
|
||||
|
||||
if (action.type === 'NAV_BACK') {
|
||||
if (!navHistory.length) break;
|
||||
|
||||
const newAction = navHistory.pop();
|
||||
action = newAction ? newAction : navHistory.pop();
|
||||
|
||||
historyGoingBack = true;
|
||||
}
|
||||
|
||||
{
|
||||
const currentRoute = state.route;
|
||||
|
||||
if (!historyGoingBack && historyCanGoBackTo(currentRoute)) {
|
||||
const previousRoute = navHistory.length && navHistory[navHistory.length - 1];
|
||||
const isDifferentRoute = !previousRoute || !fastDeepEqual(navHistory[navHistory.length - 1], currentRoute);
|
||||
|
||||
// Avoid multiple consecutive duplicate screens in the navigation history -- these can make
|
||||
// pressing "back" seem to have no effect.
|
||||
if (isDifferentRoute) {
|
||||
navHistory.push(currentRoute);
|
||||
}
|
||||
}
|
||||
|
||||
if (action.clearHistory) {
|
||||
navHistory.splice(0, navHistory.length);
|
||||
}
|
||||
|
||||
newState = { ...state };
|
||||
|
||||
newState.selectedNoteHash = '';
|
||||
|
||||
if (action.routeName === 'Search') {
|
||||
newState.notesParentType = 'Search';
|
||||
}
|
||||
|
||||
if ('noteId' in action) {
|
||||
newState.selectedNoteIds = action.noteId ? [action.noteId] : [];
|
||||
}
|
||||
|
||||
if ('folderId' in action) {
|
||||
newState.selectedFolderId = action.folderId;
|
||||
newState.notesParentType = 'Folder';
|
||||
}
|
||||
|
||||
if ('tagId' in action) {
|
||||
newState.selectedTagId = action.tagId;
|
||||
newState.notesParentType = 'Tag';
|
||||
}
|
||||
|
||||
if ('smartFilterId' in action) {
|
||||
newState.smartFilterId = action.smartFilterId;
|
||||
newState.selectedSmartFilterId = action.smartFilterId;
|
||||
newState.notesParentType = 'SmartFilter';
|
||||
}
|
||||
|
||||
if ('itemType' in action) {
|
||||
newState.selectedItemType = action.itemType;
|
||||
}
|
||||
|
||||
if ('noteHash' in action) {
|
||||
newState.selectedNoteHash = action.noteHash;
|
||||
}
|
||||
|
||||
if ('sharedData' in action) {
|
||||
newState.sharedData = action.sharedData;
|
||||
} else {
|
||||
newState.sharedData = null;
|
||||
}
|
||||
|
||||
newState.route = action;
|
||||
newState.historyCanGoBack = !!navHistory.length;
|
||||
|
||||
logger.debug('Navigated to route:', newState.route?.routeName, 'with notesParentType:', newState.notesParentType);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'SIDE_MENU_TOGGLE':
|
||||
|
||||
newState = { ...state };
|
||||
newState.showSideMenu = !newState.showSideMenu;
|
||||
break;
|
||||
|
||||
case 'SIDE_MENU_OPEN':
|
||||
|
||||
newState = { ...state };
|
||||
newState.showSideMenu = true;
|
||||
break;
|
||||
|
||||
case 'SIDE_MENU_CLOSE':
|
||||
|
||||
newState = { ...state };
|
||||
newState.showSideMenu = false;
|
||||
break;
|
||||
|
||||
case 'SET_PLUGIN_PANELS_DIALOG_VISIBLE':
|
||||
newState = { ...state };
|
||||
newState.showPanelsDialog = action.visible;
|
||||
break;
|
||||
|
||||
case 'NOTE_SELECTION_TOGGLE':
|
||||
|
||||
{
|
||||
newState = { ...state };
|
||||
|
||||
const noteId = action.id;
|
||||
const newSelectedNoteIds = state.selectedNoteIds.slice();
|
||||
const existingIndex = state.selectedNoteIds.indexOf(noteId);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
newSelectedNoteIds.splice(existingIndex, 1);
|
||||
} else {
|
||||
newSelectedNoteIds.push(noteId);
|
||||
}
|
||||
|
||||
newState.selectedNoteIds = newSelectedNoteIds;
|
||||
newState.noteSelectionEnabled = !!newSelectedNoteIds.length;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'NOTE_SELECTION_START':
|
||||
|
||||
if (!state.noteSelectionEnabled) {
|
||||
newState = { ...state };
|
||||
newState.noteSelectionEnabled = true;
|
||||
newState.selectedNoteIds = [action.id];
|
||||
}
|
||||
break;
|
||||
|
||||
case 'NOTE_SELECTION_END':
|
||||
|
||||
newState = { ...state };
|
||||
newState.noteSelectionEnabled = false;
|
||||
newState.selectedNoteIds = [];
|
||||
break;
|
||||
|
||||
case 'NOTE_SIDE_MENU_OPTIONS_SET':
|
||||
|
||||
newState = { ...state };
|
||||
newState.noteSideMenuOptions = action.options;
|
||||
break;
|
||||
|
||||
case 'SET_SIDE_MENU_TOUCH_GESTURES_DISABLED':
|
||||
newState = { ...state };
|
||||
newState.disableSideMenuGestures = action.disableSideMenuGestures;
|
||||
break;
|
||||
|
||||
case 'MOBILE_DATA_WARNING_UPDATE':
|
||||
|
||||
newState = { ...state };
|
||||
newState.isOnMobileData = action.isOnMobileData;
|
||||
break;
|
||||
|
||||
case 'KEYBOARD_VISIBLE_CHANGE':
|
||||
newState = { ...state, keyboardVisible: action.visible };
|
||||
break;
|
||||
|
||||
case 'NOTE_EDITOR_VISIBLE_CHANGE':
|
||||
newState = { ...state, noteEditorVisible: action.visible };
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
error.message = `In reducer: ${error.message} Action: ${JSON.stringify(action)}`;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return reducer(newState, action) as AppState;
|
||||
};
|
||||
|
||||
export default appReducer;
|
||||
@@ -67,10 +67,10 @@ import MigrationService from '@joplin/lib/services/MigrationService';
|
||||
import { clearSharedFilesCache } from '../utils/ShareUtils';
|
||||
import setIgnoreTlsErrors from '../utils/TlsUtils';
|
||||
import ShareService from '@joplin/lib/services/share/ShareService';
|
||||
import { loadMasterKeysFromSettings, migrateMasterPassword } from '@joplin/lib/services/e2ee/utils';
|
||||
import { setRSA } from '@joplin/lib/services/e2ee/ppk';
|
||||
import { loadMasterKeysFromSettings, migrateMasterPassword, migratePpk } from '@joplin/lib/services/e2ee/utils';
|
||||
import { setRSA } from '@joplin/lib/services/e2ee/ppk/ppk';
|
||||
import RSA from '../services/e2ee/RSA.react-native';
|
||||
import { runIntegrationTests as runRsaIntegrationTests } from '@joplin/lib/services/e2ee/ppkTestUtils';
|
||||
import { runIntegrationTests as runRsaIntegrationTests } from '@joplin/lib/services/e2ee/ppk/ppkTestUtils';
|
||||
import { runIntegrationTests as runCryptoIntegrationTests } from '@joplin/lib/services/e2ee/cryptoTestUtils';
|
||||
import { getCurrentProfile } from '@joplin/lib/services/profileConfig';
|
||||
import { getDatabaseName, getPluginDataDir, getProfilesRootDir, getResourceDir } from '../services/profiles';
|
||||
@@ -356,6 +356,9 @@ const buildStartupTasks = (
|
||||
addTask('buildStartupTasks/set up sharing', async () => {
|
||||
await ShareService.instance().initialize(store, EncryptionService.instance());
|
||||
});
|
||||
addTask('buildStartupTasks/migrate PPK', async () => {
|
||||
await migratePpk();
|
||||
});
|
||||
addTask('buildStartupTasks/load folders', async () => {
|
||||
await refreshFolders(dispatch, '');
|
||||
|
||||
@@ -463,11 +466,7 @@ const buildStartupTasks = (
|
||||
// just print some messages in the console.
|
||||
// ----------------------------------------------------------------------------
|
||||
if (Setting.value('env') === 'dev') {
|
||||
if (Platform.OS !== 'web') {
|
||||
await runRsaIntegrationTests();
|
||||
} else {
|
||||
logger.info('Skipping encryption tests -- not supported on web.');
|
||||
}
|
||||
await runRsaIntegrationTests();
|
||||
await runCryptoIntegrationTests();
|
||||
await runOnDeviceFsDriverTests();
|
||||
}
|
||||
|
||||
@@ -236,15 +236,20 @@ export class WorkerApi {
|
||||
const folderName = removeReservedWords(basename(path));
|
||||
|
||||
let handle: FileSystemDirectoryHandle;
|
||||
try {
|
||||
handle = await parent.getDirectoryHandle(folderName, { create });
|
||||
this.directoryHandleCache_.set(path, handle);
|
||||
} catch (error) {
|
||||
if (!isNotFoundError(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!parent) {
|
||||
logger.debug('Parent not found for path', path);
|
||||
handle = null;
|
||||
} else {
|
||||
try {
|
||||
handle = await parent.getDirectoryHandle(folderName, { create });
|
||||
this.directoryHandleCache_.set(path, handle);
|
||||
} catch (error) {
|
||||
if (!isNotFoundError(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
handle = null;
|
||||
}
|
||||
}
|
||||
|
||||
return handle;
|
||||
|
||||
24
packages/app-mobile/utils/hooks/useIsScreenReaderEnabled.ts
Normal file
24
packages/app-mobile/utils/hooks/useIsScreenReaderEnabled.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AccessibilityInfo } from 'react-native';
|
||||
|
||||
let lastScreenReaderEnabled = false;
|
||||
const useIsScreenReaderEnabled = () => {
|
||||
const [screenReaderEnabled, setIsScreenReaderEnabled] = useState(lastScreenReaderEnabled);
|
||||
useEffect(() => {
|
||||
AccessibilityInfo.addEventListener('screenReaderChanged', (enabled) => {
|
||||
lastScreenReaderEnabled = enabled;
|
||||
setIsScreenReaderEnabled(enabled);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useAsyncEffect(async () => {
|
||||
const enabled = await AccessibilityInfo.isScreenReaderEnabled();
|
||||
lastScreenReaderEnabled = enabled;
|
||||
setIsScreenReaderEnabled(enabled);
|
||||
}, []);
|
||||
|
||||
return screenReaderEnabled;
|
||||
};
|
||||
|
||||
export default useIsScreenReaderEnabled;
|
||||
@@ -5,19 +5,26 @@ const useKeyboardState = () => {
|
||||
const [keyboardVisible, setKeyboardVisible] = useState(false);
|
||||
const [hasSoftwareKeyboard, setHasSoftwareKeyboard] = useState(false);
|
||||
const [isFloatingKeyboard, setIsFloatingKeyboard] = useState(false);
|
||||
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
||||
useEffect(() => {
|
||||
const showListener = Keyboard.addListener('keyboardDidShow', () => {
|
||||
const showListener = Keyboard.addListener('keyboardDidShow', (evt) => {
|
||||
setKeyboardVisible(true);
|
||||
setHasSoftwareKeyboard(true);
|
||||
// Floating keyboards on Android result in a negative height being set when portrait
|
||||
setKeyboardHeight(evt.endCoordinates.height > 0 ? evt.endCoordinates.height : 0);
|
||||
});
|
||||
const hideListener = Keyboard.addListener('keyboardDidHide', () => {
|
||||
setKeyboardVisible(false);
|
||||
setKeyboardHeight(0);
|
||||
});
|
||||
const floatingListener = Keyboard.addListener('keyboardWillChangeFrame', (evt) => {
|
||||
// The keyboardWillChangeFrame event only applies to iOS as it does not exist on Android, in which case isFloatingKeyboard will always be false.
|
||||
// But we only need to utilise isFloatingKeyboard to workaround a KeyboardAvoidingView issue on iOS
|
||||
const windowWidth = Dimensions.get('window').width;
|
||||
// If the keyboard isn't as wide as the window, the floating keyboard is disabled.
|
||||
// See https://github.com/facebook/react-native/issues/29473#issuecomment-696658937
|
||||
setIsFloatingKeyboard(evt.endCoordinates.width < windowWidth);
|
||||
setKeyboardHeight(evt.endCoordinates.height);
|
||||
});
|
||||
|
||||
return (() => {
|
||||
@@ -28,8 +35,13 @@ const useKeyboardState = () => {
|
||||
});
|
||||
|
||||
return useMemo(() => {
|
||||
return { keyboardVisible, hasSoftwareKeyboard, isFloatingKeyboard };
|
||||
}, [keyboardVisible, hasSoftwareKeyboard, isFloatingKeyboard]);
|
||||
return {
|
||||
keyboardVisible,
|
||||
hasSoftwareKeyboard,
|
||||
isFloatingKeyboard,
|
||||
dockedKeyboardHeight: isFloatingKeyboard ? 0 : keyboardHeight,
|
||||
};
|
||||
}, [keyboardVisible, hasSoftwareKeyboard, isFloatingKeyboard, keyboardHeight]);
|
||||
};
|
||||
|
||||
export default useKeyboardState;
|
||||
|
||||
@@ -10,8 +10,8 @@ const useSafeAreaPadding = () => {
|
||||
return isLandscape ? {
|
||||
paddingRight: safeAreaInsets.right,
|
||||
paddingLeft: safeAreaInsets.left,
|
||||
paddingTop: 15,
|
||||
paddingBottom: 15,
|
||||
paddingTop: safeAreaInsets.top,
|
||||
paddingBottom: 0,
|
||||
} : {
|
||||
paddingTop: safeAreaInsets.top,
|
||||
paddingBottom: safeAreaInsets.bottom,
|
||||
|
||||
2
packages/app-mobile/utils/polyfills/index.web.ts
Normal file
2
packages/app-mobile/utils/polyfills/index.web.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Only some of the React Native polyfills should be used on Web:
|
||||
import './bufferPolyfill';
|
||||
@@ -1,15 +1,16 @@
|
||||
import reducer from '@joplin/lib/reducer';
|
||||
import { createStore } from 'redux';
|
||||
import appDefaultState from '../appDefaultState';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { AppState } from '../types';
|
||||
import appReducer from '../appReducer';
|
||||
|
||||
const testReducer = (state: AppState|undefined, action: unknown): AppState => {
|
||||
state ??= {
|
||||
...appDefaultState,
|
||||
settings: Setting.toPlainObject(),
|
||||
};
|
||||
return { ...state, ...reducer(state, action) };
|
||||
|
||||
return { ...state, ...appReducer(state, action) };
|
||||
};
|
||||
|
||||
const createMockReduxStore = () => {
|
||||
|
||||
@@ -7,11 +7,11 @@ class MockPluginRunner extends BasePluginRunner {
|
||||
public override async stop() {}
|
||||
}
|
||||
|
||||
const pluginServiceSetup = (store: Store) => {
|
||||
const mockPluginServiceSetup = (store: Store) => {
|
||||
const runner = new MockPluginRunner();
|
||||
PluginService.instance().initialize(
|
||||
'2.14.0', { joplin: {} }, runner, store,
|
||||
);
|
||||
};
|
||||
|
||||
export default pluginServiceSetup;
|
||||
export default mockPluginServiceSetup;
|
||||
@@ -13,6 +13,7 @@ import toggleInlineSelectionFormat from './utils/formatting/toggleInlineSelectio
|
||||
import getSearchState from './utils/getSearchState';
|
||||
import { noteIdFacet, setNoteIdEffect } from './extensions/selectedNoteIdExtension';
|
||||
import jumpToHash from './editorCommands/jumpToHash';
|
||||
import { resetImageResourceEffect } from './extensions/rendering/renderBlockImages';
|
||||
|
||||
interface Callbacks {
|
||||
onUndoRedo(): void;
|
||||
@@ -229,8 +230,12 @@ export default class CodeMirrorControl extends CodeMirror5Emulation implements E
|
||||
};
|
||||
}
|
||||
|
||||
public onResourceDownloaded(_id: string) {
|
||||
// Unused
|
||||
public onResourceChanged(id: string) {
|
||||
this.editor.dispatch({
|
||||
effects: [
|
||||
resetImageResourceEffect.of({ id }),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
public setContentScripts(plugins: ContentScriptData[]) {
|
||||
|
||||
@@ -16,6 +16,8 @@ import { Prec } from '@codemirror/state';
|
||||
import insertNewlineContinueMarkup from './editorCommands/insertNewlineContinueMarkup';
|
||||
import renderingExtension from './extensions/rendering/renderingExtension';
|
||||
import { RenderedContentContext } from './extensions/rendering/types';
|
||||
import highlightActiveLineExtension from './extensions/highlightActiveLineExtension';
|
||||
import renderBlockImages from './extensions/rendering/renderBlockImages';
|
||||
|
||||
const configFromSettings = (settings: EditorSettings, context: RenderedContentContext) => {
|
||||
const languageExtension = (() => {
|
||||
@@ -87,9 +89,15 @@ const configFromSettings = (settings: EditorSettings, context: RenderedContentCo
|
||||
}
|
||||
|
||||
if (settings.inlineRenderingEnabled) {
|
||||
extensions.push(renderingExtension(context, {
|
||||
renderImages: settings.imageRenderingEnabled,
|
||||
}));
|
||||
extensions.push(renderingExtension());
|
||||
}
|
||||
|
||||
if (settings.imageRenderingEnabled) {
|
||||
extensions.push(renderBlockImages(context));
|
||||
}
|
||||
|
||||
if (settings.highlightActiveLine) {
|
||||
extensions.push(highlightActiveLineExtension());
|
||||
}
|
||||
|
||||
return extensions;
|
||||
|
||||
@@ -39,6 +39,7 @@ import selectedNoteIdExtension, { setNoteIdEffect } from './extensions/selectedN
|
||||
import ctrlKeyStateClassExtension from './extensions/modifierKeyCssExtension';
|
||||
import ctrlClickLinksExtension from './extensions/links/ctrlClickLinksExtension';
|
||||
import { RenderedContentContext } from './extensions/rendering/types';
|
||||
import ctrlClickCheckboxExtension from './extensions/ctrlClickCheckboxExtension';
|
||||
|
||||
// Newer versions of CodeMirror by default use Chrome's EditContext API.
|
||||
// While this might be stable enough for desktop use, it causes significant
|
||||
@@ -50,7 +51,7 @@ import { RenderedContentContext } from './extensions/rendering/types';
|
||||
type ExtendedEditorView = typeof EditorView & { EDIT_CONTEXT: boolean };
|
||||
(EditorView as ExtendedEditorView).EDIT_CONTEXT = false;
|
||||
|
||||
export type ResolveImageCallback = (imageSrc: string)=> Promise<string>;
|
||||
export type ResolveImageCallback = (imageSrc: string, reloadCounter: number)=> Promise<string>;
|
||||
|
||||
interface CodeMirrorProps {
|
||||
resolveImageSrc: ResolveImageCallback;
|
||||
@@ -65,8 +66,8 @@ const createEditor = (
|
||||
props.onLogMessage('Initializing CodeMirror...');
|
||||
|
||||
const context: RenderedContentContext = {
|
||||
resolveImageSrc: (src) => {
|
||||
return props.resolveImageSrc(src);
|
||||
resolveImageSrc: (src, counter) => {
|
||||
return props.resolveImageSrc(src, counter);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -255,6 +256,7 @@ const createEditor = (
|
||||
ctrlClickLinksExtension(link => {
|
||||
props.onEvent({ kind: EditorEventType.FollowLink, link });
|
||||
}),
|
||||
ctrlClickCheckboxExtension(),
|
||||
|
||||
highlightSpecialChars(),
|
||||
indentOnInput(),
|
||||
@@ -351,6 +353,9 @@ const createEditor = (
|
||||
onLogMessage: props.onLogMessage,
|
||||
onRemove: () => {
|
||||
editor.destroy();
|
||||
props.onEvent({
|
||||
kind: EditorEventType.Remove,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { Prec } from '@codemirror/state';
|
||||
|
||||
const hasMultipleCursors = (view: EditorView) => {
|
||||
return view.state.selection.ranges.length > 1;
|
||||
};
|
||||
|
||||
type OnCtrlClick = (view: EditorView, event: MouseEvent)=> boolean;
|
||||
|
||||
const ctrlClickActionExtension = (onCtrlClick: OnCtrlClick) => {
|
||||
return [
|
||||
Prec.high([
|
||||
EditorView.domEventHandlers({
|
||||
mousedown: (event: MouseEvent, view: EditorView) => {
|
||||
const hasModifier = event.ctrlKey || event.metaKey;
|
||||
// The default CodeMirror action for ctrl-click is to add another cursor
|
||||
// to the document. If the user already has multiple cursors, assume that
|
||||
// the ctrl-click action is intended to add another.
|
||||
if (hasModifier && !hasMultipleCursors(view)) {
|
||||
const handled = onCtrlClick(view, event);
|
||||
if (handled) {
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
]),
|
||||
];
|
||||
};
|
||||
|
||||
export default ctrlClickActionExtension;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user