diff --git a/extensions/src/hello-world/web-views/hello-world.web-view.tsx b/extensions/src/hello-world/web-views/hello-world.web-view.tsx index 361b13b864..54f4bb3d29 100644 --- a/extensions/src/hello-world/web-views/hello-world.web-view.tsx +++ b/extensions/src/hello-world/web-views/hello-world.web-view.tsx @@ -59,7 +59,7 @@ papi globalThis.webViewComponent = function HelloWorld() { const test = useContext(TestContext) || "Context didn't work!! :("; - const [clicks, setClicks] = useState(0); + const [clicks, setClicks] = globalThis.useWebViewState('clicks', 0); const [rows, setRows] = useState(initializeRows()); const [selectedRows, setSelectedRows] = useState(new Set()); const [scrRef, setScrRef] = useSetting('platform.verseRef', defaultScrRef); @@ -67,7 +67,12 @@ globalThis.webViewComponent = function HelloWorld() { // Update the clicks when we are informed helloWorld has been run useEvent( 'helloWorld.onHelloWorld', - useCallback(({ times }: HelloWorldEvent) => setClicks(times), []), + useCallback( + ({ times }: HelloWorldEvent) => { + if (times > clicks) setClicks(times); + }, + [clicks, setClicks], + ), ); const [echoResult] = usePromise( diff --git a/lib/papi-dts/papi.d.ts b/lib/papi-dts/papi.d.ts index b0e5650ae1..7f0afb7f95 100644 --- a/lib/papi-dts/papi.d.ts +++ b/lib/papi-dts/papi.d.ts @@ -3,7 +3,7 @@ /// declare module 'shared/global-this.model' { import { LogLevel } from 'electron-log'; - import { FunctionComponent } from 'react'; + import { FunctionComponent, Dispatch, SetStateAction } from 'react'; /** * Variables that are defined in global scope. These must be defined in main.ts (main), index.ts (renderer), and extension-host.ts (extension host) */ @@ -20,9 +20,30 @@ declare module 'shared/global-this.model' { var logLevel: LogLevel; /** * A function that each React WebView extension must provide for Paranext to display it. - * Only used in WebView iframes + * Only used in WebView iframes. */ var webViewComponent: FunctionComponent; + /** + * A React hook for working with a state object tied to a webview. + * Only used in WebView iframes. + * @param stateKey Key of the state value to use. The webview state holds a unique value per key. + * NOTE: `stateKey` needs to be a constant string, not something that could change during execution. + * @param defaultStateValue Value to use if the web view state didn't contain a value for the given 'stateKey' + * @returns string holding the state value and a function to use to update the state value + * @example const [lastPersonSeen, setLastPersonSeen] = useWebViewState('lastSeen'); + */ + var useWebViewState: ( + stateKey: string, + defaultStateValue: NonNullable, + ) => [webViewState: NonNullable, setWebViewState: Dispatch>>]; + /** + * Retrieve the value from web view state with the given 'stateKey', if it exists. + */ + var getWebViewState: (stateKey: string) => T | undefined; + /** + * Set the value for a given key in the web view state. + */ + var setWebViewState: (stateKey: string, stateValue: NonNullable) => void; } /** Type of Paranext process */ export enum ProcessType { @@ -1725,6 +1746,8 @@ declare module 'shared/data/web-view.model' { content: string; /** Name of the tab for the WebView */ title?: string; + /** General object to store unique state for this webview */ + state?: Record; }; /** WebView representation using React */ export type WebViewDefinitionReact = WebViewDefinitionBase & { @@ -1907,6 +1930,40 @@ declare module 'shared/log-error.model' { constructor(message?: string); } } +declare module 'renderer/services/web-view-state.service' { + /** + * Get the web view state associated with the given ID + * This function is only intended to be used at startup. getWebViewState is intended for web views to call. + * @param id ID of the web view + * @returns state object of the given web view + */ + export function getFullWebViewStateById(id: string): Record; + /** + * Set the web view state associated with the given ID + * This function is only intended to be used at startup. setWebViewState is intended for web views to call. + * @param id ID of the web view + * @param state State to set for the given web view + */ + export function setFullWebViewStateById(id: string, state: Record): void; + /** + * Get the web view state associated with the given ID + * @param id ID of the web view + * @param stateKey Key used to retrieve the state value + * @returns string (if it exists) containing the state for the given key of the given web view + */ + export function getWebViewStateById(id: string, stateKey: string): T | undefined; + /** + * Set the web view state object associated with the given ID + * @param id ID of the web view + * @param stateKey Key for the associated state + * @param stateValue Value of the state for the given key of the given web view - must work with JSON.stringify/parse + */ + export function setWebViewStateById(id: string, stateKey: string, stateValue: T): void; + /** Purge any web view state that hasn't been touched since the process has been running. + * Only call this once all web views have been loaded. + */ + export function cleanupOldWebViewState(): void; +} declare module 'shared/services/web-view.service' { import { Unsubscriber } from 'shared/utils/papi-util'; import { MutableRefObject } from 'react'; diff --git a/src/renderer/global-this.model.ts b/src/renderer/global-this.model.ts index ee1a0b074d..388db7fb07 100644 --- a/src/renderer/global-this.model.ts +++ b/src/renderer/global-this.model.ts @@ -10,6 +10,11 @@ import * as SillsdevScripture from '@sillsdev/scripture'; import { ProcessType } from '@shared/global-this.model'; import papi, { Papi } from '@renderer/services/papi-frontend.service'; import { getModuleSimilarApiMessage } from '@shared/utils/papi-util'; +import { + getWebViewStateById, + setWebViewStateById, +} from '@renderer/services/web-view-state.service'; +import useWebViewState from '@renderer/hooks/use-webview-state'; // #region webpack DefinePlugin types setup - these should be from the renderer webpack DefinePlugin @@ -57,6 +62,9 @@ declare global { var createRoot: typeof ReactDOMClient.createRoot; var SillsdevScripture: SillsdevScriptureType; var webViewRequire: WebViewRequire; + // Web view state functions are used in the default imports for each webview in web-view.service.ts + var getWebViewStateById: (id: string, stateKey: string) => T | undefined; + var setWebViewStateById: (id: string, stateKey: string, stateValue: NonNullable) => void; } /* eslint-enable */ @@ -81,5 +89,10 @@ globalThis.ReactDOMClient = ReactDOMClient; globalThis.createRoot = ReactDOMClient.createRoot; globalThis.SillsdevScripture = SillsdevScripture; globalThis.webViewRequire = webViewRequire; +// We don't expose get/setWebViewStateById in PAPI because web views don't have access to IDs +globalThis.getWebViewStateById = getWebViewStateById; +globalThis.setWebViewStateById = setWebViewStateById; +// We store the hook reference because we need it to bind it to the webview's iframe 'window' context +globalThis.useWebViewState = useWebViewState; // #endregion diff --git a/src/renderer/hooks/use-webview-state.ts b/src/renderer/hooks/use-webview-state.ts new file mode 100644 index 0000000000..c231f1523c --- /dev/null +++ b/src/renderer/hooks/use-webview-state.ts @@ -0,0 +1,21 @@ +import { useState, useEffect, Dispatch, SetStateAction } from 'react'; + +// We don't add this to PAPI directly like other hooks because `this` has to be bound to a web view's iframe context +/** See src/shared/global-this.model.ts for normal hook documentation */ +export default function useWebViewState( + this: { + getWebViewState: (stateKey: string) => T | undefined; + setWebViewState: (stateKey: string, stateValue: NonNullable) => void; + }, + stateKey: string, + defaultStateValue: NonNullable, +): [webViewState: NonNullable, setWebViewState: Dispatch>>] { + const [state, setState] = useState(() => this.getWebViewState(stateKey) ?? defaultStateValue); + + // Whenever the state changes, save the updated value + useEffect(() => { + this.setWebViewState(stateKey, state); + }, [stateKey, state]); + + return [state, setState]; +} diff --git a/src/renderer/index.tsx b/src/renderer/index.tsx index 0886e42af9..d1747dea9e 100644 --- a/src/renderer/index.tsx +++ b/src/renderer/index.tsx @@ -6,6 +6,7 @@ import * as webViewService from '@shared/services/web-view.service'; import logger from '@shared/services/logger.service'; import webViewProviderService from '@shared/services/web-view-provider.service'; import App from './app.component'; +import { cleanupOldWebViewState } from './services/web-view-state.service'; logger.info('Starting renderer'); @@ -18,3 +19,8 @@ webViewService.initialize(); const container = document.getElementById('root'); const root = createRoot(container as HTMLElement); root.render(); + +// This doesn't run if the renderer has an uncaught exception (which is a good thing) +window.addEventListener('beforeunload', () => { + cleanupOldWebViewState(); +}); diff --git a/src/renderer/services/web-view-state.service.ts b/src/renderer/services/web-view-state.service.ts new file mode 100644 index 0000000000..1e9c167f40 --- /dev/null +++ b/src/renderer/services/web-view-state.service.ts @@ -0,0 +1,109 @@ +const WEBVIEW_STATE_KEY = 'web-view-state'; +const stateMap = new Map>(); +const idsLookedUp = new Set(); + +function loadIfNeeded(): void { + // If we have any data or tried to look something up, we've already loaded + if (stateMap.size > 0 || idsLookedUp.size > 0) return; + + const serializedState = localStorage.getItem(WEBVIEW_STATE_KEY); + if (!serializedState) return; + + const entries = JSON.parse(serializedState) as [[string, Record]]; + entries.forEach(([key, value]) => { + if (key && value) stateMap.set(key, value); + }); +} + +function save(): void { + // If no one looked anything up, don't overwrite anything + if (idsLookedUp.size <= 0) return; + + const stateToSave = JSON.stringify(Array.from(stateMap.entries())); + localStorage.setItem(WEBVIEW_STATE_KEY, stateToSave); +} + +function getRecord(id: string): Record { + loadIfNeeded(); + idsLookedUp.add(id); + + const savedState = stateMap.get(id); + if (savedState) return savedState; + + const newState = {}; + stateMap.set(id, newState); + return newState; +} + +/** + * Get the web view state associated with the given ID + * This function is only intended to be used at startup. getWebViewState is intended for web views to call. + * @param id ID of the web view + * @returns state object of the given web view + */ +export function getFullWebViewStateById(id: string): Record { + if (!id) throw new Error('id must be provided to get webview state'); + return getRecord(id); +} + +/** + * Set the web view state associated with the given ID + * This function is only intended to be used at startup. setWebViewState is intended for web views to call. + * @param id ID of the web view + * @param state State to set for the given web view + */ +export function setFullWebViewStateById(id: string, state: Record): void { + if (!id || !state) throw new Error('id and state must be provided to set webview state'); + loadIfNeeded(); + idsLookedUp.add(id); + stateMap.set(id, state); + save(); +} + +/** + * Get the web view state associated with the given ID + * @param id ID of the web view + * @param stateKey Key used to retrieve the state value + * @returns string (if it exists) containing the state for the given key of the given web view + */ +export function getWebViewStateById(id: string, stateKey: string): T | undefined { + if (!id || !stateKey) throw new Error('id and stateKey must be provided to get webview state'); + const state: Record = getRecord(id); + const stateValue: string | undefined = state[stateKey]; + return stateValue ? (JSON.parse(stateValue) as T) : undefined; +} + +/** + * Set the web view state object associated with the given ID + * @param id ID of the web view + * @param stateKey Key for the associated state + * @param stateValue Value of the state for the given key of the given web view - must work with JSON.stringify/parse + */ +export function setWebViewStateById(id: string, stateKey: string, stateValue: T): void { + if (!id || !stateKey) throw new Error('id and stateKey must be provided to set webview state'); + const stringifiedValue = JSON.stringify(stateValue); + try { + const roundTripped = JSON.parse(stringifiedValue); + const roundTrippedStringified = JSON.stringify(roundTripped); + if (stringifiedValue !== roundTrippedStringified) { + throw new Error(`roundtrip failure`); + } + } catch (err) { + throw new Error(`"${stateKey}" value cannot round trip with JSON.stringify and JSON.parse.`); + } + + const state: Record = getRecord(id); + state[stateKey] = stringifiedValue; + save(); +} + +/** Purge any web view state that hasn't been touched since the process has been running. + * Only call this once all web views have been loaded. + */ +export function cleanupOldWebViewState(): void { + if (stateMap.size <= 0 || idsLookedUp.size <= 0) return; + stateMap.forEach((_, id) => { + if (!idsLookedUp.has(id)) stateMap.delete(id); + }); + save(); +} diff --git a/src/shared/data/web-view.model.ts b/src/shared/data/web-view.model.ts index d54005cb98..1df6f8b90c 100644 --- a/src/shared/data/web-view.model.ts +++ b/src/shared/data/web-view.model.ts @@ -89,6 +89,8 @@ type WebViewDefinitionBase = { content: string; /** Name of the tab for the WebView */ title?: string; + /** General object to store unique state for this webview */ + state?: Record; }; /** WebView representation using React */ diff --git a/src/shared/global-this.model.ts b/src/shared/global-this.model.ts index ad7e9a95e9..caaf20e2e9 100644 --- a/src/shared/global-this.model.ts +++ b/src/shared/global-this.model.ts @@ -2,7 +2,7 @@ /* eslint-disable no-var */ import { LogLevel } from 'electron-log'; -import { FunctionComponent } from 'react'; +import { FunctionComponent, Dispatch, SetStateAction } from 'react'; /** * Variables that are defined in global scope. These must be defined in main.ts (main), index.ts (renderer), and extension-host.ts (extension host) @@ -21,9 +21,30 @@ declare global { var logLevel: LogLevel; /** * A function that each React WebView extension must provide for Paranext to display it. - * Only used in WebView iframes + * Only used in WebView iframes. */ var webViewComponent: FunctionComponent; + /** + * A React hook for working with a state object tied to a webview. + * Only used in WebView iframes. + * @param stateKey Key of the state value to use. The webview state holds a unique value per key. + * NOTE: `stateKey` needs to be a constant string, not something that could change during execution. + * @param defaultStateValue Value to use if the web view state didn't contain a value for the given 'stateKey' + * @returns string holding the state value and a function to use to update the state value + * @example const [lastPersonSeen, setLastPersonSeen] = useWebViewState('lastSeen'); + */ + var useWebViewState: ( + stateKey: string, + defaultStateValue: NonNullable, + ) => [webViewState: NonNullable, setWebViewState: Dispatch>>]; + /** + * Retrieve the value from web view state with the given 'stateKey', if it exists. + */ + var getWebViewState: (stateKey: string) => T | undefined; + /** + * Set the value for a given key in the web view state. + */ + var setWebViewState: (stateKey: string, stateValue: NonNullable) => void; } /** Type of Paranext process */ diff --git a/src/shared/services/web-view.service.ts b/src/shared/services/web-view.service.ts index 1797c86aec..487d7d6c65 100644 --- a/src/shared/services/web-view.service.ts +++ b/src/shared/services/web-view.service.ts @@ -36,6 +36,10 @@ import AsyncVariable from '@shared/utils/async-variable'; import logger from '@shared/services/logger.service'; import LogError from '@shared/log-error.model'; import memoizeOne from 'memoize-one'; +import { + getFullWebViewStateById, + setFullWebViewStateById, +} from '@renderer/services/web-view-state.service'; /** rc-dock's onLayoutChange prop made asynchronous - resolves */ export type OnLayoutChangeRCDock = ( @@ -423,6 +427,8 @@ export const getWebView = async ( existingSavedWebView = convertWebViewDefinitionToSaved( existingWebView.data as WebViewDefinition, ); + // Load the web view state since the web view provider doesn't have access to the data store + existingSavedWebView.state = getFullWebViewStateById(existingWebView.id); didFindExistingWebView = true; } } @@ -441,6 +447,9 @@ export const getWebView = async ( // The web view provider didn't want to create this web view if (!webView) return undefined; + // The web view provider might have updated the web view state, so save it + if (webView.state) setFullWebViewStateById(webView.id, webView.state); + /** * The web view we are getting is new. Either the webview provider gave us a new webview instead * of the existing one or there wasn't an existing one in the first place @@ -457,7 +466,9 @@ export const getWebView = async ( // WebView.contentType is assumed to be React by default. Extensions can specify otherwise const contentType = webView.contentType ? webView.contentType : WebViewContentType.React; - // Note: `webViewRequire` below is defined in `src\renderer\global-this.model.ts`. + // `webViewRequire`, `getWebViewStateById`, and `setWebViewStateById` below are defined in `src\renderer\global-this.model.ts` + // `useWebViewState` below is defined in `src\shared\global-this.model.ts` + // We have to bind `useWebViewState` to the current `window` context because calls within PAPI don't have access to a webview's `window` context /** String that sets up 'import' statements in the webview to pull in libraries and clear out internet access and such */ const imports = ` var papi = window.parent.papi; @@ -468,6 +479,11 @@ export const getWebView = async ( var createRoot = window.parent.createRoot; var SillsdevScripture = window.parent.SillsdevScripture; var require = window.parent.webViewRequire; + var getWebViewStateById = window.parent.getWebViewStateById; + var setWebViewStateById = window.parent.setWebViewStateById; + window.getWebViewState = (stateKey) => { return getWebViewStateById('${webView.id}', stateKey) }; + window.setWebViewState = (stateKey, stateValue) => { setWebViewStateById('${webView.id}', stateKey, stateValue) }; + window.useWebViewState = window.parent.useWebViewState.bind(window); window.fetch = papi.fetch; delete window.parent; delete window.top;