mirror of
https://github.com/laurent22/joplin.git
synced 2026-10-25 02:47:28 +00:00
Mobile: Fix issues with modals created via plugins (#16674)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
This commit is contained in:
1 parent
21d0a8ad71
commit
c87f694dc7
13 files changed
+235
-25
No files matched your search
@@ -864,6 +864,7 @@ packages/app-mobile/components/plugins/PluginNotification.js
|
||||
packages/app-mobile/components/plugins/PluginRunner.js
|
||||
packages/app-mobile/components/plugins/PluginRunnerWebView.test.js
|
||||
packages/app-mobile/components/plugins/PluginRunnerWebView.js
|
||||
packages/app-mobile/components/plugins/backgroundPage/initializeDialogWebView.test.js
|
||||
packages/app-mobile/components/plugins/backgroundPage/initializeDialogWebView.js
|
||||
packages/app-mobile/components/plugins/backgroundPage/initializePluginBackgroundIframe.js
|
||||
packages/app-mobile/components/plugins/backgroundPage/pluginRunnerBackgroundPage.js
|
||||
|
||||
@@ -890,6 +890,7 @@ packages/app-mobile/components/plugins/PluginNotification.js
|
||||
packages/app-mobile/components/plugins/PluginRunner.js
|
||||
packages/app-mobile/components/plugins/PluginRunnerWebView.test.js
|
||||
packages/app-mobile/components/plugins/PluginRunnerWebView.js
|
||||
packages/app-mobile/components/plugins/backgroundPage/initializeDialogWebView.test.js
|
||||
packages/app-mobile/components/plugins/backgroundPage/initializeDialogWebView.js
|
||||
packages/app-mobile/components/plugins/backgroundPage/initializePluginBackgroundIframe.js
|
||||
packages/app-mobile/components/plugins/backgroundPage/pluginRunnerBackgroundPage.js
|
||||
|
||||
@@ -24,6 +24,8 @@ interface Props {
|
||||
children: React.ReactNode;
|
||||
heading?: string;
|
||||
scrollOverflow?: boolean;
|
||||
useNativeModal?: boolean;
|
||||
inert?: boolean;
|
||||
|
||||
size: DialogVariant;
|
||||
}
|
||||
@@ -124,15 +126,22 @@ const DismissibleDialog: React.FC<Props> = props => {
|
||||
return (
|
||||
<Modal
|
||||
visible={props.visible}
|
||||
onClose={props.onDismiss}
|
||||
onClose={props.inert ? null : props.onDismiss}
|
||||
containerStyle={styles.dialogContainer}
|
||||
modalBackgroundStyle={styles.modalBackground}
|
||||
backgroundColor={theme.backgroundColorTransparent2}
|
||||
backgroundColor={props.inert ? 'transparent' : theme.backgroundColorTransparent2}
|
||||
scrollOverflow={props.scrollOverflow}
|
||||
// Allows the modal background to extend under the statusbar
|
||||
statusBarTranslucent
|
||||
useNativeModal={props.useNativeModal}
|
||||
>
|
||||
<Surface style={styles.dialogSurface} elevation={1}>
|
||||
<Surface
|
||||
style={styles.dialogSurface}
|
||||
elevation={1}
|
||||
pointerEvents={props.inert ? 'none' : 'auto'}
|
||||
importantForAccessibility={props.inert ? 'no-hide-descendants' : 'auto'}
|
||||
accessibilityElementsHidden={props.inert}
|
||||
>
|
||||
{closeButtonRow}
|
||||
{props.children}
|
||||
</Surface>
|
||||
|
||||
@@ -57,6 +57,15 @@ const ExtendedWebView = (props: Props, ref: Ref<WebViewControl>) => {
|
||||
useEffect(() => {
|
||||
// JSDOM polyfills
|
||||
dom.window.eval(polyfillScrollFunctions);
|
||||
dom.window.eval(`
|
||||
// JSDOM does not implement ResizeObserver. Observer behaviour is covered by
|
||||
// the initializeDialogWebView unit tests.
|
||||
window.ResizeObserver ??= class ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
};
|
||||
`);
|
||||
|
||||
dom.window.eval(`
|
||||
// JSDOM iframes are missing certain functionality required by Joplin,
|
||||
|
||||
@@ -29,6 +29,9 @@ export interface ModalElementProps {
|
||||
// iOS only: Called once the modal has finished its dismissal transition
|
||||
onDismissed?: ()=> void;
|
||||
animationType?: 'fade'|'none';
|
||||
// When false, render within the surrounding portal rather than creating a
|
||||
// separate native/top-layer modal.
|
||||
useNativeModal?: boolean;
|
||||
wrapContent?: (view: React.ReactNode)=> React.ReactNode;
|
||||
|
||||
statusBarTranslucent?: boolean;
|
||||
@@ -162,6 +165,7 @@ const ModalElement: React.FC<ModalElementProps> = ({
|
||||
onClose,
|
||||
onDismissed,
|
||||
wrapContent,
|
||||
useNativeModal = true,
|
||||
...forwardedProps
|
||||
}) => {
|
||||
const styles = useStyles(!!scrollOverflow, backgroundColor);
|
||||
@@ -209,9 +213,19 @@ const ModalElement: React.FC<ModalElementProps> = ({
|
||||
}
|
||||
|
||||
const extraScrollViewProps = (typeof scrollOverflow === 'object' ? scrollOverflow : {});
|
||||
const modalContent = scrollOverflow ? (
|
||||
<KeyboardAvoidingView style={styles.keyboardAvoidingView} enabled={true}>
|
||||
<ScrollView
|
||||
{...extraScrollViewProps}
|
||||
style={[styles.modalScrollView, extraScrollViewProps.style]}
|
||||
contentContainerStyle={[styles.modalScrollViewContent, extraScrollViewProps.contentContainerStyle]}
|
||||
>{contentAndBackdrop}</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
) : contentAndBackdrop;
|
||||
|
||||
const result = (
|
||||
<FocusControl.ModalWrapper state={modalStatus}>
|
||||
<ModalComponent
|
||||
{useNativeModal ? <ModalComponent
|
||||
// supportedOrientations: On iOS, this allows the dialog to be shown in non-portrait orientations.
|
||||
supportedOrientations={['portrait', 'portrait-upside-down', 'landscape', 'landscape-left', 'landscape-right']}
|
||||
animationType='fade'
|
||||
@@ -225,16 +239,8 @@ const ModalElement: React.FC<ModalElementProps> = ({
|
||||
onRequestClose={onClose}
|
||||
{...forwardedProps}
|
||||
>
|
||||
{scrollOverflow ? (
|
||||
<KeyboardAvoidingView style={styles.keyboardAvoidingView} enabled={true}>
|
||||
<ScrollView
|
||||
{...extraScrollViewProps}
|
||||
style={[styles.modalScrollView, extraScrollViewProps.style]}
|
||||
contentContainerStyle={[styles.modalScrollViewContent, extraScrollViewProps.contentContainerStyle]}
|
||||
>{contentAndBackdrop}</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
) : contentAndBackdrop}
|
||||
</ModalComponent>
|
||||
{modalContent}
|
||||
</ModalComponent> : (forwardedProps.visible ? modalContent : null)}
|
||||
</FocusControl.ModalWrapper>
|
||||
);
|
||||
|
||||
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
/** @jest-environment jsdom */
|
||||
import { DialogWebViewApi } from '../types';
|
||||
import initializeDialogWebView from './initializeDialogWebView';
|
||||
|
||||
let mockDialogApi: DialogWebViewApi;
|
||||
jest.mock('../../../utils/ipc/WebViewToRNMessenger', () => {
|
||||
return class {
|
||||
public remoteApi = {
|
||||
postMessage: jest.fn(),
|
||||
onMessage: jest.fn(),
|
||||
onError: jest.fn(),
|
||||
onLog: jest.fn(),
|
||||
};
|
||||
|
||||
public constructor(_messageChannelId: string, localApi: DialogWebViewApi) {
|
||||
mockDialogApi = localApi;
|
||||
}
|
||||
};
|
||||
});
|
||||
jest.mock('./utils/reportUnhandledErrors', () => jest.fn());
|
||||
jest.mock('./utils/wrapConsoleLog', () => jest.fn());
|
||||
|
||||
class MockResizeObserver {
|
||||
public static instances: MockResizeObserver[] = [];
|
||||
public observe = jest.fn();
|
||||
public disconnect = jest.fn();
|
||||
|
||||
public constructor(private callback: ResizeObserverCallback) {
|
||||
MockResizeObserver.instances.push(this);
|
||||
}
|
||||
|
||||
public notify() {
|
||||
this.callback([], this as unknown as ResizeObserver);
|
||||
}
|
||||
}
|
||||
|
||||
describe('initializeDialogWebView', () => {
|
||||
let contentWidth = 0;
|
||||
let contentHeight = 0;
|
||||
let viewportWidth = 0;
|
||||
let innerWidth = 0;
|
||||
|
||||
beforeEach(() => {
|
||||
MockResizeObserver.instances = [];
|
||||
global.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
|
||||
document.body.innerHTML = '<div id="joplin-plugin-content"></div>';
|
||||
|
||||
const content = document.getElementById('joplin-plugin-content');
|
||||
Object.defineProperty(content, 'clientWidth', { configurable: true, get: () => contentWidth });
|
||||
Object.defineProperty(content, 'clientHeight', { configurable: true, get: () => contentHeight });
|
||||
Object.defineProperty(document.documentElement, 'clientWidth', { configurable: true, get: () => viewportWidth });
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, get: () => innerWidth });
|
||||
|
||||
contentWidth = 320;
|
||||
contentHeight = 200;
|
||||
viewportWidth = 1000;
|
||||
innerWidth = 1000;
|
||||
initializeDialogWebView('test-channel');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.dispatchEvent(new Event('pagehide'));
|
||||
});
|
||||
|
||||
test('should report width reductions observed without DOM mutations', async () => {
|
||||
expect(await mockDialogApi.getContentSize(true)).toMatchObject({ width: 320, height: 200 });
|
||||
|
||||
contentWidth = 220;
|
||||
MockResizeObserver.instances[0].notify();
|
||||
|
||||
expect(await mockDialogApi.getContentSize(true)).toMatchObject({ width: 220, height: 200 });
|
||||
});
|
||||
|
||||
test('should remain stable while dialog text changes continuously', async () => {
|
||||
contentWidth = 300;
|
||||
viewportWidth = 300;
|
||||
innerWidth = 315;
|
||||
expect(await mockDialogApi.getContentSize(true)).toMatchObject({ width: 315, height: 200 });
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
document.getElementById('joplin-plugin-content').textContent = `Update ${i}`;
|
||||
contentHeight++;
|
||||
MockResizeObserver.instances[0].notify();
|
||||
expect(await mockDialogApi.getContentSize(true)).toMatchObject({ width: 315, height: 201 + i });
|
||||
}
|
||||
});
|
||||
|
||||
test('should only observe while watching for size changes', async () => {
|
||||
await mockDialogApi.getContentSize(false);
|
||||
expect(MockResizeObserver.instances).toHaveLength(0);
|
||||
|
||||
await mockDialogApi.getContentSize(true);
|
||||
expect(MockResizeObserver.instances[0].observe).toHaveBeenCalledWith(
|
||||
document.getElementById('joplin-plugin-content'),
|
||||
{ box: 'border-box' },
|
||||
);
|
||||
|
||||
await mockDialogApi.getContentSize(false);
|
||||
expect(MockResizeObserver.instances[0].disconnect).toHaveBeenCalledTimes(1);
|
||||
|
||||
await mockDialogApi.getContentSize(true);
|
||||
window.dispatchEvent(new Event('pagehide'));
|
||||
expect(MockResizeObserver.instances[1].disconnect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,42 @@ let themeCssElement: HTMLStyleElement|null = null;
|
||||
|
||||
const initializeDialogWebView = (messageChannelId: string) => {
|
||||
const loadedPaths: Set<string> = new Set();
|
||||
let contentResizeObserver: ResizeObserver|null = null;
|
||||
let observedElement: HTMLElement|null = null;
|
||||
let observedContentSize: { width: number; height: number }|null = null;
|
||||
let pageHideListenerRegistered = false;
|
||||
|
||||
const measureContent = (element: HTMLElement) => {
|
||||
const viewportWidth = document.documentElement.clientWidth;
|
||||
const scrollbarWidth = Math.max(0, window.innerWidth - viewportWidth);
|
||||
// Include the scrollbar gutter when the content fills the viewport so feeding this
|
||||
// measurement back into the iframe does not repeatedly shrink it.
|
||||
const width = element.clientWidth === viewportWidth ? element.clientWidth + scrollbarWidth : element.clientWidth;
|
||||
return { width, height: element.clientHeight };
|
||||
};
|
||||
const stopObservingContentSize = () => {
|
||||
contentResizeObserver?.disconnect();
|
||||
contentResizeObserver = null;
|
||||
observedElement = null;
|
||||
observedContentSize = null;
|
||||
if (pageHideListenerRegistered) {
|
||||
window.removeEventListener('pagehide', stopObservingContentSize);
|
||||
pageHideListenerRegistered = false;
|
||||
}
|
||||
};
|
||||
const observeContentSize = (element: HTMLElement) => {
|
||||
if (observedElement === element && contentResizeObserver) return;
|
||||
|
||||
stopObservingContentSize();
|
||||
observedElement = element;
|
||||
observedContentSize = measureContent(element);
|
||||
contentResizeObserver = new ResizeObserver(() => {
|
||||
if (observedElement === element) observedContentSize = measureContent(element);
|
||||
});
|
||||
contentResizeObserver.observe(element, { box: 'border-box' });
|
||||
window.addEventListener('pagehide', stopObservingContentSize);
|
||||
pageHideListenerRegistered = true;
|
||||
};
|
||||
|
||||
type ScriptType = 'js'|'css';
|
||||
const includeScriptsOrStyles = (type: ScriptType, paths: string[]) => {
|
||||
@@ -73,15 +109,21 @@ const initializeDialogWebView = (messageChannelId: string) => {
|
||||
document.body.appendChild(styleElement);
|
||||
themeCssElement = styleElement;
|
||||
},
|
||||
getContentSize: async () => {
|
||||
getContentSize: async (watchForSizeChanges = false) => {
|
||||
// To convert to React Native pixel units from browser pixel units,
|
||||
// we need to multiply by the devicePixelRatio:
|
||||
const dpr = window.devicePixelRatio ?? 1;
|
||||
|
||||
const element = document.getElementById('joplin-plugin-content') ?? document.body;
|
||||
if (watchForSizeChanges) {
|
||||
observeContentSize(element);
|
||||
} else {
|
||||
stopObservingContentSize();
|
||||
}
|
||||
const contentSize = observedContentSize ?? measureContent(element);
|
||||
return {
|
||||
width: element.clientWidth * dpr,
|
||||
height: element.clientHeight * dpr,
|
||||
width: contentSize.width * dpr,
|
||||
height: contentSize.height * dpr,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -57,8 +57,8 @@ const PluginDialogManager: React.FC<Props> = props => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<PluginPanelViewer inert={dialogs.length > 0}/>
|
||||
{dialogs}
|
||||
<PluginPanelViewer/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { PluginHtmlContents, PluginStates, ViewInfo } from '@joplin/lib/services/plugins/reducer';
|
||||
import { StyleSheet, View, useWindowDimensions } from 'react-native';
|
||||
import { Dimensions, StyleSheet, View, useWindowDimensions } from 'react-native';
|
||||
import usePlugin from '@joplin/lib/hooks/plugins/usePlugin';
|
||||
import { DialogContentSize, DialogWebViewApi } from '../types';
|
||||
import { Button } from 'react-native-paper';
|
||||
@@ -12,6 +12,8 @@ import WebviewController, { ContainerType } from '@joplin/lib/services/plugins/W
|
||||
import { Theme } from '@joplin/lib/themes/type';
|
||||
import useDialogSize from './hooks/useDialogSize';
|
||||
import PluginUserWebView from './PluginUserWebView';
|
||||
import useKeyboardState from '../../../utils/hooks/useKeyboardState';
|
||||
import useSafeAreaPadding from '../../../utils/hooks/useSafeAreaPadding';
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
@@ -26,6 +28,8 @@ const useStyles = (
|
||||
fitToContent: boolean,
|
||||
) => {
|
||||
const windowSize = useWindowDimensions();
|
||||
const keyboardState = useKeyboardState();
|
||||
const safeAreaPadding = useSafeAreaPadding();
|
||||
|
||||
return useMemo(() => {
|
||||
const theme: Theme = themeStyle(themeId);
|
||||
@@ -33,8 +37,28 @@ const useStyles = (
|
||||
const useDialogSize = fitToContent && dialogContentSize;
|
||||
const dialogHasLoaded = !!dialogContentSize;
|
||||
|
||||
const maxWidth = windowSize.width * 0.97;
|
||||
const maxHeight = windowSize.height * 0.95;
|
||||
const availableWidth = Math.max(
|
||||
0,
|
||||
windowSize.width - safeAreaPadding.paddingLeft - safeAreaPadding.paddingRight,
|
||||
);
|
||||
const maxWidth = availableWidth * 0.97;
|
||||
// Opening the keyboard may resize the window fully, partially, or not at all.
|
||||
// Use the smaller height so the dialog stays above any remaining keyboard
|
||||
// overlap without deducting the keyboard twice.
|
||||
const screenHeight = Dimensions.get('screen').height;
|
||||
// Convert the screen-relative keyboard position to window coordinates. Cap the
|
||||
// offset because the screen/window height difference may also include an IME resize.
|
||||
const screenToWindowOffset = Math.min(
|
||||
safeAreaPadding.paddingBottom,
|
||||
Math.max(0, screenHeight - windowSize.height),
|
||||
);
|
||||
const keyboardTop = screenHeight - screenToWindowOffset - keyboardState.dockedKeyboardHeight;
|
||||
const bottomPadding = safeAreaPadding.paddingBottom;
|
||||
const availableHeight = Math.max(
|
||||
0,
|
||||
Math.min(windowSize.height, keyboardTop) - safeAreaPadding.paddingTop - bottomPadding,
|
||||
);
|
||||
const maxHeight = availableHeight * 0.97;
|
||||
const dialogWidth = useDialogSize ? dialogContentSize.width : maxWidth;
|
||||
const dialogHeight = useDialogSize ? dialogContentSize.height : maxHeight;
|
||||
|
||||
@@ -71,7 +95,7 @@ const useStyles = (
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
});
|
||||
}, [themeId, dialogContentSize, fitToContent, windowSize.width, windowSize.height]);
|
||||
}, [themeId, dialogContentSize, fitToContent, windowSize.width, windowSize.height, keyboardState.dockedKeyboardHeight, safeAreaPadding]);
|
||||
};
|
||||
|
||||
const defaultButtonSpecs: ButtonSpec[] = [
|
||||
|
||||
@@ -21,6 +21,7 @@ interface Props {
|
||||
pluginHtmlContents: PluginHtmlContents;
|
||||
pluginStates: PluginStates;
|
||||
visible: boolean;
|
||||
inert?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -166,6 +167,8 @@ const PluginPanelViewer: React.FC<Props> = props => {
|
||||
visible={props.visible}
|
||||
size={DialogVariant.Large}
|
||||
onDismiss={onClose}
|
||||
useNativeModal={false}
|
||||
inert={props.inert}
|
||||
>
|
||||
{renderTabContent()}
|
||||
{renderTabSelector()}
|
||||
|
||||
@@ -22,7 +22,7 @@ const useDialogSize = (props: Props) => {
|
||||
}
|
||||
|
||||
while (!event.cancelled) {
|
||||
const contentSize = await dialogControl.getContentSize();
|
||||
const contentSize = await dialogControl.getContentSize(props.watchForSizeChanges);
|
||||
if (event.cancelled) return;
|
||||
|
||||
const lastSize = lastSizeRef.current;
|
||||
@@ -44,7 +44,7 @@ const useDialogSize = (props: Props) => {
|
||||
}, 500);
|
||||
});
|
||||
}
|
||||
}, [dialogControl, setDialogSize, webViewLoadCount]);
|
||||
}, [dialogControl, setDialogSize, webViewLoadCount, props.watchForSizeChanges]);
|
||||
|
||||
return dialogSize;
|
||||
};
|
||||
|
||||
@@ -61,5 +61,5 @@ export interface DialogWebViewApi {
|
||||
|
||||
setThemeCss: (css: string)=> Promise<void>;
|
||||
getFormData: ()=> Promise<SerializableData>;
|
||||
getContentSize: ()=> Promise<DialogContentSize>;
|
||||
getContentSize: (watchForSizeChanges?: boolean)=> Promise<DialogContentSize>;
|
||||
}
|
||||
@@ -99,6 +99,7 @@ import { setDispatch } from './services/profiles';
|
||||
import { ReactNode } from 'react';
|
||||
import autodetectTheme, { onSystemColorSchemeChange } from './utils/autodetectTheme';
|
||||
import PluginRunnerWebView from './components/plugins/PluginRunnerWebView';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import { refreshFolders, scheduleRefreshFolders } from '@joplin/lib/folders-screen-utils';
|
||||
import ShareManager from './components/screens/ShareManager';
|
||||
import { setDateFormat, setTimeFormat, setTimeLocale } from '@joplin/utils/time';
|
||||
@@ -310,6 +311,7 @@ interface AppComponentProps {
|
||||
disableSideMenuGestures: boolean;
|
||||
historyCanGoBack: boolean;
|
||||
showSideMenu: boolean;
|
||||
showPanelsDialog: boolean;
|
||||
noteSelectionEnabled: boolean;
|
||||
syncStarted: boolean;
|
||||
}
|
||||
@@ -599,6 +601,13 @@ class AppComponent extends React.Component<AppComponentProps, AppComponentState>
|
||||
}
|
||||
|
||||
private async backButtonHandler() {
|
||||
// Plugin dialogs use Paper's Modal, whose later hardwareBackPress listener runs first
|
||||
// (React Native dispatches newest-first), so an open dialog consumes Back before this branch.
|
||||
if (this.props.showPanelsDialog) {
|
||||
await CommandService.instance().execute('dismissPluginPanels');
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.props.noteSelectionEnabled) {
|
||||
this.props.dispatch({ type: 'NOTE_SELECTION_END' });
|
||||
return true;
|
||||
@@ -890,6 +899,7 @@ const mapStateToProps = (state: AppState) => {
|
||||
return {
|
||||
historyCanGoBack: state.historyCanGoBack,
|
||||
showSideMenu: state.showSideMenu,
|
||||
showPanelsDialog: state.showPanelsDialog,
|
||||
syncStarted: state.syncStarted,
|
||||
appState: state.appState,
|
||||
noteSelectionEnabled: state.noteSelectionEnabled,
|
||||
|
||||
Reference in new issue
Block a user