import * as React from 'react'; import { useRef, useImperativeHandle, forwardRef, useEffect, useMemo, useContext, useCallback } from 'react'; import useViewIsReady from './hooks/useViewIsReady'; import useThemeCss from './hooks/useThemeCss'; import useContentSize from './hooks/useContentSize'; import useHtmlLoader from './hooks/useHtmlLoader'; import useWebviewToPluginMessages from './hooks/useWebviewToPluginMessages'; import useScriptLoader from './hooks/useScriptLoader'; import Logger from '@joplin/utils/Logger'; import { focus } from '@joplin/lib/utils/focusHandler'; import { WindowIdContext } from '../../gui/NewWindowOrIFrame'; import useSubmitHandler from './hooks/useSubmitHandler'; import useFormData from './hooks/useFormData'; import Setting from '@joplin/lib/models/Setting'; import getAssetPath from '../../utils/getAssetPath'; import { toForwardSlashes } from '@joplin/utils/path'; const logger = Logger.create('UserWebview'); export interface Props { html: string; scripts: string[]; pluginId: string; viewId: string; themeId: number; minWidth?: number; minHeight?: number; fitToContent?: boolean; borderBottom?: boolean; theme?: Record; onSubmit?: ()=> void; onDismiss?: ()=> void; onReady?: ()=> void; } export interface UserWebviewRef { formData: ()=> unknown; focus: ()=> void; } function UserWebview(props: Props, ref: React.Ref) { const minWidth = props.minWidth ? props.minWidth : 200; const minHeight = props.minHeight ? props.minHeight : 20; const viewRef = useRef(null); const isReady = useViewIsReady(viewRef); const cssFilePath = useThemeCss({ pluginId: props.pluginId, themeId: props.themeId }); useEffect(() => { if (isReady && props.onReady) props.onReady(); // eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied }, [isReady]); function frameWindow() { if (!viewRef.current) return null; return viewRef.current.contentWindow; } const postMessage = useCallback((name: string, args: unknown = null) => { const win = frameWindow(); if (!win) return; logger.debug('Got message', name, args); win.postMessage({ target: 'webview', name, args }, '*'); }, []); const { getFormData } = useFormData(viewRef, postMessage); useImperativeHandle(ref, () => { return { formData: function() { if (viewRef.current) { return getFormData(); } else { return null; } }, focus: function() { if (viewRef.current) focus('UserWebView::focus', viewRef.current); }, }; }, [getFormData]); const htmlHash = useHtmlLoader( viewRef, isReady, postMessage, props.html, ); const contentSize = useContentSize( viewRef, htmlHash, minWidth, minHeight, ); useSubmitHandler( viewRef, props.onSubmit, props.onDismiss, ); const windowId = useContext(WindowIdContext); useWebviewToPluginMessages( viewRef, isReady, props.pluginId, props.viewId, windowId, postMessage, ); useScriptLoader( postMessage, isReady, props.scripts, cssFilePath, ); const style = useMemo(() => ({ '--content-width': `${contentSize.width}px`, '--content-height': `${contentSize.height}px`, } as React.CSSProperties), [contentSize.width, contentSize.height]); const src = useMemo(() => { let isolate = Setting.value('featureFlag.plugins.isolatePluginWebViews'); isolate ||= needsIsolation(props.pluginId); const path = toForwardSlashes(getAssetPath('services/plugins/UserWebviewIndex.html')); if (isolate) { return `joplin-content://plugin-webview/${path}`; } else { return `file://${path}`; } }, [props.pluginId]); return ; } export default forwardRef(UserWebview); const needsIsolation = (pluginId: string) => { // Some plugins are broken unless isolated from the main application. // Always enable isolation for these plugins, even if disabled in settings: return ['joplin.plugin.note.tabs', 'joplin.plugin.benji.favorites', 'outline'].includes(pluginId); };