1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-12-21 09:38:01 +02:00

Plugins: Fixed issue with dialog being empty in some cases

This commit is contained in:
Laurent Cozic 2020-11-16 16:14:26 +00:00
parent a808281dd2
commit adde092ea6
13 changed files with 337 additions and 104 deletions

View File

@ -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

15
.gitignore vendored
View File

@ -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

View File

@ -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, `
<p>Testing dialog with form elements</p>
<form name="user">

View File

@ -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<Size>({ 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 <StyledFrame
id={props.viewId}

View File

@ -1,8 +1,8 @@
import * as React from 'react';
import { useRef, useCallback } from 'react';
import { ButtonSpec, DialogResult } from '@joplin/lib/services/plugins/api/types';
import PluginService from '@joplin/lib/services/plugins/PluginService';
import WebviewController from '@joplin/lib/services/plugins/WebviewController';
import * as React from 'react';
import { useRef } from 'react';
import UserWebview, { Props as UserWebviewProps } from './UserWebview';
import UserWebviewDialogButtonBar from './UserWebviewDialogButtonBar';
const styled = require('styled-components').default;
@ -49,6 +49,18 @@ function defaultButtons(): ButtonSpec[] {
];
}
function findSubmitButton(buttons: ButtonSpec[]): ButtonSpec | null {
return buttons.find((b: ButtonSpec) => {
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 (
<StyledRoot>
<Dialog>
@ -82,6 +108,8 @@ export default function UserWebviewDialog(props: Props) {
themeId={props.themeId}
borderBottom={false}
fitToContent={true}
onSubmit={onSubmit}
onDismiss={onDismiss}
/>
</UserWebViewWrapper>
<UserWebviewDialogButtonBar buttons={buttons}/>

View File

@ -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' }, '*');
});
});
})();

View File

@ -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<Size>({
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;
}

View File

@ -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;
}

View File

@ -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]);
}

View File

@ -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]);
}

View File

@ -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();
}

View File

@ -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]);
}

View File

@ -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 {