diff --git a/.eslintignore b/.eslintignore index 6a78e310f..bc8a6c042 100644 --- a/.eslintignore +++ b/.eslintignore @@ -763,12 +763,27 @@ packages/app-desktop/services/plugins/UserWebviewDialog.js.map packages/app-desktop/services/plugins/UserWebviewDialogButtonBar.d.ts packages/app-desktop/services/plugins/UserWebviewDialogButtonBar.js packages/app-desktop/services/plugins/UserWebviewDialogButtonBar.js.map +packages/app-desktop/services/plugins/hooks/useContentSize.d.ts +packages/app-desktop/services/plugins/hooks/useContentSize.js +packages/app-desktop/services/plugins/hooks/useContentSize.js.map +packages/app-desktop/services/plugins/hooks/useHtmlLoader.d.ts +packages/app-desktop/services/plugins/hooks/useHtmlLoader.js +packages/app-desktop/services/plugins/hooks/useHtmlLoader.js.map +packages/app-desktop/services/plugins/hooks/useScriptLoader.d.ts +packages/app-desktop/services/plugins/hooks/useScriptLoader.js +packages/app-desktop/services/plugins/hooks/useScriptLoader.js.map +packages/app-desktop/services/plugins/hooks/useSubmitHandler.d.ts +packages/app-desktop/services/plugins/hooks/useSubmitHandler.js +packages/app-desktop/services/plugins/hooks/useSubmitHandler.js.map packages/app-desktop/services/plugins/hooks/useThemeCss.d.ts packages/app-desktop/services/plugins/hooks/useThemeCss.js packages/app-desktop/services/plugins/hooks/useThemeCss.js.map packages/app-desktop/services/plugins/hooks/useViewIsReady.d.ts packages/app-desktop/services/plugins/hooks/useViewIsReady.js packages/app-desktop/services/plugins/hooks/useViewIsReady.js.map +packages/app-desktop/services/plugins/hooks/useWebviewToPluginMessages.d.ts +packages/app-desktop/services/plugins/hooks/useWebviewToPluginMessages.js +packages/app-desktop/services/plugins/hooks/useWebviewToPluginMessages.js.map packages/app-desktop/services/spellChecker/SpellCheckerServiceDriverNative.d.ts packages/app-desktop/services/spellChecker/SpellCheckerServiceDriverNative.js packages/app-desktop/services/spellChecker/SpellCheckerServiceDriverNative.js.map diff --git a/.gitignore b/.gitignore index 2ef14d445..61a8dcf05 100644 --- a/.gitignore +++ b/.gitignore @@ -755,12 +755,27 @@ packages/app-desktop/services/plugins/UserWebviewDialog.js.map packages/app-desktop/services/plugins/UserWebviewDialogButtonBar.d.ts packages/app-desktop/services/plugins/UserWebviewDialogButtonBar.js packages/app-desktop/services/plugins/UserWebviewDialogButtonBar.js.map +packages/app-desktop/services/plugins/hooks/useContentSize.d.ts +packages/app-desktop/services/plugins/hooks/useContentSize.js +packages/app-desktop/services/plugins/hooks/useContentSize.js.map +packages/app-desktop/services/plugins/hooks/useHtmlLoader.d.ts +packages/app-desktop/services/plugins/hooks/useHtmlLoader.js +packages/app-desktop/services/plugins/hooks/useHtmlLoader.js.map +packages/app-desktop/services/plugins/hooks/useScriptLoader.d.ts +packages/app-desktop/services/plugins/hooks/useScriptLoader.js +packages/app-desktop/services/plugins/hooks/useScriptLoader.js.map +packages/app-desktop/services/plugins/hooks/useSubmitHandler.d.ts +packages/app-desktop/services/plugins/hooks/useSubmitHandler.js +packages/app-desktop/services/plugins/hooks/useSubmitHandler.js.map packages/app-desktop/services/plugins/hooks/useThemeCss.d.ts packages/app-desktop/services/plugins/hooks/useThemeCss.js packages/app-desktop/services/plugins/hooks/useThemeCss.js.map packages/app-desktop/services/plugins/hooks/useViewIsReady.d.ts packages/app-desktop/services/plugins/hooks/useViewIsReady.js packages/app-desktop/services/plugins/hooks/useViewIsReady.js.map +packages/app-desktop/services/plugins/hooks/useWebviewToPluginMessages.d.ts +packages/app-desktop/services/plugins/hooks/useWebviewToPluginMessages.js +packages/app-desktop/services/plugins/hooks/useWebviewToPluginMessages.js.map packages/app-desktop/services/spellChecker/SpellCheckerServiceDriverNative.d.ts packages/app-desktop/services/spellChecker/SpellCheckerServiceDriverNative.js packages/app-desktop/services/spellChecker/SpellCheckerServiceDriverNative.js.map diff --git a/packages/app-cli/tests/support/plugins/dialog/src/index.ts b/packages/app-cli/tests/support/plugins/dialog/src/index.ts index 488803d1a..97177afc7 100644 --- a/packages/app-cli/tests/support/plugins/dialog/src/index.ts +++ b/packages/app-cli/tests/support/plugins/dialog/src/index.ts @@ -27,7 +27,7 @@ joplin.plugins.register({ const result2 = await dialogs.open(handle2); alert('Got result: ' + JSON.stringify(result2)); - const handle3 = await dialogs.create(); + const handle3 = await dialogs.create('myDialog3'); await dialogs.setHtml(handle3, `

Testing dialog with form elements

diff --git a/packages/app-desktop/services/plugins/UserWebview.tsx b/packages/app-desktop/services/plugins/UserWebview.tsx index 7f0c22891..e8b659f46 100644 --- a/packages/app-desktop/services/plugins/UserWebview.tsx +++ b/packages/app-desktop/services/plugins/UserWebview.tsx @@ -1,7 +1,12 @@ import * as React from 'react'; -import { useRef, useEffect, useState, useImperativeHandle, forwardRef } from 'react'; +import { useRef, useImperativeHandle, forwardRef } from 'react'; import useViewIsReady from './hooks/useViewIsReady'; import useThemeCss from './hooks/useThemeCss'; +import useContentSize from './hooks/useContentSize'; +import useSubmitHandler from './hooks/useSubmitHandler'; +import useHtmlLoader from './hooks/useHtmlLoader'; +import useWebviewToPluginMessages from './hooks/useWebviewToPluginMessages'; +import useScriptLoader from './hooks/useScriptLoader'; const styled = require('styled-components').default; export interface Props { @@ -16,11 +21,8 @@ export interface Props { fitToContent?: boolean; borderBottom?: boolean; theme?: any; -} - -interface Size { - width: number; - height: number; + onSubmit?: any; + onDismiss?: any; } const StyledFrame = styled.iframe` @@ -61,19 +63,6 @@ function UserWebview(props: Props, ref: any) { const viewRef = useRef(null); const isReady = useViewIsReady(viewRef); const cssFilePath = useThemeCss({ pluginId: props.pluginId, themeId: props.themeId }); - const [contentSize, setContentSize] = useState({ width: minWidth, height: minHeight }); - - useImperativeHandle(ref, () => { - return { - formData: function() { - if (viewRef.current) { - return serializeForms(viewRef.current.contentWindow.document); - } else { - return null; - } - }, - }; - }); function frameWindow() { if (!viewRef.current) return null; @@ -86,86 +75,54 @@ function UserWebview(props: Props, ref: any) { win.postMessage({ target: 'webview', name, args }, '*'); } - function updateContentSize() { - const win = frameWindow(); - if (!win) return null; - - const rect = win.document.getElementById('joplin-plugin-content').getBoundingClientRect(); - - let w = rect.width; - let h = rect.height; - if (w < minWidth) w = minWidth; - if (h < minHeight) h = minHeight; - - const newSize = { width: w, height: h }; - - setContentSize((current: Size) => { - if (current.width === newSize.width && current.height === newSize.height) return current; - return newSize; - }); - - return newSize; - } - - useEffect(() => { - if (!isReady) return () => {}; - let cancelled = false; - postMessage('setHtml', { html: props.html }); - - setTimeout(() => { - if (cancelled) return; - updateContentSize(); - }, 100); - - return () => { - cancelled = true; + useImperativeHandle(ref, () => { + return { + formData: function() { + if (viewRef.current) { + return serializeForms(frameWindow().document); + } else { + return null; + } + }, }; - }, [props.html, isReady]); + }); - useEffect(() => { - if (!isReady) return; - postMessage('setScripts', { scripts: props.scripts }); - }, [props.scripts, isReady]); + const htmlHash = useHtmlLoader( + frameWindow(), + isReady, + postMessage, + props.html + ); - useEffect(() => { - if (!isReady || !cssFilePath) return; - postMessage('setScript', { script: cssFilePath, key: 'themeCss' }); - }, [isReady, cssFilePath]); + const contentSize = useContentSize( + frameWindow(), + htmlHash, + minWidth, + minHeight, + props.fitToContent, + isReady + ); - useEffect(() => { - function onMessage(event: any) { - if (!event.data || event.data.target !== 'plugin') return; - props.onMessage({ - pluginId: props.pluginId, - viewId: props.viewId, - message: event.data.message, - }); - } + useSubmitHandler( + frameWindow(), + props.onSubmit, + props.onDismiss, + htmlHash + ); - viewRef.current.contentWindow.addEventListener('message', onMessage); + useWebviewToPluginMessages( + frameWindow(), + props.onMessage, + props.pluginId, + props.viewId + ); - return () => { - viewRef.current.contentWindow.removeEventListener('message', onMessage); - }; - }, [props.onMessage, props.pluginId, props.viewId]); - - useEffect(() => { - if (!props.fitToContent || !isReady) return () => {}; - - // The only reliable way to make sure that the iframe has the same dimensions - // as its content is to poll the dimensions at regular intervals. Other methods - // work most of the time but will fail in various edge cases. Most reliable way - // is probably iframe-resizer package, but still with 40 unfixed bugs. - // - // Polling in our case is fine since this is only used when displaying plugin - // dialogs, which should be short lived. updateContentSize() is also optimised - // to do nothing when size hasn't changed. - const updateFrameSizeIID = setInterval(updateContentSize, 2000); - - return () => { - clearInterval(updateFrameSizeIID); - }; - }, [props.fitToContent, isReady, minWidth, minHeight]); + useScriptLoader( + postMessage, + isReady, + props.scripts, + cssFilePath + ); return { + return ['ok', 'yes', 'confirm', 'submit'].includes(b.id); + }); +} + +function findDismissButton(buttons: ButtonSpec[]): ButtonSpec | null { + return buttons.find((b: ButtonSpec) => { + return ['cancel', 'no', 'reject'].includes(b.id); + }); +} + export default function UserWebviewDialog(props: Props) { const webviewRef = useRef(null); @@ -68,6 +80,20 @@ export default function UserWebviewDialog(props: Props) { }; }); + const onSubmit = useCallback(() => { + const submitButton = findSubmitButton(buttons); + if (submitButton) { + submitButton.onClick(); + } + }, [buttons]); + + const onDismiss = useCallback(() => { + const dismissButton = findDismissButton(buttons); + if (dismissButton) { + dismissButton.onClick(); + } + }, [buttons]); + return ( @@ -82,6 +108,8 @@ export default function UserWebviewDialog(props: Props) { themeId={props.themeId} borderBottom={false} fitToContent={true} + onSubmit={onSubmit} + onDismiss={onDismiss} /> diff --git a/packages/app-desktop/services/plugins/UserWebviewIndex.js b/packages/app-desktop/services/plugins/UserWebviewIndex.js index c6bd0806a..e88a1d41b 100644 --- a/packages/app-desktop/services/plugins/UserWebviewIndex.js +++ b/packages/app-desktop/services/plugins/UserWebviewIndex.js @@ -57,6 +57,13 @@ const webviewApi = { const ipc = { setHtml: (args) => { contentElement.innerHTML = args.html; + + console.debug('UserWebView frame: setting html to', args.html); + + window.requestAnimationFrame(() => { + console.debug('UserWebView frame: setting html callback', args.hash); + window.postMessage({ target: 'UserWebview', message: 'htmlIsSet', hash: args.hash }, '*'); + }); }, setScript: (args) => { @@ -102,8 +109,14 @@ const webviewApi = { } })); - // Send a message to the containing component to notify - // it that the view content is fully ready. - window.postMessage({ target: 'UserWebview', message: 'ready' }, '*'); + // Send a message to the containing component to notify it that the + // view content is fully ready. + // + // Need to send it with a delay to make sure all listeners are + // ready when the message is sent. + window.requestAnimationFrame(() => { + console.debug('UserWebView frame: calling isReady'); + window.postMessage({ target: 'UserWebview', message: 'ready' }, '*'); + }); }); })(); diff --git a/packages/app-desktop/services/plugins/hooks/useContentSize.ts b/packages/app-desktop/services/plugins/hooks/useContentSize.ts new file mode 100644 index 000000000..7d0790466 --- /dev/null +++ b/packages/app-desktop/services/plugins/hooks/useContentSize.ts @@ -0,0 +1,61 @@ +import { useEffect, useState } from 'react'; + +interface Size { + width: number; + height: number; + hash: string; +} + +export default function(frameWindow: any, htmlHash: string, minWidth: number, minHeight: number, fitToContent: boolean, isReady: boolean) { + const [contentSize, setContentSize] = useState({ + width: minWidth, + height: minHeight, + hash: '', + }); + + function updateContentSize(hash: string) { + if (!frameWindow) return; + + const rect = frameWindow.document.getElementById('joplin-plugin-content').getBoundingClientRect(); + + let w = rect.width; + let h = rect.height; + if (w < minWidth) w = minWidth; + if (h < minHeight) h = minHeight; + + const newSize = { width: w, height: h, hash: hash }; + + setContentSize((current: Size) => { + if (current.width === newSize.width && current.height === newSize.height && current.hash === hash) return current; + return newSize; + }); + } + + useEffect(() => { + updateContentSize(htmlHash); + }, [htmlHash]); + + useEffect(() => { + if (!fitToContent || !isReady) return () => {}; + + function onTick() { + updateContentSize(htmlHash); + } + + // The only reliable way to make sure that the iframe has the same dimensions + // as its content is to poll the dimensions at regular intervals. Other methods + // work most of the time but will fail in various edge cases. Most reliable way + // is probably iframe-resizer package, but still with 40 unfixed bugs. + // + // Polling in our case is fine since this is only used when displaying plugin + // dialogs, which should be short lived. updateContentSize() is also optimised + // to do nothing when size hasn't changed. + const updateFrameSizeIID = setInterval(onTick, 100); + + return () => { + clearInterval(updateFrameSizeIID); + }; + }, [fitToContent, isReady, minWidth, minHeight, htmlHash]); + + return contentSize; +} diff --git a/packages/app-desktop/services/plugins/hooks/useHtmlLoader.ts b/packages/app-desktop/services/plugins/hooks/useHtmlLoader.ts new file mode 100644 index 000000000..9b69cc870 --- /dev/null +++ b/packages/app-desktop/services/plugins/hooks/useHtmlLoader.ts @@ -0,0 +1,51 @@ +import { useEffect, useState, useMemo } from 'react'; +const md5 = require('md5'); + +export default function(frameWindow: any, isReady: boolean, postMessage: Function, html: string) { + const [loadedHtmlHash, setLoadedHtmlHash] = useState(''); + + const htmlHash = useMemo(() => { + return md5(html); + }, [html]); + + useEffect(() => { + if (!frameWindow) return () => {}; + + function onMessage(event: any) { + const data = event.data; + + if (!data || data.target !== 'UserWebview') return; + + console.info('useHtmlLoader: message', data); + + // We only update if the HTML that was loaded is the same as + // the active one. Otherwise it means the content has been + // changed between the moment it was set by the user and the + // moment it was loaded in the view. + if (data.message === 'htmlIsSet' && data.hash === htmlHash) { + setLoadedHtmlHash(data.hash); + } + } + + frameWindow.addEventListener('message', onMessage); + + return () => { + frameWindow.removeEventListener('message', onMessage); + }; + }, [frameWindow, htmlHash]); + + useEffect(() => { + console.info('useHtmlLoader: isReady', isReady); + + if (!isReady) return; + + console.info('useHtmlLoader: setHtml', htmlHash, html); + + postMessage('setHtml', { + hash: htmlHash, + html: html, + }); + }, [html, htmlHash, isReady]); + + return loadedHtmlHash; +} diff --git a/packages/app-desktop/services/plugins/hooks/useScriptLoader.ts b/packages/app-desktop/services/plugins/hooks/useScriptLoader.ts new file mode 100644 index 000000000..977e07926 --- /dev/null +++ b/packages/app-desktop/services/plugins/hooks/useScriptLoader.ts @@ -0,0 +1,13 @@ +import { useEffect } from 'react'; + +export default function(postMessage: Function, isReady: boolean, scripts: string[], cssFilePath: string) { + useEffect(() => { + if (!isReady) return; + postMessage('setScripts', { scripts: scripts }); + }, [scripts, isReady]); + + useEffect(() => { + if (!isReady || !cssFilePath) return; + postMessage('setScript', { script: cssFilePath, key: 'themeCss' }); + }, [isReady, cssFilePath]); +} diff --git a/packages/app-desktop/services/plugins/hooks/useSubmitHandler.ts b/packages/app-desktop/services/plugins/hooks/useSubmitHandler.ts new file mode 100644 index 000000000..894807ecd --- /dev/null +++ b/packages/app-desktop/services/plugins/hooks/useSubmitHandler.ts @@ -0,0 +1,30 @@ +import { useEffect } from 'react'; + +export default function(frameWindow: any, onSubmit: Function, onDismiss: Function, loadedHtmlHash: string) { + useEffect(() => { + if (!frameWindow) return () => {}; + + function onFormSubmit(event: any) { + event.preventDefault(); + if (onSubmit) onSubmit(); + } + + function onKeyDown(event: any) { + if (event.key === 'Escape') { + if (onDismiss) onDismiss(); + } + + if (event.key === 'Enter') { + if (onSubmit) onSubmit(); + } + } + + frameWindow.document.addEventListener('submit', onFormSubmit); + frameWindow.document.addEventListener('keydown', onKeyDown); + + return () => { + if (frameWindow) frameWindow.document.removeEventListener('submit', onFormSubmit); + if (frameWindow) frameWindow.document.removeEventListener('keydown', onKeyDown); + }; + }, [frameWindow, loadedHtmlHash, onSubmit, onDismiss]); +} diff --git a/packages/app-desktop/services/plugins/hooks/useViewIsReady.ts b/packages/app-desktop/services/plugins/hooks/useViewIsReady.ts index 1ecddbcdc..0e473467a 100644 --- a/packages/app-desktop/services/plugins/hooks/useViewIsReady.ts +++ b/packages/app-desktop/services/plugins/hooks/useViewIsReady.ts @@ -9,7 +9,10 @@ export default function useViewIsReady(viewRef: any) { const [iframeContentReady, setIFrameContentReady] = useState(false); useEffect(() => { + console.debug('useViewIsReady ============== Setup Listeners'); + function onIFrameReady() { + console.debug('useViewIsReady: onIFrameReady'); setIFrameReady(true); } @@ -18,6 +21,8 @@ export default function useViewIsReady(viewRef: any) { if (!data || data.target !== 'UserWebview') return; + console.debug('useViewIsReady: message', data); + if (data.message === 'ready') { setIFrameContentReady(true); } @@ -25,6 +30,8 @@ export default function useViewIsReady(viewRef: any) { const iframeDocument = viewRef.current.contentWindow.document; + console.debug('useViewIsReady readyState', iframeDocument.readyState); + if (iframeDocument.readyState === 'complete') { onIFrameReady(); } diff --git a/packages/app-desktop/services/plugins/hooks/useWebviewToPluginMessages.ts b/packages/app-desktop/services/plugins/hooks/useWebviewToPluginMessages.ts new file mode 100644 index 000000000..fc45b88a9 --- /dev/null +++ b/packages/app-desktop/services/plugins/hooks/useWebviewToPluginMessages.ts @@ -0,0 +1,22 @@ +import { useEffect } from 'react'; + +export default function(frameWindow: any, onMessage: Function, pluginId: string, viewId: string) { + useEffect(() => { + if (!frameWindow) return () => {}; + + function onMessage(event: any) { + if (!event.data || event.data.target !== 'plugin') return; + onMessage({ + pluginId: pluginId, + viewId: viewId, + message: event.data.message, + }); + } + + frameWindow.addEventListener('message', onMessage); + + return () => { + frameWindow.removeEventListener('message', onMessage); + }; + }, [onMessage, pluginId, viewId]); +} diff --git a/packages/lib/services/plugins/api/JoplinViewsDialogs.ts b/packages/lib/services/plugins/api/JoplinViewsDialogs.ts index 73958a2f9..4f37432f3 100644 --- a/packages/lib/services/plugins/api/JoplinViewsDialogs.ts +++ b/packages/lib/services/plugins/api/JoplinViewsDialogs.ts @@ -4,12 +4,33 @@ import WebviewController, { ContainerType } from '../WebviewController'; import { ButtonSpec, ViewHandle, DialogResult } from './types'; /** - * Allows creating and managing dialogs. A dialog is modal window that contains a webview and a row of buttons. You can update the update the webview using the `setHtml` method. - * Dialogs are hidden by default and you need to call `open()` to open them. Once the user clicks on a button, the `open` call will return an object indicating what button was clicked - * on. If your HTML content included one or more form, a `formData` object will also be included with the key/value for each form. - * There is currently no "close" method since the dialog should be thought as a modal one and thus can only be closed by clicking on one of the buttons. + * Allows creating and managing dialogs. A dialog is modal window that + * contains a webview and a row of buttons. You can update the update the + * webview using the `setHtml` method. Dialogs are hidden by default and + * you need to call `open()` to open them. Once the user clicks on a + * button, the `open` call will return an object indicating what button was + * clicked on. * - * [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/dialog) + * ## Retrieving form values + * + * If your HTML content included one or more forms, a `formData` object + * will also be included with the key/value for each form. + * + * ## Special button IDs + * + * The following buttons IDs have a special meaning: + * + * - `ok`, `yes`, `submit`, `confirm`: They are considered "submit" buttons + * - `cancel`, `no`, `reject`: They are considered "dismiss" buttons + * + * This information is used by the application to determine what action + * should be done when the user presses "Enter" or "Escape" within the + * dialog. If they press "Enter", the first "submit" button will be + * automatically clicked. If they press "Escape" the first "dismiss" button + * will be automatically clicked. + * + * [View the demo + * plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/dialog) */ export default class JoplinViewsDialogs {