Compare commits
1 Commits
android-v3
...
sqlcipher_
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
683dea01ba |
@@ -158,7 +158,6 @@ packages/app-desktop/commands/exportFolders.js
|
||||
packages/app-desktop/commands/exportNotes.js
|
||||
packages/app-desktop/commands/focusElement.js
|
||||
packages/app-desktop/commands/index.js
|
||||
packages/app-desktop/commands/newAppInstance.js
|
||||
packages/app-desktop/commands/openNoteInNewWindow.js
|
||||
packages/app-desktop/commands/openProfileDirectory.js
|
||||
packages/app-desktop/commands/replaceMisspelling.js
|
||||
@@ -273,7 +272,6 @@ packages/app-desktop/gui/NoteEditor/WarningBanner/BannerContent.js
|
||||
packages/app-desktop/gui/NoteEditor/WarningBanner/WarningBanner.js
|
||||
packages/app-desktop/gui/NoteEditor/commands/focusElementNoteBody.js
|
||||
packages/app-desktop/gui/NoteEditor/commands/focusElementNoteTitle.js
|
||||
packages/app-desktop/gui/NoteEditor/commands/focusElementNoteViewer.js
|
||||
packages/app-desktop/gui/NoteEditor/commands/focusElementToolbar.js
|
||||
packages/app-desktop/gui/NoteEditor/commands/index.js
|
||||
packages/app-desktop/gui/NoteEditor/commands/pasteAsText.js
|
||||
@@ -687,14 +685,7 @@ packages/app-mobile/components/SideMenuContentNote.js
|
||||
packages/app-mobile/components/TextInput.js
|
||||
packages/app-mobile/components/ToggleSpaceButton.js
|
||||
packages/app-mobile/components/accessibility/AccessibleModalMenu.js
|
||||
packages/app-mobile/components/accessibility/AccessibleView.test.js
|
||||
packages/app-mobile/components/accessibility/AccessibleView.js
|
||||
packages/app-mobile/components/accessibility/FocusControl/AutoFocusProvider.js
|
||||
packages/app-mobile/components/accessibility/FocusControl/FocusControl.js
|
||||
packages/app-mobile/components/accessibility/FocusControl/FocusControlProvider.js
|
||||
packages/app-mobile/components/accessibility/FocusControl/MainAppContent.js
|
||||
packages/app-mobile/components/accessibility/FocusControl/ModalWrapper.js
|
||||
packages/app-mobile/components/accessibility/FocusControl/types.js
|
||||
packages/app-mobile/components/app-nav.js
|
||||
packages/app-mobile/components/base-screen.js
|
||||
packages/app-mobile/components/biometrics/BiometricPopup.js
|
||||
@@ -1035,7 +1026,6 @@ packages/lib/commands/renderMarkup.test.js
|
||||
packages/lib/commands/renderMarkup.js
|
||||
packages/lib/commands/showEditorPlugin.js
|
||||
packages/lib/commands/synchronize.js
|
||||
packages/lib/commands/toggleAllFolders.test.js
|
||||
packages/lib/commands/toggleAllFolders.js
|
||||
packages/lib/commands/toggleEditorPlugin.js
|
||||
packages/lib/components/EncryptionConfigScreen/utils.test.js
|
||||
|
||||
@@ -57,8 +57,6 @@ module.exports = {
|
||||
'tinymce': 'readonly',
|
||||
|
||||
'JSX': 'readonly',
|
||||
|
||||
'NodeJS': 'readonly',
|
||||
},
|
||||
'parserOptions': {
|
||||
'ecmaVersion': 2018,
|
||||
@@ -311,7 +309,7 @@ module.exports = {
|
||||
selector: 'interface',
|
||||
format: null,
|
||||
'filter': {
|
||||
'regex': '^(RSA|RSAKeyPair|iOS.*)$',
|
||||
'regex': '^(RSA|RSAKeyPair)$',
|
||||
'match': true,
|
||||
},
|
||||
},
|
||||
|
||||
4
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,8 +1,8 @@
|
||||
blank_issues_enabled: true
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Feature Requests
|
||||
url: https://discourse.joplinapp.org/c/features/
|
||||
about: Discuss ideas for new features or changes
|
||||
- name: Support
|
||||
url: https://discourse.joplinapp.org/c/support/
|
||||
about: Please ask for help here
|
||||
about: Please ask for help here
|
||||
4
.github/workflows/github-actions-main.yml
vendored
@@ -9,7 +9,7 @@ jobs:
|
||||
matrix:
|
||||
# Do not use unbuntu-latest because it causes `The operation was canceled` failures:
|
||||
# https://github.com/actions/runner-images/issues/6709
|
||||
os: [macos-13, ubuntu-22.04, windows-2019]
|
||||
os: [macos-13, ubuntu-20.04, windows-2019]
|
||||
steps:
|
||||
|
||||
# Trying to fix random networking issues on Windows
|
||||
@@ -150,7 +150,7 @@ jobs:
|
||||
matrix:
|
||||
# Do not use unbuntu-latest because it causes `The operation was canceled` failures:
|
||||
# https://github.com/actions/runner-images/issues/6709
|
||||
os: [ubuntu-22.04]
|
||||
os: [ubuntu-20.04]
|
||||
steps:
|
||||
|
||||
- name: Install Docker Engine
|
||||
|
||||
10
.gitignore
vendored
@@ -133,7 +133,6 @@ packages/app-desktop/commands/exportFolders.js
|
||||
packages/app-desktop/commands/exportNotes.js
|
||||
packages/app-desktop/commands/focusElement.js
|
||||
packages/app-desktop/commands/index.js
|
||||
packages/app-desktop/commands/newAppInstance.js
|
||||
packages/app-desktop/commands/openNoteInNewWindow.js
|
||||
packages/app-desktop/commands/openProfileDirectory.js
|
||||
packages/app-desktop/commands/replaceMisspelling.js
|
||||
@@ -248,7 +247,6 @@ packages/app-desktop/gui/NoteEditor/WarningBanner/BannerContent.js
|
||||
packages/app-desktop/gui/NoteEditor/WarningBanner/WarningBanner.js
|
||||
packages/app-desktop/gui/NoteEditor/commands/focusElementNoteBody.js
|
||||
packages/app-desktop/gui/NoteEditor/commands/focusElementNoteTitle.js
|
||||
packages/app-desktop/gui/NoteEditor/commands/focusElementNoteViewer.js
|
||||
packages/app-desktop/gui/NoteEditor/commands/focusElementToolbar.js
|
||||
packages/app-desktop/gui/NoteEditor/commands/index.js
|
||||
packages/app-desktop/gui/NoteEditor/commands/pasteAsText.js
|
||||
@@ -662,14 +660,7 @@ packages/app-mobile/components/SideMenuContentNote.js
|
||||
packages/app-mobile/components/TextInput.js
|
||||
packages/app-mobile/components/ToggleSpaceButton.js
|
||||
packages/app-mobile/components/accessibility/AccessibleModalMenu.js
|
||||
packages/app-mobile/components/accessibility/AccessibleView.test.js
|
||||
packages/app-mobile/components/accessibility/AccessibleView.js
|
||||
packages/app-mobile/components/accessibility/FocusControl/AutoFocusProvider.js
|
||||
packages/app-mobile/components/accessibility/FocusControl/FocusControl.js
|
||||
packages/app-mobile/components/accessibility/FocusControl/FocusControlProvider.js
|
||||
packages/app-mobile/components/accessibility/FocusControl/MainAppContent.js
|
||||
packages/app-mobile/components/accessibility/FocusControl/ModalWrapper.js
|
||||
packages/app-mobile/components/accessibility/FocusControl/types.js
|
||||
packages/app-mobile/components/app-nav.js
|
||||
packages/app-mobile/components/base-screen.js
|
||||
packages/app-mobile/components/biometrics/BiometricPopup.js
|
||||
@@ -1010,7 +1001,6 @@ packages/lib/commands/renderMarkup.test.js
|
||||
packages/lib/commands/renderMarkup.js
|
||||
packages/lib/commands/showEditorPlugin.js
|
||||
packages/lib/commands/synchronize.js
|
||||
packages/lib/commands/toggleAllFolders.test.js
|
||||
packages/lib/commands/toggleAllFolders.js
|
||||
packages/lib/commands/toggleEditorPlugin.js
|
||||
packages/lib/components/EncryptionConfigScreen/utils.test.js
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
# This patch improves the note actions menu (the kebab menu)'s accessibility
|
||||
# by labelling its dismiss button.
|
||||
diff --git a/build/rnpm.js b/build/rnpm.js
|
||||
index 1111c2de99b3d4c5651ca4eee3ba59c0ce8e13e1..d410ee12b38d02c399b0a40973217da0082d73c0 100644
|
||||
--- a/build/rnpm.js
|
||||
+++ b/build/rnpm.js
|
||||
@@ -1573,7 +1573,9 @@
|
||||
onPress = _this$props.onPress,
|
||||
style = _this$props.style;
|
||||
return /*#__PURE__*/React__default.createElement(reactNative.TouchableWithoutFeedback, {
|
||||
- onPress: onPress
|
||||
+ onPress: onPress,
|
||||
+ accessibilityLabel: _this$props.accessibilityLabel,
|
||||
+ accessibilityRole: 'button',
|
||||
}, /*#__PURE__*/React__default.createElement(reactNative.Animated.View, {
|
||||
style: [styles.fullscreen, {
|
||||
opacity: this.fadeAnim
|
||||
@@ -1588,7 +1590,8 @@
|
||||
}(React.Component);
|
||||
|
||||
Backdrop.propTypes = {
|
||||
- onPress: propTypes.func.isRequired
|
||||
+ onPress: propTypes.func.isRequired,
|
||||
+ accessibilityLabel: propTypes.string,
|
||||
};
|
||||
var styles = reactNative.StyleSheet.create({
|
||||
fullscreen: {
|
||||
@@ -1658,6 +1661,7 @@
|
||||
style: styles$1.placeholder
|
||||
}, /*#__PURE__*/React__default.createElement(Backdrop, {
|
||||
onPress: ctx._onBackdropPress,
|
||||
+ accessibilityLabel: this.props.closeButtonLabel,
|
||||
style: backdropStyles,
|
||||
ref: ctx.onBackdropRef
|
||||
}), ctx._makeOptions());
|
||||
@@ -2090,6 +2094,7 @@
|
||||
}), /*#__PURE__*/React__default.createElement(MenuPlaceholder, {
|
||||
ctx: this,
|
||||
backdropStyles: customStyles.backdrop,
|
||||
+ closeButtonLabel: this.props.closeButtonLabel,
|
||||
ref: this._onPlaceholderRef
|
||||
}))));
|
||||
}
|
||||
diff --git a/src/index.d.ts b/src/index.d.ts
|
||||
index 1db1e643a915e4bfb715e33354678ec1be219f50..007157e366d1935368bdd8eff5e7a0773e183d0f 100644
|
||||
--- a/src/index.d.ts
|
||||
+++ b/src/index.d.ts
|
||||
@@ -18,6 +18,7 @@ declare module "react-native-popup-menu" {
|
||||
menuProviderWrapper?: StyleProp<ViewStyle>;
|
||||
backdrop?: StyleProp<ViewStyle>;
|
||||
};
|
||||
+ closeButtonLabel: string;
|
||||
backHandler?: boolean | Function;
|
||||
skipInstanceCheck?: boolean;
|
||||
children: React.ReactNode;
|
||||
@@ -1,77 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
viewBox="0 0 682.66669 682.66669"
|
||||
height="682.66669"
|
||||
width="682.66669"
|
||||
xml:space="preserve"
|
||||
id="svg2"
|
||||
version="1.1"
|
||||
inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)"
|
||||
sodipodi:docname="JoplinLetterBlue.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
|
||||
id="namedview13"
|
||||
pagecolor="#505050"
|
||||
bordercolor="#ffffff"
|
||||
borderopacity="1"
|
||||
inkscape:pageshadow="0"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pagecheckerboard="1"
|
||||
showgrid="false"
|
||||
inkscape:zoom="0.77490232"
|
||||
inkscape:cx="366.49781"
|
||||
inkscape:cy="360.69062"
|
||||
inkscape:window-width="1366"
|
||||
inkscape:window-height="708"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="30"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg2" />
|
||||
<defs
|
||||
id="defs6">
|
||||
<linearGradient
|
||||
id="linearGradient26"
|
||||
spreadMethod="pad"
|
||||
gradientTransform="matrix(-4387.91,4387.91,4387.91,4387.91,4753.95,366.05)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
y2="0"
|
||||
x2="1"
|
||||
y1="0"
|
||||
x1="0">
|
||||
<stop
|
||||
id="stop22"
|
||||
offset="0"
|
||||
style="stop-opacity:1;stop-color:#004caf" />
|
||||
<stop
|
||||
id="stop24"
|
||||
offset="1"
|
||||
style="stop-opacity:1;stop-color:#1f95f8" />
|
||||
</linearGradient>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<clipPath
|
||||
clipPathUnits="userSpaceOnUse"
|
||||
id="clipPath829"><path
|
||||
id="path831"
|
||||
style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.999997"
|
||||
d="M 3961.59,4435.23 H 2570.18 c -13.15,0 -23.78,-10.64 -23.78,-23.77 v -441.84 c 0,-14.87 12.04,-26.92 26.92,-26.92 h 190.77 c 77.16,0 139.73,-59.35 146.43,-134.77 V 3505 3336.23 1728.75 1717.36 h -0.052 c 0.48,-16.84 -0.1898,-33.4 -1.83,-49.71 -0.18,-2.38 -0.5003,-4.73 -0.7902,-7.09 -1.0998,-9.53 -2.3199,-19.01 -4.17,-28.29 -1.0098,-5.29 -2.4399,-10.44 -3.7098,-15.65 -1.71,-6.93 -3.09,-13.97 -5.22,-20.75 -12.5802,-40.27 -32.4702,-77.62 -59.9802,-110.5 -1.0098,-1.17 -2.2599,-2.25 -3.2598,-3.41 -8.3901,-9.72 -17.2002,-19.19 -26.9502,-28.06 -9.84,-8.95 -20.2599,-17.27 -31.2099,-25 -77.8401,-55.14 -182.61,-79.4 -299.67,-68.2 -149.2599,14.03 -297.3399,81.72 -417.03,190.62 -119.6701,108.89 -194.08,243.62 -209.4799,379.41 -13.8501,121.48 22.5498,228.38 102.42,301.05 0.21,0.1598 0.3997,0.3098 0.5602,0.48 3.09,2.77 6.4901,5.2 9.6701,7.87 57.16,47.89 131.6701,76.91 216.7,84.91 0.96,0.09 1.8801,0.24 2.79,0.3203 8.9499,0.79 18.0699,1.15 27.27,1.49 4.8099,0.1598 9.5601,0.5003 14.4399,0.54 1.62,0.023 3.1602,0.1898 4.7802,0.1898 2.8998,0 5.91,-0.3803 8.8098,-0.42 13.4001,-0.21 26.9001,-0.7601 40.6701,-1.9401 1.74,-0.1402 3.3999,-0.08 5.19,-0.24 1.2699,-0.1297 2.5299,-0.4102 3.8001,-0.54 78,-7.82 155.2299,-31.11 228.5199,-66.3999 1.53,-0.068 3.3,-0.54 5.5099,-1.7601 22.34,-12.3399 26.6201,0.9 27.2801,9.6501 v 382.2399 282.8201 c 0,19.05 -13.2501,35.8999 -31.83,39.99 -394.7601,86.88 -782.08,-3.5501 -1055.38,-252.3401 -238.7499,-217.1799 -354.24,-530.5799 -316.8201,-859.7899 33.39,-293.23 183.9102,-574.94 423.88,-793.33 233.8901,-212.79003 531.69,-345.86006 838.8801,-374.80106 42.33,-3.918 84.8601,-5.93797 126.36,-5.93797 293.3799,0 565.6099,100.59802 766.54,283.37903 190.3401,173.3 304.35,411.27 321.0799,670.16 l 1.55,1697.91 h 0.1703 v 453.97 h 0.06 v 7.92 c 1.72,80.1199 67.05,144.58 147.61,144.58 h 190.77 c 14.8599,0 26.9199,12.05 26.9199,26.9199 v 441.84 c 0,13.13 -10.6299,23.77 -23.7799,23.77" /></clipPath></defs>
|
||||
<g
|
||||
id="g14"
|
||||
transform="matrix(0.13333333,0,0,-0.13333333,0,682.66667)"
|
||||
mask="none"
|
||||
clip-path="url(#clipPath829)">
|
||||
<g
|
||||
clip-path="url(#clipPath20)"
|
||||
id="g16">
|
||||
<path
|
||||
id="path28"
|
||||
style="fill:url(#linearGradient26);fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="M 3873.89,0 H 1246.11 C 560.754,0 0,560.75 0,1246.11 V 3873.88 C 0,4559.25 560.754,5120 1246.11,5120 H 3873.89 C 4559.25,5120 5120,4559.25 5120,3873.88 V 1246.11 C 5120,560.75 4559.25,0 3873.89,0" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.0 KiB |
@@ -80,7 +80,7 @@ async function setupDownloadPage() {
|
||||
|
||||
if (href.indexOf('-Setup') > 0) downloadLinks['windows'] = href;
|
||||
if (href.indexOf('.dmg') > 0) downloadLinks['macOs'] = href;
|
||||
if (href.indexOf('arm64.DMG') > 0) downloadLinks['macOsM1'] = href;
|
||||
if (href.endsWith('arm64.DMG')) downloadLinks['macOsM1'] = href;
|
||||
if (href.indexOf('.AppImage') > 0) downloadLinks['linux'] = href;
|
||||
});
|
||||
|
||||
@@ -98,8 +98,6 @@ async function setupDownloadPage() {
|
||||
} else {
|
||||
const os = await getOs();
|
||||
|
||||
console.info('Found OS: ' + os);
|
||||
|
||||
if (os === 'macOsUndefined') {
|
||||
// If we don't know which macOS version it is, we let the user choose.
|
||||
$('.main-content .intro').html('<p class="macos-m1-info">The macOS release is available for Intel processors or for Apple Silicon (M1) processors. Please select your version:</p>');
|
||||
|
||||
@@ -398,7 +398,7 @@
|
||||
|
||||
<div class="text-center sponsors-org">
|
||||
{{#sponsors.orgs}}
|
||||
<a class="sponsor-org-item" href="{{url}}"><img alt="{{alt}}" title="{{title}}" src="{{imageBaseUrl}}/sponsors/{{imageName}}"></a>
|
||||
<a class="sponsor-org-item" href="{{url}}"><img title="{{title}}" src="{{imageBaseUrl}}/sponsors/{{imageName}}"></a>
|
||||
{{/sponsors.orgs}}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -67,23 +67,10 @@ showHelp() {
|
||||
fi
|
||||
}
|
||||
|
||||
#-----------------------------------------------------
|
||||
# Setup Download Helper: DL
|
||||
#-----------------------------------------------------
|
||||
if [[ `command -v wget2` ]]; then
|
||||
DL='wget2 -qO'
|
||||
elif [[ `command -v wget` ]]; then
|
||||
DL='wget -qO'
|
||||
elif [[ `command -v curl` ]]; then
|
||||
DL='curl -sLo'
|
||||
else
|
||||
print "${COLOR_RED}Error: wget2, wget, and curl not found. Please install one of these tools.${COLOR_RESET}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
#-----------------------------------------------------
|
||||
# PARSE ARGUMENTS
|
||||
#-----------------------------------------------------
|
||||
|
||||
optspec=":h-:"
|
||||
while getopts "${optspec}" OPT; do
|
||||
[ "${OPT}" = " " ] && continue
|
||||
@@ -153,9 +140,9 @@ fi
|
||||
|
||||
# Get the latest version to download
|
||||
if [[ "$INCLUDE_PRE_RELEASE" == true ]]; then
|
||||
RELEASE_VERSION=$($DL - "https://api.github.com/repos/laurent22/joplin/releases" | grep -Po '"tag_name": ?"v\K.*?(?=")' | sort -rV | head -1)
|
||||
RELEASE_VERSION=$(wget -qO - "https://api.github.com/repos/laurent22/joplin/releases" | grep -Po '"tag_name": ?"v\K.*?(?=")' | sort -rV | head -1)
|
||||
else
|
||||
RELEASE_VERSION=$($DL - "https://api.github.com/repos/laurent22/joplin/releases/latest" | grep -Po '"tag_name": ?"v\K.*?(?=")')
|
||||
RELEASE_VERSION=$(wget -qO - "https://api.github.com/repos/laurent22/joplin/releases/latest" | grep -Po '"tag_name": ?"v\K.*?(?=")')
|
||||
fi
|
||||
|
||||
# Check if it's in the latest version
|
||||
@@ -176,8 +163,8 @@ fi
|
||||
#-----------------------------------------------------
|
||||
print 'Downloading Joplin...'
|
||||
TEMP_DIR=$(mktemp -d)
|
||||
$DL "${TEMP_DIR}/Joplin.AppImage" "https://objects.joplinusercontent.com/v${RELEASE_VERSION}/Joplin-${RELEASE_VERSION}.AppImage?source=LinuxInstallScript&type=$DOWNLOAD_TYPE"
|
||||
$DL "${TEMP_DIR}/joplin.png" https://joplinapp.org/images/Icon512.png
|
||||
wget -O "${TEMP_DIR}/Joplin.AppImage" "https://objects.joplinusercontent.com/v${RELEASE_VERSION}/Joplin-${RELEASE_VERSION}.AppImage?source=LinuxInstallScript&type=$DOWNLOAD_TYPE"
|
||||
wget -O "${TEMP_DIR}/joplin.png" https://joplinapp.org/images/Icon512.png
|
||||
|
||||
#-----------------------------------------------------
|
||||
print 'Installing Joplin...'
|
||||
@@ -300,7 +287,7 @@ echo "$RELEASE_VERSION" > "${INSTALL_DIR}/VERSION"
|
||||
|
||||
#-----------------------------------------------------
|
||||
if [[ "$SHOW_CHANGELOG" == true ]]; then
|
||||
NOTES=$($DL - https://api.github.com/repos/laurent22/joplin/releases/latest | grep -Po '"body": "\K.*(?=")')
|
||||
NOTES=$(wget -qO - https://api.github.com/repos/laurent22/joplin/releases/latest | grep -Po '"body": "\K.*(?=")')
|
||||
print "${COLOR_BLUE}Changelog:${COLOR_RESET}\n${NOTES}"
|
||||
fi
|
||||
|
||||
|
||||
@@ -50,10 +50,9 @@ Please see the [donation page](https://github.com/laurent22/joplin/blob/dev/read
|
||||
Name | Description
|
||||
--- | ---
|
||||
[Support Forum](https://discourse.joplinapp.org/) | This is the main place for general discussion about Joplin, user support, software development questions, and to discuss new features. Also where the latest beta versions are released and discussed.
|
||||
[Patreon page](https://www.patreon.com/joplin) |The latest news are often posted there
|
||||
[Bluesky feed](https://bsky.app/profile/joplinapp.bsky.social) | Follow us on Bluesky
|
||||
[Mastodon feed](https://mastodon.social/@joplinapp) | Follow us on Mastodon
|
||||
[YouTube](https://www.youtube.com/@joplinapp) | Discover information and tutorials on how to use the apps
|
||||
[Patreon page](https://www.patreon.com/joplin) |The latest news are often posted there
|
||||
[Discord server](https://discord.gg/VSj7AFHvpq) | Our chat server
|
||||
[LinkedIn](https://www.linkedin.com/company/joplin) | Our LinkedIn page
|
||||
[Lemmy Community](https://sopuli.xyz/c/joplinapp) | Also a good place to get help
|
||||
|
||||
@@ -25,8 +25,7 @@
|
||||
"version": "latest",
|
||||
"excluded_platforms": ["aarch64-darwin", "x86_64-darwin"],
|
||||
},
|
||||
"git": "latest",
|
||||
"giflib": "latest",
|
||||
"git": "latest",
|
||||
},
|
||||
"shell": {
|
||||
"init_hook": [
|
||||
|
||||
@@ -116,7 +116,6 @@
|
||||
"app-builder-lib@26.0.0-alpha.7": "patch:app-builder-lib@npm%3A26.0.0-alpha.7#./.yarn/patches/app-builder-lib-npm-26.0.0-alpha.7-e1b3dca119.patch",
|
||||
"app-builder-lib@24.13.3": "patch:app-builder-lib@npm%3A24.13.3#./.yarn/patches/app-builder-lib-npm-24.13.3-86a66c0bf3.patch",
|
||||
"react-native-sqlite-storage@6.0.1": "patch:react-native-sqlite-storage@npm%3A6.0.1#./.yarn/patches/react-native-sqlite-storage-npm-6.0.1-8369d747bd.patch",
|
||||
"react-native-paper@5.13.1": "patch:react-native-paper@npm%3A5.13.1#./.yarn/patches/react-native-paper-npm-5.13.1-f153e542e2.patch",
|
||||
"react-native-popup-menu@0.16.1": "patch:react-native-popup-menu@npm%3A0.16.1#./.yarn/patches/react-native-popup-menu-npm-0.16.1-28fd66ecb5.patch"
|
||||
"react-native-paper@5.13.1": "patch:react-native-paper@npm%3A5.13.1#./.yarn/patches/react-native-paper-npm-5.13.1-f153e542e2.patch"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import Logger, { LoggerWrapper, TargetType } from '@joplin/utils/Logger';
|
||||
import Logger, { LoggerWrapper } from '@joplin/utils/Logger';
|
||||
import { PluginMessage } from './services/plugins/PluginRunner';
|
||||
import AutoUpdaterService, { defaultUpdateInterval, initialUpdateStartup } from './services/autoUpdater/AutoUpdaterService';
|
||||
import type ShimType from '@joplin/lib/shim';
|
||||
const shim: typeof ShimType = require('@joplin/lib/shim').default;
|
||||
import { isCallbackUrl } from '@joplin/lib/callbackUrlUtils';
|
||||
import { FileLocker } from '@joplin/utils/fs';
|
||||
import { IpcMessageHandler, IpcServer, Message, newHttpError, sendMessage, SendMessageOptions, startServer, stopServer } from '@joplin/utils/ipc';
|
||||
import { BrowserWindow, Tray, WebContents, screen, App } from 'electron';
|
||||
|
||||
import { BrowserWindow, Tray, WebContents, screen } from 'electron';
|
||||
import bridge from './bridge';
|
||||
const url = require('url');
|
||||
const path = require('path');
|
||||
@@ -20,7 +19,6 @@ import handleCustomProtocols, { CustomProtocolHandler } from './utils/customProt
|
||||
import { clearTimeout, setTimeout } from 'timers';
|
||||
import { resolve } from 'path';
|
||||
import { defaultWindowId } from '@joplin/lib/reducer';
|
||||
import { msleep, Second } from '@joplin/utils/time';
|
||||
|
||||
interface RendererProcessQuitReply {
|
||||
canClose: boolean;
|
||||
@@ -38,7 +36,8 @@ interface SecondaryWindowData {
|
||||
|
||||
export default class ElectronAppWrapper {
|
||||
private logger_: Logger = null;
|
||||
private electronApp_: App;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
private electronApp_: any;
|
||||
private env_: string;
|
||||
private isDebugMode_: boolean;
|
||||
private profilePath_: string;
|
||||
@@ -59,28 +58,13 @@ export default class ElectronAppWrapper {
|
||||
private customProtocolHandler_: CustomProtocolHandler = null;
|
||||
private updatePollInterval_: ReturnType<typeof setTimeout>|null = null;
|
||||
|
||||
private profileLocker_: FileLocker|null = null;
|
||||
private ipcServer_: IpcServer|null = null;
|
||||
private ipcStartPort_ = 2658;
|
||||
|
||||
private ipcLogger_: Logger;
|
||||
|
||||
public constructor(electronApp: App, env: string, profilePath: string|null, isDebugMode: boolean, initialCallbackUrl: string) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
public constructor(electronApp: any, env: string, profilePath: string|null, isDebugMode: boolean, initialCallbackUrl: string) {
|
||||
this.electronApp_ = electronApp;
|
||||
this.env_ = env;
|
||||
this.isDebugMode_ = isDebugMode;
|
||||
this.profilePath_ = profilePath;
|
||||
this.initialCallbackUrl_ = initialCallbackUrl;
|
||||
|
||||
this.profileLocker_ = new FileLocker(`${this.profilePath_}/lock`);
|
||||
|
||||
// Note: in certain contexts `this.logger_` doesn't seem to be available, especially for IPC
|
||||
// calls, either because it hasn't been set or other issue. So we set one here specifically
|
||||
// for this.
|
||||
this.ipcLogger_ = new Logger();
|
||||
this.ipcLogger_.addTarget(TargetType.File, {
|
||||
path: `${profilePath}/log-cross-app-ipc.txt`,
|
||||
});
|
||||
}
|
||||
|
||||
public electronApp() {
|
||||
@@ -426,7 +410,7 @@ export default class ElectronAppWrapper {
|
||||
if (message.target === 'plugin') {
|
||||
const win = this.pluginWindows_[message.pluginId];
|
||||
if (!win) {
|
||||
this.ipcLogger_.error(`Trying to send IPC message to non-existing plugin window: ${message.pluginId}`);
|
||||
this.logger().error(`Trying to send IPC message to non-existing plugin window: ${message.pluginId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -481,24 +465,12 @@ export default class ElectronAppWrapper {
|
||||
});
|
||||
}
|
||||
|
||||
private onExit() {
|
||||
this.stopPeriodicUpdateCheck();
|
||||
this.profileLocker_.unlockSync();
|
||||
|
||||
// Probably doesn't matter if the server is not closed cleanly? Thus the lack of `await`
|
||||
// eslint-disable-next-line promise/prefer-await-to-then -- Needed here because onExit() is not async
|
||||
void stopServer(this.ipcServer_).catch(_error => {
|
||||
// Ignore it since we're stopping, and to prevent unnecessary messages.
|
||||
});
|
||||
}
|
||||
|
||||
public quit() {
|
||||
this.onExit();
|
||||
this.stopPeriodicUpdateCheck();
|
||||
this.electronApp_.quit();
|
||||
}
|
||||
|
||||
public exit(errorCode = 0) {
|
||||
this.onExit();
|
||||
this.electronApp_.exit(errorCode);
|
||||
}
|
||||
|
||||
@@ -564,26 +536,20 @@ export default class ElectronAppWrapper {
|
||||
this.tray_ = null;
|
||||
}
|
||||
|
||||
public async sendCrossAppIpcMessage(message: Message, port: number|null = null, options: SendMessageOptions = null) {
|
||||
this.ipcLogger_.info('Sending message:', message);
|
||||
public ensureSingleInstance() {
|
||||
if (this.env_ === 'dev') return false;
|
||||
|
||||
if (port === null) port = this.ipcStartPort_;
|
||||
const gotTheLock = this.electronApp_.requestSingleInstanceLock();
|
||||
|
||||
return await sendMessage(port, { ...message, sourcePort: this.ipcServer_.port }, {
|
||||
logger: this.ipcLogger_,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
public async ensureSingleInstance() {
|
||||
// if (this.env_ === 'dev') return false;
|
||||
|
||||
interface OnSecondInstanceMessageData {
|
||||
profilePath: string;
|
||||
argv: string[];
|
||||
if (!gotTheLock) {
|
||||
// Another instance is already running - exit
|
||||
this.quit();
|
||||
return true;
|
||||
}
|
||||
|
||||
const activateWindow = (argv: string[]) => {
|
||||
// Someone tried to open a second instance - focus our window instead
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
this.electronApp_.on('second-instance', (_e: any, argv: string[]) => {
|
||||
const win = this.mainWindow();
|
||||
if (!win) return;
|
||||
if (win.isMinimized()) win.restore();
|
||||
@@ -596,86 +562,9 @@ export default class ElectronAppWrapper {
|
||||
void this.openCallbackUrl(url);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const messageHandlers: Record<string, IpcMessageHandler> = {
|
||||
'onSecondInstance': async (message) => {
|
||||
const data = message.data as OnSecondInstanceMessageData;
|
||||
if (data.profilePath === this.profilePath_) activateWindow(data.argv);
|
||||
},
|
||||
|
||||
'restartAltInstance': async (message) => {
|
||||
if (bridge().altInstanceId()) return false;
|
||||
|
||||
// We do this in a timeout after a short interval because we need this call to
|
||||
// return the response immediately, so that the caller can call `quit()`
|
||||
setTimeout(async () => {
|
||||
const maxWait = 10000;
|
||||
const interval = 300;
|
||||
const loopCount = Math.ceil(maxWait / interval);
|
||||
let callingAppGone = false;
|
||||
|
||||
for (let i = 0; i < loopCount; i++) {
|
||||
const response = await this.sendCrossAppIpcMessage({
|
||||
action: 'ping',
|
||||
data: null,
|
||||
}, message.sourcePort, {
|
||||
sendToSpecificPortOnly: true,
|
||||
});
|
||||
|
||||
if (!response.length) {
|
||||
callingAppGone = true;
|
||||
break;
|
||||
}
|
||||
|
||||
await msleep(interval);
|
||||
}
|
||||
|
||||
if (callingAppGone) {
|
||||
this.ipcLogger_.warn('restartAltInstance: App is gone - restarting it');
|
||||
void bridge().launchNewAppInstance(this.env());
|
||||
} else {
|
||||
this.ipcLogger_.warn('restartAltInstance: Could not restart calling app because it was still open');
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
'ping': async (_message) => {
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
this.ipcServer_ = await startServer(this.ipcStartPort_, async (message) => {
|
||||
if (messageHandlers[message.action]) {
|
||||
this.ipcLogger_.info('Got message:', message);
|
||||
return messageHandlers[message.action](message);
|
||||
}
|
||||
|
||||
throw newHttpError(404);
|
||||
}, {
|
||||
logger: this.ipcLogger_,
|
||||
});
|
||||
|
||||
// First check that no other app is running from that profile folder
|
||||
const gotAppLock = await this.profileLocker_.lock();
|
||||
if (gotAppLock) return false;
|
||||
|
||||
const message: Message = {
|
||||
action: 'onSecondInstance',
|
||||
data: {
|
||||
senderPort: this.ipcServer_.port,
|
||||
profilePath: this.profilePath_,
|
||||
argv: process.argv,
|
||||
},
|
||||
};
|
||||
|
||||
await this.sendCrossAppIpcMessage(message);
|
||||
|
||||
this.quit();
|
||||
if (this.env() === 'dev') console.warn(`Closing the application because another instance is already running, or the previous instance was force-quit within the last ${Math.round(this.profileLocker_.options.interval / Second)} seconds.`);
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
public initializeCustomProtocolHandler(logger: LoggerWrapper) {
|
||||
@@ -717,7 +606,7 @@ export default class ElectronAppWrapper {
|
||||
// the "ready" event. So we use the function below to make sure that the app is ready.
|
||||
await this.waitForElectronAppReady();
|
||||
|
||||
const alreadyRunning = await this.ensureSingleInstance();
|
||||
const alreadyRunning = this.ensureSingleInstance();
|
||||
if (alreadyRunning) return;
|
||||
|
||||
this.createWindow();
|
||||
|
||||
@@ -617,11 +617,10 @@ class Application extends BaseApplication {
|
||||
clipperLogger.addTarget(TargetType.Console);
|
||||
|
||||
ClipperServer.instance().initialize(actionApi);
|
||||
ClipperServer.instance().setEnabled(!Setting.value('altInstanceId'));
|
||||
ClipperServer.instance().setLogger(clipperLogger);
|
||||
ClipperServer.instance().setDispatch(this.store().dispatch);
|
||||
|
||||
if (ClipperServer.instance().enabled() && Setting.value('clipperServer.autoStart')) {
|
||||
if (Setting.value('clipperServer.autoStart')) {
|
||||
void ClipperServer.instance().start();
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ import isSafeToOpen from './utils/isSafeToOpen';
|
||||
import { closeSync, openSync, readSync, statSync } from 'fs';
|
||||
import { KB } from '@joplin/utils/bytes';
|
||||
import { defaultWindowId } from '@joplin/lib/reducer';
|
||||
import { execCommand } from '@joplin/utils';
|
||||
|
||||
interface LastSelectedPath {
|
||||
file: string;
|
||||
@@ -44,18 +43,16 @@ export class Bridge {
|
||||
private appName_: string;
|
||||
private appId_: string;
|
||||
private logFilePath_ = '';
|
||||
private altInstanceId_ = '';
|
||||
|
||||
private extraAllowedExtensions_: string[] = [];
|
||||
private onAllowedExtensionsChangeListener_: OnAllowedExtensionsChange = ()=>{};
|
||||
|
||||
public constructor(electronWrapper: ElectronAppWrapper, appId: string, appName: string, rootProfileDir: string, autoUploadCrashDumps: boolean, altInstanceId: string) {
|
||||
public constructor(electronWrapper: ElectronAppWrapper, appId: string, appName: string, rootProfileDir: string, autoUploadCrashDumps: boolean) {
|
||||
this.electronWrapper_ = electronWrapper;
|
||||
this.appId_ = appId;
|
||||
this.appName_ = appName;
|
||||
this.rootProfileDir_ = rootProfileDir;
|
||||
this.autoUploadCrashDumps_ = autoUploadCrashDumps;
|
||||
this.altInstanceId_ = altInstanceId;
|
||||
this.lastSelectedPaths_ = {
|
||||
file: null,
|
||||
directory: null,
|
||||
@@ -221,10 +218,6 @@ export class Bridge {
|
||||
return this.electronApp().electronApp().getLocale();
|
||||
};
|
||||
|
||||
public altInstanceId() {
|
||||
return this.altInstanceId_;
|
||||
}
|
||||
|
||||
// Applies to electron-context-menu@3:
|
||||
//
|
||||
// For now we have to disable spell checking in non-editor text
|
||||
@@ -498,38 +491,7 @@ export class Bridge {
|
||||
}
|
||||
}
|
||||
|
||||
public appLaunchCommand(env: string, altInstanceId = '') {
|
||||
const altInstanceArgs = altInstanceId ? ['--alt-instance-id', altInstanceId] : [];
|
||||
|
||||
if (env === 'dev') {
|
||||
// This is convenient to quickly test on dev, but the path needs to be adjusted
|
||||
// depending on how things are setup.
|
||||
|
||||
return {
|
||||
execPath: `${homedir()}/.npm-global/bin/electron`,
|
||||
args: [
|
||||
`${homedir()}/src/joplin/packages/app-desktop`,
|
||||
'--env', 'dev',
|
||||
'--log-level', 'debug',
|
||||
'--open-dev-tools',
|
||||
'--no-welcome',
|
||||
].concat(altInstanceArgs),
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
execPath: bridge().electronApp().electronApp().getPath('exe'),
|
||||
args: [].concat(altInstanceArgs),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async launchNewAppInstance(env: string) {
|
||||
const cmd = this.appLaunchCommand(env, 'alt1');
|
||||
|
||||
await execCommand([cmd.execPath].concat(cmd.args), { detached: true });
|
||||
}
|
||||
|
||||
public async restart() {
|
||||
public restart(linuxSafeRestart = true) {
|
||||
// Note that in this case we are not sending the "appClose" event
|
||||
// to notify services and component that the app is about to close
|
||||
// but for the current use-case it's not really needed.
|
||||
@@ -540,34 +502,8 @@ export class Bridge {
|
||||
execPath: process.env.PORTABLE_EXECUTABLE_FILE,
|
||||
};
|
||||
app.relaunch(options);
|
||||
} else if (this.altInstanceId_) {
|
||||
// Couldn't get it to work using relaunch() - it would just "close" the app, but it
|
||||
// would still be open in the tray except unusable. Or maybe it reopens it quickly but
|
||||
// in a broken state. It might be due to the way it is launched from the main instance.
|
||||
// So here we ask the main instance to relaunch this app after a short delay.
|
||||
|
||||
const responses = await this.electronApp().sendCrossAppIpcMessage({
|
||||
action: 'restartAltInstance',
|
||||
data: null,
|
||||
});
|
||||
|
||||
// However is the main instance is not running, we're stuck, so the user needs to
|
||||
// manually restart. `relaunch()` doesn't appear to work even when the main instance is
|
||||
// not running.
|
||||
const r = responses.find(r => !!r.response);
|
||||
|
||||
if (!r || !r.response) {
|
||||
this.showInfoMessageBox(_('The app is now going to close. Please relaunch it to complete the process.'));
|
||||
|
||||
// Note: this should work, but doesn't:
|
||||
|
||||
// const cmd = this.appLaunchCommand(this.env(), this.altInstanceId_);
|
||||
|
||||
// app.relaunch({
|
||||
// execPath: cmd.execPath,
|
||||
// args: cmd.args,
|
||||
// });
|
||||
}
|
||||
} else if (shim.isLinux() && linuxSafeRestart) {
|
||||
this.showInfoMessageBox(_('The app is now going to close. Please relaunch it to complete the process.'));
|
||||
} else {
|
||||
app.relaunch();
|
||||
}
|
||||
@@ -598,9 +534,9 @@ export class Bridge {
|
||||
|
||||
let bridge_: Bridge = null;
|
||||
|
||||
export function initBridge(wrapper: ElectronAppWrapper, appId: string, appName: string, rootProfileDir: string, autoUploadCrashDumps: boolean, altInstanceId: string) {
|
||||
export function initBridge(wrapper: ElectronAppWrapper, appId: string, appName: string, rootProfileDir: string, autoUploadCrashDumps: boolean) {
|
||||
if (bridge_) throw new Error('Bridge already initialized');
|
||||
bridge_ = new Bridge(wrapper, appId, appName, rootProfileDir, autoUploadCrashDumps, altInstanceId);
|
||||
bridge_ = new Bridge(wrapper, appId, appName, rootProfileDir, autoUploadCrashDumps);
|
||||
return bridge_;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import * as exportDeletionLog from './exportDeletionLog';
|
||||
import * as exportFolders from './exportFolders';
|
||||
import * as exportNotes from './exportNotes';
|
||||
import * as focusElement from './focusElement';
|
||||
import * as newAppInstance from './newAppInstance';
|
||||
import * as openNoteInNewWindow from './openNoteInNewWindow';
|
||||
import * as openProfileDirectory from './openProfileDirectory';
|
||||
import * as replaceMisspelling from './replaceMisspelling';
|
||||
@@ -29,7 +28,6 @@ const index: any[] = [
|
||||
exportFolders,
|
||||
exportNotes,
|
||||
focusElement,
|
||||
newAppInstance,
|
||||
openNoteInNewWindow,
|
||||
openProfileDirectory,
|
||||
replaceMisspelling,
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import bridge from '../services/bridge';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'newAppInstance',
|
||||
label: () => _('New application instance...'),
|
||||
};
|
||||
|
||||
export const runtime = (): CommandRuntime => {
|
||||
return {
|
||||
execute: async (_context: CommandContext) => {
|
||||
await bridge().launchNewAppInstance(Setting.value('env'));
|
||||
},
|
||||
|
||||
enabledCondition: '!isAltInstance',
|
||||
};
|
||||
};
|
||||
@@ -24,7 +24,6 @@ class ClipperConfigScreenComponent extends React.Component {
|
||||
}
|
||||
|
||||
private enableClipperServer_click() {
|
||||
if (!ClipperServer.instance().enabled()) return;
|
||||
Setting.setValue('clipperServer.autoStart', true);
|
||||
void ClipperServer.instance().start();
|
||||
}
|
||||
@@ -71,8 +70,6 @@ class ClipperConfigScreenComponent extends React.Component {
|
||||
|
||||
const webClipperStatusComps = [];
|
||||
|
||||
const clipperEnabled = ClipperServer.instance().enabled();
|
||||
|
||||
if (this.props.clipperServerAutoStart) {
|
||||
webClipperStatusComps.push(
|
||||
<p key="text_1" style={theme.textStyle}>
|
||||
@@ -98,22 +95,13 @@ class ClipperConfigScreenComponent extends React.Component {
|
||||
</button>,
|
||||
);
|
||||
} else {
|
||||
if (!clipperEnabled) {
|
||||
webClipperStatusComps.push(
|
||||
<p key="text_4" style={theme.textStyle}>
|
||||
{_('The web clipper service cannot be enabled in this instance of Joplin.')}
|
||||
</p>,
|
||||
);
|
||||
} else {
|
||||
webClipperStatusComps.push(
|
||||
<p key="text_4" style={theme.textStyle}>
|
||||
{_('The web clipper service is not enabled.')}
|
||||
</p>,
|
||||
);
|
||||
}
|
||||
|
||||
webClipperStatusComps.push(
|
||||
<button key="enable_button" style={buttonStyle} onClick={this.enableClipperServer_click} disabled={!clipperEnabled}>
|
||||
<p key="text_4" style={theme.textStyle}>
|
||||
{_('The web clipper service is not enabled.')}
|
||||
</p>,
|
||||
);
|
||||
webClipperStatusComps.push(
|
||||
<button key="enable_button" style={buttonStyle} onClick={this.enableClipperServer_click}>
|
||||
{_('Enable Web Clipper Service')}
|
||||
</button>,
|
||||
);
|
||||
|
||||
@@ -83,7 +83,6 @@ interface Props {
|
||||
notesColumns: NoteListColumns;
|
||||
showInvalidJoplinCloudCredential: boolean;
|
||||
toast: Toast;
|
||||
shouldSwitchToAppleSiliconVersion: boolean;
|
||||
}
|
||||
|
||||
interface ShareFolderDialogOptions {
|
||||
@@ -479,10 +478,6 @@ class MainScreenComponent extends React.Component<Props, State> {
|
||||
});
|
||||
};
|
||||
|
||||
const onDisableSync = () => {
|
||||
Setting.setValue('sync.target', null);
|
||||
};
|
||||
|
||||
const onViewSyncSettingsScreen = () => {
|
||||
this.props.dispatch({
|
||||
type: 'NAV_GO',
|
||||
@@ -493,11 +488,6 @@ class MainScreenComponent extends React.Component<Props, State> {
|
||||
});
|
||||
};
|
||||
|
||||
const onDownloadAppleSiliconVersion = () => {
|
||||
// The website should redirect to the correct version
|
||||
shim.openUrl('https://joplinapp.org/download/');
|
||||
};
|
||||
|
||||
const onRestartAndUpgrade = async () => {
|
||||
Setting.setValue('sync.upgradeState', Setting.SYNC_UPGRADE_STATE_MUST_DO);
|
||||
await Setting.saveAll();
|
||||
@@ -580,19 +570,11 @@ class MainScreenComponent extends React.Component<Props, State> {
|
||||
);
|
||||
} else if (this.props.mustUpgradeAppMessage) {
|
||||
msg = this.renderNotificationMessage(this.props.mustUpgradeAppMessage);
|
||||
} else if (this.props.shouldSwitchToAppleSiliconVersion) {
|
||||
msg = this.renderNotificationMessage(
|
||||
_('You are running the Intel version of Joplin on an Apple Silicon processor. Download the Apple Silicon one for better performance.'),
|
||||
_('Download it now'),
|
||||
onDownloadAppleSiliconVersion,
|
||||
);
|
||||
} else if (this.props.showInvalidJoplinCloudCredential) {
|
||||
msg = this.renderNotificationMessage(
|
||||
_('Your Joplin Cloud credentials are invalid, please login.'),
|
||||
_('Login to Joplin Cloud.'),
|
||||
onViewJoplinCloudLoginScreen,
|
||||
_('Disable synchronisation'),
|
||||
onDisableSync,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -623,8 +605,7 @@ class MainScreenComponent extends React.Component<Props, State> {
|
||||
this.showShareInvitationNotification(props) ||
|
||||
this.props.needApiAuth ||
|
||||
!!this.props.mustUpgradeAppMessage ||
|
||||
props.showInvalidJoplinCloudCredential ||
|
||||
props.shouldSwitchToAppleSiliconVersion;
|
||||
props.showInvalidJoplinCloudCredential;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
@@ -852,7 +833,6 @@ const mapStateToProps = (state: AppState) => {
|
||||
notesColumns: validateColumns(state.settings['notes.columns']),
|
||||
showInvalidJoplinCloudCredential: state.settings['sync.target'] === 10 && state.mustAuthenticate,
|
||||
toast: state.toast,
|
||||
shouldSwitchToAppleSiliconVersion: shim.isAppleSilicon() && process.arch !== 'arm64',
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -172,7 +172,6 @@ interface Props {
|
||||
pluginMenus: any[];
|
||||
['spellChecker.enabled']: boolean;
|
||||
['spellChecker.languages']: string[];
|
||||
markdownEditorVisible: boolean;
|
||||
plugins: PluginStates;
|
||||
customCss: string;
|
||||
locale: string;
|
||||
@@ -279,7 +278,6 @@ function useMenuStates(menu: any, props: Props) {
|
||||
props['notes.sortOrder.reverse'],
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
props['folders.sortOrder.reverse'],
|
||||
props.markdownEditorVisible,
|
||||
props.tabMovesFocus,
|
||||
props.noteListRendererId,
|
||||
props.showNoteCounts,
|
||||
@@ -481,7 +479,6 @@ function useMenu(props: Props) {
|
||||
menuItemDic.focusElementNoteList,
|
||||
menuItemDic.focusElementNoteTitle,
|
||||
menuItemDic.focusElementNoteBody,
|
||||
menuItemDic.focusElementNoteViewer,
|
||||
menuItemDic.focusElementToolbar,
|
||||
];
|
||||
|
||||
@@ -555,7 +552,6 @@ function useMenu(props: Props) {
|
||||
const newFolderItem = menuItemDic.newFolder;
|
||||
const newSubFolderItem = menuItemDic.newSubFolder;
|
||||
const printItem = menuItemDic.print;
|
||||
const newAppInstance = menuItemDic.newAppInstance;
|
||||
const switchProfileItem = {
|
||||
label: _('Switch profile'),
|
||||
submenu: switchProfileMenuItems,
|
||||
@@ -719,11 +715,8 @@ function useMenu(props: Props) {
|
||||
}, {
|
||||
type: 'separator',
|
||||
},
|
||||
printItem, {
|
||||
type: 'separator',
|
||||
},
|
||||
printItem,
|
||||
switchProfileItem,
|
||||
newAppInstance,
|
||||
],
|
||||
};
|
||||
|
||||
@@ -796,7 +789,6 @@ function useMenu(props: Props) {
|
||||
shim.isMac() ? noItem : menuItemDic.toggleMenuBar,
|
||||
menuItemDic.toggleNoteList,
|
||||
menuItemDic.toggleVisiblePanes,
|
||||
menuItemDic.toggleEditorPlugin,
|
||||
{
|
||||
label: _('Layout button sequence'),
|
||||
submenu: layoutButtonSequenceMenuItems,
|
||||
@@ -1147,7 +1139,7 @@ function MenuBar(props: Props): any {
|
||||
|
||||
|
||||
const mapStateToProps = (state: AppState): Partial<Props> => {
|
||||
const whenClauseContext = stateToWhenClauseContext(state, { windowId: state.windowId });
|
||||
const whenClauseContext = stateToWhenClauseContext(state);
|
||||
|
||||
const secondaryWindowFocused = state.windowId !== defaultWindowId;
|
||||
|
||||
@@ -1173,7 +1165,6 @@ const mapStateToProps = (state: AppState): Partial<Props> => {
|
||||
pluginMenus: stateUtils.selectArrayShallow({ array: pluginUtils.viewsByType(state.pluginService.plugins, 'menu') }, 'menuBar.pluginMenus'),
|
||||
['spellChecker.languages']: state.settings['spellChecker.languages'],
|
||||
['spellChecker.enabled']: state.settings['spellChecker.enabled'],
|
||||
markdownEditorVisible: whenClauseContext.markdownEditorVisible,
|
||||
plugins: state.pluginService.plugins,
|
||||
customCss: state.customViewerCss,
|
||||
profileConfig: state.profileConfig,
|
||||
|
||||
@@ -129,14 +129,6 @@ const useEditorCommands = (props: Props) => {
|
||||
props.webviewRef.current.send('focus');
|
||||
}
|
||||
},
|
||||
'viewer.focus': () => {
|
||||
if (props.visiblePanes.includes('viewer')) {
|
||||
const editorCursorLine = editorRef.current.getCursor().line;
|
||||
props.webviewRef.current.focusLine(editorCursorLine);
|
||||
} else {
|
||||
logger.info('Viewer not focused (not visible).');
|
||||
}
|
||||
},
|
||||
search: () => {
|
||||
return editorRef.current.execCommand(EditorCommandType.ShowSearch);
|
||||
},
|
||||
|
||||
@@ -1105,13 +1105,6 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
// Meta+P is bound by default to print by TinyMCE. It can be unbound, but it seems necessary
|
||||
// to do so after the editor loads. Meta+P should be able to trigger Joplin built-in shortcuts.
|
||||
editor.shortcuts.remove('Meta+P');
|
||||
}, [editor]);
|
||||
|
||||
// -----------------------------------------------------------------------------------------
|
||||
// Handle onChange event
|
||||
// -----------------------------------------------------------------------------------------
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { CommandRuntime, CommandDeclaration } from '@joplin/lib/services/CommandService';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { FocusElementOptions } from '../../../commands/focusElement';
|
||||
import { WindowCommandDependencies } from '../utils/types';
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'focusElementNoteViewer',
|
||||
label: () => _('Note viewer'),
|
||||
parentLabel: () => _('Focus'),
|
||||
};
|
||||
|
||||
export const runtime = (dependencies: WindowCommandDependencies): CommandRuntime => {
|
||||
return {
|
||||
execute: async (_context: unknown, options?: FocusElementOptions) => {
|
||||
await dependencies.editorRef.current.execCommand({
|
||||
name: 'viewer.focus',
|
||||
value: options,
|
||||
});
|
||||
},
|
||||
enabledCondition: 'markdownEditorVisible',
|
||||
};
|
||||
};
|
||||
@@ -1,7 +1,6 @@
|
||||
// AUTO-GENERATED using `gulp buildScriptIndexes`
|
||||
import * as focusElementNoteBody from './focusElementNoteBody';
|
||||
import * as focusElementNoteTitle from './focusElementNoteTitle';
|
||||
import * as focusElementNoteViewer from './focusElementNoteViewer';
|
||||
import * as focusElementToolbar from './focusElementToolbar';
|
||||
import * as pasteAsText from './pasteAsText';
|
||||
import * as showLocalSearch from './showLocalSearch';
|
||||
@@ -10,7 +9,6 @@ import * as showRevisions from './showRevisions';
|
||||
const index: any[] = [
|
||||
focusElementNoteBody,
|
||||
focusElementNoteTitle,
|
||||
focusElementNoteViewer,
|
||||
focusElementToolbar,
|
||||
pasteAsText,
|
||||
showLocalSearch,
|
||||
|
||||
@@ -163,9 +163,6 @@ const declarations: CommandDeclaration[] = [
|
||||
{
|
||||
name: 'editor.execCommand',
|
||||
},
|
||||
{
|
||||
name: 'viewer.focus',
|
||||
},
|
||||
];
|
||||
|
||||
export default declarations;
|
||||
|
||||
@@ -10,7 +10,6 @@ const commandsWithDependencies = [
|
||||
require('../commands/showLocalSearch'),
|
||||
require('../commands/focusElementNoteTitle'),
|
||||
require('../commands/focusElementNoteBody'),
|
||||
require('../commands/focusElementNoteViewer'),
|
||||
require('../commands/focusElementToolbar'),
|
||||
require('../commands/pasteAsText'),
|
||||
];
|
||||
|
||||
@@ -28,7 +28,6 @@ export interface NoteViewerControl {
|
||||
domReady(): boolean;
|
||||
setHtml(html: string, options: SetHtmlOptions): void;
|
||||
send(channel: string, arg0?: unknown, arg1?: unknown): void;
|
||||
focusLine(editorLine: number): void;
|
||||
focus(): void;
|
||||
hasFocus(): boolean;
|
||||
}
|
||||
@@ -108,10 +107,6 @@ const NoteTextViewer = forwardRef((props: Props, ref: ForwardedRef<NoteViewerCon
|
||||
win.postMessage({ target: 'webview', name: 'focus', data: {} }, '*');
|
||||
}
|
||||
|
||||
if (channel === 'focusLine') {
|
||||
win.postMessage({ target: 'webview', name: 'focusLine', data: { line: arg0 } }, '*');
|
||||
}
|
||||
|
||||
// External code should use .setHtml (rather than send('setHtml', ...))
|
||||
if (channel === 'setHtml') {
|
||||
win.postMessage({ target: 'webview', name: 'setHtml', data: { html: arg0, options: arg1 } }, '*');
|
||||
@@ -144,15 +139,6 @@ const NoteTextViewer = forwardRef((props: Props, ref: ForwardedRef<NoteViewerCon
|
||||
hasFocus: () => {
|
||||
return webviewRef.current?.contains(parentDoc.activeElement);
|
||||
},
|
||||
focusLine: (lineNumber: number) => {
|
||||
if (webviewRef.current) {
|
||||
focus('NoteTextViewer::focusLine', webviewRef.current);
|
||||
// A timeout seems necessary after focusing the viewer to prevent focus from jumping to the top
|
||||
setTimeout(() => {
|
||||
result.send('focusLine', lineNumber);
|
||||
}, 100);
|
||||
}
|
||||
},
|
||||
};
|
||||
return result;
|
||||
}, [parentDoc]);
|
||||
|
||||
@@ -22,14 +22,13 @@ interface CollapseExpandAllButtonProps {
|
||||
}
|
||||
|
||||
const CollapseExpandAllButton = (props: CollapseExpandAllButtonProps) => {
|
||||
// To allow it to be accessed by accessibility tools, the toggle button
|
||||
// To allow it to be accessed by accessibility tools, the new folder button
|
||||
// is not included in the portion of the list with role='tree'.
|
||||
const icon = props.allFoldersCollapsed ? 'far fa-caret-square-right' : 'far fa-caret-square-down';
|
||||
const label = props.allFoldersCollapsed ? _('Expand all notebooks') : _('Collapse all notebooks');
|
||||
|
||||
return <button onClick={() => onToggleAllFolders(props.allFoldersCollapsed)} className='sidebar-header-button -collapseall'>
|
||||
<i
|
||||
aria-label={label}
|
||||
aria-label={_('Collapse / Expand all notebooks')}
|
||||
role='img'
|
||||
className={icon}
|
||||
/>
|
||||
|
||||
@@ -23,7 +23,6 @@ interface Props {
|
||||
draggable?: boolean;
|
||||
'data-folder-id'?: string;
|
||||
'data-id'?: string;
|
||||
'data-tag-id'?: string;
|
||||
'data-type'?: ModelType;
|
||||
}
|
||||
|
||||
@@ -56,7 +55,6 @@ const ListItemWrapper: React.FC<Props> = props => {
|
||||
style={style}
|
||||
data-folder-id={props['data-folder-id']}
|
||||
data-id={props['data-id']}
|
||||
data-tag-id={props['data-tag-id']}
|
||||
data-type={props['data-type']}
|
||||
>
|
||||
{props.children}
|
||||
|
||||
@@ -4,7 +4,6 @@ export default function() {
|
||||
'copyDevCommand',
|
||||
'exportPdf',
|
||||
'focusElementNoteBody',
|
||||
'focusElementNoteViewer',
|
||||
'focusElementNoteList',
|
||||
'focusElementNoteTitle',
|
||||
'focusElementSideBar',
|
||||
@@ -44,11 +43,9 @@ export default function() {
|
||||
'togglePerFolderSortOrder',
|
||||
'toggleSideBar',
|
||||
'toggleVisiblePanes',
|
||||
'toggleEditorPlugin',
|
||||
'toggleTabMovesFocus',
|
||||
'editor.deleteLine',
|
||||
'editor.duplicateLine',
|
||||
'newAppInstance',
|
||||
// We cannot put the undo/redo commands in the menu because they are
|
||||
// editor-specific commands. If we put them there it will break the
|
||||
// undo/redo in regular text fields.
|
||||
|
||||
@@ -377,53 +377,6 @@
|
||||
contentElement.scrollTop = scrollTop;
|
||||
}
|
||||
|
||||
const getLineCorrespondingTo = (editorLineNumber) => {
|
||||
const lineElements = document.getElementsByClassName('maps-to-line');
|
||||
let lastLineElement;
|
||||
let lastLine = 0;
|
||||
for (const element of lineElements) {
|
||||
// Stop just before the element that corresponds to a greater position
|
||||
if (Number(element.getAttribute('source-line')) > editorLineNumber) {
|
||||
break;
|
||||
}
|
||||
lastLineElement = element;
|
||||
}
|
||||
return lastLineElement;
|
||||
};
|
||||
|
||||
const makeTemporarilyFocusable = (element) => {
|
||||
const dataOriginalTabIndexAttr = 'data-original-tabindex';
|
||||
const originalTabIndex = (
|
||||
element.getAttribute(dataOriginalTabIndexAttr) ?? element.getAttribute('tabindex')
|
||||
);
|
||||
element.setAttribute(dataOriginalTabIndexAttr, originalTabIndex);
|
||||
element.setAttribute('tabindex', '0');
|
||||
|
||||
return {
|
||||
reset: () => {
|
||||
element.setAttribute('tabindex', originalTabIndex);
|
||||
element.removeAttribute(dataOriginalTabIndexAttr);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
ipc.focusLine = (event) => {
|
||||
const targetLine = event.line;
|
||||
const lineElement = getLineCorrespondingTo(targetLine);
|
||||
if (lineElement) {
|
||||
// To allow focusing, the element needs to briefly have tabindex=0.
|
||||
const { reset } = makeTemporarilyFocusable(lineElement);
|
||||
|
||||
lineElement.focus({ preventScroll: true });
|
||||
|
||||
// Reset the tabindex after the browser has had time to focus the element.
|
||||
// When a screen reader is enabled, focus stays on the lineElement.
|
||||
setTimeout(() => {
|
||||
reset();
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
|
||||
const rewriteFileUrls = (accessKey) => {
|
||||
if (!accessKey) return;
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
# Integration tests
|
||||
|
||||
The integration tests in this directory can be run with `yarn test-ui`.
|
||||
The integration tests in this directory can be run with `yarn playwright test`.
|
||||
|
||||
- To run all tests from a specific file, use `yarn test-ui testFileName`. For example, `yarn test-ui wcag` to run the tests in `wcag.ts`.
|
||||
- To run all tests matching a pattern, use `yarn test-ui -g "pattern here"`, where `-g` is short for "grep".
|
||||
- Tests use a `test-profile` directory that should be re-created before every test.
|
||||
- Only one Electron application should be instantiated per test file.
|
||||
- Files in the `models/` directory follow [the page object model](https://playwright.dev/docs/pom).
|
||||
@@ -17,11 +15,3 @@ with Playwright:
|
||||
- [The Playwright ElectronApp docs](https://playwright.dev/docs/api/class-electronapplication)
|
||||
- [Electron Playwright example repository](https://github.com/spaceagetv/electron-playwright-example)
|
||||
- [Playwright best practices](https://playwright.dev/docs/best-practices)
|
||||
|
||||
# FAQ
|
||||
|
||||
## How do I fix timeout-related test failures?
|
||||
|
||||
If Playwright tests are timing out, consider modifying `playwright.config.ts` in the `app-desktop` folder. For example, increase the `timeout` option to `120_000` (2 minutes).
|
||||
|
||||
Alternatively, try temporarily disabling `fullyParallel` (which disables running tests in parallel).
|
||||
|
||||
@@ -116,10 +116,7 @@ test.describe('main', () => {
|
||||
await editor.attachFileButton.click();
|
||||
|
||||
const viewerFrame = editor.getNoteViewerFrameLocator();
|
||||
const renderedImage = viewerFrame
|
||||
.getByAltText(filename)
|
||||
// Work around occasional "resolved to 2 elements" errors in CI
|
||||
.last();
|
||||
const renderedImage = viewerFrame.getByAltText(filename);
|
||||
|
||||
const fullSize = await getImageSourceSize(renderedImage);
|
||||
|
||||
|
||||
@@ -230,28 +230,5 @@ test.describe('markdownEditor', () => {
|
||||
// Editor should be focused
|
||||
await expect(focusInMarkdownEditor).toBeAttached();
|
||||
});
|
||||
|
||||
test('focusElementNoteViewer should move focus to the viewer', async ({ mainWindow, electronApp }) => {
|
||||
const mainScreen = await new MainScreen(mainWindow).setup();
|
||||
await mainScreen.waitFor();
|
||||
const noteEditor = mainScreen.noteEditor;
|
||||
|
||||
await mainScreen.createNewNote('Note');
|
||||
|
||||
await noteEditor.focusCodeMirrorEditor();
|
||||
await mainWindow.keyboard.type('# Test');
|
||||
await mainWindow.keyboard.press('Enter');
|
||||
await mainWindow.keyboard.press('Enter');
|
||||
await mainWindow.keyboard.type('Test paragraph.');
|
||||
|
||||
// Wait for rendering
|
||||
await expect(noteEditor.getNoteViewerFrameLocator().getByText('Test paragraph.')).toBeAttached();
|
||||
|
||||
// Move focus
|
||||
await mainScreen.goToAnything.runCommand(electronApp, 'focusElementNoteViewer');
|
||||
|
||||
// Note viewer should be focused
|
||||
await expect(noteEditor.noteViewerContainer).toBeFocused();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ const EncryptionService = require('@joplin/lib/services/e2ee/EncryptionService')
|
||||
const FileApiDriverLocal = require('@joplin/lib/file-api-driver-local').default;
|
||||
const React = require('react');
|
||||
const nodeSqlite = require('sqlite3');
|
||||
const nodeSqliteCipher = require('@journeyapps/sqlcipher');
|
||||
const initLib = require('@joplin/lib/initLib').default;
|
||||
const pdfJs = require('pdfjs-dist');
|
||||
require('@sentry/electron/renderer');
|
||||
@@ -109,6 +110,7 @@ const main = async () => {
|
||||
appVersion,
|
||||
electronBridge: bridge(),
|
||||
nodeSqlite,
|
||||
nodeSqliteCipher,
|
||||
pdfJs,
|
||||
});
|
||||
|
||||
|
||||
@@ -25,27 +25,28 @@ process.on('unhandledRejection', (reason, p) => {
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
const getFlagValueFromArgs = (args, flag, defaultValue) => {
|
||||
// Likewise, we want to know if a profile is specified early, in particular
|
||||
// to save the window state data.
|
||||
function getProfileFromArgs(args) {
|
||||
if (!args) return null;
|
||||
const index = args.indexOf(flag);
|
||||
if (index <= 0 || index >= args.length - 1) return defaultValue;
|
||||
const value = args[index + 1];
|
||||
return value ? value : defaultValue;
|
||||
};
|
||||
const profileIndex = args.indexOf('--profile');
|
||||
if (profileIndex <= 0 || profileIndex >= args.length - 1) return null;
|
||||
const profileValue = args[profileIndex + 1];
|
||||
return profileValue ? profileValue : null;
|
||||
}
|
||||
|
||||
Logger.fsDriver_ = new FsDriverNode();
|
||||
|
||||
const env = envFromArgs(process.argv);
|
||||
const profileFromArgs = getFlagValueFromArgs(process.argv, '--profile', null);
|
||||
const profileFromArgs = getProfileFromArgs(process.argv);
|
||||
const isDebugMode = !!process.argv && process.argv.indexOf('--debug') >= 0;
|
||||
const altInstanceId = getFlagValueFromArgs(process.argv, '--alt-instance-id', '');
|
||||
|
||||
// We initialize all these variables here because they are needed from the main process. They are
|
||||
// then passed to the renderer process via the bridge.
|
||||
const appId = `net.cozic.joplin${env === 'dev' ? 'dev' : ''}-desktop`;
|
||||
let appName = env === 'dev' ? 'joplindev' : 'joplin';
|
||||
if (appId.indexOf('-desktop') >= 0) appName += '-desktop';
|
||||
const { rootProfileDir } = determineBaseAppDirs(profileFromArgs, appName, altInstanceId);
|
||||
const { rootProfileDir } = determineBaseAppDirs(profileFromArgs, appName);
|
||||
const settingsPath = `${rootProfileDir}/settings.json`;
|
||||
let autoUploadCrashDumps = false;
|
||||
|
||||
@@ -66,7 +67,7 @@ const initialCallbackUrl = process.argv.find((arg) => isCallbackUrl(arg));
|
||||
|
||||
const wrapper = new ElectronAppWrapper(electronApp, env, rootProfileDir, isDebugMode, initialCallbackUrl);
|
||||
|
||||
initBridge(wrapper, appId, appName, rootProfileDir, autoUploadCrashDumps, altInstanceId);
|
||||
initBridge(wrapper, appId, appName, rootProfileDir, autoUploadCrashDumps);
|
||||
|
||||
wrapper.start().catch((error) => {
|
||||
console.error('Electron App fatal error:');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/app-desktop",
|
||||
"version": "3.3.3",
|
||||
"version": "3.3.2",
|
||||
"description": "Joplin for Desktop",
|
||||
"main": "main.js",
|
||||
"private": true,
|
||||
@@ -144,7 +144,7 @@
|
||||
"@types/styled-components": "5.1.32",
|
||||
"@types/tesseract.js": "2.0.0",
|
||||
"axios": "^1.7.7",
|
||||
"electron": "35.0.1",
|
||||
"electron": "34.0.0",
|
||||
"electron-builder": "24.13.3",
|
||||
"glob": "10.4.5",
|
||||
"gulp": "4.0.2",
|
||||
@@ -166,6 +166,7 @@
|
||||
"@joplin/lib": "~3.3",
|
||||
"@joplin/renderer": "~3.3",
|
||||
"@joplin/utils": "~3.3",
|
||||
"@journeyapps/sqlcipher": "5.3.1",
|
||||
"@sentry/electron": "4.24.0",
|
||||
"@types/mustache": "4.2.5",
|
||||
"async-mutex": "0.5.0",
|
||||
|
||||
@@ -24,7 +24,7 @@ export default defineConfig({
|
||||
reporter: process.env.CI ? 'line' : 'html',
|
||||
|
||||
// The CI machines can sometimes be very slow. Increase per-test timeout in CI.
|
||||
timeout: process.env.CI ? 70_000 : 60_000, // milliseconds
|
||||
timeout: process.env.CI ? 50_000 : 30_000, // milliseconds
|
||||
|
||||
// Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions.
|
||||
use: {
|
||||
|
||||
@@ -180,7 +180,7 @@ fi
|
||||
|
||||
if [ "$IS_DESKTOP" = "1" ]; then
|
||||
cd "$ROOT_DIR/packages/app-desktop"
|
||||
yarn start --profile "$PROFILE_DIR" --alt-instance-id $USER_NUM
|
||||
yarn start --profile "$PROFILE_DIR"
|
||||
else
|
||||
cd "$ROOT_DIR/packages/app-cli"
|
||||
if [[ $CMD == "--" ]]; then
|
||||
|
||||
@@ -12,7 +12,6 @@ export default function stateToWhenClauseContext(state: AppState, options: WhenC
|
||||
const windowId = options?.windowId ?? defaultWindowId;
|
||||
const isMainWindow = windowId === defaultWindowId;
|
||||
const windowState = stateUtils.windowStateById(state, windowId);
|
||||
const isAltInstance = !!state.settings.altInstanceId;
|
||||
|
||||
return {
|
||||
...libStateToWhenClauseContext(state, options),
|
||||
@@ -27,7 +26,6 @@ export default function stateToWhenClauseContext(state: AppState, options: WhenC
|
||||
gotoAnythingVisible: !!state.visibleDialogs['gotoAnything'],
|
||||
sidebarVisible: isMainWindow && !!state.mainLayout && layoutItemProp(state.mainLayout, 'sideBar', 'visible'),
|
||||
noteListHasNotes: !!windowState.notes.length,
|
||||
isAltInstance,
|
||||
|
||||
// Deprecated
|
||||
sideBarVisible: !!state.mainLayout && layoutItemProp(state.mainLayout, 'sideBar', 'visible'),
|
||||
|
||||
@@ -2,9 +2,9 @@ import Setting from '@joplin/lib/models/Setting';
|
||||
import bridge from './bridge';
|
||||
|
||||
|
||||
export default async () => {
|
||||
export default async (linuxSafeRestart = true) => {
|
||||
Setting.setValue('wasClosedSuccessfully', true);
|
||||
await Setting.saveAll();
|
||||
|
||||
await bridge().restart();
|
||||
bridge().restart(linuxSafeRestart);
|
||||
};
|
||||
|
||||
@@ -25,7 +25,7 @@ async function main() {
|
||||
// wrong one. However it means it will have to be manually upgraded for each
|
||||
// new Electron release. Some ABI map there:
|
||||
// https://github.com/electron/node-abi/tree/master/test
|
||||
const forceAbiArgs = '--force-abi 134';
|
||||
const forceAbiArgs = '--force-abi 132';
|
||||
|
||||
if (isWindows()) {
|
||||
// Cannot run this in parallel, or the 64-bit version might end up
|
||||
|
||||
@@ -21,14 +21,14 @@ const restartInSafeModeFromMain = async () => {
|
||||
shimInit({});
|
||||
|
||||
const startFlags = await processStartFlags(bridge().processArgv());
|
||||
const { rootProfileDir } = determineBaseAppDirs(startFlags.matched.profileDir, appName, Setting.value('altInstanceId'));
|
||||
const { rootProfileDir } = determineBaseAppDirs(startFlags.matched.profileDir, appName);
|
||||
const { profileDir } = await initProfile(rootProfileDir);
|
||||
|
||||
// We can't access the database, so write to a file instead.
|
||||
const safeModeFlagFile = join(profileDir, safeModeFlagFilename);
|
||||
await writeFile(safeModeFlagFile, 'true', 'utf8');
|
||||
|
||||
await bridge().restart();
|
||||
bridge().restart();
|
||||
};
|
||||
|
||||
export default restartInSafeModeFromMain;
|
||||
|
||||
@@ -86,8 +86,8 @@ android {
|
||||
applicationId "net.cozic.joplin"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 2097767
|
||||
versionName "3.3.4"
|
||||
versionCode 2097764
|
||||
versionName "3.3.1"
|
||||
ndk {
|
||||
abiFilters "armeabi-v7a", "x86", "arm64-v8a", "x86_64"
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
#include "findLongestSilence.h"
|
||||
#include "androidUtil.h"
|
||||
|
||||
WhisperSession::WhisperSession(const std::string& modelPath, std::string lang, std::string prompt, bool shortAudioContext)
|
||||
: lang_ {std::move(lang)}, prompt_ {std::move(prompt)}, shortAudioContext_ {shortAudioContext} {
|
||||
WhisperSession::WhisperSession(const std::string& modelPath, std::string lang, std::string prompt)
|
||||
: lang_ {std::move(lang)}, prompt_ {std::move(prompt)} {
|
||||
whisper_context_params contextParams = whisper_context_default_params();
|
||||
|
||||
// Lifetime(pModelPath): Whisper.cpp creates a copy of pModelPath and stores it in a std::string.
|
||||
@@ -34,9 +34,9 @@ WhisperSession::buildWhisperParams_() {
|
||||
// WHISPER_SAMPLING_BEAM_SEARCH is an alternative to greedy:
|
||||
// params.beam_search = { .beam_size = 2 };
|
||||
params.print_realtime = false;
|
||||
// Disable timestamps: They make creating custom Whisper models more difficult:
|
||||
// Disable timestamps: They make creating custom Whisper models more difficult:
|
||||
params.print_timestamps = false;
|
||||
params.no_timestamps = true;
|
||||
params.no_timestamps = true;
|
||||
|
||||
params.print_progress = false;
|
||||
params.translate = false;
|
||||
@@ -54,7 +54,6 @@ WhisperSession::buildWhisperParams_() {
|
||||
params.initial_prompt = prompt_.c_str();
|
||||
params.prompt_tokens = nullptr;
|
||||
params.prompt_n_tokens = 0;
|
||||
params.audio_ctx = 0;
|
||||
|
||||
// Lifetime: lifetime(params) < lifetime(lang_) = lifetime(this).
|
||||
params.language = lang_.c_str();
|
||||
@@ -69,26 +68,7 @@ WhisperSession::transcribe_(const std::vector<float>& audio, size_t transcribeCo
|
||||
return "";
|
||||
}
|
||||
|
||||
float seconds = static_cast<float>(audio.size()) / WHISPER_SAMPLE_RATE;
|
||||
if (seconds > 30.0f) {
|
||||
LOGW("Warning: Audio is longer than 30 seconds. Not all audio will be transcribed");
|
||||
}
|
||||
|
||||
whisper_full_params params = buildWhisperParams_();
|
||||
|
||||
// If supported by the model, allow shortening the transcription. This can significantly
|
||||
// improve performance, but requires a fine-tuned model.
|
||||
// See https://github.com/futo-org/whisper-acft
|
||||
if (this->shortAudioContext_) {
|
||||
// audio_ctx: 1500 every 30 seconds (50 units in one second).
|
||||
// See https://github.com/futo-org/whisper-acft/issues/6
|
||||
float padding = 64.0f;
|
||||
params.audio_ctx = static_cast<int>(seconds * (1500.0f / 30.0f) + padding);
|
||||
|
||||
if (params.audio_ctx > 1500) {
|
||||
params.audio_ctx = 1500;
|
||||
}
|
||||
}
|
||||
whisper_reset_timings(pContext_);
|
||||
|
||||
transcribeCount = std::min(audio.size(), transcribeCount);
|
||||
@@ -124,130 +104,51 @@ WhisperSession::splitAndTranscribeBefore_(int transcribeUpTo, int trimTo) {
|
||||
return result;
|
||||
}
|
||||
|
||||
bool WhisperSession::isBufferSilent_() {
|
||||
int toleranceSamples = WHISPER_SAMPLE_RATE / 8; // 0.125s
|
||||
auto silence = findLongestSilence(
|
||||
audioBuffer_,
|
||||
LongestSilenceOptions {
|
||||
.sampleRate = WHISPER_SAMPLE_RATE,
|
||||
.minSilenceLengthSeconds = 0.0f,
|
||||
.maximumSilenceStartSamples = toleranceSamples, // 0.5s
|
||||
.returnFirstMatch = true
|
||||
}
|
||||
);
|
||||
return silence.end >= audioBuffer_.size() - toleranceSamples;
|
||||
}
|
||||
|
||||
std::string
|
||||
WhisperSession::transcribeNextChunkNoPreview_() {
|
||||
std::stringstream result;
|
||||
WhisperSession::transcribeNextChunk(const float *pAudio, int sizeAudio) {
|
||||
std::string finalizedContent;
|
||||
|
||||
// Handles a silence detected between (splitStart, splitEnd).
|
||||
auto splitAndProcess = [&] (int splitStart, int splitEnd) {
|
||||
int tolerance = WHISPER_SAMPLE_RATE / 20; // 0.05s
|
||||
bool isCompletelySilent = splitStart < tolerance && splitEnd > audioBuffer_.size() - tolerance;
|
||||
LOGD("WhisperSession: Found silence range from %.2f -> %.2f", splitStart / (float) WHISPER_SAMPLE_RATE, splitEnd / (float) WHISPER_SAMPLE_RATE);
|
||||
|
||||
if (isCompletelySilent) {
|
||||
audioBuffer_.clear();
|
||||
return false;
|
||||
} else if (splitEnd > tolerance) { // Anything to transcribe?
|
||||
result << splitAndTranscribeBefore_(splitStart, splitEnd) << "\n\n";
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
int maximumSamples = WHISPER_SAMPLE_RATE * 25;
|
||||
|
||||
// Handle paragraph breaks indicated by long pauses
|
||||
while (audioBuffer_.size() > WHISPER_SAMPLE_RATE * 3) {
|
||||
LOGD("WhisperSession: Checking for a longer pauses.");
|
||||
// Allow brief pauses to create new paragraphs:
|
||||
float minSilenceSeconds = 1.5f;
|
||||
auto splitPoint = findLongestSilence(
|
||||
audioBuffer_,
|
||||
LongestSilenceOptions {
|
||||
.sampleRate = WHISPER_SAMPLE_RATE,
|
||||
.minSilenceLengthSeconds = minSilenceSeconds,
|
||||
.maximumSilenceStartSamples = maximumSamples,
|
||||
.returnFirstMatch = true
|
||||
}
|
||||
);
|
||||
if (!splitPoint.isValid) {
|
||||
break;
|
||||
}
|
||||
if (!splitAndProcess(splitPoint.start, splitPoint.end)) {
|
||||
break;
|
||||
}
|
||||
// Update the local audio buffer
|
||||
for (int i = 0; i < sizeAudio; i++) {
|
||||
audioBuffer_.push_back(pAudio[i]);
|
||||
}
|
||||
|
||||
// If there are no long pauses, force a paragraph break somewhere
|
||||
// Does the audio buffer need to be split somewhere?
|
||||
int maximumSamples = WHISPER_SAMPLE_RATE * 25;
|
||||
if (audioBuffer_.size() >= maximumSamples) {
|
||||
LOGD("WhisperSession: Allowing shorter pauses to break.");
|
||||
float minSilenceSeconds = 0.3f;
|
||||
auto silenceRange = findLongestSilence(
|
||||
audioBuffer_,
|
||||
LongestSilenceOptions {
|
||||
.sampleRate = WHISPER_SAMPLE_RATE,
|
||||
.minSilenceLengthSeconds = minSilenceSeconds,
|
||||
.maximumSilenceStartSamples = maximumSamples,
|
||||
.returnFirstMatch = false
|
||||
}
|
||||
audioBuffer_, WHISPER_SAMPLE_RATE, minSilenceSeconds, maximumSamples
|
||||
);
|
||||
|
||||
// In this case, the audio is long enough that it needs to be split somewhere. If there's
|
||||
// no suitable pause available, default to splitting in the middle.
|
||||
int halfBufferSize = audioBuffer_.size() / 2;
|
||||
int splitStart = silenceRange.isValid ? silenceRange.start : halfBufferSize;
|
||||
int splitEnd = silenceRange.isValid ? silenceRange.end : halfBufferSize;
|
||||
splitAndProcess(splitStart, splitEnd);
|
||||
int transcribeTo = silenceRange.isValid ? silenceRange.start : halfBufferSize;
|
||||
int trimTo = silenceRange.isValid ? silenceRange.end : halfBufferSize;
|
||||
|
||||
finalizedContent = splitAndTranscribeBefore_(transcribeTo, trimTo);
|
||||
} else if (audioBuffer_.size() > WHISPER_SAMPLE_RATE * 3) {
|
||||
// Allow brief pauses to create new paragraphs:
|
||||
float minSilenceSeconds = 2.0f;
|
||||
auto splitPoint = findLongestSilence(
|
||||
audioBuffer_, WHISPER_SAMPLE_RATE, minSilenceSeconds, maximumSamples
|
||||
);
|
||||
if (splitPoint.isValid) {
|
||||
int tolerance = WHISPER_SAMPLE_RATE / 20; // 0.05s
|
||||
bool isCompletelySilent = splitPoint.start < tolerance && splitPoint.end > audioBuffer_.size() - tolerance;
|
||||
if (isCompletelySilent) {
|
||||
audioBuffer_.clear();
|
||||
} else {
|
||||
finalizedContent = splitAndTranscribeBefore_(splitPoint.start, splitPoint.end);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result.str();
|
||||
}
|
||||
|
||||
|
||||
void WhisperSession::addAudio(const float *pAudio, int sizeAudio) {
|
||||
// Update the local audio buffer
|
||||
for (int i = 0; i < sizeAudio; i++) {
|
||||
audioBuffer_.push_back(pAudio[i]);
|
||||
}
|
||||
}
|
||||
|
||||
std::string WhisperSession::transcribeNextChunk() {
|
||||
std::string finalizedContent = transcribeNextChunkNoPreview_();
|
||||
previewText_ = transcribe_(audioBuffer_, audioBuffer_.size());
|
||||
return finalizedContent;
|
||||
}
|
||||
|
||||
std::string WhisperSession::transcribeAll() {
|
||||
if (isBufferSilent_()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
std::stringstream result;
|
||||
|
||||
std::string transcribed;
|
||||
auto update_transcribed = [&] {
|
||||
transcribed = transcribeNextChunkNoPreview_();
|
||||
return !transcribed.empty();
|
||||
};
|
||||
while (update_transcribed()) {
|
||||
result << transcribed << "\n\n";
|
||||
}
|
||||
|
||||
// Transcribe content considered by transcribeNextChunk as partial:
|
||||
if (!isBufferSilent_()) {
|
||||
result << transcribe_(audioBuffer_, audioBuffer_.size());
|
||||
}
|
||||
audioBuffer_.clear();
|
||||
|
||||
previewText_ = "";
|
||||
return result.str();
|
||||
}
|
||||
|
||||
std::string WhisperSession::getPreview() {
|
||||
return previewText_;
|
||||
}
|
||||
|
||||
@@ -5,15 +5,9 @@
|
||||
|
||||
class WhisperSession {
|
||||
public:
|
||||
WhisperSession(const std::string& modelPath, std::string lang, std::string prompt, bool shortAudioContext);
|
||||
WhisperSession(const std::string& modelPath, std::string lang, std::string prompt);
|
||||
~WhisperSession();
|
||||
// Adds to the buffer
|
||||
void addAudio(const float *pAudio, int sizeAudio);
|
||||
// Returns the next finalized slice of audio (if any) and updates the preview.
|
||||
std::string transcribeNextChunk();
|
||||
// Transcribes all buffered audio data that hasn't been finalized yet
|
||||
std::string transcribeAll();
|
||||
// Returns the transcription of any unfinalized audio
|
||||
std::string transcribeNextChunk(const float *pAudio, int sizeAudio);
|
||||
std::string getPreview();
|
||||
|
||||
private:
|
||||
@@ -23,18 +17,10 @@ private:
|
||||
whisper_full_params buildWhisperParams_();
|
||||
std::string transcribe_(const std::vector<float>& audio, size_t samplesToTranscribe);
|
||||
std::string splitAndTranscribeBefore_(int transcribeUpTo, int trimTo);
|
||||
// Like transcribeNextChunk, but does not update the preview state
|
||||
// and does not add a new chunk to the buffer.
|
||||
// Since updating the preview state can be slow, this may be preferred
|
||||
// for internal operations where the preview does not need to be kept up-to-date.
|
||||
std::string transcribeNextChunkNoPreview_();
|
||||
|
||||
bool isBufferSilent_();
|
||||
|
||||
whisper_context *pContext_;
|
||||
const std::string lang_;
|
||||
const std::string prompt_;
|
||||
const bool shortAudioContext_;
|
||||
|
||||
std::vector<float> audioBuffer_;
|
||||
};
|
||||
|
||||
@@ -19,18 +19,14 @@ static void highpass(std::vector<float>& data, int sampleRate) {
|
||||
|
||||
SilenceRange findLongestSilence(
|
||||
const std::vector<float>& audioData,
|
||||
LongestSilenceOptions options
|
||||
int sampleRate,
|
||||
float minSilenceLengthSeconds,
|
||||
int maxSilencePosition
|
||||
) {
|
||||
// Options variables
|
||||
int sampleRate = options.sampleRate;
|
||||
int maxSilencePosition = options.maximumSilenceStartSamples;
|
||||
float minSilenceLengthSeconds = options.minSilenceLengthSeconds;
|
||||
bool returnFirstMatch = options.returnFirstMatch;
|
||||
|
||||
// State
|
||||
int bestCandidateLength = 0;
|
||||
int bestCandidateStart = -1;
|
||||
int bestCandidateEnd = -1;
|
||||
|
||||
int currentCandidateStart = -1;
|
||||
|
||||
std::vector<float> processedAudio { audioData };
|
||||
@@ -39,7 +35,7 @@ SilenceRange findLongestSilence(
|
||||
// Break into windows of size `windowSize`:
|
||||
int windowSize = 256;
|
||||
int windowsPerSecond = sampleRate / windowSize;
|
||||
int quietWindows = 0; // Number of relatively quiet windows encountered
|
||||
int quietWindows = 0;
|
||||
|
||||
// Finishes the current candidate for longest silence
|
||||
auto finalizeCandidate = [&] (int currentOffset) {
|
||||
@@ -90,20 +86,12 @@ SilenceRange findLongestSilence(
|
||||
}
|
||||
|
||||
int minQuietWindows = static_cast<int>(windowsPerSecond * minSilenceLengthSeconds);
|
||||
if (quietWindows >= minQuietWindows && currentCandidateStart == -1) { // Found silence
|
||||
// Ignore the first window, which probably contains some of the start of the audio
|
||||
// and the most recent window, which came after windowOffset.
|
||||
int windowsToIgnore = 2;
|
||||
int estimatedQuietSamples = std::max(0, quietWindows - windowsToIgnore) * windowSize;
|
||||
currentCandidateStart = windowOffset - estimatedQuietSamples;
|
||||
} else if (quietWindows == 0) { // Silence ended
|
||||
if (quietWindows >= minQuietWindows && currentCandidateStart == -1) {
|
||||
// Found a candidate. Start it.
|
||||
currentCandidateStart = windowOffset;
|
||||
} else if (quietWindows == 0) {
|
||||
// Ended a candidate. Is it better than the best?
|
||||
finalizeCandidate(windowOffset);
|
||||
|
||||
// Search for more candidates or return now?
|
||||
if (returnFirstMatch && bestCandidateLength > 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,24 +10,15 @@ struct SilenceRange {
|
||||
int end;
|
||||
};
|
||||
|
||||
struct LongestSilenceOptions {
|
||||
int sampleRate;
|
||||
|
||||
// Minimum length of a silence range (e.g. 3.0 seconds)
|
||||
float minSilenceLengthSeconds;
|
||||
|
||||
// The maximum position for a silence range to start (ignore
|
||||
// all silences after this position).
|
||||
int maximumSilenceStartSamples;
|
||||
|
||||
// Return the first silence satisfying the conditions instead of
|
||||
// the longest.
|
||||
bool returnFirstMatch;
|
||||
};
|
||||
|
||||
SilenceRange findLongestSilence(
|
||||
const std::vector<float>& audioData,
|
||||
LongestSilenceOptions options
|
||||
int sampleRate,
|
||||
|
||||
// Minimum length of silence in seconds
|
||||
float minSilenceLengthSeconds,
|
||||
|
||||
// Doesn't check for silence at a position greater than maximumSilenceStart
|
||||
int maximumSilenceStart
|
||||
);
|
||||
|
||||
|
||||
|
||||
@@ -122,12 +122,9 @@ static float samplesToSeconds(int samples, int sampleRate) {
|
||||
static void expectNoSilence(const GeneratedAudio& audio, const std::string& testLabel) {
|
||||
auto silence = findLongestSilence(
|
||||
audio.data,
|
||||
LongestSilenceOptions {
|
||||
.sampleRate = audio.sampleRate,
|
||||
.minSilenceLengthSeconds = 0.02f,
|
||||
.maximumSilenceStartSamples = audio.sampleCount,
|
||||
.returnFirstMatch = false,
|
||||
}
|
||||
audio.sampleRate,
|
||||
0.02f,
|
||||
audio.sampleCount
|
||||
);
|
||||
if (silence.isValid) {
|
||||
std::stringstream errorBuilder;
|
||||
@@ -144,12 +141,9 @@ static void expectNoSilence(const GeneratedAudio& audio, const std::string& test
|
||||
static void expectSilenceBetween(const GeneratedAudio& audio, float startTimeSeconds, float stopTimeSeconds, const std::string& testLabel) {
|
||||
auto silenceResult = findLongestSilence(
|
||||
audio.data,
|
||||
LongestSilenceOptions {
|
||||
.sampleRate = audio.sampleRate,
|
||||
.minSilenceLengthSeconds = 0.02f,
|
||||
.maximumSilenceStartSamples = audio.sampleCount,
|
||||
.returnFirstMatch = false,
|
||||
}
|
||||
audio.sampleRate,
|
||||
0.02f,
|
||||
audio.sampleCount
|
||||
);
|
||||
|
||||
if (!silenceResult.isValid) {
|
||||
|
||||
@@ -54,14 +54,13 @@ Java_net_cozic_joplin_audio_NativeWhisperLib_00024Companion_init(
|
||||
jobject thiz,
|
||||
jstring modelPath,
|
||||
jstring language,
|
||||
jstring prompt,
|
||||
jboolean useShortAudioContext
|
||||
jstring prompt
|
||||
) {
|
||||
whisper_log_set(log_android, nullptr);
|
||||
|
||||
try {
|
||||
auto *pSession = new WhisperSession(
|
||||
stringToCXX(env, modelPath), stringToCXX(env, language), stringToCXX(env, prompt), useShortAudioContext
|
||||
stringToCXX(env, modelPath), stringToCXX(env, language), stringToCXX(env, prompt)
|
||||
);
|
||||
return (jlong) pSession;
|
||||
} catch (const std::exception& exception) {
|
||||
@@ -79,8 +78,8 @@ Java_net_cozic_joplin_audio_NativeWhisperLib_00024Companion_free(JNIEnv *env, jo
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT void JNICALL
|
||||
Java_net_cozic_joplin_audio_NativeWhisperLib_00024Companion_addAudio(JNIEnv *env,
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_net_cozic_joplin_audio_NativeWhisperLib_00024Companion_fullTranscribe(JNIEnv *env,
|
||||
jobject thiz,
|
||||
jlong pointer,
|
||||
jfloatArray audio_data) {
|
||||
@@ -90,55 +89,21 @@ Java_net_cozic_joplin_audio_NativeWhisperLib_00024Companion_addAudio(JNIEnv *env
|
||||
std::string result;
|
||||
|
||||
try {
|
||||
pSession->addAudio(pAudioData, lenAudioData);
|
||||
LOGD("Starting Whisper, transcribe %d", lenAudioData);
|
||||
result = pSession->transcribeNextChunk(pAudioData, lenAudioData);
|
||||
auto preview = pSession->getPreview();
|
||||
LOGD("Ran Whisper. Got %s (preview %s)", result.c_str(), preview.c_str());
|
||||
} catch (const std::exception& exception) {
|
||||
LOGW("Failed to add to audio buffer: %s", exception.what());
|
||||
LOGW("Failed to run whisper: %s", exception.what());
|
||||
throwException(env, exception.what());
|
||||
}
|
||||
|
||||
// JNI_ABORT: "free the buffer without copying back the possible changes", pass 0 to copy
|
||||
// changes (there should be no changes)
|
||||
env->ReleaseFloatArrayElements(audio_data, pAudioData, JNI_ABORT);
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_net_cozic_joplin_audio_NativeWhisperLib_00024Companion_transcribeNextChunk(JNIEnv *env,
|
||||
jobject thiz,
|
||||
jlong pointer) {
|
||||
auto *pSession = reinterpret_cast<WhisperSession *> (pointer);
|
||||
std::string result;
|
||||
|
||||
try {
|
||||
result = pSession->transcribeNextChunk();
|
||||
} catch (const std::exception& exception) {
|
||||
LOGW("Failed to run whisper: %s", exception.what());
|
||||
throwException(env, exception.what());
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
return stringToJava(env, result);
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_net_cozic_joplin_audio_NativeWhisperLib_00024Companion_transcribeRemaining(JNIEnv *env,
|
||||
jobject thiz,
|
||||
jlong pointer) {
|
||||
auto *pSession = reinterpret_cast<WhisperSession *> (pointer);
|
||||
std::string result;
|
||||
|
||||
try {
|
||||
result = pSession->transcribeAll();
|
||||
} catch (const std::exception& exception) {
|
||||
LOGW("Failed to run whisper: %s", exception.what());
|
||||
throwException(env, exception.what());
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
return stringToJava(env, result);
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_net_cozic_joplin_audio_NativeWhisperLib_00024Companion_getPreview(
|
||||
@@ -157,4 +122,4 @@ Java_net_cozic_joplin_audio_NativeWhisperLib_00024Companion_runTests(JNIEnv *env
|
||||
LOGW("Failed to run tests: %s", exception.what());
|
||||
throwException(env, exception.what());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,9 +15,7 @@ typealias AudioRecorderFactory = (context: Context)->AudioRecorder;
|
||||
|
||||
class AudioRecorder(context: Context) : Closeable {
|
||||
private val sampleRate = 16_000
|
||||
// Don't allow the unprocessed audio buffer to grow indefinitely -- discard
|
||||
// data if longer than this:
|
||||
private val maxLengthSeconds = 120
|
||||
private val maxLengthSeconds = 30 // Whisper supports a maximum of 30s
|
||||
private val maxBufferSize = sampleRate * maxLengthSeconds
|
||||
private val buffer = FloatArray(maxBufferSize)
|
||||
private var bufferWriteOffset = 0
|
||||
|
||||
@@ -6,7 +6,6 @@ class NativeWhisperLib(
|
||||
modelPath: String,
|
||||
languageCode: String,
|
||||
prompt: String,
|
||||
shortAudioContext: Boolean,
|
||||
) : Closeable {
|
||||
companion object {
|
||||
init {
|
||||
@@ -17,40 +16,22 @@ class NativeWhisperLib(
|
||||
|
||||
// TODO: The example whisper.cpp project transfers pointers as Longs to the Kotlin code.
|
||||
// This seems unsafe. Try changing how this is managed.
|
||||
private external fun init(modelPath: String, languageCode: String, prompt: String, shortAudioContext: Boolean): Long;
|
||||
private external fun init(modelPath: String, languageCode: String, prompt: String): Long;
|
||||
private external fun free(pointer: Long): Unit;
|
||||
|
||||
private external fun addAudio(pointer: Long, audioData: FloatArray): Unit;
|
||||
private external fun transcribeNextChunk(pointer: Long): String;
|
||||
private external fun transcribeRemaining(pointer: Long): String;
|
||||
private external fun fullTranscribe(pointer: Long, audioData: FloatArray): String;
|
||||
private external fun getPreview(pointer: Long): String;
|
||||
}
|
||||
|
||||
private var closed = false
|
||||
private val pointer: Long = init(modelPath, languageCode, prompt, shortAudioContext)
|
||||
private val pointer: Long = init(modelPath, languageCode, prompt)
|
||||
|
||||
fun addAudio(audioData: FloatArray) {
|
||||
if (closed) {
|
||||
throw Exception("Cannot add audio data to a closed session")
|
||||
}
|
||||
|
||||
Companion.addAudio(pointer, audioData)
|
||||
}
|
||||
|
||||
fun transcribeNextChunk(): String {
|
||||
fun transcribe(audioData: FloatArray): String {
|
||||
if (closed) {
|
||||
throw Exception("Cannot transcribe using a closed session")
|
||||
}
|
||||
|
||||
return Companion.transcribeNextChunk(pointer)
|
||||
}
|
||||
|
||||
fun transcribeRemaining(): String {
|
||||
if (closed) {
|
||||
throw Exception("Cannot transcribeAll using a closed session")
|
||||
}
|
||||
|
||||
return Companion.transcribeRemaining(pointer)
|
||||
return fullTranscribe(pointer, audioData)
|
||||
}
|
||||
|
||||
fun getPreview(): String {
|
||||
|
||||
@@ -8,7 +8,6 @@ class SpeechToTextConverter(
|
||||
modelPath: String,
|
||||
locale: String,
|
||||
prompt: String,
|
||||
useShortAudioCtx: Boolean,
|
||||
recorderFactory: AudioRecorderFactory,
|
||||
context: Context,
|
||||
) : Closeable {
|
||||
@@ -18,7 +17,6 @@ class SpeechToTextConverter(
|
||||
modelPath,
|
||||
languageCode,
|
||||
prompt,
|
||||
useShortAudioCtx,
|
||||
)
|
||||
|
||||
fun start() {
|
||||
@@ -27,8 +25,7 @@ class SpeechToTextConverter(
|
||||
|
||||
private fun convert(data: FloatArray): String {
|
||||
Log.d("Whisper", "Pre-transcribe data of size ${data.size}")
|
||||
whisper.addAudio(data)
|
||||
val result = whisper.transcribeNextChunk()
|
||||
val result = whisper.transcribe(data)
|
||||
Log.d("Whisper", "Post transcribe. Got $result")
|
||||
return result;
|
||||
}
|
||||
@@ -50,8 +47,7 @@ class SpeechToTextConverter(
|
||||
// Converts as many seconds of buffered data as possible, without waiting
|
||||
fun convertRemaining(): String {
|
||||
val buffer = recorder.pullAvailable()
|
||||
whisper.addAudio(buffer)
|
||||
return whisper.transcribeRemaining()
|
||||
return convert(buffer)
|
||||
}
|
||||
|
||||
fun getPreview(): String {
|
||||
|
||||
@@ -43,11 +43,11 @@ class SpeechToTextPackage : ReactPackage {
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun openSession(modelPath: String, locale: String, prompt: String, useShortAudioCtx: Boolean, promise: Promise) {
|
||||
fun openSession(modelPath: String, locale: String, prompt: String, promise: Promise) {
|
||||
val appContext = context.applicationContext
|
||||
|
||||
try {
|
||||
val sessionId = sessionManager.openSession(modelPath, locale, prompt, useShortAudioCtx, appContext)
|
||||
val sessionId = sessionManager.openSession(modelPath, locale, prompt, appContext)
|
||||
promise.resolve(sessionId)
|
||||
} catch (exception: Throwable) {
|
||||
promise.reject(exception)
|
||||
|
||||
@@ -21,13 +21,12 @@ class SpeechToTextSessionManager(
|
||||
modelPath: String,
|
||||
locale: String,
|
||||
prompt: String,
|
||||
useShortAudioCtx: Boolean,
|
||||
context: Context,
|
||||
): Int {
|
||||
val sessionId = nextSessionId++
|
||||
sessions[sessionId] = SpeechToTextSession(
|
||||
SpeechToTextConverter(
|
||||
modelPath, locale, prompt, useShortAudioCtx, recorderFactory = AudioRecorder.factory, context,
|
||||
modelPath, locale, prompt, recorderFactory = AudioRecorder.factory, context,
|
||||
)
|
||||
)
|
||||
return sessionId
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import { RefObject, useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { GestureResponderEvent, Modal, ModalProps, Platform, Pressable, ScrollView, StyleSheet, View, ViewStyle, useWindowDimensions } from 'react-native';
|
||||
import { RefObject, useCallback, useMemo, useRef } from 'react';
|
||||
import { GestureResponderEvent, Modal, ModalProps, ScrollView, StyleSheet, View, ViewStyle, useWindowDimensions } from 'react-native';
|
||||
import { hasNotch } from 'react-native-device-info';
|
||||
import FocusControl from './accessibility/FocusControl/FocusControl';
|
||||
import { msleep, Second } from '@joplin/utils/time';
|
||||
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
|
||||
import { ModalState } from './accessibility/FocusControl/types';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
|
||||
interface ModalElementProps extends ModalProps {
|
||||
children: React.ReactNode;
|
||||
@@ -54,13 +49,6 @@ const useStyles = (hasScrollView: boolean, backgroundColor: string|undefined) =>
|
||||
// This makes it possible to vertically center the content of scrollable modals.
|
||||
flexGrow: 1,
|
||||
},
|
||||
dismissButton: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
height: 12,
|
||||
width: '100%',
|
||||
zIndex: -1,
|
||||
},
|
||||
});
|
||||
}, [hasScrollView, isLandscape, backgroundColor]);
|
||||
};
|
||||
@@ -79,36 +67,6 @@ const useBackgroundTouchListeners = (onRequestClose: (event: GestureResponderEve
|
||||
return { onShouldBackgroundCaptureTouch, onBackgroundTouchFinished };
|
||||
};
|
||||
|
||||
const useModalStatus = (containerComponent: View|null, visible: boolean) => {
|
||||
const contentMounted = !!containerComponent;
|
||||
const [controlsFocus, setControlsFocus] = useState(false);
|
||||
useAsyncEffect(async (event) => {
|
||||
if (contentMounted) {
|
||||
setControlsFocus(true);
|
||||
} else {
|
||||
// Accessibility: Work around Android's default focus-setting behavior.
|
||||
// By default, React Native's Modal on Android sets focus about 0.8 seconds
|
||||
// after the modal is dismissed. As a result, the Modal controls focus until
|
||||
// roughly one second after the modal is dismissed.
|
||||
if (Platform.OS === 'android') {
|
||||
await msleep(Second);
|
||||
}
|
||||
|
||||
if (!event.cancelled) {
|
||||
setControlsFocus(false);
|
||||
}
|
||||
}
|
||||
}, [contentMounted]);
|
||||
|
||||
let modalStatus = ModalState.Closed;
|
||||
if (controlsFocus) {
|
||||
modalStatus = visible ? ModalState.Open : ModalState.Closing;
|
||||
} else if (visible) {
|
||||
modalStatus = ModalState.Open;
|
||||
}
|
||||
return modalStatus;
|
||||
};
|
||||
|
||||
const ModalElement: React.FC<ModalElementProps> = ({
|
||||
children,
|
||||
containerStyle,
|
||||
@@ -126,48 +84,29 @@ const ModalElement: React.FC<ModalElementProps> = ({
|
||||
</View>
|
||||
);
|
||||
|
||||
const backgroundRef = useRef<View>();
|
||||
const { onShouldBackgroundCaptureTouch, onBackgroundTouchFinished } = useBackgroundTouchListeners(modalProps.onRequestClose, backgroundRef);
|
||||
|
||||
const [containerComponent, setContainerComponent] = useState<View|null>(null);
|
||||
const modalStatus = useModalStatus(containerComponent, modalProps.visible);
|
||||
|
||||
const containerRef = useRef<View|null>(null);
|
||||
containerRef.current = containerComponent;
|
||||
const { onShouldBackgroundCaptureTouch, onBackgroundTouchFinished } = useBackgroundTouchListeners(modalProps.onRequestClose, containerRef);
|
||||
|
||||
|
||||
// A close button for accessibility tools. Since iOS accessibility focus order is based on the position
|
||||
// of the element on the screen, the close button is placed after the modal content, rather than behind.
|
||||
const closeButton = modalProps.onRequestClose ? <Pressable
|
||||
style={styles.dismissButton}
|
||||
onPress={modalProps.onRequestClose}
|
||||
accessibilityLabel={_('Close dialog')}
|
||||
accessibilityRole='button'
|
||||
/> : null;
|
||||
const contentAndBackdrop = <View
|
||||
ref={setContainerComponent}
|
||||
ref={backgroundRef}
|
||||
style={styles.modalBackground}
|
||||
onStartShouldSetResponder={onShouldBackgroundCaptureTouch}
|
||||
onResponderRelease={onBackgroundTouchFinished}
|
||||
>
|
||||
{content}
|
||||
{closeButton}
|
||||
</View>;
|
||||
>{content}</View>;
|
||||
|
||||
// supportedOrientations: On iOS, this allows the dialog to be shown in non-portrait orientations.
|
||||
return (
|
||||
<FocusControl.ModalWrapper state={modalStatus}>
|
||||
<Modal
|
||||
// supportedOrientations: On iOS, this allows the dialog to be shown in non-portrait orientations.
|
||||
supportedOrientations={['portrait', 'portrait-upside-down', 'landscape', 'landscape-left', 'landscape-right']}
|
||||
{...modalProps}
|
||||
>
|
||||
{scrollOverflow ? (
|
||||
<ScrollView
|
||||
style={styles.modalScrollView}
|
||||
contentContainerStyle={styles.modalScrollViewContent}
|
||||
>{contentAndBackdrop}</ScrollView>
|
||||
) : contentAndBackdrop}
|
||||
</Modal>
|
||||
</FocusControl.ModalWrapper>
|
||||
<Modal
|
||||
supportedOrientations={['portrait', 'portrait-upside-down', 'landscape', 'landscape-left', 'landscape-right']}
|
||||
{...modalProps}
|
||||
>
|
||||
{scrollOverflow ? (
|
||||
<ScrollView
|
||||
style={styles.modalScrollView}
|
||||
contentContainerStyle={styles.modalScrollViewContent}
|
||||
>{contentAndBackdrop}</ScrollView>
|
||||
) : contentAndBackdrop}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import '@testing-library/jest-native/extend-expect';
|
||||
|
||||
import NoteBodyViewer from './NoteBodyViewer';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { MenuProvider } from 'react-native-popup-menu';
|
||||
import { resourceFetcher, setupDatabaseAndSynchronizer, supportDir, switchClient, synchronizerStart } from '@joplin/lib/testing/test-utils';
|
||||
import { MarkupLanguage } from '@joplin/renderer';
|
||||
import { HandleMessageCallback, OnMarkForDownloadCallback } from './hooks/useOnMessage';
|
||||
@@ -15,8 +16,6 @@ import shim from '@joplin/lib/shim';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import { ResourceInfo } from './hooks/useRerenderHandler';
|
||||
import getWebViewDomById from '../../utils/testing/getWebViewDomById';
|
||||
import TestProviderStack from '../testing/TestProviderStack';
|
||||
import createMockReduxStore from '../../utils/testing/createMockReduxStore';
|
||||
|
||||
interface WrapperProps {
|
||||
noteBody: string;
|
||||
@@ -30,7 +29,6 @@ interface WrapperProps {
|
||||
const emptyObject = {};
|
||||
const emptyArray: string[] = [];
|
||||
const noOpFunction = () => {};
|
||||
const testStore = createMockReduxStore();
|
||||
const WrappedNoteViewer: React.FC<WrapperProps> = (
|
||||
{
|
||||
noteBody,
|
||||
@@ -41,7 +39,7 @@ const WrappedNoteViewer: React.FC<WrapperProps> = (
|
||||
onMarkForDownload,
|
||||
}: WrapperProps,
|
||||
) => {
|
||||
return <TestProviderStack store={testStore}>
|
||||
return <MenuProvider>
|
||||
<NoteBodyViewer
|
||||
themeId={Setting.THEME_LIGHT}
|
||||
style={emptyObject}
|
||||
@@ -58,7 +56,7 @@ const WrappedNoteViewer: React.FC<WrapperProps> = (
|
||||
onScroll={onScroll}
|
||||
pluginStates={emptyObject}
|
||||
/>
|
||||
</TestProviderStack>;
|
||||
</MenuProvider>;
|
||||
};
|
||||
|
||||
const getNoteViewerDom = async () => {
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import * as React from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { StyleSheet, TextStyle, View, Text, ScrollView, useWindowDimensions, Platform } from 'react-native';
|
||||
import { StyleSheet, TextStyle, View, Text, ScrollView, useWindowDimensions } from 'react-native';
|
||||
import { themeStyle } from '../global-style';
|
||||
import { Menu, MenuOption as MenuOptionComponent, MenuOptions, MenuTrigger } from 'react-native-popup-menu';
|
||||
import AccessibleView from '../accessibility/AccessibleView';
|
||||
import debounce from '../../utils/debounce';
|
||||
import FocusControl from '../accessibility/FocusControl/FocusControl';
|
||||
import { ModalState } from '../accessibility/FocusControl/types';
|
||||
|
||||
interface MenuOptionDivider {
|
||||
isDivider: true;
|
||||
@@ -83,22 +81,18 @@ const MenuComponent: React.FC<Props> = props => {
|
||||
// When undefined/null: Don't auto-focus anything.
|
||||
const [refocusCounter, setRefocusCounter] = useState<number|undefined>(undefined);
|
||||
|
||||
let keyCounter = 0;
|
||||
let key = 0;
|
||||
let isFirst = true;
|
||||
for (const option of props.options) {
|
||||
if (option.isDivider === true) {
|
||||
menuOptionComponents.push(
|
||||
<View key={`menuOption_divider_${keyCounter++}`} style={styles.divider} />,
|
||||
<View key={`menuOption_divider_${key++}`} style={styles.divider} />,
|
||||
);
|
||||
} else {
|
||||
// Don't auto-focus on iOS -- as of RN 0.74, this causes focus to get stuck. However,
|
||||
// the auto-focus seems to be necessary on web (and possibly Android) to avoid first focusing
|
||||
// the dismiss button and other items not in the menu:
|
||||
const canAutoFocus = isFirst && Platform.OS !== 'ios';
|
||||
const key = `menuOption_${option.key ?? keyCounter++}`;
|
||||
const canAutoFocus = isFirst;
|
||||
menuOptionComponents.push(
|
||||
<MenuOptionComponent value={option.onPress} key={key} style={styles.contextMenuItem} disabled={!!option.disabled}>
|
||||
<AccessibleView refocusCounter={canAutoFocus ? refocusCounter : undefined} testID={key}>
|
||||
<MenuOptionComponent value={option.onPress} key={`menuOption_${option.key ?? key++}`} style={styles.contextMenuItem} disabled={!!option.disabled}>
|
||||
<AccessibleView refocusCounter={canAutoFocus ? refocusCounter : undefined}>
|
||||
<Text
|
||||
style={option.disabled ? styles.contextMenuItemTextDisabled : styles.contextMenuItemText}
|
||||
disabled={!!option.disabled}
|
||||
@@ -111,47 +105,42 @@ const MenuComponent: React.FC<Props> = props => {
|
||||
}
|
||||
}
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const onMenuItemSelect = useCallback((value: unknown) => {
|
||||
if (typeof value === 'function') {
|
||||
value();
|
||||
}
|
||||
setRefocusCounter(undefined);
|
||||
setOpen(false);
|
||||
}, []);
|
||||
|
||||
// debounce: If the menu is focused during its transition animation, it briefly
|
||||
// appears to be in the wrong place. As such, add a brief delay before focusing.
|
||||
const onMenuOpened = useMemo(() => debounce(() => {
|
||||
const onMenuOpen = useMemo(() => debounce(() => {
|
||||
setRefocusCounter(counter => (counter ?? 0) + 1);
|
||||
setOpen(true);
|
||||
}, 200), []);
|
||||
|
||||
// Resetting the refocus counter to undefined causes the menu to not be focused immediately
|
||||
// after opening.
|
||||
const onMenuClosed = useCallback(() => {
|
||||
const onMenuClose = useCallback(() => {
|
||||
setRefocusCounter(undefined);
|
||||
setOpen(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Menu
|
||||
onSelect={onMenuItemSelect}
|
||||
onClose={onMenuClosed}
|
||||
onOpen={onMenuOpened}
|
||||
onClose={onMenuClose}
|
||||
onOpen={onMenuOpen}
|
||||
style={styles.contextMenu}
|
||||
>
|
||||
<MenuTrigger style={styles.contextMenuButton} testID='screen-header-menu-trigger'>
|
||||
{props.children}
|
||||
</MenuTrigger>
|
||||
<MenuOptions>
|
||||
<FocusControl.ModalWrapper state={open ? ModalState.Open : ModalState.Closed}>
|
||||
<ScrollView
|
||||
style={styles.menuContentScroller}
|
||||
testID={`menu-content-${refocusCounter ? 'refocusing' : ''}`}
|
||||
>{menuOptionComponents}</ScrollView>
|
||||
</FocusControl.ModalWrapper>
|
||||
<ScrollView
|
||||
style={styles.menuContentScroller}
|
||||
aria-modal={true}
|
||||
accessibilityViewIsModal={true}
|
||||
testID={`menu-content-${refocusCounter ? 'refocusing' : ''}`}
|
||||
>{menuOptionComponents}</ScrollView>
|
||||
</MenuOptions>
|
||||
</Menu>
|
||||
);
|
||||
|
||||
@@ -502,15 +502,15 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
function sortButton(styles: any, onPress: OnPressCallback) {
|
||||
return (
|
||||
<IconButton
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
themeId={themeId}
|
||||
|
||||
description={_('Sort notes by')}
|
||||
iconName='ionicon filter-outline'
|
||||
contentWrapperStyle={styles.iconButton}
|
||||
iconStyle={styles.topIcon}
|
||||
/>
|
||||
accessibilityLabel={_('Sort notes by')}
|
||||
accessibilityRole="button">
|
||||
<View style={styles.iconButton}>
|
||||
<Icon name="filter-outline" style={styles.topIcon} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -271,14 +271,12 @@ const SideMenuComponent: React.FC<Props> = props => {
|
||||
<AccessibleView
|
||||
inert={!open}
|
||||
style={styles.menuWrapper}
|
||||
testID='menu-wrapper'
|
||||
>
|
||||
<AccessibleView
|
||||
// Auto-focuses an empty view at the beginning of the sidemenu -- if we instead
|
||||
// focus the container view, VoiceOver fails to focus to any components within
|
||||
// the sidebar.
|
||||
refocusCounter={!open ? 1 : undefined}
|
||||
testID='sidemenu-menu-focus-region'
|
||||
/>
|
||||
|
||||
{props.menu}
|
||||
@@ -289,9 +287,8 @@ const SideMenuComponent: React.FC<Props> = props => {
|
||||
<AccessibleView
|
||||
inert={open}
|
||||
style={styles.contentWrapper}
|
||||
testID='content-wrapper'
|
||||
>
|
||||
<AccessibleView refocusCounter={open ? 1 : undefined} testID='sidemenu-content-focus-region' />
|
||||
<AccessibleView refocusCounter={open ? 1 : undefined} />
|
||||
{props.children}
|
||||
</AccessibleView>
|
||||
);
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import FocusControl from './FocusControl/FocusControl';
|
||||
import { render } from '@testing-library/react-native';
|
||||
import AccessibleView from './AccessibleView';
|
||||
import { AccessibilityInfo } from 'react-native';
|
||||
import ModalWrapper from './FocusControl/ModalWrapper';
|
||||
import { ModalState } from './FocusControl/types';
|
||||
|
||||
interface TestContentWrapperProps {
|
||||
mainContent: React.ReactNode;
|
||||
dialogs: React.ReactNode;
|
||||
}
|
||||
|
||||
const TestContentWrapper: React.FC<TestContentWrapperProps> = props => {
|
||||
return <FocusControl.Provider>
|
||||
{props.dialogs}
|
||||
<FocusControl.MainAppContent>
|
||||
{props.mainContent}
|
||||
</FocusControl.MainAppContent>
|
||||
</FocusControl.Provider>;
|
||||
};
|
||||
|
||||
jest.mock('react-native', () => {
|
||||
const ReactNative = jest.requireActual('react-native');
|
||||
ReactNative.AccessibilityInfo.setAccessibilityFocus = jest.fn();
|
||||
return ReactNative;
|
||||
});
|
||||
|
||||
describe('AccessibleView', () => {
|
||||
test('should wait for the currently-open dialog to dismiss before applying focus requests', () => {
|
||||
const setFocusMock = AccessibilityInfo.setAccessibilityFocus as jest.Mock;
|
||||
setFocusMock.mockClear();
|
||||
|
||||
interface TestContentOptions {
|
||||
modalState: ModalState;
|
||||
refocusCounter: undefined|number;
|
||||
}
|
||||
const renderTestContent = ({ modalState, refocusCounter }: TestContentOptions) => {
|
||||
const mainContent = <AccessibleView refocusCounter={refocusCounter}/>;
|
||||
const visibleDialog = <ModalWrapper state={modalState}>{null}</ModalWrapper>;
|
||||
return <TestContentWrapper
|
||||
mainContent={mainContent}
|
||||
dialogs={visibleDialog}
|
||||
/>;
|
||||
};
|
||||
|
||||
render(renderTestContent({
|
||||
refocusCounter: undefined,
|
||||
modalState: ModalState.Open,
|
||||
}));
|
||||
|
||||
// Increasing the refocusCounter for a background view while a dialog is visible
|
||||
// should not try to focus the background view.
|
||||
render(renderTestContent({
|
||||
refocusCounter: 1,
|
||||
modalState: ModalState.Open,
|
||||
}));
|
||||
expect(setFocusMock).not.toHaveBeenCalled();
|
||||
|
||||
// Focus should not be set until done closing
|
||||
render(renderTestContent({
|
||||
refocusCounter: 1,
|
||||
modalState: ModalState.Closing,
|
||||
}));
|
||||
expect(setFocusMock).not.toHaveBeenCalled();
|
||||
|
||||
// Keeping the same refocus counter, but dismissing the dialog should focus
|
||||
// the test view.
|
||||
render(renderTestContent({
|
||||
refocusCounter: 1,
|
||||
modalState: ModalState.Closed,
|
||||
}));
|
||||
expect(setFocusMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,8 @@
|
||||
import { focus } from '@joplin/lib/utils/focusHandler';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import * as React from 'react';
|
||||
import { useContext, useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AccessibilityInfo, findNodeHandle, Platform, UIManager, View, ViewProps } from 'react-native';
|
||||
import { AutoFocusContext } from './FocusControl/AutoFocusProvider';
|
||||
|
||||
const logger = Logger.create('AccessibleView');
|
||||
|
||||
@@ -17,68 +16,9 @@ interface Props extends ViewProps {
|
||||
refocusCounter?: number;
|
||||
}
|
||||
|
||||
const useAutoFocus = (refocusCounter: number|null, containerNode: View|HTMLElement|null, debugLabel: string) => {
|
||||
const autoFocusControl = useContext(AutoFocusContext);
|
||||
const autoFocusControlRef = useRef(autoFocusControl);
|
||||
autoFocusControlRef.current = autoFocusControl;
|
||||
const debugLabelRef = useRef(debugLabel);
|
||||
debugLabelRef.current = debugLabel;
|
||||
|
||||
useEffect(() => {
|
||||
if ((refocusCounter ?? null) === null) return () => {};
|
||||
if (!containerNode) return () => {};
|
||||
|
||||
const focusContainer = () => {
|
||||
const doFocus = () => {
|
||||
if (Platform.OS === 'web') {
|
||||
// react-native-web defines UIManager.focus for setting the keyboard focus. However,
|
||||
// this property is not available in standard react-native. As such, access it using type
|
||||
// narrowing:
|
||||
// eslint-disable-next-line no-restricted-properties
|
||||
if (!('focus' in UIManager) || typeof UIManager.focus !== 'function') {
|
||||
throw new Error('Failed to focus sidebar. UIManager.focus is not a function.');
|
||||
}
|
||||
|
||||
// Disable the "use focusHandler for all focus calls" rule -- UIManager.focus requires
|
||||
// an argument, which is not supported by focusHandler.
|
||||
// eslint-disable-next-line no-restricted-properties
|
||||
UIManager.focus(containerNode);
|
||||
} else {
|
||||
const handle = findNodeHandle(containerNode as View);
|
||||
if (handle !== null) {
|
||||
AccessibilityInfo.setAccessibilityFocus(handle);
|
||||
} else {
|
||||
logger.warn('Couldn\'t find a view to focus.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
focus(`AccessibleView::${debugLabelRef.current}`, {
|
||||
focus: doFocus,
|
||||
});
|
||||
};
|
||||
|
||||
const canFocusNow = !autoFocusControlRef.current || autoFocusControlRef.current.canAutoFocus();
|
||||
if (canFocusNow) {
|
||||
focusContainer();
|
||||
return () => {};
|
||||
} else { // Delay autofocus
|
||||
logger.debug(`Delaying autofocus for ${debugLabelRef.current}`);
|
||||
// Allows the view to be refocused when, for example, a dialog is dismissed
|
||||
autoFocusControlRef.current?.setAutofocusCallback(focusContainer);
|
||||
return () => {
|
||||
autoFocusControlRef.current?.removeAutofocusCallback(focusContainer);
|
||||
};
|
||||
}
|
||||
}, [containerNode, refocusCounter]);
|
||||
};
|
||||
|
||||
const AccessibleView: React.FC<Props> = ({ inert, refocusCounter, children, ...viewProps }) => {
|
||||
const [containerRef, setContainerRef] = useState<View|HTMLElement|null>(null);
|
||||
|
||||
const debugLabel = viewProps.testID ?? 'AccessibleView';
|
||||
useAutoFocus(refocusCounter, containerRef, debugLabel);
|
||||
|
||||
// On web, there's no clear way to disable keyboard focus for an element **and its descendants**
|
||||
// without accessing the underlying HTML.
|
||||
useEffect(() => {
|
||||
@@ -92,6 +32,39 @@ const AccessibleView: React.FC<Props> = ({ inert, refocusCounter, children, ...v
|
||||
}
|
||||
}, [containerRef, inert]);
|
||||
|
||||
useEffect(() => {
|
||||
if ((refocusCounter ?? null) === null) return;
|
||||
if (!containerRef) return;
|
||||
|
||||
const autoFocus = () => {
|
||||
if (Platform.OS === 'web') {
|
||||
// react-native-web defines UIManager.focus for setting the keyboard focus. However,
|
||||
// this property is not available in standard react-native. As such, access it using type
|
||||
// narrowing:
|
||||
// eslint-disable-next-line no-restricted-properties
|
||||
if (!('focus' in UIManager) || typeof UIManager.focus !== 'function') {
|
||||
throw new Error('Failed to focus sidebar. UIManager.focus is not a function.');
|
||||
}
|
||||
|
||||
// Disable the "use focusHandler for all focus calls" rule -- UIManager.focus requires
|
||||
// an argument, which is not supported by focusHandler.
|
||||
// eslint-disable-next-line no-restricted-properties
|
||||
UIManager.focus(containerRef);
|
||||
} else {
|
||||
const handle = findNodeHandle(containerRef as View);
|
||||
if (handle !== null) {
|
||||
AccessibilityInfo.setAccessibilityFocus(handle);
|
||||
} else {
|
||||
logger.warn('Couldn\'t find a view to focus.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
focus('AccessibleView', {
|
||||
focus: autoFocus,
|
||||
});
|
||||
}, [containerRef, refocusCounter]);
|
||||
|
||||
const canFocus = (refocusCounter ?? null) !== null;
|
||||
|
||||
return <View
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { createContext, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
type AutoFocusCallback = ()=> void;
|
||||
|
||||
interface AutoFocusControl {
|
||||
// It isn't always possible to autofocus (e.g. due to a dialog obscuring focus).
|
||||
canAutoFocus(): boolean;
|
||||
|
||||
// Sets the callback to be triggered when it becomes possible to autofocus
|
||||
setAutofocusCallback(callback: AutoFocusCallback): void;
|
||||
removeAutofocusCallback(callback: AutoFocusCallback): void;
|
||||
}
|
||||
|
||||
export const AutoFocusContext = createContext<AutoFocusControl|null>(null);
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
allowAutoFocus: boolean;
|
||||
}
|
||||
|
||||
const AutoFocusProvider: React.FC<Props> = ({ allowAutoFocus, children }) => {
|
||||
const [autoFocusCallback, setAutofocusCallback] = useState<AutoFocusCallback|null>(null);
|
||||
const allowAutoFocusRef = useRef(allowAutoFocus);
|
||||
allowAutoFocusRef.current = allowAutoFocus;
|
||||
|
||||
useEffect(() => {
|
||||
if (allowAutoFocus && autoFocusCallback) {
|
||||
autoFocusCallback();
|
||||
setAutofocusCallback(null);
|
||||
}
|
||||
}, [autoFocusCallback, allowAutoFocus]);
|
||||
|
||||
const removeAutofocusCallback = useCallback((toRemove: AutoFocusCallback) => {
|
||||
setAutofocusCallback(callback => {
|
||||
// Update the callback only if it's different
|
||||
if (callback === toRemove) {
|
||||
return null;
|
||||
} else {
|
||||
return callback;
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const autoFocusControl = useMemo((): AutoFocusControl => {
|
||||
return {
|
||||
canAutoFocus: () => {
|
||||
return allowAutoFocusRef.current;
|
||||
},
|
||||
setAutofocusCallback: (callback) => {
|
||||
setAutofocusCallback(() => callback);
|
||||
},
|
||||
removeAutofocusCallback,
|
||||
};
|
||||
}, [removeAutofocusCallback, setAutofocusCallback]);
|
||||
|
||||
return <AutoFocusContext.Provider value={autoFocusControl}>
|
||||
{children}
|
||||
</AutoFocusContext.Provider>;
|
||||
};
|
||||
|
||||
export default AutoFocusProvider;
|
||||
@@ -1,11 +0,0 @@
|
||||
import FocusControlProvider from './FocusControlProvider';
|
||||
import MainAppContent from './MainAppContent';
|
||||
import ModalWrapper from './ModalWrapper';
|
||||
|
||||
const FocusControl = {
|
||||
Provider: FocusControlProvider,
|
||||
ModalWrapper,
|
||||
MainAppContent,
|
||||
};
|
||||
|
||||
export default FocusControl;
|
||||
@@ -1,51 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { createContext, useCallback, useMemo, useState } from 'react';
|
||||
import { ModalState } from './types';
|
||||
|
||||
export interface FocusControl {
|
||||
setModalState(dialogId: string, state: ModalState): void;
|
||||
|
||||
hasOpenModal: boolean;
|
||||
hasClosingModal: boolean;
|
||||
}
|
||||
|
||||
export const FocusControlContext = createContext<FocusControl|null>(null);
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const FocusControlProvider: React.FC<Props> = props => {
|
||||
type ModalStates = Record<string, ModalState>;
|
||||
const [modalStates, setModalStates] = useState<ModalStates>({});
|
||||
|
||||
const setModalOpen = useCallback((dialogId: string, state: ModalState) => {
|
||||
setModalStates(modalStates => {
|
||||
modalStates = { ...modalStates };
|
||||
if (state === ModalState.Closed) {
|
||||
delete modalStates[dialogId];
|
||||
} else {
|
||||
modalStates[dialogId] = state;
|
||||
}
|
||||
return modalStates;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const modalStateValues = Object.values(modalStates);
|
||||
const hasOpenModal = modalStateValues.includes(ModalState.Open);
|
||||
const hasClosingModal = modalStateValues.includes(ModalState.Closing);
|
||||
|
||||
const focusControl = useMemo((): FocusControl => {
|
||||
return {
|
||||
hasOpenModal: hasOpenModal,
|
||||
hasClosingModal: hasClosingModal,
|
||||
setModalState: setModalOpen,
|
||||
};
|
||||
}, [hasOpenModal, hasClosingModal, setModalOpen]);
|
||||
|
||||
return <FocusControlContext.Provider value={focusControl}>
|
||||
{props.children}
|
||||
</FocusControlContext.Provider>;
|
||||
};
|
||||
|
||||
export default FocusControlProvider;
|
||||
@@ -1,27 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import AccessibleView from '../AccessibleView';
|
||||
import { FocusControlContext } from './FocusControlProvider';
|
||||
import { useContext } from 'react';
|
||||
import { StyleProp, ViewStyle } from 'react-native';
|
||||
import AutoFocusProvider from './AutoFocusProvider';
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
// A region that should not be accessibility focusable while a dialog
|
||||
// is open.
|
||||
const MainAppContent: React.FC<Props> = props => {
|
||||
const { hasOpenModal, hasClosingModal } = useContext(FocusControlContext);
|
||||
const blockFocus = hasOpenModal;
|
||||
const allowAutoFocus = !hasClosingModal && !blockFocus;
|
||||
|
||||
return <AccessibleView inert={blockFocus} style={props.style}>
|
||||
<AutoFocusProvider allowAutoFocus={allowAutoFocus}>
|
||||
{props.children}
|
||||
</AutoFocusProvider>
|
||||
</AccessibleView>;
|
||||
};
|
||||
|
||||
export default MainAppContent;
|
||||
@@ -1,34 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { useContext, useEffect, useId } from 'react';
|
||||
import { FocusControlContext } from './FocusControlProvider';
|
||||
import { ModalState } from './types';
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
state: ModalState;
|
||||
}
|
||||
|
||||
// A wrapper component that notifies the focus handler that a modal-like component
|
||||
// is visible. Modals that capture focus should wrap their content in this component.
|
||||
const ModalWrapper: React.FC<Props> = props => {
|
||||
const { setModalState: setDialogState } = useContext(FocusControlContext);
|
||||
const id = useId();
|
||||
useEffect(() => {
|
||||
if (!setDialogState) {
|
||||
throw new Error('ModalContent components must have a FocusControlProvider as an ancestor. Is FocusControlProvider part of the provider stack?');
|
||||
}
|
||||
setDialogState(id, props.state);
|
||||
}, [id, props.state, setDialogState]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setDialogState?.(id, ModalState.Closed);
|
||||
};
|
||||
}, [id, setDialogState]);
|
||||
|
||||
return <>
|
||||
{props.children}
|
||||
</>;
|
||||
};
|
||||
|
||||
export default ModalWrapper;
|
||||
@@ -1,13 +0,0 @@
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export -- FocusControl currently only has one shared type for external use
|
||||
export enum ModalState {
|
||||
// When `Open`, a modal blocks focus for the main app content.
|
||||
Open,
|
||||
// When `Closing`, a modal doesn't block main app content focus, but focus
|
||||
// shouldn't be moved to the main app content yet.
|
||||
// This is useful for native Modals, which have their own focus handling logic.
|
||||
// If Joplin moves accessibility focus before the native Modal focus handling
|
||||
// has completed, the Joplin-specified accessibility focus may be ignored.
|
||||
Closing,
|
||||
Closed,
|
||||
}
|
||||
@@ -15,7 +15,6 @@ const baseStyle = {
|
||||
fontSizeSmaller: 14,
|
||||
disabledOpacity: 0.2,
|
||||
lineHeight: '1.6em',
|
||||
listTabSize: '1.7em',
|
||||
// The default, may be overridden in settings:
|
||||
noteViewerFontSize: 16,
|
||||
};
|
||||
|
||||
@@ -7,8 +7,6 @@ import PluginService from '@joplin/lib/services/plugins/PluginService';
|
||||
import WebviewController, { ContainerType } from '@joplin/lib/services/plugins/WebviewController';
|
||||
import useViewInfos from './hooks/useViewInfos';
|
||||
import PluginPanelViewer from './PluginPanelViewer';
|
||||
import FocusControl from '../../accessibility/FocusControl/FocusControl';
|
||||
import { ModalState } from '../../accessibility/FocusControl/types';
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
@@ -42,14 +40,12 @@ const PluginDialogManager: React.FC<Props> = props => {
|
||||
visible={true}
|
||||
onDismiss={() => dismissDialog(viewInfo)}
|
||||
>
|
||||
<FocusControl.ModalWrapper state={ModalState.Open}>
|
||||
<PluginDialogWebView
|
||||
viewInfo={viewInfo}
|
||||
themeId={props.themeId}
|
||||
pluginStates={props.pluginStates}
|
||||
pluginHtmlContents={props.pluginHtmlContents}
|
||||
/>
|
||||
</FocusControl.ModalWrapper>
|
||||
<PluginDialogWebView
|
||||
viewInfo={viewInfo}
|
||||
themeId={props.themeId}
|
||||
pluginStates={props.pluginStates}
|
||||
pluginHtmlContents={props.pluginHtmlContents}
|
||||
/>
|
||||
</Modal>
|
||||
</Portal>,
|
||||
);
|
||||
|
||||
@@ -81,11 +81,10 @@ const PluginBox: React.FC<Props> = props => {
|
||||
const styles = useStyles(props.isCompatible);
|
||||
|
||||
const CardWrapper = props.onShowPluginInfo ? TouchableRipple : View;
|
||||
const containerIsButton = !!props.onShowPluginInfo;
|
||||
return (
|
||||
<CardWrapper
|
||||
accessibilityRole={containerIsButton ? 'button' : null}
|
||||
accessible={containerIsButton}
|
||||
accessibilityRole={props.onShowPluginInfo ? 'button' : null}
|
||||
accessible={true}
|
||||
onPress={props.onShowPluginInfo ? onPress : null}
|
||||
style={styles.cardContainer}
|
||||
>
|
||||
|
||||
@@ -2,11 +2,13 @@ import * as React from 'react';
|
||||
import { PluginSettings } from '@joplin/lib/services/plugins/PluginService';
|
||||
import configScreenStyles from '../../configScreenStyles';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { Provider } from 'react-redux';
|
||||
import { Store } from 'redux';
|
||||
import { PaperProvider } from 'react-native-paper';
|
||||
import PluginStates from '../PluginStates';
|
||||
import { AppState } from '../../../../../utils/types';
|
||||
import { useCallback, useState } from 'react';
|
||||
import TestProviderStack from '../../../../testing/TestProviderStack';
|
||||
import { MenuProvider } from 'react-native-popup-menu';
|
||||
|
||||
interface WrapperProps {
|
||||
initialPluginSettings: PluginSettings;
|
||||
@@ -27,15 +29,19 @@ const PluginStatesWrapper = (props: WrapperProps) => {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<TestProviderStack store={props.store}>
|
||||
<PluginStates
|
||||
styles={styles}
|
||||
themeId={Setting.THEME_LIGHT}
|
||||
updatePluginStates={updatePluginStates}
|
||||
pluginSettings={pluginSettings}
|
||||
shouldShowBasedOnSearchQuery={shouldShowBasedOnSettingSearchQuery}
|
||||
/>
|
||||
</TestProviderStack>
|
||||
<Provider store={props.store}>
|
||||
<MenuProvider>
|
||||
<PaperProvider>
|
||||
<PluginStates
|
||||
styles={styles}
|
||||
themeId={Setting.THEME_LIGHT}
|
||||
updatePluginStates={updatePluginStates}
|
||||
pluginSettings={pluginSettings}
|
||||
shouldShowBasedOnSearchQuery={shouldShowBasedOnSettingSearchQuery}
|
||||
/>
|
||||
</PaperProvider>
|
||||
</MenuProvider>
|
||||
</Provider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import { MenuProvider } from 'react-native-popup-menu';
|
||||
import { Provider } from 'react-redux';
|
||||
import { Store } from 'redux';
|
||||
import { AppState } from '../../utils/types';
|
||||
import FocusControl from '../accessibility/FocusControl/FocusControl';
|
||||
|
||||
interface Props {
|
||||
store: Store<AppState>;
|
||||
@@ -13,13 +12,11 @@ interface Props {
|
||||
|
||||
const TestProviderStack: React.FC<Props> = props => {
|
||||
return <Provider store={props.store}>
|
||||
<FocusControl.Provider>
|
||||
<MenuProvider closeButtonLabel='Dismiss'>
|
||||
<PaperProvider>
|
||||
{props.children}
|
||||
</PaperProvider>
|
||||
</MenuProvider>
|
||||
</FocusControl.Provider>
|
||||
<MenuProvider>
|
||||
<PaperProvider>
|
||||
{props.children}
|
||||
</PaperProvider>
|
||||
</MenuProvider>
|
||||
</Provider>;
|
||||
};
|
||||
|
||||
|
||||
@@ -43,15 +43,12 @@ const styles = StyleSheet.create({
|
||||
|
||||
const RecordingControls: React.FC<Props> = props => {
|
||||
const renderIcon = () => {
|
||||
const loadingIcon: IconSource = ({ size }: { size: number }) => {
|
||||
return <ActivityIndicator animating={true} style={{ width: size, height: size }} />;
|
||||
};
|
||||
const components: Record<RecorderState, IconSource> = {
|
||||
[RecorderState.Loading]: loadingIcon,
|
||||
[RecorderState.Loading]: ({ size }: { size: number }) => <ActivityIndicator animating={true} style={{ width: size, height: size }} />,
|
||||
[RecorderState.Recording]: 'microphone',
|
||||
[RecorderState.Idle]: 'microphone',
|
||||
[RecorderState.Processing]: loadingIcon,
|
||||
[RecorderState.Downloading]: loadingIcon,
|
||||
[RecorderState.Processing]: 'microphone',
|
||||
[RecorderState.Downloading]: ({ size }: { size: number }) => <ActivityIndicator animating={true} style={{ width: size, height: size }} />,
|
||||
[RecorderState.Error]: 'alert-circle-outline',
|
||||
};
|
||||
|
||||
@@ -70,7 +67,6 @@ const RecordingControls: React.FC<Props> = props => {
|
||||
refocusCounter={1}
|
||||
aria-live='polite'
|
||||
role='heading'
|
||||
testID='recording-controls-heading'
|
||||
>
|
||||
<Text variant='bodyMedium'>
|
||||
{props.heading}
|
||||
|
||||
@@ -35,7 +35,6 @@ const useVoiceTyping = ({ locale, provider, onSetPreview, onText }: UseVoiceTypi
|
||||
const [error, setError] = useState<Error|null>(null);
|
||||
const [mustDownloadModel, setMustDownloadModel] = useState<boolean | null>(null);
|
||||
const [modelIsOutdated, setModelIsOutdated] = useState(false);
|
||||
const [stoppingSession, setIsStoppingSession] = useState(false);
|
||||
|
||||
const onTextRef = useRef(onText);
|
||||
onTextRef.current = onText;
|
||||
@@ -63,7 +62,7 @@ const useVoiceTyping = ({ locale, provider, onSetPreview, onText }: UseVoiceTypi
|
||||
// should be hidden (and voice typing should start).
|
||||
setError(null);
|
||||
|
||||
await voiceTypingRef.current?.cancel();
|
||||
await voiceTypingRef.current?.stop();
|
||||
onSetPreviewRef.current?.('');
|
||||
|
||||
setModelIsOutdated(await builder.isDownloadedFromOutdatedUrl());
|
||||
@@ -92,20 +91,18 @@ const useVoiceTyping = ({ locale, provider, onSetPreview, onText }: UseVoiceTypi
|
||||
}, [builder]);
|
||||
|
||||
useEffect(() => () => {
|
||||
void voiceTypingRef.current?.cancel();
|
||||
void voiceTypingRef.current?.stop();
|
||||
}, []);
|
||||
|
||||
const onRequestRedownload = useCallback(async () => {
|
||||
setIsStoppingSession(true);
|
||||
await voiceTypingRef.current?.cancel();
|
||||
await voiceTypingRef.current?.stop();
|
||||
await builder.clearDownloads();
|
||||
setMustDownloadModel(true);
|
||||
setIsStoppingSession(false);
|
||||
setRedownloadCounter(value => value + 1);
|
||||
}, [builder]);
|
||||
|
||||
return {
|
||||
error, mustDownloadModel, stoppingSession, voiceTyping, onRequestRedownload, modelIsOutdated,
|
||||
error, mustDownloadModel, voiceTyping, onRequestRedownload, modelIsOutdated,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -116,7 +113,6 @@ const SpeechToTextComponent: React.FC<Props> = props => {
|
||||
error: modelError,
|
||||
mustDownloadModel,
|
||||
voiceTyping,
|
||||
stoppingSession,
|
||||
onRequestRedownload,
|
||||
modelIsOutdated,
|
||||
} = useVoiceTyping({
|
||||
@@ -140,24 +136,14 @@ const SpeechToTextComponent: React.FC<Props> = props => {
|
||||
}
|
||||
}, [mustDownloadModel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (stoppingSession) {
|
||||
setRecorderState(RecorderState.Processing);
|
||||
}
|
||||
}, [stoppingSession]);
|
||||
|
||||
useEffect(() => {
|
||||
if (recorderState === RecorderState.Recording) {
|
||||
void voiceTyping.start();
|
||||
}
|
||||
}, [recorderState, voiceTyping, props.onText]);
|
||||
|
||||
const onDismiss = useCallback(async () => {
|
||||
if (voiceTyping) {
|
||||
setRecorderState(RecorderState.Processing);
|
||||
await voiceTyping.stop();
|
||||
setRecorderState(RecorderState.Idle);
|
||||
}
|
||||
const onDismiss = useCallback(() => {
|
||||
void voiceTyping?.stop();
|
||||
props.onDismiss();
|
||||
}, [voiceTyping, props.onDismiss]);
|
||||
|
||||
@@ -166,9 +152,7 @@ const SpeechToTextComponent: React.FC<Props> = props => {
|
||||
[RecorderState.Loading]: () => _('Loading...'),
|
||||
[RecorderState.Idle]: () => 'Waiting...', // Not used for now
|
||||
[RecorderState.Recording]: () => _('Please record your voice...'),
|
||||
[RecorderState.Processing]: () => (
|
||||
stoppingSession ? _('Closing session...') : _('Converting speech to text...')
|
||||
),
|
||||
[RecorderState.Processing]: () => _('Converting speech to text...'),
|
||||
[RecorderState.Downloading]: () => _('Downloading %s language files...', languageName(props.locale)),
|
||||
[RecorderState.Error]: () => _('Error: %s', modelError?.message),
|
||||
};
|
||||
@@ -177,18 +161,10 @@ const SpeechToTextComponent: React.FC<Props> = props => {
|
||||
};
|
||||
|
||||
const renderPreview = () => {
|
||||
if (recorderState !== RecorderState.Recording) {
|
||||
return null;
|
||||
}
|
||||
return <Text variant='labelSmall'>{preview}</Text>;
|
||||
};
|
||||
|
||||
const reDownloadButton = <Button
|
||||
// Usually, stoppingSession is true because the re-download button has
|
||||
// just been pressed.
|
||||
disabled={stoppingSession}
|
||||
onPress={onRequestRedownload}
|
||||
>
|
||||
const reDownloadButton = <Button onPress={onRequestRedownload}>
|
||||
{modelIsOutdated ? _('Download updated model') : _('Re-download model')}
|
||||
</Button>;
|
||||
const allowReDownload = recorderState === RecorderState.Error || modelIsOutdated;
|
||||
@@ -197,7 +173,6 @@ const SpeechToTextComponent: React.FC<Props> = props => {
|
||||
{allowReDownload ? reDownloadButton : null}
|
||||
<PrimaryButton
|
||||
onPress={onDismiss}
|
||||
disabled={recorderState === RecorderState.Processing}
|
||||
accessibilityHint={_('Ends voice typing')}
|
||||
>{_('Done')}</PrimaryButton>
|
||||
</>;
|
||||
|
||||
@@ -7,7 +7,4 @@
|
||||
# Customize the NODE_BINARY variable here.
|
||||
# For example, to use nvm with brew, add the following line
|
||||
# . "$(brew --prefix nvm)/nvm.sh" --no-use
|
||||
|
||||
# Note: `$(command -v node)` doesn't work so hardcode the path here - but it means it needs to be
|
||||
# manually updated when Node is upgraded.
|
||||
export NODE_BINARY=/opt/homebrew/opt/node@20/bin/node
|
||||
export NODE_BINARY=$(command -v node)
|
||||
|
||||
@@ -535,13 +535,13 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 135;
|
||||
CURRENT_PROJECT_VERSION = 134;
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Joplin/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.4;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 13.3.2;
|
||||
MARKETING_VERSION = 13.3.1;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
@@ -567,12 +567,12 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 135;
|
||||
CURRENT_PROJECT_VERSION = 134;
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
INFOPLIST_FILE = Joplin/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.4;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 13.3.2;
|
||||
MARKETING_VERSION = 13.3.1;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
@@ -758,14 +758,14 @@
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 135;
|
||||
CURRENT_PROJECT_VERSION = 134;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
INFOPLIST_FILE = ShareExtension/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.4;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
||||
MARKETING_VERSION = 13.3.2;
|
||||
MARKETING_VERSION = 13.3.1;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
OTHER_LDFLAGS = (
|
||||
@@ -797,14 +797,14 @@
|
||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 135;
|
||||
CURRENT_PROJECT_VERSION = 134;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
INFOPLIST_FILE = ShareExtension/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.4;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
||||
MARKETING_VERSION = 13.3.2;
|
||||
MARKETING_VERSION = 13.3.1;
|
||||
MTL_FAST_MATH = YES;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
|
||||
@@ -1,517 +1,116 @@
|
||||
{
|
||||
"images" : [
|
||||
"images": [
|
||||
{
|
||||
"filename" : "ios20x20@2x.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"scale" : "2x",
|
||||
"size" : "20x20"
|
||||
"filename": "ios_marketing1024x1024.png",
|
||||
"idiom": "ios-marketing",
|
||||
"size": "1024x1024",
|
||||
"scale": "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "ios20x20@3x.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"scale" : "3x",
|
||||
"size" : "20x20"
|
||||
"filename": "iphone_notification20x20@2x.png",
|
||||
"idiom": "iphone",
|
||||
"size": "20x20",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "ios29x29@2x.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"scale" : "2x",
|
||||
"size" : "29x29"
|
||||
"filename": "iphone_notification20x20@3x.png",
|
||||
"idiom": "iphone",
|
||||
"size": "20x20",
|
||||
"scale": "3x"
|
||||
},
|
||||
{
|
||||
"filename" : "ios29x29@3x.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"scale" : "3x",
|
||||
"size" : "29x29"
|
||||
"filename": "iphone_settings29x29@2x.png",
|
||||
"idiom": "iphone",
|
||||
"size": "29x29",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "ios38x38@2x.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"scale" : "2x",
|
||||
"size" : "38x38"
|
||||
"filename": "iphone_settings29x29@3x.png",
|
||||
"idiom": "iphone",
|
||||
"size": "29x29",
|
||||
"scale": "3x"
|
||||
},
|
||||
{
|
||||
"filename" : "ios38x38@3x.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"scale" : "3x",
|
||||
"size" : "38x38"
|
||||
"filename": "iphone_spotlight40x40@2x.png",
|
||||
"idiom": "iphone",
|
||||
"size": "40x40",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "ios40x40@2x.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"scale" : "2x",
|
||||
"size" : "40x40"
|
||||
"filename": "iphone_spotlight40x40@3x.png",
|
||||
"idiom": "iphone",
|
||||
"size": "40x40",
|
||||
"scale": "3x"
|
||||
},
|
||||
{
|
||||
"filename" : "ios40x40@3x.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"scale" : "3x",
|
||||
"size" : "40x40"
|
||||
"filename": "iphone_app60x60@2x.png",
|
||||
"idiom": "iphone",
|
||||
"size": "60x60",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "ios60x60@2x.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"scale" : "2x",
|
||||
"size" : "60x60"
|
||||
"filename": "iphone_app60x60@3x.png",
|
||||
"idiom": "iphone",
|
||||
"size": "60x60",
|
||||
"scale": "3x"
|
||||
},
|
||||
{
|
||||
"filename" : "ios60x60@3x.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"scale" : "3x",
|
||||
"size" : "60x60"
|
||||
"filename": "ipad_notification20x20.png",
|
||||
"idiom": "ipad",
|
||||
"size": "20x20",
|
||||
"scale": "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "ios64x64@2x.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"scale" : "2x",
|
||||
"size" : "64x64"
|
||||
"filename": "ipad_notification20x20@2x.png",
|
||||
"idiom": "ipad",
|
||||
"size": "20x20",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "ios64x64@3x.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"scale" : "3x",
|
||||
"size" : "64x64"
|
||||
"filename": "ipad_settings29x29.png",
|
||||
"idiom": "ipad",
|
||||
"size": "29x29",
|
||||
"scale": "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "ios68x68@2x.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"scale" : "2x",
|
||||
"size" : "68x68"
|
||||
"filename": "ipad_settings29x29@2x.png",
|
||||
"idiom": "ipad",
|
||||
"size": "29x29",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "ios76x76@2x.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"scale" : "2x",
|
||||
"size" : "76x76"
|
||||
"filename": "ipad_spotlight40x40.png",
|
||||
"idiom": "ipad",
|
||||
"size": "40x40",
|
||||
"scale": "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "ios83.5x83.5@2x.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"scale" : "2x",
|
||||
"size" : "83.5x83.5"
|
||||
"filename": "ipad_spotlight40x40@2x.png",
|
||||
"idiom": "ipad",
|
||||
"size": "40x40",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "ios1024x1024.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
"filename": "ipad_app76x76.png",
|
||||
"idiom": "ipad",
|
||||
"size": "76x76",
|
||||
"scale": "1x"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "ios_dark20x20@2x.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"scale" : "2x",
|
||||
"size" : "20x20"
|
||||
"filename": "ipad_app76x76@2x.png",
|
||||
"idiom": "ipad",
|
||||
"size": "76x76",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "ios_dark20x20@3x.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"scale" : "3x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "ios_dark29x29@2x.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"scale" : "2x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "ios_dark29x29@3x.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"scale" : "3x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "ios_dark38x38@2x.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"scale" : "2x",
|
||||
"size" : "38x38"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "ios_dark38x38@3x.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"scale" : "3x",
|
||||
"size" : "38x38"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "ios_dark40x40@2x.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"scale" : "2x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "ios_dark40x40@3x.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"scale" : "3x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "ios_dark60x60@2x.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"scale" : "2x",
|
||||
"size" : "60x60"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "ios_dark60x60@3x.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"scale" : "3x",
|
||||
"size" : "60x60"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "ios_dark64x64@2x.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"scale" : "2x",
|
||||
"size" : "64x64"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "ios_dark64x64@3x.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"scale" : "3x",
|
||||
"size" : "64x64"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "ios_dark68x68@2x.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"scale" : "2x",
|
||||
"size" : "68x68"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "ios_dark76x76@2x.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"scale" : "2x",
|
||||
"size" : "76x76"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "ios_dark83.5x83.5@2x.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"scale" : "2x",
|
||||
"size" : "83.5x83.5"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "ios_dark1024x1024.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "tinted"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"scale" : "2x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "tinted"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"scale" : "3x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "tinted"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"scale" : "2x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "tinted"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"scale" : "3x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "tinted"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"scale" : "2x",
|
||||
"size" : "38x38"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "tinted"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"scale" : "3x",
|
||||
"size" : "38x38"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "tinted"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"scale" : "2x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "tinted"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"scale" : "3x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "tinted"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"scale" : "2x",
|
||||
"size" : "60x60"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "tinted"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"scale" : "3x",
|
||||
"size" : "60x60"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "tinted"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"scale" : "2x",
|
||||
"size" : "64x64"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "tinted"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"scale" : "3x",
|
||||
"size" : "64x64"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "tinted"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"scale" : "2x",
|
||||
"size" : "68x68"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "tinted"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"scale" : "2x",
|
||||
"size" : "76x76"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "tinted"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"scale" : "2x",
|
||||
"size" : "83.5x83.5"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "tinted"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
"filename": "ipad_pro_app83.5x83.5@2x.png",
|
||||
"idiom": "ipad",
|
||||
"size": "83.5x83.5",
|
||||
"scale": "2x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
"info": {
|
||||
"version": 1,
|
||||
"author": "xcode"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 871 B |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |