1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-07-16 00:14:34 +02:00

Chore: Refactor mobile plugin logic into locations more consistent with other parts of the app (#10636)

This commit is contained in:
Henry Heino
2024-06-25 05:59:59 -07:00
committed by GitHub
parent 801d36c41f
commit c7116b135f
34 changed files with 155 additions and 91 deletions

View File

@ -0,0 +1,62 @@
import * as React from 'react';
import { ReactElement } from 'react';
import { PluginHtmlContents, PluginStates, ViewInfo } from '@joplin/lib/services/plugins/reducer';
import PluginDialogWebView from './PluginDialogWebView';
import { Modal, Portal } from 'react-native-paper';
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';
interface Props {
themeId: number;
pluginHtmlContents: PluginHtmlContents;
pluginStates: PluginStates;
}
const dismissDialog = (viewInfo: ViewInfo) => {
if (!viewInfo.view.opened) return;
const plugin = PluginService.instance().pluginById(viewInfo.plugin.id);
const viewController = plugin.viewController(viewInfo.view.id) as WebviewController;
viewController.closeWithResponse(null);
};
const PluginDialogManager: React.FC<Props> = props => {
const viewInfos = useViewInfos(props.pluginStates);
const dialogs: ReactElement[] = [];
for (const viewInfo of viewInfos) {
if (viewInfo.view.containerType === ContainerType.Panel || !viewInfo.view.opened) {
continue;
}
dialogs.push(
<Portal
key={`${viewInfo.plugin.id}-${viewInfo.view.id}`}
>
<Modal
visible={true}
onDismiss={() => dismissDialog(viewInfo)}
>
<PluginDialogWebView
viewInfo={viewInfo}
themeId={props.themeId}
pluginStates={props.pluginStates}
pluginHtmlContents={props.pluginHtmlContents}
/>
</Modal>
</Portal>,
);
}
return (
<>
{dialogs}
<PluginPanelViewer/>
</>
);
};
export default PluginDialogManager;

View File

@ -0,0 +1,166 @@
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 usePlugin from '@joplin/lib/hooks/usePlugin';
import { DialogContentSize, DialogWebViewApi } from '../types';
import { Button } from 'react-native-paper';
import { themeStyle } from '@joplin/lib/theme';
import { ButtonSpec, DialogResult } from '@joplin/lib/services/plugins/api/types';
import { _ } from '@joplin/lib/locale';
import WebviewController, { ContainerType } from '@joplin/lib/services/plugins/WebviewController';
import { Theme } from '@joplin/lib/themes/type';
import useDialogSize from './hooks/useDialogSize';
import PluginUserWebView from './PluginUserWebView';
interface Props {
themeId: number;
pluginHtmlContents: PluginHtmlContents;
pluginStates: PluginStates;
viewInfo: ViewInfo;
}
const useStyles = (
themeId: number,
dialogContentSize: DialogContentSize|null,
fitToContent: boolean,
) => {
const windowSize = useWindowDimensions();
return useMemo(() => {
const theme: Theme = themeStyle(themeId);
const useDialogSize = fitToContent && dialogContentSize;
const dialogHasLoaded = !!dialogContentSize;
const maxWidth = windowSize.width * 0.97;
const maxHeight = windowSize.height * 0.95;
const dialogWidth = useDialogSize ? dialogContentSize.width : maxWidth;
const dialogHeight = useDialogSize ? dialogContentSize.height : maxHeight;
return StyleSheet.create({
webView: {
backgroundColor: 'transparent',
display: 'flex',
},
webViewContainer: {
flexGrow: 1,
flexShrink: 1,
maxWidth,
maxHeight,
width: dialogWidth,
height: dialogHeight,
},
dialog: {
backgroundColor: theme.backgroundColor,
borderRadius: 12,
maxWidth,
maxHeight,
opacity: dialogHasLoaded ? 1 : 0.1,
// Center
marginLeft: 'auto',
marginRight: 'auto',
},
buttonRow: {
flexDirection: 'row',
flexShrink: 0,
padding: 12,
justifyContent: 'flex-end',
},
});
}, [themeId, dialogContentSize, fitToContent, windowSize.width, windowSize.height]);
};
const defaultButtonSpecs: ButtonSpec[] = [
{ id: 'ok' }, { id: 'cancel' },
];
const PluginDialogWebView: React.FC<Props> = props => {
const viewInfo = props.viewInfo;
const view = viewInfo.view;
const pluginId = viewInfo.plugin.id;
const viewId = view.id;
const plugin = usePlugin(pluginId);
const [webViewLoadCount, setWebViewLoadCount] = useState(0);
const [dialogControl, setDialogControl] = useState<DialogWebViewApi|null>(null);
const dialogSize = useDialogSize({
dialogControl,
webViewLoadCount,
watchForSizeChanges: view.fitToContent,
});
const styles = useStyles(props.themeId, dialogSize, view.fitToContent);
const onButtonPress = useCallback(async (button: ButtonSpec) => {
const closeWithResponse = (response?: DialogResult|null) => {
const viewController = plugin.viewController(viewId) as WebviewController;
if (view.containerType === ContainerType.Dialog) {
viewController.closeWithResponse(response);
} else {
viewController.close();
}
};
let formData = undefined;
if (button.id !== 'cancel') {
formData = await dialogControl.getFormData();
}
closeWithResponse({ id: button.id, formData });
button.onClick?.();
}, [dialogControl, plugin, viewId, view.containerType]);
const buttonComponents: React.ReactElement[] = [];
const buttonSpecs = view.buttons ?? defaultButtonSpecs;
for (const button of buttonSpecs) {
let iconName = undefined;
let buttonTitle = button.title ?? button.id;
if (button.id === 'cancel') {
iconName = 'close-outline';
buttonTitle = button.title ?? _('Cancel');
} else if (button.id === 'ok') {
iconName = 'check';
buttonTitle = button.title ?? _('OK');
}
buttonComponents.push(
<Button
key={button.id}
icon={iconName}
mode='text'
onPress={() => onButtonPress(button)}
>
{buttonTitle}
</Button>,
);
}
const onWebViewLoaded = useCallback(() => {
setWebViewLoadCount(webViewLoadCount + 1);
}, [setWebViewLoadCount, webViewLoadCount]);
return (
<View style={styles.dialog}>
<View style={styles.webViewContainer}>
<PluginUserWebView
style={styles.webView}
themeId={props.themeId}
viewInfo={props.viewInfo}
pluginHtmlContents={props.pluginHtmlContents}
onLoadEnd={onWebViewLoaded}
setDialogControl={setDialogControl}
/>
</View>
<View style={styles.buttonRow}>
{buttonComponents}
</View>
</View>
);
};
export default PluginDialogWebView;

View File

@ -0,0 +1,188 @@
import { PluginHtmlContents, PluginStates, ViewInfo } from '@joplin/lib/services/plugins/reducer';
import * as React from 'react';
import { Button, Portal, SegmentedButtons, Text } from 'react-native-paper';
import useViewInfos from './hooks/useViewInfos';
import { ContainerType } from '@joplin/lib/services/plugins/WebviewController';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import PluginService from '@joplin/lib/services/plugins/PluginService';
import { connect } from 'react-redux';
import { AppState } from '../../../utils/types';
import PluginUserWebView from './PluginUserWebView';
import { View, StyleSheet, AccessibilityInfo } from 'react-native';
import { _ } from '@joplin/lib/locale';
import Setting from '@joplin/lib/models/Setting';
import { Dispatch } from 'redux';
import DismissibleDialog, { DialogSize } from '../../../components/DismissibleDialog';
interface Props {
themeId: number;
pluginHtmlContents: PluginHtmlContents;
pluginStates: PluginStates;
visible: boolean;
dispatch: Dispatch;
}
const styles = StyleSheet.create({
webView: {
backgroundColor: 'transparent',
display: 'flex',
},
webViewContainer: {
flexGrow: 1,
flexShrink: 1,
},
});
type ButtonInfo = {
value: string;
label: string;
icon: string;
};
const useSelectedTabId = (
buttonInfos: ButtonInfo[],
viewInfoById: Record<string, ViewInfo>,
) => {
const viewInfoByIdRef = useRef(viewInfoById);
viewInfoByIdRef.current = viewInfoById;
const getDefaultSelectedTabId = useCallback((): string|undefined => {
const lastSelectedId = Setting.value('ui.lastSelectedPluginPanel');
const lastSelectedInfo = viewInfoByIdRef.current[lastSelectedId];
if (lastSelectedId && lastSelectedInfo && lastSelectedInfo.view.opened) {
return lastSelectedId;
} else {
return buttonInfos[0]?.value;
}
}, [buttonInfos]);
const [selectedTabId, setSelectedTabId] = useState<string|undefined>(getDefaultSelectedTabId);
useEffect(() => {
if (!selectedTabId || !viewInfoById[selectedTabId]?.view?.opened) {
setSelectedTabId(getDefaultSelectedTabId());
}
}, [selectedTabId, viewInfoById, getDefaultSelectedTabId]);
useEffect(() => {
if (!selectedTabId) return;
const info = viewInfoByIdRef.current[selectedTabId];
Setting.setValue('ui.lastSelectedPluginPanel', selectedTabId);
AccessibilityInfo.announceForAccessibility(_('%s tab opened', getTabLabel(info)));
}, [selectedTabId]);
return { setSelectedTabId, selectedTabId };
};
const emptyCallback = () => {};
const getTabLabel = (info: ViewInfo) => {
// Handles the case where a plugin just unloaded or hasn't loaded yet.
if (!info) {
return '...';
}
return PluginService.instance().pluginById(info.plugin.id).manifest.name;
};
const PluginPanelViewer: React.FC<Props> = props => {
const viewInfos = useViewInfos(props.pluginStates);
const viewInfoById = useMemo(() => {
const result: Record<string, ViewInfo> = {};
for (const info of viewInfos) {
result[info.view.id] = info;
}
return result;
}, [viewInfos]);
const buttonInfos = useMemo(() => {
return Object.entries(viewInfoById)
.filter(([_id, info]) => info.view.containerType === ContainerType.Panel)
.filter(([_id, info]) => info.view.opened)
.map(([id, info]) => {
return {
value: id,
label: getTabLabel(info),
icon: 'puzzle',
};
});
}, [viewInfoById]);
const { selectedTabId, setSelectedTabId } = useSelectedTabId(buttonInfos, viewInfoById);
const viewInfo = viewInfoById[selectedTabId];
const renderTabContent = () => {
if (!viewInfo) {
return <Text>{_('No tab selected')}</Text>;
}
return (
<View style={styles.webViewContainer}>
<PluginUserWebView
key={selectedTabId}
themeId={props.themeId}
style={styles.webView}
viewInfo={viewInfo}
pluginHtmlContents={props.pluginHtmlContents}
onLoadEnd={emptyCallback}
setDialogControl={emptyCallback}
/>
</View>
);
};
const renderTabSelector = () => {
// SegmentedButtons doesn't display correctly when there's only one button.
// As such, we include a special case:
if (buttonInfos.length === 1) {
const buttonInfo = buttonInfos[0];
return (
<Button icon={buttonInfo.icon} onPress={()=>setSelectedTabId(buttonInfo.value)}>
{buttonInfo.label}
</Button>
);
}
return (
<SegmentedButtons
value={selectedTabId}
onValueChange={setSelectedTabId}
buttons={buttonInfos}
/>
);
};
const onClose = useCallback(() => {
props.dispatch({
type: 'SET_PLUGIN_PANELS_DIALOG_VISIBLE',
visible: false,
});
}, [props.dispatch]);
return (
<Portal>
<DismissibleDialog
themeId={props.themeId}
visible={props.visible}
size={DialogSize.Large}
onDismiss={onClose}
>
{renderTabContent()}
{renderTabSelector()}
</DismissibleDialog>
</Portal>
);
};
export default connect((state: AppState) => {
return {
themeId: state.settings.theme,
pluginHtmlContents: state.pluginService.pluginHtmlContents,
visible: state.showPanelsDialog,
pluginStates: state.pluginService.plugins,
};
})(PluginPanelViewer);

View File

@ -0,0 +1,132 @@
import * as React from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { PluginHtmlContents, ViewInfo } from '@joplin/lib/services/plugins/reducer';
import ExtendedWebView, { WebViewControl } from '../../../components/ExtendedWebView';
import { ViewStyle } from 'react-native';
import usePlugin from '@joplin/lib/hooks/usePlugin';
import shim from '@joplin/lib/shim';
import useDialogMessenger from './hooks/useDialogMessenger';
import useWebViewSetup from './hooks/useWebViewSetup';
import { DialogWebViewApi } from '../types';
interface Props {
themeId: number;
pluginHtmlContents: PluginHtmlContents;
viewInfo: ViewInfo;
style: ViewStyle;
onLoadEnd: ()=> void;
setDialogControl: (dialogControl: DialogWebViewApi)=> void;
}
const PluginUserWebView = (props: Props) => {
const viewInfo = props.viewInfo;
const view = viewInfo.view;
const pluginId = viewInfo.plugin.id;
const viewId = view.id;
const plugin = usePlugin(pluginId);
const [webViewLoadCount, setWebViewLoadCount] = useState(0);
const webviewRef = useRef<WebViewControl>(null);
const messageChannelId = `dialog-messenger-${pluginId}-${viewId}`;
const messenger = useDialogMessenger({
pluginId,
viewId,
webviewRef,
messageChannelId,
});
useEffect(() => {
// Because of how messenger.remoteApi handles message forwarding (property names
// are not known), we need to send methods individually and can't use an object
// spread or send messenger.remoteApi.
props.setDialogControl({
includeCssFiles: messenger.remoteApi.includeCssFiles,
includeJsFiles: messenger.remoteApi.includeJsFiles,
setThemeCss: messenger.remoteApi.setThemeCss,
getFormData: messenger.remoteApi.getFormData,
getContentSize: messenger.remoteApi.getContentSize,
});
}, [messenger, props.setDialogControl]);
useWebViewSetup({
themeId: props.themeId,
dialogControl: messenger.remoteApi,
scriptPaths: view.scripts ?? [],
pluginBaseDir: plugin.baseDir,
webViewLoadCount,
});
const htmlContent = props.pluginHtmlContents[pluginId]?.[viewId] ?? '';
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>
<title>Plugin Dialog</title>
<style>
body {
box-sizing: border-box;
padding: 0;
margin: 0;
color: var(--joplin-color);
/* -apple-system-body allows for correct font scaling on iOS devices */
font: -apple-system-body;
font-family: var(--joplin-font-family, sans-serif);
}
/* We need "display: flex" in order to accurately get the content size */
/* including margin and padding of children */
#joplin-plugin-content {
display: flex;
flex-direction: column;
padding: 10px;
box-sizing: border-box;
}
</style>
</head>
<body>
<div id="joplin-plugin-content">
${htmlContent}
</div>
</body>
</html>
`;
const injectedJs = useMemo(() => {
return `
if (!window.backgroundPageLoaded) {
${shim.injectedJs('pluginBackgroundPage')}
pluginBackgroundPage.initializeDialogWebView(
${JSON.stringify(messageChannelId)}
);
window.backgroundPageLoaded = true;
}
`;
}, [messageChannelId]);
const onWebViewLoaded = useCallback(() => {
setWebViewLoadCount(webViewLoadCount + 1);
props.onLoadEnd();
messenger.onWebViewLoaded();
}, [messenger, setWebViewLoadCount, webViewLoadCount, props.onLoadEnd]);
return (
<ExtendedWebView
style={props.style}
baseDirectory={plugin.baseDir}
webviewInstanceId='joplin__PluginDialogWebView'
html={html}
hasPluginScripts={true}
injectedJavaScript={injectedJs}
onMessage={messenger.onWebViewMessage}
onLoadEnd={onWebViewLoaded}
ref={webviewRef}
/>
);
};
export default PluginUserWebView;

View File

@ -0,0 +1,53 @@
import { useMemo, RefObject } from 'react';
import { DialogMainProcessApi, DialogWebViewApi } from '../../types';
import Logger from '@joplin/utils/Logger';
import { WebViewControl } from '../../../../components/ExtendedWebView';
import createOnLogHander from '../../utils/createOnLogHandler';
import RNToWebViewMessenger from '../../../../utils/ipc/RNToWebViewMessenger';
import { SerializableData } from '@joplin/lib/utils/ipc/types';
import PostMessageService, { ResponderComponentType } from '@joplin/lib/services/PostMessageService';
import PluginService from '@joplin/lib/services/plugins/PluginService';
interface Props {
pluginId: string;
viewId: string;
webviewRef: RefObject<WebViewControl>;
messageChannelId: string;
}
const useDialogMessenger = (props: Props) => {
const { pluginId, webviewRef, viewId, messageChannelId } = props;
return useMemo(() => {
const plugin = PluginService.instance().pluginById(pluginId);
const logger = Logger.create(`PluginDialogWebView(${pluginId})`);
const dialogApi: DialogMainProcessApi = {
postMessage: async (message: SerializableData) => {
return await plugin.viewController(viewId).emitMessage({ message });
},
onMessage: async (callback) => {
PostMessageService.instance().registerViewMessageHandler(
ResponderComponentType.UserWebview,
viewId,
(message: SerializableData) => {
// For compatibility with desktop, the message needs to be wrapped in
// an object.
return callback({ message });
},
);
},
onError: async (error: string) => {
logger.error(`Unhandled error: ${error}`);
plugin.hasErrors = true;
},
onLog: createOnLogHander(plugin, logger),
};
return new RNToWebViewMessenger<DialogMainProcessApi, DialogWebViewApi>(
messageChannelId, webviewRef, dialogApi,
);
}, [webviewRef, pluginId, viewId, messageChannelId]);
};
export default useDialogMessenger;

View File

@ -0,0 +1,52 @@
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
import { useRef, useState } from 'react';
import { DialogContentSize, DialogWebViewApi } from '../../types';
import { PixelRatio } from 'react-native';
interface Props {
dialogControl: DialogWebViewApi;
webViewLoadCount: number;
watchForSizeChanges: boolean;
}
const useDialogSize = (props: Props) => {
const { dialogControl, webViewLoadCount } = props;
const [dialogSize, setDialogSize] = useState<DialogContentSize|null>(null);
const lastSizeRef = useRef(dialogSize);
lastSizeRef.current = dialogSize;
useAsyncEffect(async event => {
if (!dialogControl) {
// May happen if the webview is still loading.
return;
}
while (!event.cancelled) {
const contentSize = await dialogControl.getContentSize();
if (event.cancelled) return;
const lastSize = lastSizeRef.current;
if (contentSize.width !== lastSize?.width || contentSize.height !== lastSize?.height) {
// We use 1000 here because getPixelSizeForLayoutSize is guaranteed to return
// an integer.
const pixelToDpRatio = 1000 / PixelRatio.getPixelSizeForLayoutSize(1000);
setDialogSize({
width: contentSize.width * pixelToDpRatio,
height: contentSize.height * pixelToDpRatio,
});
}
if (!props.watchForSizeChanges) return;
await new Promise<void>(resolve => {
setTimeout(() => {
resolve();
}, 500);
});
}
}, [dialogControl, setDialogSize, webViewLoadCount]);
return dialogSize;
};
export default useDialogSize;

View File

@ -0,0 +1,10 @@
import { PluginStates, utils as pluginUtils } from '@joplin/lib/services/plugins/reducer';
import { useMemo } from 'react';
const useViewInfos = (pluginStates: PluginStates) => {
return useMemo(() => {
return pluginUtils.viewInfosByType(pluginStates, 'webview');
}, [pluginStates]);
};
export default useViewInfos;

View File

@ -0,0 +1,43 @@
import { useEffect } from 'react';
import { DialogWebViewApi } from '../../types';
import shim from '@joplin/lib/shim';
import { themeStyle } from '../../../../components/global-style';
import themeToCss from '@joplin/lib/services/style/themeToCss';
interface Props {
themeId: number;
scriptPaths: string[];
dialogControl: DialogWebViewApi;
pluginBaseDir: string;
// Whenever the WebView reloads, we need to re-inject CSS and JavaScript.
webViewLoadCount: number;
}
const useWebViewSetup = (props: Props) => {
const { scriptPaths, dialogControl, pluginBaseDir, themeId } = props;
useEffect(() => {
const jsPaths = [];
const cssPaths = [];
for (const rawPath of scriptPaths) {
const resolvedPath = shim.fsDriver().resolveRelativePathWithinDir(pluginBaseDir, rawPath);
if (resolvedPath.match(/\.css$/i)) {
cssPaths.push(resolvedPath);
} else {
jsPaths.push(resolvedPath);
}
}
void dialogControl.includeCssFiles(cssPaths);
void dialogControl.includeJsFiles(jsPaths);
}, [dialogControl, scriptPaths, props.webViewLoadCount, pluginBaseDir]);
useEffect(() => {
const theme = themeStyle(themeId);
const themeVariableCss = themeToCss(theme);
void dialogControl.setThemeCss(themeVariableCss);
}, [dialogControl, themeId, props.webViewLoadCount]);
};
export default useWebViewSetup;