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 '@joplin/lib/hooks/dom/useDocument'; import { _ } from '@joplin/lib/locale'; import getAssetPath from '../utils/getAssetPath'; import { toForwardSlashes } from '@joplin/utils/path'; interface IpcMessageEvent { channel?: string; args?: unknown[]; } interface Props { onDomReady: (event: Event)=> void; onIpcMessage: (event: IpcMessageEvent)=> void; viewerStyle: React.CSSProperties; 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; focusLine(editorLine: number): void; focus(): void; hasFocus(): boolean; stopMediaPlayback(): void; } 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 useAllowAttribute = () => { // Specifies what content in the note viewer can do. See // https://developer.mozilla.org/en-US/docs/Web/API/HTMLIFrameElement/allow // allow=fullscreen: Required to allow the user to fullscreen videos. return [ 'clipboard-write', 'fullscreen', 'autoplay', 'local-fonts', 'encrypted-media', ].map( attr => `${attr} joplin-content://note-viewer/`, ).join('; '); }; 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().getContentProtocolHandler(); // 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: {} }, '*'); } if (channel === 'focusLine') { win.postMessage({ target: 'webview', name: 'focusLine', data: { line: arg0 } }, '*'); } // 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 } }, '*'); } if (channel === 'stopMediaPlayback') { win.postMessage({ target: 'webview', name: 'stopMediaPlayback', data: {} }, '*'); } }, 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); }, focusLine: (lineNumber: number) => { if (webviewRef.current) { focus('NoteTextViewer::focusLine', webviewRef.current); // A timeout seems necessary after focusing the viewer to prevent focus from jumping to the top setTimeout(() => { result.send('focusLine', lineNumber); }, 100); } }, stopMediaPlayback: () => { result.send('stopMediaPlayback'); }, }; return result; }, [parentDoc]); const webview_domReadyRef = useRef(null); webview_domReadyRef.current = (event: Event) => { domReadyRef.current = true; if (props.onDomReady) props.onDomReady(event); }; const webview_ipcMessageRef = useRef(null); webview_ipcMessageRef.current = (event: Event) => { // The webview 'ipc-message' event carries channel/args, though it is statically typed as a bare Event here. if (props.onIpcMessage) props.onIpcMessage(event as IpcMessageEvent); }; const webview_loadRef = useRef(null); webview_loadRef.current = (event: Event) => { webview_domReadyRef.current(event); }; type MessageEventListener = (event: MessageEvent)=> void; const webview_messageRef = useRef(null); 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]); const allow = useAllowAttribute(); return ( ); }); export default NoteTextViewer;