1
0
mirror of https://github.com/laurent22/joplin.git synced 2026-01-08 00:14:28 +02:00

Compare commits

..

3 Commits

Author SHA1 Message Date
Laurent Cozic
04666be15f ts 2025-03-09 09:38:05 +00:00
Laurent Cozic
64d0bec2c5 fixed ts error 2025-03-08 11:12:56 +00:00
Laurent Cozic
ab28f2a794 init 2025-03-08 10:19:51 +00:00
145 changed files with 903 additions and 3031 deletions

View File

@@ -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

View File

@@ -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,
},
},

9
.gitignore vendored
View File

@@ -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

View File

@@ -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;

View File

@@ -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

View File

@@ -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>

View File

@@ -25,8 +25,7 @@
"version": "latest",
"excluded_platforms": ["aarch64-darwin", "x86_64-darwin"],
},
"git": "latest",
"giflib": "latest",
"git": "latest",
},
"shell": {
"init_hook": [

View File

@@ -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"
}
}

View File

@@ -1,26 +1,21 @@
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, App, Event, dialog, ipcMain } from 'electron';
import bridge from './bridge';
const url = require('url');
const path = require('path');
const { dirname } = require('@joplin/lib/path-utils');
const fs = require('fs-extra');
import { dialog, ipcMain } from 'electron';
import { _ } from '@joplin/lib/locale';
import restartInSafeModeFromMain from './utils/restartInSafeModeFromMain';
import handleCustomProtocols, { CustomProtocolHandler } from './utils/customProtocols/handleCustomProtocols';
import { clearTimeout, setTimeout } from 'timers';
import { resolve } from 'path';
import { defaultWindowId } from '@joplin/lib/reducer';
import { msleep } from '@joplin/utils/time';
interface RendererProcessQuitReply {
canClose: boolean;
@@ -49,8 +44,7 @@ export default class ElectronAppWrapper {
private secondaryWindows_: Map<SecondaryWindowId, SecondaryWindowData> = new Map();
private willQuitApp_ = false;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
private tray_: any = null;
private tray_: Tray = null;
private buildDir_: string = null;
private rendererProcessQuitReply_: RendererProcessQuitReply = null;
@@ -58,29 +52,15 @@ export default class ElectronAppWrapper {
private updaterService_: AutoUpdaterService = null;
private customProtocolHandler_: CustomProtocolHandler = null;
private updatePollInterval_: ReturnType<typeof setTimeout>|null = null;
private isAltInstance_: boolean;
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) {
public constructor(electronApp: App, env: string, profilePath: string|null, isDebugMode: boolean, initialCallbackUrl: string, isAltInstance: boolean) {
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`,
});
this.isAltInstance_ = isAltInstance;
}
public electronApp() {
@@ -103,6 +83,10 @@ export default class ElectronAppWrapper {
return BrowserWindow.getFocusedWindow() ?? this.win_;
}
public isAltInstance() {
return this.isAltInstance_;
}
public windowById(joplinId: string) {
if (joplinId === defaultWindowId) {
return this.mainWindow();
@@ -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 (this.isAltInstance_) 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
this.electronApp_.on('second-instance', (_event: Event, argv: string[], _workingDirectory: string) => {
const win = this.mainWindow();
if (!win) return;
if (win.isMinimized()) win.restore();
@@ -596,85 +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();
return true;
return false;
}
public initializeCustomProtocolHandler(logger: LoggerWrapper) {
@@ -716,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();

View File

@@ -617,7 +617,7 @@ class Application extends BaseApplication {
clipperLogger.addTarget(TargetType.Console);
ClipperServer.instance().initialize(actionApi);
ClipperServer.instance().setEnabled(!Setting.value('altInstanceId'));
ClipperServer.instance().setEnabled(!Setting.value('isAltInstance'));
ClipperServer.instance().setLogger(clipperLogger);
ClipperServer.instance().setDispatch(this.store().dispatch);

View File

@@ -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_;
}

View File

@@ -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,

View File

@@ -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',
};
};

View File

@@ -478,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',
@@ -579,8 +575,6 @@ class MainScreenComponent extends React.Component<Props, State> {
_('Your Joplin Cloud credentials are invalid, please login.'),
_('Login to Joplin Cloud.'),
onViewJoplinCloudLoginScreen,
_('Disable synchronisation'),
onDisableSync,
);
}

View File

@@ -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,
],
};
@@ -1147,7 +1140,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 +1166,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,

View File

@@ -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);
},

View File

@@ -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
// -----------------------------------------------------------------------------------------

View File

@@ -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',
};
};

View File

@@ -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,

View File

@@ -163,9 +163,6 @@ const declarations: CommandDeclaration[] = [
{
name: 'editor.execCommand',
},
{
name: 'viewer.focus',
},
];
export default declarations;

View File

@@ -10,7 +10,6 @@ const commandsWithDependencies = [
require('../commands/showLocalSearch'),
require('../commands/focusElementNoteTitle'),
require('../commands/focusElementNoteBody'),
require('../commands/focusElementNoteViewer'),
require('../commands/focusElementToolbar'),
require('../commands/pasteAsText'),
];

View File

@@ -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]);

View File

@@ -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}

View File

@@ -4,7 +4,6 @@ export default function() {
'copyDevCommand',
'exportPdf',
'focusElementNoteBody',
'focusElementNoteViewer',
'focusElementNoteList',
'focusElementNoteTitle',
'focusElementSideBar',
@@ -48,7 +47,6 @@ export default function() {
'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.

View File

@@ -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;

View File

@@ -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).

View File

@@ -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);

View File

@@ -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();
});
});

View File

@@ -25,27 +25,29 @@ 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', '');
const isAltInstance = !!process.argv && process.argv.indexOf('--is-alt-instance') >= 0;
// 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;
@@ -64,9 +66,9 @@ void registerCustomProtocols();
const initialCallbackUrl = process.argv.find((arg) => isCallbackUrl(arg));
const wrapper = new ElectronAppWrapper(electronApp, env, rootProfileDir, isDebugMode, initialCallbackUrl);
const wrapper = new ElectronAppWrapper(electronApp, env, rootProfileDir, isDebugMode, initialCallbackUrl, isAltInstance);
initBridge(wrapper, appId, appName, rootProfileDir, autoUploadCrashDumps, altInstanceId);
initBridge(wrapper, appId, appName, rootProfileDir, autoUploadCrashDumps);
wrapper.start().catch((error) => {
console.error('Electron App fatal error:');

View File

@@ -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",

View File

@@ -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: {

View File

@@ -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

View File

@@ -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'),

View File

@@ -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);
};

View File

@@ -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

View File

@@ -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, Setting.value('isAltInstance'));
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;

View File

@@ -86,8 +86,8 @@ android {
applicationId "net.cozic.joplin"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 2097766
versionName "3.3.3"
versionCode 2097765
versionName "3.3.2"
ndk {
abiFilters "armeabi-v7a", "x86", "arm64-v8a", "x86_64"
}

View File

@@ -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>
);
};

View File

@@ -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 () => {

View File

@@ -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>
);

View File

@@ -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>
);
}

View File

@@ -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>
);

View File

@@ -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();
});
});

View File

@@ -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

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,
}

View File

@@ -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>,
);

View File

@@ -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}
>

View File

@@ -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>
);
};

View File

@@ -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>;
};

View File

@@ -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}

View File

@@ -62,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());
@@ -91,11 +91,11 @@ const useVoiceTyping = ({ locale, provider, onSetPreview, onText }: UseVoiceTypi
}, [builder]);
useEffect(() => () => {
void voiceTypingRef.current?.cancel();
void voiceTypingRef.current?.stop();
}, []);
const onRequestRedownload = useCallback(async () => {
await voiceTypingRef.current?.cancel();
await voiceTypingRef.current?.stop();
await builder.clearDownloads();
setMustDownloadModel(true);
setRedownloadCounter(value => value + 1);
@@ -142,12 +142,8 @@ const SpeechToTextComponent: React.FC<Props> = props => {
}
}, [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]);
@@ -177,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>
</>;

View File

@@ -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"
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 871 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 973 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

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