import PostMessageService, { MessageResponse, ResponderComponentType } from '@joplin/lib/services/PostMessageService'; import * as React from 'react'; import { reg } from '@joplin/lib/registry'; import bridge from '../services/bridge'; import { focus } from '@joplin/lib/utils/focusHandler'; import { ForwardedRef, forwardRef, RefObject, useContext, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; import { WindowIdContext } from './NewWindowOrIFrame'; import useDocument from './hooks/useDocument'; import { _ } from '@joplin/lib/locale'; interface Props { // eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied onDomReady: Function; // eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied onIpcMessage: Function; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied viewerStyle: any; contentMaxWidth?: number; themeId: number; } interface SetHtmlOptions { pluginAssets: { path: string }[]; } export interface NoteViewerControl { domReady(): boolean; setHtml(html: string, options: SetHtmlOptions): void; send(channel: string, arg0?: unknown, arg1?: unknown): void; focus(): void; hasFocus(): boolean; } const usePluginMessageResponder = (webviewRef: RefObject) => { const windowId = useContext(WindowIdContext); useEffect(() => { PostMessageService.instance().registerResponder(ResponderComponentType.NoteTextViewer, '', windowId, (message: MessageResponse) => { if (!webviewRef?.current?.contentWindow) { reg.logger().warn('Cannot respond to message because target is gone', message); return; } webviewRef.current.contentWindow.postMessage({ target: 'webview', name: 'postMessageService.response', data: message, }, '*'); }); return () => { PostMessageService.instance().unregisterResponder(ResponderComponentType.NoteTextViewer, '', windowId); }; }, [webviewRef, windowId]); }; const NoteTextViewer = forwardRef((props: Props, ref: ForwardedRef) => { const [webview, setWebview] = useState(null); const webviewRef = useRef(null); webviewRef.current = webview; usePluginMessageResponder(webviewRef); const domReadyRef = useRef(false); type RemovePluginAssetsCallback = ()=> void; const removePluginAssetsCallbackRef = useRef(null); const parentDoc = useDocument(webview); const containerWindow = parentDoc?.defaultView; useImperativeHandle(ref, () => { const result: NoteViewerControl = { domReady: () => domReadyRef.current, setHtml: (html: string, options: SetHtmlOptions) => { const protocolHandler = bridge().electronApp().getCustomProtocolHandler(); // Grant & remove asset access. if (options.pluginAssets) { removePluginAssetsCallbackRef.current?.(); const pluginAssetPaths: string[] = options.pluginAssets.map((asset) => asset.path); const assetAccesses = pluginAssetPaths.map( path => protocolHandler.allowReadAccessToFile(path), ); removePluginAssetsCallbackRef.current = () => { for (const accessControl of assetAccesses) { accessControl.remove(); } removePluginAssetsCallbackRef.current = null; }; } result.send('setHtml', html, { ...options, mediaAccessKey: protocolHandler.getMediaAccessKey(), }); }, send: (channel: string, arg0: unknown = null, arg1: unknown = null) => { const win = webviewRef.current?.contentWindow; // Window may already be closed if (!win) return; if (channel === 'focus') { win.postMessage({ target: 'webview', name: 'focus', data: {} }, '*'); } // External code should use .setHtml (rather than send('setHtml', ...)) if (channel === 'setHtml') { win.postMessage({ target: 'webview', name: 'setHtml', data: { html: arg0, options: arg1 } }, '*'); } if (channel === 'scrollToHash') { win.postMessage({ target: 'webview', name: 'scrollToHash', data: { hash: arg0 } }, '*'); } if (channel === 'setPercentScroll') { win.postMessage({ target: 'webview', name: 'setPercentScroll', data: { percent: arg0 } }, '*'); } if (channel === 'setMarkers') { win.postMessage({ target: 'webview', name: 'setMarkers', data: { keywords: arg0, options: arg1 } }, '*'); } }, focus: () => { if (webviewRef.current) { // Calling focus on webviewRef seems to be necessary when NoteTextViewer.focus // is called outside of a user event (e.g. in a setTimeout) or during automated // tests: focus('NoteTextViewer::focus', webviewRef.current); // Calling .focus on this.webviewRef.current isn't sufficient. // To allow arrow-key scrolling, focus must also be set within the iframe: result.send('focus'); } }, hasFocus: () => { return webviewRef.current?.contains(parentDoc.activeElement); }, }; return result; }, [parentDoc]); const webview_domReadyRef = useRef(); webview_domReadyRef.current = (event: Event) => { domReadyRef.current = true; if (props.onDomReady) props.onDomReady(event); }; const webview_ipcMessageRef = useRef(); webview_ipcMessageRef.current = (event: Event) => { if (props.onIpcMessage) props.onIpcMessage(event); }; const webview_loadRef = useRef(); webview_loadRef.current = (event: Event) => { webview_domReadyRef.current(event); }; type MessageEventListener = (event: MessageEvent)=> void; const webview_messageRef = useRef(); webview_messageRef.current = (event: MessageEvent) => { if (event.source !== webviewRef.current?.contentWindow) return; if (!event.data || event.data.target !== 'main') return; const callName = event.data.name; const args = event.data.args; if (props.onIpcMessage) { props.onIpcMessage({ channel: callName, args: args, }); } }; useEffect(() => { const wv = webviewRef.current; if (!wv || !containerWindow) return () => {}; const webviewListeners: Record = { 'dom-ready': (event) => webview_domReadyRef.current(event), 'ipc-message': (event) => webview_ipcMessageRef.current(event), 'load': (event) => webview_loadRef.current(event), }; for (const n in webviewListeners) { if (!webviewListeners.hasOwnProperty(n)) continue; const fn = webviewListeners[n]; wv.addEventListener(n, fn); } const messageListener: MessageEventListener = event => webview_messageRef.current(event); containerWindow.addEventListener('message', messageListener); return () => { domReadyRef.current = false; const wv = webviewRef.current; if (!wv) return; for (const n in webviewListeners) { if (!webviewListeners.hasOwnProperty(n)) continue; const fn = webviewListeners[n]; wv.removeEventListener(n, fn); } containerWindow?.removeEventListener('message', messageListener); removePluginAssetsCallbackRef.current?.(); }; }, [containerWindow]); const viewerStyle = useMemo(() => { return { border: 'none', ...props.viewerStyle }; }, [props.viewerStyle]); // allow=fullscreen: Required to allow the user to fullscreen videos. return ( ); }); export default NoteTextViewer;