Mobile: Fix issues with modals created via plugins (#16674)

Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
This commit is contained in:
mrjo118andLaurent Cozic authored and GitHub committed 2026-10-11 16:18:55 +01:00
1 parent 21d0a8ad71
commit c87f694dc7
13 files changed
+235 -25

No files matched your search

+1
View File
@@ -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
+1
View File
@@ -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,
+17 -11
View File
@@ -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>
);
@@ -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>;
}
+10
View File
@@ -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,