mirror of https://github.com/laurent22/joplin.git
Compare commits
91 Commits
Author | SHA1 | Date |
---|---|---|
|
72698ec573 | |
|
68abc27c6a | |
|
1acb3d0726 | |
|
5bf97dc3b8 | |
|
e0e04fbc91 | |
|
625cd1221c | |
|
110d5bde2d | |
|
93a85b3207 | |
|
ff305f42fd | |
|
99ba854ee1 | |
|
38b368e997 | |
|
f9ffe6c4e6 | |
|
5adc0170fc | |
|
f54c364b4d | |
|
9f541b9b9d | |
|
bd0af08c57 | |
|
ac06c6750d | |
|
23b07094b7 | |
|
7eefc016de | |
|
c002be76cd | |
|
2cd29aaaea | |
|
4cb6b01c71 | |
|
91c79b9488 | |
|
fc516d05b3 | |
|
2769c9586c | |
|
fd15d5a6d3 | |
|
7237d7faa7 | |
|
3025d62568 | |
|
5b5dcf34a1 | |
|
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 | |
|
b9c5b8f187 | |
|
da8e638359 | |
|
56ed471a2f | |
|
650594ecea | |
|
78fb07d4c7 | |
|
78c5c4d7c3 | |
|
57093b35ea | |
|
bc2832e78f | |
|
424cc96d36 | |
|
56fd5d828f | |
|
03843b087a | |
|
f6851314d2 | |
|
eaec45cb3f | |
|
9be954496c | |
|
98ef5e619b |
|
@ -96,6 +96,7 @@ packages/onenote-converter/pkg/onenote_converter.js
|
|||
packages/app-cli/app/LinkSelector.js
|
||||
packages/app-cli/app/app.js
|
||||
packages/app-cli/app/base-command.js
|
||||
packages/app-cli/app/cli-integration-tests.js
|
||||
packages/app-cli/app/command-apidoc.js
|
||||
packages/app-cli/app/command-attach.js
|
||||
packages/app-cli/app/command-batch.js
|
||||
|
@ -676,6 +677,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
|
||||
|
@ -813,7 +816,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
|
||||
|
@ -901,14 +903,13 @@ 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
|
||||
packages/app-mobile/services/voiceTyping/VoiceTyping.js
|
||||
packages/app-mobile/services/voiceTyping/utils/unzip.android.js
|
||||
packages/app-mobile/services/voiceTyping/utils/unzip.js
|
||||
packages/app-mobile/services/voiceTyping/vosk.android.js
|
||||
packages/app-mobile/services/voiceTyping/vosk.js
|
||||
packages/app-mobile/services/voiceTyping/whisper.test.js
|
||||
packages/app-mobile/services/voiceTyping/whisper.js
|
||||
packages/app-mobile/setupQuickActions.js
|
||||
|
@ -922,6 +923,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
|
||||
|
@ -961,6 +963,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
|
||||
|
@ -971,6 +974,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
|
||||
|
@ -1404,14 +1408,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
|
||||
|
|
|
@ -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"
|
|
@ -69,6 +69,7 @@ docs/**/*.mustache
|
|||
packages/app-cli/app/LinkSelector.js
|
||||
packages/app-cli/app/app.js
|
||||
packages/app-cli/app/base-command.js
|
||||
packages/app-cli/app/cli-integration-tests.js
|
||||
packages/app-cli/app/command-apidoc.js
|
||||
packages/app-cli/app/command-attach.js
|
||||
packages/app-cli/app/command-batch.js
|
||||
|
@ -649,6 +650,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
|
||||
|
@ -786,7 +789,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
|
||||
|
@ -874,14 +876,13 @@ 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
|
||||
packages/app-mobile/services/voiceTyping/VoiceTyping.js
|
||||
packages/app-mobile/services/voiceTyping/utils/unzip.android.js
|
||||
packages/app-mobile/services/voiceTyping/utils/unzip.js
|
||||
packages/app-mobile/services/voiceTyping/vosk.android.js
|
||||
packages/app-mobile/services/voiceTyping/vosk.js
|
||||
packages/app-mobile/services/voiceTyping/whisper.test.js
|
||||
packages/app-mobile/services/voiceTyping/whisper.js
|
||||
packages/app-mobile/setupQuickActions.js
|
||||
|
@ -895,6 +896,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
|
||||
|
@ -934,6 +936,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
|
||||
|
@ -944,6 +947,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
|
||||
|
@ -1377,14 +1381,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
|
||||
|
|
|
@ -1,209 +0,0 @@
|
|||
diff --git a/android/build.gradle b/android/build.gradle
|
||||
index 6afcbbf0cc8ca2d69dd78077d61e59a90b2136bb..9f8d72b4ec5b2b3d290975d6a255917c95300854 100644
|
||||
--- a/android/build.gradle
|
||||
+++ b/android/build.gradle
|
||||
@@ -67,19 +67,19 @@ repositories {
|
||||
}
|
||||
|
||||
// Generate UUIDs for each models contained in android/src/main/assets/
|
||||
-tasks.register('genUUID') {
|
||||
- doLast {
|
||||
- fileTree(dir: "$rootDir/app/src/main/assets", exclude: ['*/*']).visit { fileDetails ->
|
||||
- if (fileDetails.directory) {
|
||||
- def odir = file("$rootDir/app/src/main/assets/$fileDetails.relativePath")
|
||||
- def ofile = file("$odir/uuid")
|
||||
- mkdir odir
|
||||
- ofile.text = UUID.randomUUID().toString()
|
||||
- }
|
||||
- }
|
||||
- }
|
||||
-}
|
||||
-preBuild.dependsOn genUUID
|
||||
+// tasks.register('genUUID') {
|
||||
+// doLast {
|
||||
+// fileTree(dir: "$rootDir/app/src/main/assets", exclude: ['*/*']).visit { fileDetails ->
|
||||
+// if (fileDetails.directory) {
|
||||
+// def odir = file("$rootDir/app/src/main/assets/$fileDetails.relativePath")
|
||||
+// def ofile = file("$odir/uuid")
|
||||
+// mkdir odir
|
||||
+// ofile.text = UUID.randomUUID().toString()
|
||||
+// }
|
||||
+// }
|
||||
+// }
|
||||
+// }
|
||||
+// preBuild.dependsOn genUUID
|
||||
|
||||
def kotlin_version = getExtOrDefault('kotlinVersion')
|
||||
|
||||
diff --git a/android/src/main/java/com/reactnativevosk/VoskModule.kt b/android/src/main/java/com/reactnativevosk/VoskModule.kt
|
||||
index 0e2b6595b1b2cf1ee01c6c64239c4b0ea37fce19..5a8539b9cce8951967640dba755e29a4e3ff404a 100644
|
||||
--- a/android/src/main/java/com/reactnativevosk/VoskModule.kt
|
||||
+++ b/android/src/main/java/com/reactnativevosk/VoskModule.kt
|
||||
@@ -19,13 +19,25 @@ class VoskModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMo
|
||||
return "Vosk"
|
||||
}
|
||||
|
||||
+ @ReactMethod
|
||||
+ fun addListener(type: String?) {
|
||||
+ // Keep: Required for RN built in Event Emitter Calls.
|
||||
+ }
|
||||
+
|
||||
+ @ReactMethod
|
||||
+ fun removeListeners(type: Int?) {
|
||||
+ // Keep: Required for RN built in Event Emitter Calls.
|
||||
+ }
|
||||
+
|
||||
override fun onResult(hypothesis: String) {
|
||||
// Get text data from string object
|
||||
val text = getHypothesisText(hypothesis)
|
||||
|
||||
// Stop recording if data found
|
||||
if (text != null && text.isNotEmpty()) {
|
||||
- cleanRecognizer();
|
||||
+ // Don't auto-stop the recogniser - we want to do that when the user
|
||||
+ // presses on "stop" only.
|
||||
+ // cleanRecognizer();
|
||||
sendEvent("onResult", text)
|
||||
}
|
||||
}
|
||||
@@ -93,12 +105,11 @@ class VoskModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMo
|
||||
@ReactMethod
|
||||
fun loadModel(path: String, promise: Promise) {
|
||||
cleanModel();
|
||||
- StorageService.unpack(context, path, "models",
|
||||
- { model: Model? ->
|
||||
- this.model = model
|
||||
- promise.resolve("Model successfully loaded")
|
||||
- }
|
||||
- ) { e: IOException ->
|
||||
+
|
||||
+ try {
|
||||
+ this.model = Model(path);
|
||||
+ promise.resolve("Model successfully loaded")
|
||||
+ } catch (e: IOException) {
|
||||
this.model = null
|
||||
promise.reject(e)
|
||||
}
|
||||
@@ -153,6 +164,25 @@ class VoskModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaMo
|
||||
cleanRecognizer();
|
||||
}
|
||||
|
||||
+ @ReactMethod
|
||||
+ fun stopOnly() {
|
||||
+ if (speechService != null) {
|
||||
+ speechService!!.stop()
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ @ReactMethod
|
||||
+ fun cleanup() {
|
||||
+ if (speechService != null) {
|
||||
+ speechService!!.shutdown();
|
||||
+ speechService = null
|
||||
+ }
|
||||
+ if (recognizer != null) {
|
||||
+ recognizer!!.close();
|
||||
+ recognizer = null;
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
@ReactMethod
|
||||
fun unload() {
|
||||
cleanRecognizer();
|
||||
diff --git a/lib/typescript/index.d.ts b/lib/typescript/index.d.ts
|
||||
index 441e41cc402cca3a60b34978ef4fea976076259c..a173acebb4b314402550442ad471e0f7c706e3c4 100644
|
||||
--- a/lib/typescript/index.d.ts
|
||||
+++ b/lib/typescript/index.d.ts
|
||||
@@ -10,6 +10,8 @@ export default class Vosk {
|
||||
currentRegisteredEvents: EmitterSubscription[];
|
||||
start: (grammar?: string[] | null) => Promise<String>;
|
||||
stop: () => void;
|
||||
+ stopOnly: () => void;
|
||||
+ cleanup: () => void;
|
||||
unload: () => void;
|
||||
onResult: (onResult: (e: VoskEvent) => void) => EventSubscription;
|
||||
onFinalResult: (onFinalResult: (e: VoskEvent) => void) => EventSubscription;
|
||||
diff --git a/package.json b/package.json
|
||||
index 707eddb8d68007f93071ac659c5b087c935c5f01..90ebe20f224eeec472c377df1fef9b15f2ff8200 100644
|
||||
--- a/package.json
|
||||
+++ b/package.json
|
||||
@@ -11,12 +11,9 @@
|
||||
"src",
|
||||
"lib",
|
||||
"android",
|
||||
- "ios",
|
||||
"cpp",
|
||||
- "react-native-vosk.podspec",
|
||||
"!lib/typescript/example",
|
||||
"!android/build",
|
||||
- "!ios/build",
|
||||
"!**/__tests__",
|
||||
"!**/__fixtures__",
|
||||
"!**/__mocks__"
|
||||
diff --git a/react-native-vosk.podspec b/react-native-vosk.podspec
|
||||
deleted file mode 100644
|
||||
index e3d41b90c5eef890c7a5108aaf16ac07d34a698b..0000000000000000000000000000000000000000
|
||||
--- a/react-native-vosk.podspec
|
||||
+++ /dev/null
|
||||
@@ -1,41 +0,0 @@
|
||||
-require "json"
|
||||
-
|
||||
-package = JSON.parse(File.read(File.join(__dir__, "package.json")))
|
||||
-folly_version = '2021.06.28.00-v2'
|
||||
-folly_compiler_flags = '-DFOLLY_NO_CONFIG -DFOLLY_MOBILE=1 -DFOLLY_USE_LIBCPP=1 -Wno-comma -Wno-shorten-64-to-32'
|
||||
-
|
||||
-Pod::Spec.new do |s|
|
||||
- s.name = "react-native-vosk"
|
||||
- s.version = package["version"]
|
||||
- s.summary = package["description"]
|
||||
- s.homepage = package["homepage"]
|
||||
- s.license = package["license"]
|
||||
- s.authors = package["author"]
|
||||
-
|
||||
- s.platforms = { :ios => "10.0" }
|
||||
- s.source = { :git => "https://github.com/riderodd/react-native-vosk.git", :tag => "#{s.version}" }
|
||||
-
|
||||
- s.source_files = "ios/**/*.{h,m,mm,swift}"
|
||||
- s.resource_bundles = { 'Vosk' => ['ios/Vosk/*'] }
|
||||
-
|
||||
- s.dependency "React-Core"
|
||||
- s.frameworks = "Accelerate"
|
||||
- s.library = "c++"
|
||||
- s.vendored_frameworks = "ios/libvosk.xcframework"
|
||||
- s.requires_arc = true
|
||||
-
|
||||
- # Don't install the dependencies when we run `pod install` in the old architecture.
|
||||
- if ENV['RCT_NEW_ARCH_ENABLED'] == '1' then
|
||||
- s.compiler_flags = folly_compiler_flags + " -DRCT_NEW_ARCH_ENABLED=1"
|
||||
- s.pod_target_xcconfig = {
|
||||
- "HEADER_SEARCH_PATHS" => "\"$(PODS_ROOT)/boost\"",
|
||||
- "CLANG_CXX_LANGUAGE_STANDARD" => "c++17"
|
||||
- }
|
||||
-
|
||||
- s.dependency "React-Codegen"
|
||||
- s.dependency "RCT-Folly", folly_version
|
||||
- s.dependency "RCTRequired"
|
||||
- s.dependency "RCTTypeSafety"
|
||||
- s.dependency "ReactCommon/turbomodule/core"
|
||||
- end
|
||||
-end
|
||||
diff --git a/src/index.tsx b/src/index.tsx
|
||||
index d9f90c921d89b1b4d85e145443ed3376546a368a..29e4068dbd7500828a73145bd25497a52c9bf638 100644
|
||||
--- a/src/index.tsx
|
||||
+++ b/src/index.tsx
|
||||
@@ -69,6 +69,15 @@ export default class Vosk {
|
||||
VoskModule.stop();
|
||||
};
|
||||
|
||||
+ stopOnly = () => {
|
||||
+ VoskModule.stopOnly();
|
||||
+ };
|
||||
+
|
||||
+ cleanup = () => {
|
||||
+ this.cleanListeners();
|
||||
+ VoskModule.cleanup();
|
||||
+ };
|
||||
+
|
||||
unload = () => {
|
||||
this.cleanListeners();
|
||||
VoskModule.unload();
|
Binary file not shown.
After Width: | Height: | Size: 36 KiB |
Binary file not shown.
After Width: | Height: | Size: 109 KiB |
Binary file not shown.
After Width: | Height: | Size: 378 KiB |
Binary file not shown.
After Width: | Height: | Size: 295 KiB |
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://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://seirei.ne.jp"><img title="Serei Network" width="256" src="https://joplinapp.org/images/sponsors/SeireiNetwork.png"/></a> <a href="https://citricsheep.com"><img title="Citric Sheep" width="256" src="https://joplinapp.org/images/sponsors/CitricSheep.png"/></a> <a href="https://sorted.travel/?utm_source=joplinapp"><img title="Sorted Travel" width="256" src="https://joplinapp.org/images/sponsors/SortedTravel.png"/></a> <a href="https://celebian.com"><img title="Celebian" width="256" src="https://joplinapp.org/images/sponsors/Celebian.png"/></a> <a href="https://bestkru.com"><img title="BestKru" width="256" src="https://joplinapp.org/images/sponsors/BestKru.png"/></a> <a href="https://www.socialfollowers.uk/buy-tiktok-followers/"><img title="Social Followers" width="256" src="https://joplinapp.org/images/sponsors/SocialFollowers.png"/></a> <a href="https://stormlikes.com/"><img title="Stormlikes" width="256" src="https://joplinapp.org/images/sponsors/Stormlikes.png"/></a> <a href="https://route4me.com"><img title="Route4Me" width="256" src="https://joplinapp.org/images/sponsors/Route4Me.png"/></a> <a href="https://topagency.webflow.io"><img title="WebDesignAgency" width="256" src="https://joplinapp.org/images/sponsors/WebDesignAgency.png" alt="topagency"/></a> <a href="https://www.slotozilla.com/nz/no-deposit-bonus"><img title="casino without making any upfront cost" width="256" src="https://joplinapp.org/images/sponsors/Slotozilla.png" alt="casino without making any upfront cost"/></a> <a href="https://writepaper.com/"><img title="best service to write my paper for me" width="256" src="https://joplinapp.org/images/sponsors/WritePaper.png" alt="best service to write my paper for me"/></a> <a href="https://paperwriter.com/"><img title="high-quality paper writing service PaperWriter" width="256" src="https://joplinapp.org/images/sponsors/PaperWriter.png" alt="high-quality paper writing service PaperWriter"/></a> <a href="https://www.bestetf.net/"><img title="BestETF" width="256" src="https://joplinapp.org/images/sponsors/BestEtf.png" alt="BestETF"/></a> <a href="https://freespinny.io/free-spins-no-deposit/"><img title="Freespinny.io Free Spins Bonus site" width="256" src="https://joplinapp.org/images/sponsors/Freespinny.png" alt="Freespinny.io Free Spins Bonus site"/></a> <a href="https://damangameplay.in"><img title="Daman Game" width="256" src="https://joplinapp.org/images/sponsors/DamanGame.png" alt="Daman Game"/></a> <a href="https://essayshark.com"><img title="EssayShark - essay writers for hire" width="256" src="https://joplinapp.org/images/sponsors/EssayShark.png" alt="EssayShark - essay writers for hire"/></a> <a href="https://pokieslab1.com/real-money-pokies/"><img title="Australian Real Money Pokies" width="256" src="https://joplinapp.org/images/sponsors/PokiesLab.png" alt="Australian Real Money Pokies"/></a> <a href="https://pokiesman1.net/real-money-pokies/"><img title="Australian Real Money Pokies" width="256" src="https://joplinapp.org/images/sponsors/Pokiesman.png" alt="Australian Real Money Pokies"/></a> <a href="https://domyessay.com"><img title="Essay writers DoMyEssay are dedicated to providing top-notch, custom-written papers that meet your academic requirements" width="256" src="https://joplinapp.org/images/sponsors/DoMyEssay.png" alt="DoMyEssay"/></a> <a href="https://essaypro.com/"><img title="best essay writing service" width="256" src="https://joplinapp.org/images/sponsors/EssayPro.png" alt="best essay writing service"/></a> <a href="https://socialkings.online"><img title="Boost your reach and buy real followers" width="256" src="https://joplinapp.org/images/sponsors/SocialKings.png" alt="Boost your reach and buy real followers"/></a> <a href="https://uk.notgamstop.com/bonuses/free-spins-no-deposit-no-gamstop/"><img title="free spins no deposit at NotGamstop" width="256" src="https://joplinapp.org/images/sponsors/NotGamStop.jpg" alt="free spins no deposit at NotGamstop"/></a>
|
||||
<!-- SPONSORS-ORG -->
|
||||
|
||||
* * *
|
||||
|
|
|
@ -101,7 +101,6 @@
|
|||
"packageManager": "yarn@4.9.2",
|
||||
"resolutions": {
|
||||
"react-native-camera@4.2.1": "patch:react-native-camera@npm%3A4.2.1#./.yarn/patches/react-native-camera-npm-4.2.1-24b2600a7e.patch",
|
||||
"react-native-vosk@0.1.12": "patch:react-native-vosk@npm%3A0.1.12#./.yarn/patches/react-native-vosk-npm-0.1.12-76b1caaae8.patch",
|
||||
"eslint": "patch:eslint@8.57.1#./.yarn/patches/eslint-npm-8.39.0-d92bace04d.patch",
|
||||
"app-builder-lib@24.4.0": "patch:app-builder-lib@npm%3A24.4.0#./.yarn/patches/app-builder-lib-npm-24.4.0-05322ff057.patch",
|
||||
"nanoid": "patch:nanoid@npm%3A3.3.7#./.yarn/patches/nanoid-npm-3.3.7-98824ba130.patch",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -2,33 +2,44 @@
|
|||
|
||||
/* eslint-disable no-console */
|
||||
|
||||
const fs = require('fs-extra');
|
||||
const Logger = require('@joplin/utils/Logger').default;
|
||||
const { dirname } = require('@joplin/lib/path-utils');
|
||||
import * as fs from 'fs-extra';
|
||||
import Logger, { TargetType } from '@joplin/utils/Logger';
|
||||
import { dirname } from '@joplin/lib/path-utils';
|
||||
const { DatabaseDriverNode } = require('@joplin/lib/database-driver-node.js');
|
||||
const JoplinDatabase = require('@joplin/lib/JoplinDatabase').default;
|
||||
const BaseModel = require('@joplin/lib/BaseModel').default;
|
||||
const Folder = require('@joplin/lib/models/Folder').default;
|
||||
const Note = require('@joplin/lib/models/Note').default;
|
||||
const Setting = require('@joplin/lib/models/Setting').default;
|
||||
import JoplinDatabase from '@joplin/lib/JoplinDatabase';
|
||||
import BaseModel from '@joplin/lib/BaseModel';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
const { sprintf } = require('sprintf-js');
|
||||
const exec = require('child_process').exec;
|
||||
const nodeSqlite = require('sqlite3');
|
||||
const { loadKeychainServiceAndSettings } = require('@joplin/lib/services/SettingUtils');
|
||||
const { default: shimInitCli } = require('./utils/shimInitCli');
|
||||
|
||||
const baseDir = `${dirname(__dirname)}/tests/cli-integration`;
|
||||
const joplinAppPath = `${__dirname}/main.js`;
|
||||
|
||||
shimInitCli({ nodeSqlite, appVersion: () => require('../package.json').version, keytar: null });
|
||||
require('@joplin/lib/testing/test-utils');
|
||||
|
||||
const logger = new Logger();
|
||||
logger.addTarget('console');
|
||||
logger.addTarget(TargetType.Console);
|
||||
logger.setLevel(Logger.LEVEL_ERROR);
|
||||
|
||||
const dbLogger = new Logger();
|
||||
dbLogger.addTarget('console');
|
||||
dbLogger.addTarget(TargetType.Console);
|
||||
dbLogger.setLevel(Logger.LEVEL_INFO);
|
||||
|
||||
const db = new JoplinDatabase(new DatabaseDriverNode());
|
||||
db.setLogger(dbLogger);
|
||||
|
||||
function createClient(id) {
|
||||
interface Client {
|
||||
id: number;
|
||||
profileDir: string;
|
||||
}
|
||||
|
||||
function createClient(id: number): Client {
|
||||
return {
|
||||
id: id,
|
||||
profileDir: `${baseDir}/client${id}`,
|
||||
|
@ -37,13 +48,13 @@ function createClient(id) {
|
|||
|
||||
const client = createClient(1);
|
||||
|
||||
function execCommand(client, command) {
|
||||
function execCommand(client: Client, command: string) {
|
||||
const exePath = `node ${joplinAppPath}`;
|
||||
const cmd = `${exePath} --update-geolocation-disabled --env dev --profile ${client.profileDir} ${command}`;
|
||||
logger.info(`${client.id}: ${command}`);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
exec(cmd, (error, stdout, stderr) => {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
exec(cmd, (error: string, stdout: string, stderr: string) => {
|
||||
if (error) {
|
||||
logger.error(stderr);
|
||||
reject(error);
|
||||
|
@ -54,17 +65,17 @@ function execCommand(client, command) {
|
|||
});
|
||||
}
|
||||
|
||||
function assertTrue(v) {
|
||||
function assertTrue(v: unknown) {
|
||||
if (!v) throw new Error(sprintf('Expected "true", got "%s"."', v));
|
||||
process.stdout.write('.');
|
||||
}
|
||||
|
||||
function assertFalse(v) {
|
||||
function assertFalse(v: unknown) {
|
||||
if (v) throw new Error(sprintf('Expected "false", got "%s"."', v));
|
||||
process.stdout.write('.');
|
||||
}
|
||||
|
||||
function assertEquals(expected, real) {
|
||||
function assertEquals(expected: unknown, real: unknown) {
|
||||
if (expected !== real) throw new Error(sprintf('Expecting "%s", got "%s"', expected, real));
|
||||
process.stdout.write('.');
|
||||
}
|
||||
|
@ -73,7 +84,7 @@ async function clearDatabase() {
|
|||
await db.transactionExecBatch(['DELETE FROM folders', 'DELETE FROM notes', 'DELETE FROM tags', 'DELETE FROM note_tags', 'DELETE FROM resources', 'DELETE FROM deleted_items']);
|
||||
}
|
||||
|
||||
const testUnits = {};
|
||||
const testUnits: Record<string, ()=> Promise<void>> = {};
|
||||
|
||||
testUnits.testFolders = async () => {
|
||||
await execCommand(client, 'mkbook nb1');
|
||||
|
@ -85,10 +96,16 @@ testUnits.testFolders = async () => {
|
|||
await execCommand(client, 'mkbook nb1');
|
||||
|
||||
folders = await Folder.all();
|
||||
assertEquals(1, folders.length);
|
||||
assertEquals(2, folders.length);
|
||||
assertEquals('nb1', folders[0].title);
|
||||
assertEquals('nb1', folders[1].title);
|
||||
|
||||
await execCommand(client, 'rm -r -f nb1');
|
||||
await execCommand(client, 'rmbook -p -f nb1');
|
||||
|
||||
folders = await Folder.all();
|
||||
assertEquals(1, folders.length);
|
||||
|
||||
await execCommand(client, 'rmbook -p -f nb1');
|
||||
|
||||
folders = await Folder.all();
|
||||
assertEquals(0, folders.length);
|
||||
|
@ -102,7 +119,7 @@ testUnits.testNotes = async () => {
|
|||
assertEquals(1, notes.length);
|
||||
assertEquals('n1', notes[0].title);
|
||||
|
||||
await execCommand(client, 'rm -f n1');
|
||||
await execCommand(client, 'rmnote -p -f n1');
|
||||
notes = await Note.all();
|
||||
assertEquals(0, notes.length);
|
||||
|
||||
|
@ -112,12 +129,19 @@ testUnits.testNotes = async () => {
|
|||
notes = await Note.all();
|
||||
assertEquals(2, notes.length);
|
||||
|
||||
await execCommand(client, 'rm -f \'blabla*\'');
|
||||
// Should fail to delete a non-existent note
|
||||
let failed = false;
|
||||
try {
|
||||
await execCommand(client, 'rmnote -f \'blabla*\'');
|
||||
} catch (error) {
|
||||
failed = true;
|
||||
}
|
||||
assertEquals(failed, true);
|
||||
|
||||
notes = await Note.all();
|
||||
assertEquals(2, notes.length);
|
||||
|
||||
await execCommand(client, 'rm -f \'n*\'');
|
||||
await execCommand(client, 'rmnote -f -p \'n*\'');
|
||||
|
||||
notes = await Note.all();
|
||||
assertEquals(0, notes.length);
|
||||
|
@ -140,10 +164,12 @@ testUnits.testCat = async () => {
|
|||
|
||||
testUnits.testConfig = async () => {
|
||||
await execCommand(client, 'config editor vim');
|
||||
await Setting.reset();
|
||||
await Setting.load();
|
||||
assertEquals('vim', Setting.value('editor'));
|
||||
|
||||
await execCommand(client, 'config editor subl');
|
||||
await Setting.reset();
|
||||
await Setting.load();
|
||||
assertEquals('subl', Setting.value('editor'));
|
||||
|
||||
|
@ -201,15 +227,47 @@ testUnits.testMv = async () => {
|
|||
await execCommand(client, 'mknote note2');
|
||||
await execCommand(client, 'mknote note3');
|
||||
await execCommand(client, 'mknote blabla');
|
||||
await execCommand(client, 'mv \'note*\' nb2');
|
||||
|
||||
notes1 = await Note.previews(f1.id);
|
||||
notes2 = await Note.previews(f2.id);
|
||||
|
||||
assertEquals(4, notes1.length);
|
||||
assertEquals(1, notes2.length);
|
||||
|
||||
await execCommand(client, 'mv \'note*\' nb2');
|
||||
|
||||
notes2 = await Note.previews(f2.id);
|
||||
notes1 = await Note.previews(f1.id);
|
||||
|
||||
assertEquals(1, notes1.length);
|
||||
assertEquals(4, notes2.length);
|
||||
};
|
||||
|
||||
testUnits.testUse = async () => {
|
||||
await execCommand(client, 'mkbook nb1');
|
||||
await execCommand(client, 'mkbook nb2');
|
||||
await execCommand(client, 'mknote n1');
|
||||
await execCommand(client, 'mknote n2');
|
||||
|
||||
const f1 = await Folder.loadByTitle('nb1');
|
||||
const f2 = await Folder.loadByTitle('nb2');
|
||||
let notes1 = await Note.previews(f1.id);
|
||||
let notes2 = await Note.previews(f2.id);
|
||||
|
||||
assertEquals(0, notes1.length);
|
||||
assertEquals(2, notes2.length);
|
||||
|
||||
await execCommand(client, 'use nb1');
|
||||
await execCommand(client, 'mknote note2');
|
||||
await execCommand(client, 'mknote note3');
|
||||
|
||||
notes1 = await Note.previews(f1.id);
|
||||
notes2 = await Note.previews(f2.id);
|
||||
|
||||
assertEquals(2, notes1.length);
|
||||
assertEquals(2, notes2.length);
|
||||
};
|
||||
|
||||
async function main() {
|
||||
await fs.remove(baseDir);
|
||||
|
||||
|
@ -217,7 +275,9 @@ async function main() {
|
|||
|
||||
await db.open({ name: `${client.profileDir}/database.sqlite` });
|
||||
BaseModel.setDb(db);
|
||||
await Setting.load();
|
||||
Setting.setConstant('rootProfileDir', client.profileDir);
|
||||
Setting.setConstant('profileDir', client.profileDir);
|
||||
await loadKeychainServiceAndSettings([]);
|
||||
|
||||
let onlyThisTest = 'testMv';
|
||||
onlyThisTest = '';
|
||||
|
@ -234,7 +294,7 @@ async function main() {
|
|||
}
|
||||
}
|
||||
|
||||
main(process.argv).catch(error => {
|
||||
main().catch(error => {
|
||||
console.info('');
|
||||
logger.error(error);
|
||||
});
|
|
@ -73,7 +73,7 @@
|
|||
"@joplin/tools": "~3.4",
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/node": "18.19.103",
|
||||
"@types/node": "18.19.111",
|
||||
"@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';
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -110,12 +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);
|
||||
if (!item) return null;
|
||||
return `${getResourceBaseUrl()}/${resourceFilename(item)}`;
|
||||
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');
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@joplin/app-desktop",
|
||||
"version": "3.4.10",
|
||||
"version": "3.4.11",
|
||||
"description": "Joplin for Desktop",
|
||||
"main": "main.bundle.js",
|
||||
"private": true,
|
||||
|
@ -131,7 +131,7 @@
|
|||
"homepage": "https://github.com/laurent22/joplin#readme",
|
||||
"devDependencies": {
|
||||
"7zip-bin": "5.2.0",
|
||||
"@axe-core/playwright": "4.10.1",
|
||||
"@axe-core/playwright": "4.10.2",
|
||||
"@electron/notarize": "2.5.0",
|
||||
"@electron/rebuild": "3.7.2",
|
||||
"@fortawesome/fontawesome-free": "5.15.4",
|
||||
|
@ -147,7 +147,7 @@
|
|||
"@testing-library/react-hooks": "8.0.1",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/mustache": "4.2.6",
|
||||
"@types/node": "18.19.103",
|
||||
"@types/node": "18.19.111",
|
||||
"@types/react": "18.3.23",
|
||||
"@types/react-dom": "18.3.7",
|
||||
"@types/react-redux": "7.1.33",
|
||||
|
@ -160,7 +160,7 @@
|
|||
"compare-versions": "6.1.1",
|
||||
"countable": "3.0.1",
|
||||
"debounce": "1.2.1",
|
||||
"electron": "35.7.5",
|
||||
"electron": "37.4.0",
|
||||
"electron-builder": "24.13.3",
|
||||
"electron-updater": "6.6.2",
|
||||
"electron-window-state": "5.0.3",
|
||||
|
|
|
@ -89,8 +89,8 @@ android {
|
|||
applicationId "net.cozic.joplin"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 2097778
|
||||
versionName "3.4.5"
|
||||
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)
|
||||
|
|
|
@ -24,29 +24,8 @@ buildscript {
|
|||
|
||||
allprojects {
|
||||
repositories {
|
||||
mavenCentral()
|
||||
|
||||
// Seems to be required for react-native-vosk, otherwise the lib looks for it at "https://maven.aliyun.com/repository/jcenter/com/alphacephei/vosk-android/0.3.46/vosk-android-0.3.46.aar" but it's not there. And we get this error:
|
||||
//
|
||||
// Execution failed for task ':app:checkDebugAarMetadata'.
|
||||
// > Could not resolve all files for configuration ':app:debugRuntimeClasspath'.
|
||||
// > Failed to transform vosk-android-0.3.46.aar (com.alphacephei:vosk-android:0.3.46) to match attributes {artifactType=android-aar-metadata, org.gradle.status=release}.
|
||||
// > Could not find vosk-android-0.3.46.aar (com.alphacephei:vosk-android:0.3.46).
|
||||
// Searched in the following locations:
|
||||
// https://maven.aliyun.com/repository/jcenter/com/alphacephei/vosk-android/0.3.46/vosk-android-0.3.46.aar
|
||||
//
|
||||
// But according to this page, the lib is on the Apache repository:
|
||||
//
|
||||
// https://search.maven.org/artifact/com.alphacephei/vosk-android/0.3.46/aar
|
||||
maven { url "https://maven.apache.org" }
|
||||
|
||||
// Also required for react-native-vosk?
|
||||
maven { url "https://maven.google.com" }
|
||||
|
||||
// Maybe still needed to fetch above package?
|
||||
|
||||
google()
|
||||
maven { url 'https://www.jitpack.io' }
|
||||
mavenCentral()
|
||||
|
||||
maven {
|
||||
// expo-camera bundles a custom com.google.android:cameraview
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -66,12 +66,12 @@ describe('ComboBox', () => {
|
|||
unmount();
|
||||
});
|
||||
|
||||
test('changing the search query should limit which items are visible', () => {
|
||||
test('changing the search query should limit which items are visible and be case insensitive', () => {
|
||||
const testItems = [
|
||||
{ title: 'a' },
|
||||
{ title: 'b' },
|
||||
{ title: 'c' },
|
||||
{ title: 'aa' },
|
||||
{ title: 'Aa' },
|
||||
];
|
||||
const { unmount } = render(
|
||||
<WrappedComboBox items={testItems}/>,
|
||||
|
@ -82,7 +82,7 @@ describe('ComboBox', () => {
|
|||
|
||||
const updatedResults = getSearchResults();
|
||||
expect(updatedResults[0]).toHaveTextContent('a');
|
||||
expect(updatedResults[1]).toHaveTextContent('aa');
|
||||
expect(updatedResults[1]).toHaveTextContent('Aa');
|
||||
expect(updatedResults).toHaveLength(2);
|
||||
|
||||
unmount();
|
||||
|
|
|
@ -12,7 +12,7 @@ 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');
|
||||
import { getCollator, getCollatorLocale } from '@joplin/lib/models/utils/getCollator';
|
||||
|
||||
|
||||
export interface Option {
|
||||
|
@ -64,17 +64,20 @@ interface UseSearchResultsOptions {
|
|||
const useSearchResults = ({
|
||||
search, setSearch, options, onAddItem, canAddItem,
|
||||
}: UseSearchResultsOptions) => {
|
||||
const collatorLocale = getCollatorLocale();
|
||||
const results = useMemo(() => {
|
||||
const collator = getCollator(collatorLocale);
|
||||
const lowerSearch = search?.toLowerCase();
|
||||
return options
|
||||
.filter(option => option.title.startsWith(search))
|
||||
.filter(option => option.title.toLowerCase().includes(lowerSearch))
|
||||
.sort((a, b) => {
|
||||
if (a.title === b.title) return 0;
|
||||
// Full matches should go first
|
||||
if (a.title === search) return -1;
|
||||
if (b.title === search) return 1;
|
||||
return naturalCompare(a.title, b.title);
|
||||
if (a.title.toLowerCase() === lowerSearch) return -1;
|
||||
if (b.title.toLowerCase() === lowerSearch) return 1;
|
||||
return collator.compare(a.title, b.title);
|
||||
});
|
||||
}, [search, options]);
|
||||
}, [search, options, collatorLocale]);
|
||||
|
||||
const canAdd = (
|
||||
!!onAddItem
|
||||
|
@ -254,6 +257,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>
|
||||
|
@ -458,10 +463,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('');
|
||||
|
@ -584,6 +590,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;
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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 },
|
||||
|
|
|
@ -230,8 +230,8 @@ const useEditorControl = (
|
|||
setSearchState: setSearchStateCallback,
|
||||
},
|
||||
|
||||
onResourceDownloaded: (id: string) => {
|
||||
editorRef.current.onResourceDownloaded(id);
|
||||
onResourceChanged: (id: string) => {
|
||||
editorRef.current.onResourceChanged(id);
|
||||
},
|
||||
|
||||
remove: () => {
|
||||
|
@ -342,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)}]`),
|
||||
|
@ -452,6 +452,8 @@ describe('RichTextEditor', () => {
|
|||
'==highlight==ed',
|
||||
'<sup>Super</sup>script',
|
||||
'<sub>Sub</sub>script',
|
||||
'',
|
||||
'<img src="data:image/svg+xml;utf8,test" width="120">',
|
||||
])('should preserve inline markup on edit (case %#)', async (initialBody) => {
|
||||
initialBody += 'test'; // Ensure that typing will add new content outside the formatting
|
||||
let body = initialBody;
|
||||
|
|
|
@ -37,6 +37,7 @@ const useStyles = (themeId: number) => {
|
|||
|
||||
const listItemPressable: ViewStyle = {
|
||||
flexGrow: 1,
|
||||
flexShrink: 1,
|
||||
alignSelf: 'stretch',
|
||||
};
|
||||
const listItemPressableWithCheckbox: ViewStyle = {
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
@ -114,7 +114,7 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
|
|||
// A small border above the header: Covers the part of the shadow that would otherwise
|
||||
// be shown above the header on Android.
|
||||
aboveHeader: {
|
||||
backgroundColor: '#323640',
|
||||
backgroundColor: theme.backgroundColor2,
|
||||
paddingBottom: 6,
|
||||
marginTop: -6,
|
||||
zIndex: 2,
|
||||
|
|
|
@ -89,7 +89,7 @@ describe('TagEditor', () => {
|
|||
|
||||
const searchResult = screen.getByRole('button', { name: 'new tag 1' });
|
||||
fireEvent.press(searchResult);
|
||||
expect(currentTags).toEqual(['test', 'new tag 1']);
|
||||
expect(currentTags).toEqual(['new tag 1', 'test']);
|
||||
|
||||
// Manually unmount to prevent warnings
|
||||
unmount();
|
||||
|
@ -115,7 +115,7 @@ describe('TagEditor', () => {
|
|||
|
||||
const addNewButton = screen.getByRole('button', { name: 'Add new' });
|
||||
fireEvent.press(addNewButton);
|
||||
expect(currentTags).toEqual(['test', 'create']);
|
||||
expect(currentTags).toEqual(['create', 'test']);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
|
|
@ -10,6 +10,7 @@ import { TagEntity } from '@joplin/lib/services/database/types';
|
|||
import { Divider } from 'react-native-paper';
|
||||
import focusView from '../utils/focusView';
|
||||
import { msleep } from '@joplin/utils/time';
|
||||
import { getCollator, getCollatorLocale } from '@joplin/lib/models/utils/getCollator';
|
||||
|
||||
export enum TagEditorMode {
|
||||
Large,
|
||||
|
@ -38,11 +39,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,
|
||||
|
@ -122,7 +125,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}
|
||||
|
@ -144,23 +151,32 @@ interface TagsBoxProps {
|
|||
}
|
||||
|
||||
const TagsBox: React.FC<TagsBoxProps> = props => {
|
||||
const collatorLocale = getCollatorLocale();
|
||||
const collator = useMemo(() => {
|
||||
return getCollator(collatorLocale);
|
||||
}, [collatorLocale]);
|
||||
|
||||
const onRemoveTag = useCallback((tag: string) => {
|
||||
props.onRemoveTag(tag);
|
||||
}, [props.onRemoveTag]);
|
||||
|
||||
const renderContent = () => {
|
||||
if (props.tags.length) {
|
||||
return props.tags.map(tag => (
|
||||
<TagCard
|
||||
key={`tag-${tag}`}
|
||||
title={tag}
|
||||
styles={props.styles}
|
||||
themeId={props.themeId}
|
||||
onRemove={onRemoveTag}
|
||||
autofocus={props.autofocusTag === tag}
|
||||
onAutoFocusComplete={props.onAutoFocusComplete}
|
||||
/>
|
||||
));
|
||||
return props.tags
|
||||
.sort((a, b) => {
|
||||
return collator.compare(a, b);
|
||||
})
|
||||
.map(tag => (
|
||||
<TagCard
|
||||
key={`tag-${tag}`}
|
||||
title={tag}
|
||||
styles={props.styles}
|
||||
themeId={props.themeId}
|
||||
onRemove={onRemoveTag}
|
||||
autofocus={props.autofocusTag === tag}
|
||||
onAutoFocusComplete={props.onAutoFocusComplete}
|
||||
/>
|
||||
));
|
||||
} else {
|
||||
return <Text
|
||||
style={props.styles.noTagsLabel}
|
||||
|
@ -189,15 +205,13 @@ const TagsBox: React.FC<TagsBoxProps> = props => {
|
|||
</View>;
|
||||
};
|
||||
|
||||
const normalizeTag = (tagText: string) => tagText.trim().toLowerCase();
|
||||
|
||||
const TagEditor: React.FC<Props> = props => {
|
||||
const styles = useStyles(props.themeId, props.headerStyle);
|
||||
|
||||
const comboBoxItems = useMemo(() => {
|
||||
return props.allTags
|
||||
// Exclude tags already associated with the note
|
||||
.filter(tag => !props.tags.includes(tag.title))
|
||||
.filter(tag => !props.tags.some(o => o.toLowerCase() === tag.title?.toLowerCase()))
|
||||
.map((tag): Option => {
|
||||
const title = tag.title ?? 'Untitled';
|
||||
return {
|
||||
|
@ -217,11 +231,13 @@ const TagEditor: React.FC<Props> = props => {
|
|||
|
||||
const onAddTag = useCallback((title: string) => {
|
||||
AccessibilityInfo.announceForAccessibility(_('Added tag: %s', title));
|
||||
props.onTagsChange([...props.tags, normalizeTag(title)]);
|
||||
props.onTagsChange([...props.tags, title.trim()]);
|
||||
}, [props.tags, props.onTagsChange]);
|
||||
|
||||
const onRemoveTag = useCallback(async (title: string) => {
|
||||
const previousTagIndex = props.tags.indexOf(title);
|
||||
if (!title) return;
|
||||
const lowercaseTitle = title.toLowerCase();
|
||||
const previousTagIndex = props.tags.findIndex(item => item.toLowerCase() === lowercaseTitle);
|
||||
const targetTag = props.tags[previousTagIndex + 1] ?? props.tags[previousTagIndex - 1];
|
||||
setAutofocusTag(targetTag);
|
||||
|
||||
|
@ -229,7 +245,7 @@ const TagEditor: React.FC<Props> = props => {
|
|||
// prevent focus from occasionally jumping away from the tag box.
|
||||
await msleep(100);
|
||||
AccessibilityInfo.announceForAccessibility(_('Removed tag: %s', title));
|
||||
props.onTagsChange(props.tags.filter(tag => tag !== title));
|
||||
props.onTagsChange(props.tags.filter(tag => tag.toLowerCase() !== lowercaseTitle));
|
||||
}, [props.tags, props.onTagsChange]);
|
||||
|
||||
const onComboBoxSelect = useCallback((item: { title: string }) => {
|
||||
|
@ -237,16 +253,16 @@ const TagEditor: React.FC<Props> = props => {
|
|||
return { willRemove: true };
|
||||
}, [onAddTag]);
|
||||
|
||||
const allTagsSet = useMemo(() => {
|
||||
const allTagsSetNormalized = useMemo(() => {
|
||||
return new Set([
|
||||
...props.allTags.map(tag => tag.title),
|
||||
...props.tags,
|
||||
...props.allTags.map(tag => tag.title?.trim()?.toLowerCase()),
|
||||
...props.tags.map(tag => tag.trim().toLowerCase()),
|
||||
]);
|
||||
}, [props.allTags, props.tags]);
|
||||
|
||||
const onCanAddTag = useCallback((tag: string) => {
|
||||
return !allTagsSet.has(normalizeTag(tag));
|
||||
}, [allTagsSet]);
|
||||
return !allTagsSetNormalized.has(tag.trim().toLowerCase());
|
||||
}, [allTagsSetNormalized]);
|
||||
|
||||
const showAssociatedTags = props.mode === TagEditorMode.Large || props.tags.length > 0;
|
||||
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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
|
|
@ -73,6 +73,7 @@ import { defaultWindowId } from '@joplin/lib/reducer';
|
|||
import useVisiblePluginEditorViewIds from '@joplin/lib/hooks/plugins/useVisiblePluginEditorViewIds';
|
||||
import { SelectionRange } from '../../../contentScripts/markdownEditorBundle/types';
|
||||
import { EditorType } from '../../NoteEditor/types';
|
||||
import IconButton from '../../IconButton';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const emptyArray: any[] = [];
|
||||
|
@ -148,6 +149,7 @@ interface State {
|
|||
};
|
||||
|
||||
showSpeechToTextDialog: boolean;
|
||||
multiline: boolean;
|
||||
}
|
||||
|
||||
class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> implements BaseNoteScreenComponent<State> {
|
||||
|
@ -219,6 +221,7 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
|||
},
|
||||
|
||||
showSpeechToTextDialog: false,
|
||||
multiline: false,
|
||||
};
|
||||
|
||||
this.titleTextFieldRef = React.createRef();
|
||||
|
@ -508,7 +511,6 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
|||
flexDirection: 'row',
|
||||
flexBasis: 'auto',
|
||||
paddingLeft: theme.marginLeft,
|
||||
paddingRight: theme.marginRight,
|
||||
borderBottomColor: theme.dividerColor,
|
||||
borderBottomWidth: 1,
|
||||
};
|
||||
|
@ -528,6 +530,16 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
|||
paddingBottom: 10, // Added for iOS (Not needed for Android??)
|
||||
};
|
||||
|
||||
styles.titleToggleIcon = {
|
||||
color: theme.colorFaded,
|
||||
fontSize: 30,
|
||||
height: 48,
|
||||
width: 48,
|
||||
verticalAlign: 'middle',
|
||||
textAlign: 'center',
|
||||
alignContent: 'center',
|
||||
};
|
||||
|
||||
this.styles_[cacheKey] = StyleSheet.create(styles);
|
||||
return this.styles_[cacheKey];
|
||||
}
|
||||
|
@ -701,7 +713,8 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
|||
}
|
||||
|
||||
private title_changeText(text: string) {
|
||||
shared.noteComponent_change(this, 'title', text);
|
||||
const newText = text.replace(/(\r\n|\n|\r)/gm, ' ');
|
||||
shared.noteComponent_change(this, 'title', newText);
|
||||
this.setState({ newAndNoTitleChangeNoteId: null });
|
||||
}
|
||||
|
||||
|
@ -1172,69 +1185,6 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
|||
await CommandService.instance().execute('attachFile', filePath);
|
||||
};
|
||||
|
||||
// private vosk_:Vosk;
|
||||
|
||||
// private async getVosk() {
|
||||
// if (this.vosk_) return this.vosk_;
|
||||
// this.vosk_ = new Vosk();
|
||||
// await this.vosk_.loadModel('model-fr-fr');
|
||||
// return this.vosk_;
|
||||
// }
|
||||
|
||||
// private async voiceRecording_onPress() {
|
||||
// logger.info('Vosk: Getting instance...');
|
||||
|
||||
// const vosk = await this.getVosk();
|
||||
|
||||
// this.voskResult_ = [];
|
||||
|
||||
// const eventHandlers: any[] = [];
|
||||
|
||||
// eventHandlers.push(vosk.onResult(e => {
|
||||
// logger.info('Vosk: result', e.data);
|
||||
// this.voskResult_.push(e.data);
|
||||
// }));
|
||||
|
||||
// eventHandlers.push(vosk.onError(e => {
|
||||
// logger.warn('Vosk: error', e.data);
|
||||
// }));
|
||||
|
||||
// eventHandlers.push(vosk.onTimeout(e => {
|
||||
// logger.warn('Vosk: timeout', e.data);
|
||||
// }));
|
||||
|
||||
// eventHandlers.push(vosk.onFinalResult(e => {
|
||||
// logger.info('Vosk: final result', e.data);
|
||||
// }));
|
||||
|
||||
// logger.info('Vosk: Starting recording...');
|
||||
|
||||
// void vosk.start();
|
||||
|
||||
// const buttonId = await dialogs.pop(this, 'Voice recording in progress...', [
|
||||
// { text: 'Stop recording', id: 'stop' },
|
||||
// { text: _('Cancel'), id: 'cancel' },
|
||||
// ]);
|
||||
|
||||
// logger.info('Vosk: Stopping recording...');
|
||||
// vosk.stop();
|
||||
|
||||
// for (const eventHandler of eventHandlers) {
|
||||
// eventHandler.remove();
|
||||
// }
|
||||
|
||||
// logger.info('Vosk: Recording stopped:', this.voskResult_);
|
||||
|
||||
// if (buttonId === 'cancel') return;
|
||||
|
||||
// const newNote: NoteEntity = { ...this.state.note };
|
||||
// newNote.body = `${newNote.body} ${this.voskResult_.join(' ')}`;
|
||||
// this.setState({ note: newNote });
|
||||
// this.scheduleSave();
|
||||
// }
|
||||
|
||||
|
||||
|
||||
public menuOptions() {
|
||||
const note = this.state.note;
|
||||
const isTodo = note && !!note.is_todo;
|
||||
|
@ -1726,6 +1676,15 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
|||
placeholder={_('Add title')}
|
||||
placeholderTextColor={theme.colorFaded}
|
||||
editable={!this.state.readOnly}
|
||||
multiline={this.state.multiline}
|
||||
submitBehavior = "blurAndSubmit"
|
||||
/>
|
||||
<IconButton
|
||||
iconName={(!this.state.multiline && 'material menu-down') || (this.state.multiline && 'material menu-up')}
|
||||
onPress={() => this.setState({ multiline: !this.state.multiline })}
|
||||
description={(!this.state.multiline && _('Expand title')) || (this.state.multiline && _('Collapse title'))}
|
||||
iconStyle={this.styles().titleToggleIcon}
|
||||
themeId={this.props.themeId}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import * as React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { View, StyleSheet, SafeAreaView, ScrollView } from 'react-native';
|
||||
import { View, StyleSheet, TextInput } from 'react-native';
|
||||
import { AppState } from '../../utils/types';
|
||||
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
|
||||
import Revision from '@joplin/lib/models/Revision';
|
||||
|
@ -102,15 +102,12 @@ const useStyles = (themeId: number) => {
|
|||
root: {
|
||||
...theme.rootStyle,
|
||||
},
|
||||
titleContainer: {
|
||||
titleViewContainer: {
|
||||
paddingLeft: theme.marginLeft,
|
||||
paddingRight: theme.marginRight,
|
||||
borderTopColor: theme.dividerColor,
|
||||
borderTopWidth: 1,
|
||||
borderBottomColor: theme.dividerColor,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
titleViewContainer: {
|
||||
flex: 0,
|
||||
flexDirection: 'row',
|
||||
flexBasis: 'auto',
|
||||
|
@ -139,6 +136,7 @@ const NoteRevisionViewer: React.FC<Props> = props => {
|
|||
const { note, resources } = useRevisionNote(revisions, currentRevisionId);
|
||||
const [initialScroll, setInitialScroll] = useState(0);
|
||||
const [hasRevisions, setHasRevisions] = useState(false);
|
||||
const [multiline, setMultiline] = useState(false);
|
||||
|
||||
const options = useMemo(() => {
|
||||
const result = [];
|
||||
|
@ -201,6 +199,9 @@ const NoteRevisionViewer: React.FC<Props> = props => {
|
|||
const onHelpPress = useCallback(() => {
|
||||
void dialogs.info(helpMessageText);
|
||||
}, [helpMessageText, dialogs]);
|
||||
const onToggleTitlePress = useCallback(() => {
|
||||
void setMultiline(!multiline);
|
||||
}, [multiline]);
|
||||
|
||||
const styles = useStyles(props.themeId);
|
||||
const dropdownLabelText = _('Revision:');
|
||||
|
@ -213,13 +214,20 @@ const NoteRevisionViewer: React.FC<Props> = props => {
|
|||
);
|
||||
|
||||
const titleComponent = (
|
||||
<SafeAreaView style={styles.titleContainer}>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View style={styles.titleViewContainer}>
|
||||
<Text style={styles.titleText}>{note?.title ?? ''}</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
<View style={styles.titleViewContainer}>
|
||||
<TextInput
|
||||
style={styles.titleText}
|
||||
value={note?.title ?? ''}
|
||||
editable={false}
|
||||
multiline={multiline}
|
||||
/>
|
||||
<IconButton
|
||||
icon={(!multiline && 'menu-down') || (multiline && 'menu-up')}
|
||||
accessibilityLabel={(!multiline && _('Expand title')) || (multiline && _('Collapse title'))}
|
||||
onPress={onToggleTitlePress}
|
||||
size={30}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
return <View style={styles.root}>
|
||||
|
|
|
@ -11,6 +11,7 @@ import { TagEntity } from '@joplin/lib/services/database/types';
|
|||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Dispatch } from 'redux';
|
||||
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
|
||||
import { getCollator, getCollatorLocale } from '@joplin/lib/models/utils/getCollator';
|
||||
|
||||
interface Props {
|
||||
dispatch: Dispatch;
|
||||
|
@ -46,13 +47,17 @@ const useStyles = (themeId: number) => {
|
|||
const TagsScreenComponent: React.FC<Props> = props => {
|
||||
const [tags, setTags] = useState<TagEntity[]>([]);
|
||||
const styles = useStyles(props.themeId);
|
||||
const collatorLocale = getCollatorLocale();
|
||||
const collator = useMemo(() => {
|
||||
return getCollator(collatorLocale);
|
||||
}, [collatorLocale]);
|
||||
|
||||
type TagItemPressEvent = { id: string };
|
||||
|
||||
useAsyncEffect(async () => {
|
||||
const tags = await Tag.allWithNotes();
|
||||
tags.sort((a, b) => {
|
||||
return a.title.toLowerCase() < b.title.toLowerCase() ? -1 : +1;
|
||||
return collator.compare(a.title, b.title);
|
||||
});
|
||||
setTags(tags);
|
||||
}, []);
|
||||
|
|
|
@ -5,9 +5,6 @@ import { _, languageName } from '@joplin/lib/locale';
|
|||
import useAsyncEffect, { AsyncEffectEvent } from '@joplin/lib/hooks/useAsyncEffect';
|
||||
import VoiceTyping, { OnTextCallback, VoiceTypingSession } from '../../services/voiceTyping/VoiceTyping';
|
||||
import whisper from '../../services/voiceTyping/whisper';
|
||||
import vosk from '../../services/voiceTyping/vosk';
|
||||
import { AppState } from '../../utils/types';
|
||||
import { connect } from 'react-redux';
|
||||
import { RecorderState } from './types';
|
||||
import RecordingControls from './RecordingControls';
|
||||
import { PrimaryButton } from '../buttons';
|
||||
|
@ -16,19 +13,17 @@ import shim from '@joplin/lib/shim';
|
|||
|
||||
interface Props {
|
||||
locale: string;
|
||||
provider: string;
|
||||
onDismiss: ()=> void;
|
||||
onText: (text: string)=> void;
|
||||
}
|
||||
|
||||
interface UseVoiceTypingProps {
|
||||
locale: string;
|
||||
provider: string;
|
||||
onSetPreview: OnTextCallback;
|
||||
onText: OnTextCallback;
|
||||
}
|
||||
|
||||
const useVoiceTyping = ({ locale, provider, onSetPreview, onText }: UseVoiceTypingProps) => {
|
||||
const useVoiceTyping = ({ locale, onSetPreview, onText }: UseVoiceTypingProps) => {
|
||||
const [voiceTyping, setVoiceTyping] = useState<VoiceTypingSession>(null);
|
||||
const [error, setError] = useState<Error|null>(null);
|
||||
const [mustDownloadModel, setMustDownloadModel] = useState<boolean | null>(null);
|
||||
|
@ -43,8 +38,8 @@ const useVoiceTyping = ({ locale, provider, onSetPreview, onText }: UseVoiceTypi
|
|||
voiceTypingRef.current = voiceTyping;
|
||||
|
||||
const builder = useMemo(() => {
|
||||
return new VoiceTyping(locale, provider?.startsWith('whisper') ? [whisper] : [vosk]);
|
||||
}, [locale, provider]);
|
||||
return new VoiceTyping(locale, [whisper]);
|
||||
}, [locale]);
|
||||
|
||||
const [redownloadCounter, setRedownloadCounter] = useState(0);
|
||||
|
||||
|
@ -121,7 +116,6 @@ const SpeechToTextComponent: React.FC<Props> = props => {
|
|||
locale: props.locale,
|
||||
onSetPreview: setPreview,
|
||||
onText: props.onText,
|
||||
provider: props.provider,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -209,6 +203,4 @@ const SpeechToTextComponent: React.FC<Props> = props => {
|
|||
/>;
|
||||
};
|
||||
|
||||
export default connect((state: AppState) => ({
|
||||
provider: state.settings['voiceTyping.preferredProvider'],
|
||||
}))(SpeechToTextComponent);
|
||||
export default SpeechToTextComponent;
|
||||
|
|
|
@ -5,6 +5,7 @@ import { EditorProcessApi, EditorProps, EditorWithParentProps, ExportedWebViewGl
|
|||
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';
|
||||
|
||||
|
@ -47,6 +48,10 @@ export const createEditorWithParent = ({
|
|||
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,
|
||||
|
@ -68,8 +73,16 @@ export const createEditorWithParent = ({
|
|||
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;
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -37,5 +37,5 @@ export interface MainProcessApi {
|
|||
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>;
|
||||
}
|
||||
|
|
|
@ -130,7 +130,7 @@ const useWebViewSetup = ({
|
|||
async onEditorAdded() {
|
||||
messenger.remoteApi.updatePlugins(codeMirrorPluginsRef.current);
|
||||
},
|
||||
async onResolveImageSrc(src) {
|
||||
async onResolveImageSrc(src, reloadCounter) {
|
||||
const url = parseResourceUrl(src);
|
||||
if (!url.itemId) return null;
|
||||
const item = await Resource.load(url.itemId);
|
||||
|
@ -144,7 +144,8 @@ const useWebViewSetup = ({
|
|||
}
|
||||
return null;
|
||||
} else {
|
||||
return Resource.fullPath(item);
|
||||
const path = Resource.fullPath(item);
|
||||
return reloadCounter ? `${path}?r=${reloadCounter}` : path;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
},
|
||||
|
@ -234,16 +238,21 @@ const useWebViewSetup = (props: Props): SetUpResult<RendererControl> => {
|
|||
|
||||
return {
|
||||
rerenderToBody: async (markup, options, cancelEvent) => {
|
||||
const { getSettings, getSettingsChanged } = await prepareRenderer(options);
|
||||
const { getSettings } = await prepareRenderer(options);
|
||||
if (cancelEvent?.cancelled) return null;
|
||||
|
||||
const output = await renderer.rerenderToBody(markup, getSettings());
|
||||
if (cancelEvent?.cancelled) return null;
|
||||
const render = async () => {
|
||||
if (cancelEvent?.cancelled) return;
|
||||
|
||||
if (getSettingsChanged()) {
|
||||
return await renderer.rerenderToBody(markup, getSettings());
|
||||
}
|
||||
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 { getSettings, getSettingsChanged } = await prepareRenderer(options);
|
||||
|
|
|
@ -44,11 +44,14 @@ const useMessenger = (props: UseMessengerProps) => {
|
|||
onAttachRef.current = props.onAttachFile;
|
||||
|
||||
const markupRenderingSettings = useRef<RenderOptions>(null);
|
||||
const baseTheme = props.settings.themeData;
|
||||
markupRenderingSettings.current = {
|
||||
themeId: props.themeId,
|
||||
highlightedKeywords: [],
|
||||
resources: props.noteResources,
|
||||
themeOverrides: {},
|
||||
themeOverrides: {
|
||||
noteViewerFontSize: `${baseTheme.fontSize}${baseTheme.fontSizeUnits ?? 'px'}`,
|
||||
},
|
||||
noteHash: '',
|
||||
initialScroll: 0,
|
||||
pluginAssetContainerSelector: null,
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import './utils/polyfills';
|
||||
import { AppRegistry } from 'react-native';
|
||||
import Root from './root';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
|
|
|
@ -535,7 +535,7 @@
|
|||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 144;
|
||||
CURRENT_PROJECT_VERSION = 145;
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Joplin/Info.plist;
|
||||
|
@ -544,7 +544,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 13.4.2;
|
||||
MARKETING_VERSION = 13.4.3;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
|
@ -570,7 +570,7 @@
|
|||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 144;
|
||||
CURRENT_PROJECT_VERSION = 145;
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
INFOPLIST_FILE = Joplin/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
|
||||
|
@ -578,7 +578,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 13.4.2;
|
||||
MARKETING_VERSION = 13.4.3;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
|
@ -771,7 +771,7 @@
|
|||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 144;
|
||||
CURRENT_PROJECT_VERSION = 145;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
|
@ -782,7 +782,7 @@
|
|||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 13.4.2;
|
||||
MARKETING_VERSION = 13.4.3;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
OTHER_LDFLAGS = (
|
||||
|
@ -814,7 +814,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 144;
|
||||
CURRENT_PROJECT_VERSION = 145;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
|
@ -825,7 +825,7 @@
|
|||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 13.4.2;
|
||||
MARKETING_VERSION = 13.4.3;
|
||||
MTL_FAST_MATH = YES;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
|
|
|
@ -58,13 +58,13 @@
|
|||
"react-native-file-viewer": "2.1.5",
|
||||
"react-native-fs": "2.20.0",
|
||||
"react-native-get-random-values": "1.11.0",
|
||||
"react-native-image-picker": "8.0.0",
|
||||
"react-native-image-picker": "8.2.1",
|
||||
"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.1",
|
||||
"react-native-securerandom": "1.0.1",
|
||||
|
@ -73,7 +73,6 @@
|
|||
"react-native-url-polyfill": "2.0.0",
|
||||
"react-native-vector-icons": "10.2.0",
|
||||
"react-native-version-info": "1.1.1",
|
||||
"react-native-vosk": "0.1.12",
|
||||
"react-native-webview": "13.13.5",
|
||||
"react-native-zip-archive": "7.0.1",
|
||||
"react-redux": "8.1.3",
|
||||
|
@ -94,19 +93,19 @@
|
|||
"@joplin/tools": "~3.4",
|
||||
"@joplin/turndown": "~4.0.80",
|
||||
"@joplin/turndown-plugin-gfm": "~1.0.62",
|
||||
"@js-draw/material-icons": "1.30.0",
|
||||
"@js-draw/material-icons": "1.30.1",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.15",
|
||||
"@react-native-community/cli": "16.0.3",
|
||||
"@react-native-community/cli-platform-android": "16.0.3",
|
||||
"@react-native-community/cli-platform-ios": "16.0.3",
|
||||
"@react-native/babel-preset": "0.79.2",
|
||||
"@react-native/metro-config": "0.79.2",
|
||||
"@react-native/babel-preset": "0.79.3",
|
||||
"@react-native/metro-config": "0.79.3",
|
||||
"@react-native/typescript-config": "0.79.2",
|
||||
"@sqlite.org/sqlite-wasm": "3.46.0-build2",
|
||||
"@testing-library/react-native": "13.2.0",
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/node": "18.19.103",
|
||||
"@types/node": "18.19.111",
|
||||
"@types/react": "19.0.14",
|
||||
"@types/react-redux": "7.1.33",
|
||||
"@types/serviceworker": "0.0.135",
|
||||
|
@ -115,7 +114,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",
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
|
@ -1,197 +0,0 @@
|
|||
import { languageCodeOnly } from '@joplin/lib/locale';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { rtrimSlashes } from '@joplin/lib/path-utils';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import Vosk from 'react-native-vosk';
|
||||
import RNFetchBlob from 'rn-fetch-blob';
|
||||
import { VoiceTypingProvider, VoiceTypingSession } from './VoiceTyping';
|
||||
import { join } from 'path';
|
||||
|
||||
const logger = Logger.create('voiceTyping/vosk');
|
||||
|
||||
enum State {
|
||||
Idle = 0,
|
||||
Recording,
|
||||
Completing,
|
||||
}
|
||||
|
||||
interface StartOptions {
|
||||
onResult: (text: string)=> void;
|
||||
}
|
||||
|
||||
let vosk_: Record<string, Vosk> = {};
|
||||
|
||||
let state_: State = State.Idle;
|
||||
|
||||
const defaultSupportedLanguages = {
|
||||
'en': 'https://alphacephei.com/vosk/models/vosk-model-small-en-us-0.15.zip',
|
||||
'zh': 'https://alphacephei.com/vosk/models/vosk-model-small-cn-0.22.zip',
|
||||
'ru': 'https://alphacephei.com/vosk/models/vosk-model-small-ru-0.22.zip',
|
||||
'fr': 'https://alphacephei.com/vosk/models/vosk-model-small-fr-0.22.zip',
|
||||
'de': 'https://alphacephei.com/vosk/models/vosk-model-small-de-0.15.zip',
|
||||
'es': 'https://alphacephei.com/vosk/models/vosk-model-small-es-0.42.zip',
|
||||
'pt': 'https://alphacephei.com/vosk/models/vosk-model-small-pt-0.3.zip',
|
||||
'tr': 'https://alphacephei.com/vosk/models/vosk-model-small-tr-0.3.zip',
|
||||
'vn': 'https://alphacephei.com/vosk/models/vosk-model-small-vn-0.4.zip',
|
||||
'it': 'https://alphacephei.com/vosk/models/vosk-model-small-it-0.22.zip',
|
||||
'nl': 'https://alphacephei.com/vosk/models/vosk-model-small-nl-0.22.zip',
|
||||
'uk': 'https://alphacephei.com/vosk/models/vosk-model-small-uk-v3-small.zip',
|
||||
'ja': 'https://alphacephei.com/vosk/models/vosk-model-small-ja-0.22.zip',
|
||||
'hi': 'https://alphacephei.com/vosk/models/vosk-model-small-hi-0.22.zip',
|
||||
'cs': 'https://alphacephei.com/vosk/models/vosk-model-small-cs-0.4-rhasspy.zip',
|
||||
'pl': 'https://alphacephei.com/vosk/models/vosk-model-small-pl-0.22.zip',
|
||||
'uz': 'https://alphacephei.com/vosk/models/vosk-model-small-uz-0.22.zip',
|
||||
'ko': 'https://alphacephei.com/vosk/models/vosk-model-small-ko-0.22.zip',
|
||||
};
|
||||
|
||||
export const isSupportedLanguage = (locale: string) => {
|
||||
const l = languageCodeOnly(locale).toLowerCase();
|
||||
return Object.keys(defaultSupportedLanguages).includes(l);
|
||||
};
|
||||
|
||||
// Where all the models files for all the languages are
|
||||
const getModelRootDir = () => {
|
||||
return `${RNFetchBlob.fs.dirs.DocumentDir}/vosk-models`;
|
||||
};
|
||||
|
||||
// Where we unzip a model after downloading it
|
||||
const getUnzipDir = (locale: string) => {
|
||||
return `${getModelRootDir()}/${locale}`;
|
||||
};
|
||||
|
||||
// Where the model for a particular language is
|
||||
const getModelDir = (locale: string) => {
|
||||
return `${getUnzipDir(locale)}/model`;
|
||||
};
|
||||
|
||||
const languageModelUrl = (locale: string): string => {
|
||||
const lang = languageCodeOnly(locale).toLowerCase();
|
||||
if (!(lang in defaultSupportedLanguages)) throw new Error(`No language file for: ${locale}`);
|
||||
|
||||
const urlTemplate = rtrimSlashes(Setting.value('voiceTypingBaseUrl').trim());
|
||||
|
||||
if (urlTemplate) {
|
||||
let url = rtrimSlashes(urlTemplate);
|
||||
if (!url.includes('{lang}')) url += '/{lang}.zip';
|
||||
return url.replace(/\{lang\}/g, lang);
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
return (defaultSupportedLanguages as any)[lang];
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
export const getVosk = async (modelDir: string, locale: string) => {
|
||||
if (vosk_[locale]) return vosk_[locale];
|
||||
|
||||
const vosk = new Vosk();
|
||||
logger.info(`Loading model from ${modelDir}`);
|
||||
await shim.fsDriver().readDirStats(modelDir);
|
||||
const result = await vosk.loadModel(modelDir);
|
||||
logger.info('getVosk:', result);
|
||||
|
||||
vosk_ = { [locale]: vosk };
|
||||
|
||||
return vosk;
|
||||
};
|
||||
|
||||
export const startRecording = (vosk: Vosk, options: StartOptions): VoiceTypingSession => {
|
||||
if (state_ !== State.Idle) throw new Error('Vosk is already recording');
|
||||
|
||||
state_ = State.Recording;
|
||||
|
||||
const result: string[] = [];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const eventHandlers: any[] = [];
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
|
||||
const finalResultPromiseResolve: Function = null;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
|
||||
const finalResultPromiseReject: Function = null;
|
||||
const finalResultTimeout = false;
|
||||
|
||||
const completeRecording = (finalResult: string, error: Error) => {
|
||||
logger.info(`Complete recording. Final result: ${finalResult}. Error:`, error);
|
||||
|
||||
for (const eventHandler of eventHandlers) {
|
||||
eventHandler.remove();
|
||||
}
|
||||
|
||||
vosk.cleanup();
|
||||
|
||||
state_ = State.Idle;
|
||||
|
||||
if (error) {
|
||||
if (finalResultPromiseReject) finalResultPromiseReject(error);
|
||||
} else {
|
||||
if (finalResultPromiseResolve) finalResultPromiseResolve(finalResult);
|
||||
}
|
||||
};
|
||||
|
||||
eventHandlers.push(vosk.onResult(e => {
|
||||
const text = e.data;
|
||||
logger.info('Result', text);
|
||||
result.push(text);
|
||||
options.onResult(text);
|
||||
}));
|
||||
|
||||
eventHandlers.push(vosk.onError(e => {
|
||||
logger.warn('Error', e.data);
|
||||
}));
|
||||
|
||||
eventHandlers.push(vosk.onTimeout(e => {
|
||||
logger.warn('Timeout', e.data);
|
||||
}));
|
||||
|
||||
eventHandlers.push(vosk.onFinalResult(e => {
|
||||
logger.info('Final result', e.data);
|
||||
|
||||
if (finalResultTimeout) {
|
||||
logger.warn('Got final result - but already timed out. Not doing anything.');
|
||||
return;
|
||||
}
|
||||
|
||||
completeRecording(e.data, null);
|
||||
}));
|
||||
|
||||
const stopOrCancel = () => {
|
||||
if (state_ === State.Recording) {
|
||||
logger.info('Cancelling...');
|
||||
state_ = State.Completing;
|
||||
vosk.stopOnly();
|
||||
completeRecording('', null);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
start: async () => {
|
||||
logger.info('Starting recording...');
|
||||
await vosk.start();
|
||||
},
|
||||
stop: async () => {
|
||||
stopOrCancel();
|
||||
},
|
||||
cancel: async () => {
|
||||
stopOrCancel();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
const vosk: VoiceTypingProvider = {
|
||||
supported: () => true,
|
||||
modelLocalFilepath: (locale: string) => getModelDir(locale),
|
||||
deleteCachedModels: async (locale: string) => {
|
||||
const path = getModelDir(locale);
|
||||
await shim.fsDriver().remove(path, { recursive: true });
|
||||
},
|
||||
getDownloadUrl: (locale) => languageModelUrl(locale),
|
||||
getUuidPath: (locale: string) => join(getModelDir(locale), 'uuid'),
|
||||
build: async ({ callbacks, locale, modelPath }) => {
|
||||
const vosk = await getVosk(modelPath, locale);
|
||||
return startRecording(vosk, { onResult: callbacks.onFinalize });
|
||||
},
|
||||
modelName: 'vosk',
|
||||
};
|
||||
|
||||
export default vosk;
|
|
@ -1,15 +0,0 @@
|
|||
import { VoiceTypingProvider } from './VoiceTyping';
|
||||
|
||||
const vosk: VoiceTypingProvider = {
|
||||
supported: () => false,
|
||||
modelLocalFilepath: () => null,
|
||||
getDownloadUrl: () => null,
|
||||
getUuidPath: () => null,
|
||||
deleteCachedModels: () => null,
|
||||
build: async () => {
|
||||
throw new Error('Unsupported!');
|
||||
},
|
||||
modelName: 'vosk',
|
||||
};
|
||||
|
||||
export default vosk;
|
|
@ -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;
|
||||
|
|
|
@ -10,13 +10,16 @@ const useKeyboardState = () => {
|
|||
const showListener = Keyboard.addListener('keyboardDidShow', (evt) => {
|
||||
setKeyboardVisible(true);
|
||||
setHasSoftwareKeyboard(true);
|
||||
setKeyboardHeight(evt.endCoordinates.height);
|
||||
// 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
|
||||
|
|
|
@ -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[]) {
|
||||
|
|
|
@ -51,7 +51,7 @@ import ctrlClickCheckboxExtension from './extensions/ctrlClickCheckboxExtension'
|
|||
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;
|
||||
|
@ -66,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);
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -1,23 +1,39 @@
|
|||
import { EditorSelection } from '@codemirror/state';
|
||||
import createTestEditor from '../../testing/createTestEditor';
|
||||
import renderBlockImages from './renderBlockImages';
|
||||
import renderBlockImages, { resetImageResourceEffect, testing__resetImageRefreshCounterCache } from './renderBlockImages';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
|
||||
const createEditor = (initialMarkdown: string, hasImage: boolean) => {
|
||||
const resolveImageSrc = jest.fn(src => Promise.resolve(src));
|
||||
return createTestEditor(
|
||||
const allowImageUrlsToBeFetched = async () => {
|
||||
// Yield to the event loop. Since image URLs are fetched asynchronously, this is needed to
|
||||
// allow the asynchronous promise code to run
|
||||
await Promise.resolve();
|
||||
};
|
||||
|
||||
const createEditor = async (initialMarkdown: string, hasImage: boolean) => {
|
||||
const resolveImageSrc = jest.fn((src, counter) => Promise.resolve(`${src}?r=${counter}`));
|
||||
const editor = await createTestEditor(
|
||||
initialMarkdown,
|
||||
EditorSelection.cursor(0),
|
||||
hasImage ? ['Image'] : [],
|
||||
[renderBlockImages({ resolveImageSrc })],
|
||||
);
|
||||
await allowImageUrlsToBeFetched();
|
||||
return editor;
|
||||
};
|
||||
|
||||
const findImage = (editor: EditorView) => {
|
||||
return editor.dom.querySelector('div.cm-md-image > .image');
|
||||
const findImages = (editor: EditorView) => {
|
||||
return editor.dom.querySelectorAll<HTMLDivElement>('div.cm-md-image > .image');
|
||||
};
|
||||
|
||||
const getImageBackgroundUrls = (editor: EditorView) => {
|
||||
return [...findImages(editor)].map(image => image.style.backgroundImage);
|
||||
};
|
||||
|
||||
describe('renderBlockImages', () => {
|
||||
beforeEach(() => {
|
||||
testing__resetImageRefreshCounterCache();
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ spaceBefore: '', spaceAfter: '\n\n', alt: 'test' },
|
||||
{ spaceBefore: '', spaceAfter: '', alt: 'This is a test!' },
|
||||
|
@ -26,17 +42,50 @@ describe('renderBlockImages', () => {
|
|||
])('should render images below their Markdown source (case %#)', async ({ spaceBefore, spaceAfter, alt }) => {
|
||||
const editor = await createEditor(`${spaceBefore}${spaceAfter}`, true);
|
||||
|
||||
const image = findImage(editor);
|
||||
expect(image).toBeTruthy();
|
||||
expect(image.role).toBe('image');
|
||||
expect(image.ariaLabel).toBe(alt);
|
||||
const images = findImages(editor);
|
||||
expect(images).toHaveLength(1);
|
||||
expect(images[0].role).toBe('image');
|
||||
expect(images[0].ariaLabel).toBe(alt);
|
||||
});
|
||||
|
||||
// For now, only Joplin resources are rendered. This simplifies the implementation and avoids
|
||||
// potentially-unwanted web requests when opening a note with only the editor open.
|
||||
test('should not render web images', async () => {
|
||||
const editor = await createEditor('\n\n', true);
|
||||
const image = findImage(editor);
|
||||
expect(image).toBeNull();
|
||||
const images = findImages(editor);
|
||||
expect(images).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('should allow reloading specific images', async () => {
|
||||
const editor = await createEditor('\n', true);
|
||||
|
||||
// Should have the expected original image URLs
|
||||
expect(getImageBackgroundUrls(editor)).toMatchObject([
|
||||
'url(:/a123456789abcdef0123456789abcdef?r=0)',
|
||||
'url(:/b123456789abcdef0123456789abcde2?r=0)',
|
||||
]);
|
||||
|
||||
editor.dispatch({
|
||||
effects: [resetImageResourceEffect.of({ id: 'a123456789abcdef0123456789abcdef' })],
|
||||
});
|
||||
await allowImageUrlsToBeFetched();
|
||||
|
||||
expect(getImageBackgroundUrls(editor)).toMatchObject([
|
||||
'url(:/a123456789abcdef0123456789abcdef?r=1)',
|
||||
'url(:/b123456789abcdef0123456789abcde2?r=0)',
|
||||
]);
|
||||
|
||||
editor.dispatch({
|
||||
effects: [
|
||||
resetImageResourceEffect.of({ id: 'a123456789abcdef0123456789abcdef' }),
|
||||
resetImageResourceEffect.of({ id: 'b123456789abcdef0123456789abcde2' }),
|
||||
],
|
||||
});
|
||||
await allowImageUrlsToBeFetched();
|
||||
|
||||
expect(getImageBackgroundUrls(editor)).toMatchObject([
|
||||
'url(:/a123456789abcdef0123456789abcdef?r=2)',
|
||||
'url(:/b123456789abcdef0123456789abcde2?r=1)',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { Decoration, EditorView, WidgetType } from '@codemirror/view';
|
||||
import { SyntaxNodeRef } from '@lezer/common';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import { EditorState, StateEffect, Transaction } from '@codemirror/state';
|
||||
import { RenderedContentContext } from './types';
|
||||
import makeBlockReplaceExtension from './utils/makeBlockReplaceExtension';
|
||||
|
||||
|
@ -16,22 +16,21 @@ class ImageWidget extends WidgetType {
|
|||
private readonly context_: RenderedContentContext,
|
||||
private readonly src_: string,
|
||||
private readonly alt_: string,
|
||||
private readonly reloadCounter_ = 0,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
public eq(other: ImageWidget) {
|
||||
return this.src_ === other.src_ && this.alt_ === other.alt_;
|
||||
return this.src_ === other.src_ && this.alt_ === other.alt_ && this.reloadCounter_ === other.reloadCounter_;
|
||||
}
|
||||
|
||||
public toDOM() {
|
||||
const container = document.createElement('div');
|
||||
container.classList.add(imageClassName);
|
||||
public updateDOM(dom: HTMLElement): boolean {
|
||||
const image = dom.querySelector<HTMLDivElement>('div.image');
|
||||
if (!image) return false;
|
||||
|
||||
const image = document.createElement('div');
|
||||
image.role = 'image';
|
||||
image.ariaLabel = this.alt_;
|
||||
image.classList.add('image');
|
||||
image.role = 'image';
|
||||
|
||||
const updateImageUrl = () => {
|
||||
if (this.resolvedSrc_) {
|
||||
|
@ -43,14 +42,26 @@ class ImageWidget extends WidgetType {
|
|||
|
||||
if (!this.resolvedSrc_) {
|
||||
void (async () => {
|
||||
this.resolvedSrc_ = await this.context_.resolveImageSrc(this.src_);
|
||||
this.resolvedSrc_ = await this.context_.resolveImageSrc(this.src_, this.reloadCounter_);
|
||||
updateImageUrl();
|
||||
})();
|
||||
} else {
|
||||
updateImageUrl();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public toDOM() {
|
||||
const container = document.createElement('div');
|
||||
container.classList.add(imageClassName);
|
||||
|
||||
const image = document.createElement('div');
|
||||
image.classList.add('image');
|
||||
|
||||
container.appendChild(image);
|
||||
this.updateDOM(container);
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
|
@ -82,6 +93,16 @@ const getImageAlt = (node: SyntaxNodeRef, state: EditorState) => {
|
|||
}
|
||||
};
|
||||
|
||||
// In Electron: To work around browser caching, these counters should continue to increase even if an old
|
||||
// editor is destroyed and a new one is created in the same window.
|
||||
const imageToRefreshCounters = new Map<string, number>();
|
||||
export const resetImageResourceEffect = StateEffect.define<{ id: string }>();
|
||||
|
||||
// Intended only for automated tests.
|
||||
export const testing__resetImageRefreshCounterCache = () => {
|
||||
imageToRefreshCounters.clear();
|
||||
};
|
||||
|
||||
const renderBlockImages = (context: RenderedContentContext) => [
|
||||
EditorView.theme({
|
||||
[`& .${imageClassName} > div`]: {
|
||||
|
@ -106,7 +127,7 @@ const renderBlockImages = (context: RenderedContentContext) => [
|
|||
if (src) {
|
||||
const isLastLine = lineTo.number === state.doc.lines;
|
||||
return Decoration.widget({
|
||||
widget: new ImageWidget(context, src, alt),
|
||||
widget: new ImageWidget(context, src, alt, imageToRefreshCounters.get(src) ?? 0),
|
||||
// "side: -1": In general, when the cursor is at the widget's location, it should be at
|
||||
// the start of the next line (and so "side" should be -1).
|
||||
//
|
||||
|
@ -128,6 +149,18 @@ const renderBlockImages = (context: RenderedContentContext) => [
|
|||
return [Math.min(nodeLine.to + 1, state.doc.length)];
|
||||
},
|
||||
hideWhenContainsSelection: false,
|
||||
|
||||
shouldFullReRender: (transaction: Transaction) => {
|
||||
let hadRefreshEffect = false;
|
||||
for (const effect of transaction.effects) {
|
||||
if (effect.is(resetImageResourceEffect)) {
|
||||
const key = `:/${effect.value.id}`;
|
||||
imageToRefreshCounters.set(key, (imageToRefreshCounters.get(key) ?? 0) + 1);
|
||||
hadRefreshEffect = true;
|
||||
}
|
||||
}
|
||||
return hadRefreshEffect;
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import type { EditorState } from '@codemirror/state';
|
||||
import type { EditorState, Transaction } from '@codemirror/state';
|
||||
import type { Decoration, WidgetType } from '@codemirror/view';
|
||||
import type { SyntaxNodeRef } from '@lezer/common';
|
||||
|
||||
|
@ -13,8 +13,11 @@ export interface ReplacementExtension {
|
|||
|
||||
// Disable the decoration when near the cursor. Defaults to true.
|
||||
hideWhenContainsSelection?: boolean;
|
||||
|
||||
// Allows specifying custom logic to refresh all decorations associated with the extension
|
||||
shouldFullReRender?: (transaction: Transaction)=> boolean;
|
||||
}
|
||||
|
||||
export interface RenderedContentContext {
|
||||
resolveImageSrc(src: string): Promise<string>;
|
||||
resolveImageSrc(src: string, reloadCounter: number): Promise<string>;
|
||||
}
|
||||
|
|
|
@ -72,7 +72,12 @@ const makeBlockReplaceExtension = (extensionSpec: ReplacementExtension) => {
|
|||
decorations = decorations.map(transaction.changes);
|
||||
const selectionChanged = !transaction.newSelection.eq(transaction.startState.selection);
|
||||
|
||||
if (transaction.docChanged || selectionChanged) {
|
||||
const wasRerenderRequested = () => {
|
||||
if (!extensionSpec.shouldFullReRender) return false;
|
||||
return extensionSpec.shouldFullReRender(transaction);
|
||||
};
|
||||
|
||||
if (transaction.docChanged || selectionChanged || wasRerenderRequested()) {
|
||||
decorations = updateDecorations(transaction.state, extensionSpec);
|
||||
}
|
||||
|
||||
|
|
|
@ -271,7 +271,7 @@ const createEditor = async (
|
|||
setContentScripts: (_plugins: ContentScriptData[]) => {
|
||||
throw new Error('setContentScripts not implemented.');
|
||||
},
|
||||
onResourceDownloaded: async (resourceId: string) => {
|
||||
onResourceChanged: async (resourceId: string) => {
|
||||
const rendered = await renderAndPostprocessHtml(`<img src=":/${resourceId}"/>`);
|
||||
const renderedImage = rendered.dom.querySelector('img');
|
||||
|
||||
|
@ -285,6 +285,7 @@ const createEditor = async (
|
|||
}
|
||||
|
||||
const resourceSrc = renderedImage?.src;
|
||||
// TODO: Handle the more general case where the resource changed externally
|
||||
onResourceDownloaded(view, resourceId, resourceSrc);
|
||||
},
|
||||
remove: () => {
|
||||
|
|
|
@ -48,10 +48,7 @@ const createEditorDialog = ({ editorApi, doneLabel, block, onSave, onDismiss }:
|
|||
block.end,
|
||||
].join(''));
|
||||
|
||||
const submitButton = document.createElement('button');
|
||||
submitButton.appendChild(createTextNode(doneLabel));
|
||||
submitButton.classList.add('submit');
|
||||
submitButton.onclick = () => {
|
||||
const onClose = () => {
|
||||
if (dialog.close) {
|
||||
dialog.close();
|
||||
} else {
|
||||
|
@ -61,6 +58,11 @@ const createEditorDialog = ({ editorApi, doneLabel, block, onSave, onDismiss }:
|
|||
}
|
||||
};
|
||||
|
||||
const submitButton = document.createElement('button');
|
||||
submitButton.appendChild(createTextNode(doneLabel));
|
||||
submitButton.classList.add('submit');
|
||||
submitButton.onclick = onClose;
|
||||
|
||||
dialog.appendChild(submitButton);
|
||||
|
||||
|
||||
|
@ -72,7 +74,9 @@ const createEditorDialog = ({ editorApi, doneLabel, block, onSave, onDismiss }:
|
|||
focus('createEditorDialog/legacy', editor);
|
||||
}
|
||||
|
||||
return {};
|
||||
return {
|
||||
dismiss: onClose,
|
||||
};
|
||||
};
|
||||
|
||||
export default createEditorDialog;
|
||||
|
|
|
@ -2,7 +2,7 @@ import { htmlentities } from '@joplin/utils/html';
|
|||
import { RenderResult } from '../../../../renderer/types';
|
||||
import createTestEditor from '../../testing/createTestEditor';
|
||||
import joplinEditorApiPlugin, { getEditorApi, setEditorApi } from '../joplinEditorApiPlugin';
|
||||
import joplinEditablePlugin from './joplinEditablePlugin';
|
||||
import joplinEditablePlugin, { editSourceBlockAt, hideSourceBlockEditor } from './joplinEditablePlugin';
|
||||
import { Second } from '@joplin/utils/time';
|
||||
|
||||
const createEditor = (html: string) => {
|
||||
|
@ -19,7 +19,7 @@ const findEditButton = (ancestor: Element): HTMLButtonElement => {
|
|||
const findEditorDialog = () => {
|
||||
const dialog = document.querySelector('dialog.editor-dialog');
|
||||
if (!dialog) {
|
||||
throw new Error('Could not find an open editor dialog.');
|
||||
return null;
|
||||
}
|
||||
|
||||
const editor = dialog.querySelector('textarea');
|
||||
|
@ -117,4 +117,15 @@ describe('joplinEditablePlugin', () => {
|
|||
hashLinks[1].click();
|
||||
expect(editor.state.selection.$from.parent.textContent).toBe('Test heading 2');
|
||||
});
|
||||
|
||||
test('should support toggling the editor dialog externally', () => {
|
||||
const editor = createEditor('<div class="joplin-editable"><pre class="joplin-source">test source</pre>rendered</div>');
|
||||
editSourceBlockAt(0)(editor.state, editor.dispatch, editor);
|
||||
|
||||
const dialog = findEditorDialog();
|
||||
expect(dialog.editor).toBeTruthy();
|
||||
|
||||
hideSourceBlockEditor(editor.state, editor.dispatch, editor);
|
||||
expect(findEditorDialog()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { Plugin } from 'prosemirror-state';
|
||||
import { Command, EditorState, Plugin } from 'prosemirror-state';
|
||||
import { Node, NodeSpec, TagParseRule } from 'prosemirror-model';
|
||||
import { EditorView, NodeView } from 'prosemirror-view';
|
||||
import sanitizeHtml from '../../utils/sanitizeHtml';
|
||||
|
@ -13,6 +13,116 @@ import makeLinksClickableInElement from '../../utils/makeLinksClickableInElement
|
|||
// writing similar ProseMirror plugins:
|
||||
// https://prosemirror.net/examples/fold/
|
||||
|
||||
type EditRequest = {
|
||||
nodeStart: number;
|
||||
showEditor: true;
|
||||
} | {
|
||||
nodeStart?: undefined;
|
||||
showEditor: false;
|
||||
};
|
||||
|
||||
export const editSourceBlockAt = (nodeStart: number): Command => (state, dispatch) => {
|
||||
const node = state.doc.nodeAt(nodeStart);
|
||||
if (node.type.name !== 'joplinEditableInline' && node.type.name !== 'joplinEditableBlock') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (dispatch) {
|
||||
const editRequest: EditRequest = {
|
||||
nodeStart,
|
||||
showEditor: true,
|
||||
};
|
||||
dispatch(state.tr.setMeta(joplinEditablePlugin, editRequest));
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const isSourceBlockEditorVisible = (state: EditorState) => {
|
||||
return joplinEditablePlugin.getState(state).editingNodeAt !== null;
|
||||
};
|
||||
|
||||
export const hideSourceBlockEditor: Command = (state, dispatch) => {
|
||||
const isEditing = isSourceBlockEditorVisible(state);
|
||||
if (!isEditing) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (dispatch) {
|
||||
const editRequest: EditRequest = {
|
||||
showEditor: false,
|
||||
};
|
||||
dispatch(state.tr.setMeta(joplinEditablePlugin, editRequest));
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const createDialogForNode = (nodePosition: number, view: EditorView) => {
|
||||
let saveCounter = 0;
|
||||
|
||||
const getNode = () => (
|
||||
view.state.doc.nodeAt(nodePosition)
|
||||
);
|
||||
|
||||
const { localize: _ } = getEditorApi(view.state);
|
||||
const { dismiss } = createEditorDialog({
|
||||
doneLabel: _('Done'),
|
||||
editorLabel: _('Code:'),
|
||||
editorApi: getEditorApi(view.state),
|
||||
block: {
|
||||
content: getNode().attrs.source,
|
||||
start: getNode().attrs.openCharacters,
|
||||
end: getNode().attrs.closeCharacters,
|
||||
},
|
||||
onSave: async (block) => {
|
||||
view.dispatch(
|
||||
view.state.tr.setNodeAttribute(
|
||||
nodePosition, 'source', block.content,
|
||||
).setNodeAttribute(
|
||||
nodePosition, 'openCharacters', block.start,
|
||||
).setNodeAttribute(
|
||||
nodePosition, 'closeCharacters', block.end,
|
||||
),
|
||||
);
|
||||
|
||||
saveCounter ++;
|
||||
const initialSaveCounter = saveCounter;
|
||||
const cancelled = () => saveCounter !== initialSaveCounter;
|
||||
|
||||
// Debounce rendering
|
||||
await msleep(400);
|
||||
if (cancelled()) return;
|
||||
|
||||
const rendered = await getEditorApi(view.state).renderer.renderMarkupToHtml(
|
||||
`${block.start}${block.content}${block.end}`,
|
||||
{ forceMarkdown: true, isFullPageRender: false },
|
||||
);
|
||||
if (cancelled()) return;
|
||||
|
||||
const html = postProcessRenderedHtml(rendered.html, getNode().isInline);
|
||||
view.dispatch(
|
||||
view.state.tr.setNodeAttribute(
|
||||
nodePosition, 'contentHtml', html,
|
||||
),
|
||||
);
|
||||
},
|
||||
onDismiss: () => {
|
||||
hideSourceBlockEditor(view.state, view.dispatch, view);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
onPositionChange: (newPosition: number) => {
|
||||
nodePosition = newPosition;
|
||||
},
|
||||
dismiss,
|
||||
};
|
||||
};
|
||||
|
||||
type DialogHandle = ReturnType<typeof createDialogForNode>;
|
||||
|
||||
|
||||
interface JoplinEditableAttributes {
|
||||
contentHtml: string;
|
||||
source: string;
|
||||
|
@ -117,7 +227,6 @@ export const nodeSpecs = {
|
|||
type GetPosition = ()=> number;
|
||||
|
||||
class EditableSourceBlockView implements NodeView {
|
||||
private editDialogVisible_ = false;
|
||||
public readonly dom: HTMLElement;
|
||||
public constructor(private node: Node, inline: boolean, private view: EditorView, private getPosition: GetPosition) {
|
||||
if ((node.attrs.contentHtml ?? undefined) === undefined) {
|
||||
|
@ -135,58 +244,7 @@ class EditableSourceBlockView implements NodeView {
|
|||
}
|
||||
|
||||
private showEditDialog_() {
|
||||
if (this.editDialogVisible_) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { localize: _ } = getEditorApi(this.view.state);
|
||||
|
||||
let saveCounter = 0;
|
||||
createEditorDialog({
|
||||
doneLabel: _('Done'),
|
||||
editorLabel: _('Code:'),
|
||||
editorApi: getEditorApi(this.view.state),
|
||||
block: {
|
||||
content: this.node.attrs.source,
|
||||
start: this.node.attrs.openCharacters,
|
||||
end: this.node.attrs.closeCharacters,
|
||||
},
|
||||
onSave: async (block) => {
|
||||
this.view.dispatch(
|
||||
this.view.state.tr.setNodeAttribute(
|
||||
this.getPosition(), 'source', block.content,
|
||||
).setNodeAttribute(
|
||||
this.getPosition(), 'openCharacters', block.start,
|
||||
).setNodeAttribute(
|
||||
this.getPosition(), 'closeCharacters', block.end,
|
||||
),
|
||||
);
|
||||
|
||||
saveCounter ++;
|
||||
const initialSaveCounter = saveCounter;
|
||||
const cancelled = () => saveCounter !== initialSaveCounter;
|
||||
|
||||
// Debounce rendering
|
||||
await msleep(400);
|
||||
if (cancelled()) return;
|
||||
|
||||
const rendered = await getEditorApi(this.view.state).renderer.renderMarkupToHtml(
|
||||
`${block.start}${block.content}${block.end}`,
|
||||
{ forceMarkdown: true, isFullPageRender: false },
|
||||
);
|
||||
if (cancelled()) return;
|
||||
|
||||
const html = postProcessRenderedHtml(rendered.html, this.node.isInline);
|
||||
this.view.dispatch(
|
||||
this.view.state.tr.setNodeAttribute(
|
||||
this.getPosition(), 'contentHtml', html,
|
||||
),
|
||||
);
|
||||
},
|
||||
onDismiss: () => {
|
||||
this.editDialogVisible_ = false;
|
||||
},
|
||||
});
|
||||
editSourceBlockAt(this.getPosition())(this.view.state, this.view.dispatch, this.view);
|
||||
}
|
||||
|
||||
private updateContent_() {
|
||||
|
@ -236,13 +294,64 @@ class EditableSourceBlockView implements NodeView {
|
|||
}
|
||||
}
|
||||
|
||||
const joplinEditablePlugin = new Plugin({
|
||||
interface PluginState {
|
||||
editingNodeAt: number|null;
|
||||
}
|
||||
|
||||
const joplinEditablePlugin = new Plugin<PluginState>({
|
||||
state: {
|
||||
init: () => ({
|
||||
editingNodeAt: null,
|
||||
}),
|
||||
apply: (tr, oldValue) => {
|
||||
let editingAt = oldValue.editingNodeAt;
|
||||
|
||||
const editRequest: EditRequest|null = tr.getMeta(joplinEditablePlugin);
|
||||
if (editRequest) {
|
||||
if (editRequest.showEditor) {
|
||||
editingAt = editRequest.nodeStart;
|
||||
} else {
|
||||
editingAt = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (editingAt) {
|
||||
editingAt = tr.mapping.map(editingAt, 1);
|
||||
}
|
||||
return { editingNodeAt: editingAt };
|
||||
},
|
||||
},
|
||||
props: {
|
||||
nodeViews: {
|
||||
joplinEditableInline: (node, view, getPos) => new EditableSourceBlockView(node, true, view, getPos),
|
||||
joplinEditableBlock: (node, view, getPos) => new EditableSourceBlockView(node, false, view, getPos),
|
||||
},
|
||||
},
|
||||
view: () => {
|
||||
let dialog: DialogHandle|null = null;
|
||||
|
||||
return {
|
||||
update(view, prevState) {
|
||||
const oldState = joplinEditablePlugin.getState(prevState);
|
||||
const newState = joplinEditablePlugin.getState(view.state);
|
||||
|
||||
if (newState.editingNodeAt !== null) {
|
||||
if (oldState.editingNodeAt === null) {
|
||||
dialog = createDialogForNode(newState.editingNodeAt, view);
|
||||
}
|
||||
dialog?.onPositionChange(newState.editingNodeAt);
|
||||
} else if (dialog) {
|
||||
const lastDialog = dialog;
|
||||
// Set dialog to null before dismissing to prevent infinite recursion.
|
||||
// Dismissing the dialog can cause the editor state to update, which can
|
||||
// result in this callback being re-run.
|
||||
dialog = null;
|
||||
|
||||
lastDialog.dismiss();
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default joplinEditablePlugin;
|
||||
|
|
|
@ -5,7 +5,7 @@ import { baseKeymap, chainCommands, exitCode, liftEmptyBlock, newlineInCode } fr
|
|||
import { liftListItem, sinkListItem, splitListItem } from 'prosemirror-schema-list';
|
||||
import commands from '../commands';
|
||||
import { EditorCommandType } from '../../types';
|
||||
import { Command, EditorState, TextSelection } from 'prosemirror-state';
|
||||
import { Command, EditorState, TextSelection, Plugin } from 'prosemirror-state';
|
||||
import splitBlockAs from '../vendor/splitBlockAs';
|
||||
import canReplaceSelectionWith from '../utils/canReplaceSelectionWith';
|
||||
|
||||
|
@ -60,6 +60,28 @@ const isInEmptyParagraph = (state: EditorState) => {
|
|||
selectionParent.content.size === 0;
|
||||
};
|
||||
|
||||
// Handle double-hard-break -> paragraph conversion with a Plugin to work around
|
||||
// a bug on Android. If convertDoubleHardBreakToNewParagraph is handled with the
|
||||
// main keymap logic (with a keymap() extension), then it's possible for the cursor
|
||||
// to get stuck in some cases.
|
||||
// See https://github.com/laurent22/joplin/issues/12960.
|
||||
const replaceDoubleHardBreaksOnEnter = new Plugin({
|
||||
props: {
|
||||
handleDOMEvents: {
|
||||
keydown: (view, event) => {
|
||||
if (event.key === 'Enter') {
|
||||
const commandResult = convertDoubleHardBreakToNewParagraph(view.state, view.dispatch);
|
||||
if (commandResult) {
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const insertHardBreak: Command = (state, dispatch) => {
|
||||
// Avoid adding hard breaks at the beginning of list items
|
||||
if (isInEmptyListItem(state)) return false;
|
||||
|
@ -88,12 +110,12 @@ const keymapExtension = [
|
|||
'Mod-[': liftListItem(itemType),
|
||||
'Mod-]': sinkListItem(itemType),
|
||||
})),
|
||||
replaceDoubleHardBreaksOnEnter,
|
||||
keymap({
|
||||
'Enter': chainCommands(
|
||||
newlineInCode,
|
||||
exitCode,
|
||||
liftEmptyBlock,
|
||||
convertDoubleHardBreakToNewParagraph,
|
||||
insertHardBreak,
|
||||
),
|
||||
}),
|
||||
|
|
|
@ -167,6 +167,8 @@ const nodes = addDefaultToplevelAttributes({
|
|||
title: { default: '', validate: 'string' },
|
||||
fromMd: { default: false, validate: 'boolean' },
|
||||
resourceId: { default: null as string|null, validate: 'string|null' },
|
||||
width: { default: '', validate: 'string' },
|
||||
height: { default: '', validate: 'string' },
|
||||
},
|
||||
parseDOM: [
|
||||
{
|
||||
|
@ -174,6 +176,8 @@ const nodes = addDefaultToplevelAttributes({
|
|||
getAttrs: node => ({
|
||||
src: node.getAttribute('src'),
|
||||
alt: node.getAttribute('alt'),
|
||||
width: node.getAttribute('width') ?? '',
|
||||
height: node.getAttribute('height') ?? '',
|
||||
title: node.getAttribute('title'),
|
||||
fromMd: node.hasAttribute('data-from-md'),
|
||||
resourceId: node.getAttribute('data-resource-id') || null,
|
||||
|
@ -181,7 +185,7 @@ const nodes = addDefaultToplevelAttributes({
|
|||
},
|
||||
],
|
||||
toDOM: node => {
|
||||
const { src, alt, title, fromMd, resourceId } = node.attrs;
|
||||
const { src, alt, title, width, height, fromMd, resourceId } = node.attrs;
|
||||
const outputAttrs: Record<string, unknown> = { src, alt, title };
|
||||
|
||||
if (fromMd) {
|
||||
|
@ -190,6 +194,12 @@ const nodes = addDefaultToplevelAttributes({
|
|||
if (resourceId) {
|
||||
outputAttrs['data-resource-id'] = resourceId;
|
||||
}
|
||||
if (width) {
|
||||
outputAttrs.width = width;
|
||||
}
|
||||
if (height) {
|
||||
outputAttrs.height = height;
|
||||
}
|
||||
|
||||
return [
|
||||
'img',
|
||||
|
|
|
@ -18,6 +18,7 @@
|
|||
"@joplin/utils": "~3.4",
|
||||
"@testing-library/react-hooks": "8.0.1",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/node": "18.19.111",
|
||||
"@types/react": "18.3.23",
|
||||
"@types/react-redux": "7.1.33",
|
||||
"@types/styled-components": "5.1.32",
|
||||
|
|
|
@ -126,8 +126,9 @@ export interface EditorControl {
|
|||
|
||||
setContentScripts(plugins: ContentScriptData[]): Promise<void>;
|
||||
|
||||
// Called when a resource associated with the current note finishes downloading.
|
||||
onResourceDownloaded(id: string): void;
|
||||
// Called when a resource associated with the current note finishes downloading
|
||||
// or has been updated in an external editor.
|
||||
onResourceChanged(id: string): void;
|
||||
|
||||
remove(): void;
|
||||
focus(): void;
|
||||
|
@ -147,7 +148,7 @@ export enum EditorKeymap {
|
|||
export interface EditorTheme extends Theme {
|
||||
themeId: number;
|
||||
fontFamily: string;
|
||||
fontSize?: number;
|
||||
fontSize: number;
|
||||
fontSizeUnits?: string;
|
||||
isDesktop?: boolean;
|
||||
monospaceFont?: string;
|
||||
|
|
|
@ -46,7 +46,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/node": "18.19.103",
|
||||
"@types/node": "18.19.111",
|
||||
"@typescript-eslint/eslint-plugin": "6.21.0",
|
||||
"@typescript-eslint/parser": "6.21.0",
|
||||
"coveralls": "3.1.1",
|
||||
|
|
|
@ -17,7 +17,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/node": "18.19.103",
|
||||
"@types/node": "18.19.111",
|
||||
"jest": "29.7.0",
|
||||
"typescript": "5.8.2"
|
||||
},
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
"private": true,
|
||||
"scripts": {
|
||||
"tsc": "",
|
||||
"buildPluginDoc_": "typedoc --exclude '../lib/models/**' --name 'Joplin Plugin API Documentation' --mode file -theme '../../Assets/PluginDocTheme/' --readme '../../Assets/PluginDocTheme/index.md' --excludeNotExported --excludeExternals --excludePrivate --excludeProtected --out ../../../joplin-website/docs/api/references/plugin_api ../lib/services/plugins/api/"
|
||||
"buildPluginDoc_": "typedoc --exclude '../lib/models/**' --exclude '../lib/services/e2ee/**' --name 'Joplin Plugin API Documentation' --mode file -theme '../../Assets/PluginDocTheme/' --readme '../../Assets/PluginDocTheme/index.md' --excludeNotExported --excludeExternals --excludePrivate --excludeProtected --out ../../../joplin-website/docs/api/references/plugin_api ../lib/services/plugins/api/"
|
||||
},
|
||||
"dependencies": {
|
||||
"typedoc": "0.17.8",
|
||||
|
|
|
@ -51,10 +51,10 @@ import handleSyncStartupOperation from './services/synchronizer/utils/handleSync
|
|||
import SyncTargetJoplinCloud from './SyncTargetJoplinCloud';
|
||||
import { setAutoFreeze } from 'immer';
|
||||
import { getEncryptionEnabled } from './services/synchronizer/syncInfoUtils';
|
||||
import { loadMasterKeysFromSettings, migrateMasterPassword } from './services/e2ee/utils';
|
||||
import { loadMasterKeysFromSettings, migrateMasterPassword, migratePpk } from './services/e2ee/utils';
|
||||
import SyncTargetNone from './SyncTargetNone';
|
||||
import { setRSA } from './services/e2ee/ppk';
|
||||
import RSA from './services/e2ee/RSA.node';
|
||||
import { setRSA } from './services/e2ee/ppk/ppk';
|
||||
import RSA from './services/e2ee/ppk/RSA.node';
|
||||
import Resource from './models/Resource';
|
||||
import { ProfileConfig } from './services/profileConfig/types';
|
||||
import initProfile from './services/profileConfig/initProfile';
|
||||
|
@ -90,8 +90,7 @@ export default class BaseApplication {
|
|||
private eventEmitter_: any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
private scheduleAutoAddResourcesIID_: any = null;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
private database_: any = null;
|
||||
protected database_: JoplinDatabase = null;
|
||||
private profileConfig_: ProfileConfig = null;
|
||||
|
||||
protected showStackTraces_ = false;
|
||||
|
@ -781,6 +780,7 @@ export default class BaseApplication {
|
|||
options.keychainEnabled ? [KeychainServiceDriverElectron, KeychainServiceDriverNode] : [],
|
||||
);
|
||||
await migrateMasterPassword();
|
||||
await migratePpk();
|
||||
await handleSyncStartupOperation();
|
||||
|
||||
appLogger.info(`Client ID: ${Setting.value('clientId')}`);
|
||||
|
@ -891,6 +891,10 @@ export default class BaseApplication {
|
|||
if (!currentFolder) currentFolder = await Folder.defaultFolder();
|
||||
Setting.setValue('activeFolderId', currentFolder ? currentFolder.id : '');
|
||||
|
||||
if (currentFolder && !this.hasGui()) {
|
||||
this.currentFolder_ = currentFolder;
|
||||
}
|
||||
|
||||
await setupAutoDeletion();
|
||||
|
||||
await MigrationService.instance().run();
|
||||
|
|
|
@ -264,6 +264,7 @@ export default class JoplinDatabase extends Database {
|
|||
todo_due: sp('When the todo is due. An alarm will be triggered on that date.'),
|
||||
todo_completed: sp('Tells whether todo is completed or not. This is a timestamp in milliseconds.'),
|
||||
source_url: sp('The full URL where the note comes from.'),
|
||||
is_shared: sp('Whether the note is published.'),
|
||||
},
|
||||
folders: {},
|
||||
resources: {},
|
||||
|
@ -288,6 +289,7 @@ export default class JoplinDatabase extends Database {
|
|||
this.tableDescriptions_[n].updated_time = sp('When the %s was last updated.', singular);
|
||||
this.tableDescriptions_[n].user_created_time = sp('When the %s was created. It may differ from created_time as it can be manually set by the user.', singular);
|
||||
this.tableDescriptions_[n].user_updated_time = sp('When the %s was last updated. It may differ from updated_time as it can be manually set by the user.', singular);
|
||||
this.tableDescriptions_[n].share_id = sp('The ID of the Joplin Server/Cloud share containing the %s. Empty if not shared.', singular);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue