From 7caed19a32f2e7ea3f0640dee0520877de8a05b2 Mon Sep 17 00:00:00 2001 From: Henry Heino <46334387+personalizedrefrigerator@users.noreply.github.com> Date: Wed, 3 Apr 2024 10:56:54 -0700 Subject: [PATCH] Mobile: Plugin API: Improve support for the Kanban and similar plugins (#10247) --- .eslintignore | 2 + .gitignore | 2 + .../backgroundPage/initializeDialogWebView.ts | 17 ++---- .../pluginRunnerBackgroundPage.ts | 2 + .../backgroundPage/utils/getFormData.test.ts | 53 +++++++++++++++++++ .../backgroundPage/utils/getFormData.ts | 24 +++++++++ .../dialogs/PluginDialogWebView.tsx | 18 +++++-- .../dialogs/PluginUserWebView.tsx | 18 ++++++- .../dialogs/hooks/useDialogSize.ts | 31 +++++++++-- .../editorCommands/editorCommands.ts | 10 ++++ packages/editor/types.ts | 2 + 11 files changed, 158 insertions(+), 21 deletions(-) create mode 100644 packages/app-mobile/plugins/PluginRunner/backgroundPage/utils/getFormData.test.ts create mode 100644 packages/app-mobile/plugins/PluginRunner/backgroundPage/utils/getFormData.ts diff --git a/.eslintignore b/.eslintignore index 1f4d4a7da..2a1c5d6c5 100644 --- a/.eslintignore +++ b/.eslintignore @@ -605,6 +605,8 @@ packages/app-mobile/plugins/PluginRunner/backgroundPage/initializeDialogWebView. packages/app-mobile/plugins/PluginRunner/backgroundPage/initializePluginBackgroundIframe.js packages/app-mobile/plugins/PluginRunner/backgroundPage/pluginRunnerBackgroundPage.js packages/app-mobile/plugins/PluginRunner/backgroundPage/startStopPlugin.js +packages/app-mobile/plugins/PluginRunner/backgroundPage/utils/getFormData.test.js +packages/app-mobile/plugins/PluginRunner/backgroundPage/utils/getFormData.js packages/app-mobile/plugins/PluginRunner/backgroundPage/utils/makeSandboxedIframe.js packages/app-mobile/plugins/PluginRunner/backgroundPage/utils/reportUnhandledErrors.js packages/app-mobile/plugins/PluginRunner/backgroundPage/utils/wrapConsoleLog.js diff --git a/.gitignore b/.gitignore index e58c84621..418a67e7c 100644 --- a/.gitignore +++ b/.gitignore @@ -585,6 +585,8 @@ packages/app-mobile/plugins/PluginRunner/backgroundPage/initializeDialogWebView. packages/app-mobile/plugins/PluginRunner/backgroundPage/initializePluginBackgroundIframe.js packages/app-mobile/plugins/PluginRunner/backgroundPage/pluginRunnerBackgroundPage.js packages/app-mobile/plugins/PluginRunner/backgroundPage/startStopPlugin.js +packages/app-mobile/plugins/PluginRunner/backgroundPage/utils/getFormData.test.js +packages/app-mobile/plugins/PluginRunner/backgroundPage/utils/getFormData.js packages/app-mobile/plugins/PluginRunner/backgroundPage/utils/makeSandboxedIframe.js packages/app-mobile/plugins/PluginRunner/backgroundPage/utils/reportUnhandledErrors.js packages/app-mobile/plugins/PluginRunner/backgroundPage/utils/wrapConsoleLog.js diff --git a/packages/app-mobile/plugins/PluginRunner/backgroundPage/initializeDialogWebView.ts b/packages/app-mobile/plugins/PluginRunner/backgroundPage/initializeDialogWebView.ts index 17d8c9552..d800af0e2 100644 --- a/packages/app-mobile/plugins/PluginRunner/backgroundPage/initializeDialogWebView.ts +++ b/packages/app-mobile/plugins/PluginRunner/backgroundPage/initializeDialogWebView.ts @@ -2,6 +2,7 @@ import { DialogWebViewApi, DialogMainProcessApi } from '../types'; import reportUnhandledErrors from './utils/reportUnhandledErrors'; import wrapConsoleLog from './utils/wrapConsoleLog'; import WebViewToRNMessenger from '../../../utils/ipc/WebViewToRNMessenger'; +import getFormData from './utils/getFormData'; let themeCssElement: HTMLStyleElement|null = null; @@ -37,16 +38,7 @@ const initializeDialogWebView = (messageChannelId: string) => { return includeScriptsOrStyles('js', paths); }, getFormData: async () => { - const firstForm = document.querySelector('form'); - if (!firstForm) return null; - - const formData = new FormData(firstForm); - - const result = Object.create(null); - for (const key of formData.keys()) { - result[key] = formData.get(key); - } - return result; + return getFormData(); }, setThemeCss: async (css: string) => { themeCssElement?.remove?.(); @@ -60,9 +52,10 @@ const initializeDialogWebView = (messageChannelId: string) => { // we need to multiply by the devicePixelRatio: const dpr = window.devicePixelRatio ?? 1; + const element = document.getElementById('joplin-plugin-content') ?? document.body; return { - width: document.body.clientWidth * dpr, - height: document.body.clientHeight * dpr, + width: element.clientWidth * dpr, + height: element.clientHeight * dpr, }; }, }; diff --git a/packages/app-mobile/plugins/PluginRunner/backgroundPage/pluginRunnerBackgroundPage.ts b/packages/app-mobile/plugins/PluginRunner/backgroundPage/pluginRunnerBackgroundPage.ts index 97471ad32..b8eaa3b12 100644 --- a/packages/app-mobile/plugins/PluginRunner/backgroundPage/pluginRunnerBackgroundPage.ts +++ b/packages/app-mobile/plugins/PluginRunner/backgroundPage/pluginRunnerBackgroundPage.ts @@ -4,6 +4,7 @@ export { runPlugin, stopPlugin } from './startStopPlugin'; const legacyPluginIds = [ 'outline', 'ylc395.noteLinkSystem', + 'com.github.joplin.kanban', ]; const pathLibrary = require('path'); @@ -28,6 +29,7 @@ export const requireModule = (moduleName: string, fromPluginId: string) => { readFile: () => '', writeFileSync: () => '', writeFile: () => '', + appendFile: () => '', }; } if (moduleName === 'process') { diff --git a/packages/app-mobile/plugins/PluginRunner/backgroundPage/utils/getFormData.test.ts b/packages/app-mobile/plugins/PluginRunner/backgroundPage/utils/getFormData.test.ts new file mode 100644 index 000000000..365f7c324 --- /dev/null +++ b/packages/app-mobile/plugins/PluginRunner/backgroundPage/utils/getFormData.test.ts @@ -0,0 +1,53 @@ +import getFormData from './getFormData'; + +describe('getFormData', () => { + afterEach(() => { + // Remove all forms to prevent tests from conflicting -- getFormData + // uses document.querySelectorAll to select all forms. + document.body.replaceChildren(); + }); + + const addTextInputWithValue = (parent: HTMLElement, value: string, name: string) => { + const input = document.createElement('input'); + input.name = name; + input.value = value; + parent.appendChild(input); + }; + + test('should return data for all forms', () => { + const testForm1 = document.createElement('form'); + testForm1.setAttribute('name', 'test-form-1'); + addTextInputWithValue(testForm1, 'Test', 'input'); + addTextInputWithValue(testForm1, 'Test2', 'another-input'); + document.body.appendChild(testForm1); + + const testForm2 = document.createElement('form'); + testForm2.setAttribute('name', 'another-test-form'); + addTextInputWithValue(testForm2, 'Test2', 'text-input'); + document.body.appendChild(testForm2); + + expect(getFormData()).toMatchObject({ + 'test-form-1': { + 'input': 'Test', + 'another-input': 'Test2', + }, + 'another-test-form': { + 'text-input': 'Test2', + }, + }); + }); + + test('should auto-number forms without a name', () => { + for (let i = 0; i < 3; i++) { + const testForm = document.createElement('form'); + addTextInputWithValue(testForm, `Form ${i}`, 'input'); + document.body.appendChild(testForm); + } + + const formData = getFormData(); + expect(Object.keys(formData)).toHaveLength(3); + expect(formData['form-0']).toMatchObject({ 'input': 'Form 0' }); + expect(formData['form-1']).toMatchObject({ 'input': 'Form 1' }); + expect(formData['form-2']).toMatchObject({ 'input': 'Form 2' }); + }); +}); diff --git a/packages/app-mobile/plugins/PluginRunner/backgroundPage/utils/getFormData.ts b/packages/app-mobile/plugins/PluginRunner/backgroundPage/utils/getFormData.ts new file mode 100644 index 000000000..fe05e883d --- /dev/null +++ b/packages/app-mobile/plugins/PluginRunner/backgroundPage/utils/getFormData.ts @@ -0,0 +1,24 @@ + +const getFormData = () => { + const forms = document.querySelectorAll('form'); + if (forms.length === 0) return null; + + const serializeForm = (form: HTMLFormElement) => { + const formData = new FormData(form); + const serializedData: Record = {}; + for (const key of formData.keys()) { + serializedData[key] = formData.get(key); + } + return serializedData; + }; + + const result = Object.create(null); + let untitledFormId = 0; + for (const form of forms) { + const formId = form.getAttribute('name') || `form-${untitledFormId++}`; + result[formId] = serializeForm(form); + } + + return result; +}; +export default getFormData; diff --git a/packages/app-mobile/plugins/PluginRunner/dialogs/PluginDialogWebView.tsx b/packages/app-mobile/plugins/PluginRunner/dialogs/PluginDialogWebView.tsx index f014b3a1f..21c7e8a73 100644 --- a/packages/app-mobile/plugins/PluginRunner/dialogs/PluginDialogWebView.tsx +++ b/packages/app-mobile/plugins/PluginRunner/dialogs/PluginDialogWebView.tsx @@ -33,6 +33,11 @@ const useStyles = ( const useDialogSize = fitToContent && dialogContentSize; const dialogHasLoaded = !!dialogContentSize; + const maxWidth = windowSize.width * 0.97; + const maxHeight = windowSize.height * 0.95; + const dialogWidth = useDialogSize ? dialogContentSize.width : maxWidth; + const dialogHeight = useDialogSize ? dialogContentSize.height : maxHeight; + return StyleSheet.create({ webView: { backgroundColor: 'transparent', @@ -41,15 +46,18 @@ const useStyles = ( webViewContainer: { flexGrow: 1, flexShrink: 1, + + maxWidth, + maxHeight, + width: dialogWidth, + height: dialogHeight, }, dialog: { backgroundColor: theme.backgroundColor, borderRadius: 12, - maxHeight: useDialogSize ? dialogContentSize?.height : undefined, - maxWidth: useDialogSize ? dialogContentSize?.width : undefined, - height: windowSize.height * 0.97, - width: windowSize.width * 0.97, + maxWidth, + maxHeight, opacity: dialogHasLoaded ? 1 : 0.1, // Center @@ -58,6 +66,7 @@ const useStyles = ( }, buttonRow: { flexDirection: 'row', + flexShrink: 0, padding: 12, justifyContent: 'flex-end', }, @@ -81,6 +90,7 @@ const PluginDialogWebView: React.FC = props => { const dialogSize = useDialogSize({ dialogControl, webViewLoadCount, + watchForSizeChanges: view.fitToContent, }); const styles = useStyles(props.themeId, dialogSize, view.fitToContent); diff --git a/packages/app-mobile/plugins/PluginRunner/dialogs/PluginUserWebView.tsx b/packages/app-mobile/plugins/PluginRunner/dialogs/PluginUserWebView.tsx index 327dd3fd4..24682635e 100644 --- a/packages/app-mobile/plugins/PluginRunner/dialogs/PluginUserWebView.tsx +++ b/packages/app-mobile/plugins/PluginRunner/dialogs/PluginUserWebView.tsx @@ -67,14 +67,30 @@ const PluginUserWebView = (props: Props) => { Plugin Dialog +
${htmlContent} +
`; diff --git a/packages/app-mobile/plugins/PluginRunner/dialogs/hooks/useDialogSize.ts b/packages/app-mobile/plugins/PluginRunner/dialogs/hooks/useDialogSize.ts index d9064754d..423ffa23e 100644 --- a/packages/app-mobile/plugins/PluginRunner/dialogs/hooks/useDialogSize.ts +++ b/packages/app-mobile/plugins/PluginRunner/dialogs/hooks/useDialogSize.ts @@ -1,26 +1,49 @@ import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect'; -import { useState } from 'react'; +import { useRef, useState } from 'react'; import { DialogContentSize, DialogWebViewApi } from '../../types'; +import { PixelRatio } from 'react-native'; interface Props { dialogControl: DialogWebViewApi; webViewLoadCount: number; + watchForSizeChanges: boolean; } const useDialogSize = (props: Props) => { const { dialogControl, webViewLoadCount } = props; const [dialogSize, setDialogSize] = useState(null); + const lastSizeRef = useRef(dialogSize); + lastSizeRef.current = dialogSize; useAsyncEffect(async event => { if (!dialogControl) { // May happen if the webview is still loading. return; } - const contentSize = await dialogControl.getContentSize(); - if (event.cancelled) return; + while (!event.cancelled) { + const contentSize = await dialogControl.getContentSize(); + if (event.cancelled) return; - setDialogSize(contentSize); + const lastSize = lastSizeRef.current; + if (contentSize.width !== lastSize?.width || contentSize.height !== lastSize?.height) { + // We use 1000 here because getPixelSizeForLayoutSize is guaranteed to return + // an integer. + const pixelToDpRatio = 1000 / PixelRatio.getPixelSizeForLayoutSize(1000); + setDialogSize({ + width: contentSize.width * pixelToDpRatio, + height: contentSize.height * pixelToDpRatio, + }); + } + + if (!props.watchForSizeChanges) return; + + await new Promise(resolve => { + setTimeout(() => { + resolve(); + }, 500); + }); + } }, [dialogControl, setDialogSize, webViewLoadCount]); return dialogSize; diff --git a/packages/editor/CodeMirror/editorCommands/editorCommands.ts b/packages/editor/CodeMirror/editorCommands/editorCommands.ts index 8666b324b..954f4709e 100644 --- a/packages/editor/CodeMirror/editorCommands/editorCommands.ts +++ b/packages/editor/CodeMirror/editorCommands/editorCommands.ts @@ -85,6 +85,16 @@ const editorCommands: Record = { }, [EditorCommandType.InsertText]: replaceSelectionCommand, [EditorCommandType.ReplaceSelection]: replaceSelectionCommand, + + [EditorCommandType.SetText]: (editor, text: string) => { + editor.dispatch({ + changes: [{ + from: 0, + to: editor.state.doc.length, + insert: text, + }], + }); + }, }; export default editorCommands; diff --git a/packages/editor/types.ts b/packages/editor/types.ts index 090687d6b..78940215e 100644 --- a/packages/editor/types.ts +++ b/packages/editor/types.ts @@ -70,6 +70,8 @@ export enum EditorCommandType { SelectedText = 'selectedText', InsertText = 'insertText', ReplaceSelection = 'replaceSelection', + + SetText = 'setText', } // Because the editor package can run in a WebView, plugin content scripts