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 d4ab3e9f12..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 { @@ -1583,6 +1604,7 @@ declare module 'papi-shared-types' { */ interface ProjectDataTypes { NotesOnly: NotesOnlyProjectDataTypes; + placeholder: MandatoryProjectDataType; } /** * Identifiers for all project types supported by PAPI. These are not intended to correspond 1:1 @@ -1724,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 & { @@ -1906,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'; @@ -2517,12 +2575,27 @@ declare module 'renderer/hooks/papi-hooks/use-event-async.hook' { ) => void; export default useEventAsync; } +declare module 'renderer/hooks/hook-generators/create-use-network-object-hook.util' { + import { NetworkObject } from 'shared/models/network-object.model'; + /** + * This function takes in a getNetworkObject function and creates a hook with that function in it + * which will return a network object + * @param getNetworkObject A function that takes in an id string and returns a network object + * @returns a function that takes in a networkObjectSource and returns a NetworkObject + */ + function createUseNetworkObjectHook( + getNetworkObject: (id: string) => Promise | undefined>, + ): ( + networkObjectSource: string | NetworkObject | undefined, + ) => NetworkObject | undefined; + export default createUseNetworkObjectHook; +} declare module 'renderer/hooks/papi-hooks/use-data-provider.hook' { import IDataProvider from 'shared/models/data-provider.interface'; /** * Gets a data provider with specified provider name - * @param dataProviderSource string name of the data provider to get OR dataProvider (result of useDataProvider if you - * want this hook to just return the data provider again) + * @param dataProviderSource string name of the data provider to get OR dataProvider (result of + * useDataProvider, if you want this hook to just return the data provider again) * @returns undefined if the data provider has not been retrieved, * data provider if it has been retrieved and is not disposed, * and undefined again if the data provider is disposed @@ -2530,9 +2603,9 @@ declare module 'renderer/hooks/papi-hooks/use-data-provider.hook' { * @type `T` - the type of data provider to return. Use `IDataProvider`, * specifying your own types, or provide a custom data provider type */ - function useDataProvider>( + const useDataProvider: >( dataProviderSource: string | T | undefined, - ): T | undefined; + ) => T | undefined; export default useDataProvider; } declare module 'renderer/hooks/papi-hooks/use-data.hook' { @@ -2668,6 +2741,25 @@ declare module 'renderer/hooks/papi-hooks/use-setting.hook' { ) => [SettingTypes[SettingName], (newSetting: SettingTypes[SettingName]) => void]; export default useSetting; } +declare module 'renderer/hooks/papi-hooks/use-project-data-provider.hook' { + import { ProjectDataTypes } from 'papi-shared-types'; + import IDataProvider from 'shared/models/data-provider.interface'; + /** + * Gets a project data provider with specified provider name + * @param projectDataProviderSource string name of the id of the project to get OR projectDataProvider (result + * of useProjectDataProvider, if you want this hook to just return the data provider again) + * @returns undefined if the project data provider has not been retrieved, the requested project + * data provider if it has been retrieved and is not disposed, and undefined again if the project + * data provider is disposed + * + * @ProjectType `T` - the project type for the project to use. The returned project data provider + * will have the project data provider type associated with this project type. + */ + const useProjectDataProvider: ( + projectDataProviderSource: string | IDataProvider | undefined, + ) => IDataProvider | undefined; + export default useProjectDataProvider; +} declare module 'renderer/hooks/papi-hooks/index' { import usePromise from 'renderer/hooks/papi-hooks/use-promise.hook'; import useEvent from 'renderer/hooks/papi-hooks/use-event.hook'; @@ -2675,10 +2767,12 @@ declare module 'renderer/hooks/papi-hooks/index' { import useDataProvider from 'renderer/hooks/papi-hooks/use-data-provider.hook'; import useData from 'renderer/hooks/papi-hooks/use-data.hook'; import useSetting from 'renderer/hooks/papi-hooks/use-setting.hook'; + import useProjectDataProvider from 'renderer/hooks/papi-hooks/use-project-data-provider.hook'; export interface PapiHooks { usePromise: typeof usePromise; useEvent: typeof useEvent; useEventAsync: typeof useEventAsync; + useProjectDataProvider: typeof useProjectDataProvider; useDataProvider: typeof useDataProvider; /** * Special React hook that subscribes to run a callback on a data provider's data with specified diff --git a/src/declarations/papi-shared-types.ts b/src/declarations/papi-shared-types.ts index f8b06bcf54..20a8ae5621 100644 --- a/src/declarations/papi-shared-types.ts +++ b/src/declarations/papi-shared-types.ts @@ -86,6 +86,10 @@ declare module 'papi-shared-types' { */ export interface ProjectDataTypes { NotesOnly: NotesOnlyProjectDataTypes; + // With only one key in this interface, `papi.d.ts` was baking in the literal string when + // `SettingNames` was being used. Adding a placeholder key makes TypeScript generate `papi.d.ts` + // correctly. When we add another setting, we can remove this placeholder. + placeholder: MandatoryProjectDataType; } /** diff --git a/src/renderer/components/run-basic-checks-dialog/run-basic-checks-tab.component.tsx b/src/renderer/components/run-basic-checks-dialog/run-basic-checks-tab.component.tsx index 09c8f263e1..38ad2056f5 100644 --- a/src/renderer/components/run-basic-checks-dialog/run-basic-checks-tab.component.tsx +++ b/src/renderer/components/run-basic-checks-dialog/run-basic-checks-tab.component.tsx @@ -2,27 +2,28 @@ import { SavedTabInfo, TabInfo } from '@shared/data/web-view.model'; import { Button, ScriptureReference, getChaptersForBook } from 'papi-components'; import logger from '@shared/services/logger.service'; import { Typography } from '@mui/material'; -import { useState } from 'react'; +import { useState, useMemo } from 'react'; import settingsService from '@shared/services/settings.service'; -import { fetchProjects } from '@renderer/components/project-dialogs/open-project-tab.component'; import BookSelector from '@renderer/components/run-basic-checks-dialog/book-selector.component'; import BasicChecks, { fetchChecks, } from '@renderer/components/run-basic-checks-dialog/basic-checks.component'; -import { Project } from '@renderer/components/project-dialogs/project-list.component'; import './run-basic-checks-tab.component.scss'; +import useProjectDataProvider from '@renderer/hooks/papi-hooks/use-project-data-provider.hook'; +import { VerseRef } from '@sillsdev/scripture'; +import usePromise from '@renderer/hooks/papi-hooks/use-promise.hook'; export const TAB_TYPE_RUN_BASIC_CHECKS = 'run-basic-checks'; // Changing global scripture reference won't effect the dialog because reference is passed in once at the start. type RunBasicChecksTabProps = { currentScriptureReference: ScriptureReference | null; - currentProject: Project | undefined; + currentProjectId: string | undefined; }; export default function RunBasicChecksTab({ currentScriptureReference, - currentProject, + currentProjectId, }: RunBasicChecksTabProps) { const currentBookNumber = currentScriptureReference?.bookNum ?? 1; const basicChecks = fetchChecks(); @@ -83,11 +84,21 @@ export default function RunBasicChecksTab({ ); }; + const project = useProjectDataProvider<'ParatextStandard'>(currentProjectId); + + const [projectString] = usePromise( + useMemo(() => { + return async () => + project === undefined + ? 'No current project' + : project.getVerseUSFM(new VerseRef('MAT 4:1')); + }, [project]), + 'Loading', + ); + return (
- - {currentProject ? currentProject.name : 'No Current Project'} - + {`Run basic checks: ${currentProjectId}, ${projectString}`} {/* Should always be two columns? */}
Checks @@ -120,14 +131,18 @@ export default function RunBasicChecksTab({ } export const loadRunBasicChecksTab = (savedTabInfo: SavedTabInfo): TabInfo => { - const project = fetchProjects().find((proj) => proj.id === 'project-1'); - return { ...savedTabInfo, tabTitle: 'Run Basic Checks', content: ( /.platform.bible/_/project/paratext + // For example: "~/.platform.bible/projects/TPKJ_b4c501ad2538989d6fb723518e92408406e232d3/project/paratext" + // Then create a file named "meta.json" in the "_" directory with this JSON: + currentProjectId="INSERT YOUR PROJECT ID HERE" currentScriptureReference={settingsService.get('platform.verseRef')} /> ), 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/hook-generators/create-use-network-object-hook.util.ts b/src/renderer/hooks/hook-generators/create-use-network-object-hook.util.ts new file mode 100644 index 0000000000..a6bcb6349e --- /dev/null +++ b/src/renderer/hooks/hook-generators/create-use-network-object-hook.util.ts @@ -0,0 +1,58 @@ +import { NetworkObject } from '@shared/models/network-object.model'; +import { useMemo, useState, useCallback } from 'react'; +import { isString } from '@shared/utils/util'; +import useEvent from '@renderer/hooks/papi-hooks/use-event.hook'; +import usePromise from '@renderer/hooks/papi-hooks/use-promise.hook'; + +/** + * This function takes in a getNetworkObject function and creates a hook with that function in it + * which will return a network object + * @param getNetworkObject A function that takes in an id string and returns a network object + * @returns a function that takes in a networkObjectSource and returns a NetworkObject + */ +function createUseNetworkObjectHook( + getNetworkObject: (id: string) => Promise | undefined>, +): ( + networkObjectSource: string | NetworkObject | undefined, +) => NetworkObject | undefined { + return function useNetworkObject( + networkObjectSource: string | NetworkObject | undefined, + ): NetworkObject | undefined { + // Check to see if they passed in the results of a useNetworkObject hook or undefined + const didReceiveNetworkObject = !isString(networkObjectSource); + + // Get the network object for this network object name + // Note: do nothing if we already a network object, but still run this hook. + // (We must make sure to run the same number of hooks in all code paths.) + const [networkObject] = usePromise( + useMemo(() => { + return didReceiveNetworkObject + ? // We already have a network object or undefined, so we don't need to run this promise + undefined + : async () => + // We have the network object's type, so we need to get the provider + networkObjectSource ? getNetworkObject(networkObjectSource) : undefined; + }, [didReceiveNetworkObject, networkObjectSource]), + undefined, + ); + + // Disable this hook when the network object is disposed + // Note: do nothing if we already received a network object, but still run this hook. + // (We must make sure to run the same number of hooks in all code paths.) + const [isDisposed, setIsDisposed] = useState(false); + useEvent( + !didReceiveNetworkObject && networkObject && !isDisposed + ? networkObject.onDidDispose + : undefined, + useCallback(() => setIsDisposed(true), []), + ); + + // If we received a network object or undefined, return it + if (didReceiveNetworkObject) return networkObjectSource; + + // If we had to get a network object, return it if it is not disposed + return networkObject && !isDisposed ? networkObject : undefined; + }; +} + +export default createUseNetworkObjectHook; diff --git a/src/renderer/hooks/papi-hooks/index.ts b/src/renderer/hooks/papi-hooks/index.ts index f82c53226a..41903583d6 100644 --- a/src/renderer/hooks/papi-hooks/index.ts +++ b/src/renderer/hooks/papi-hooks/index.ts @@ -4,12 +4,14 @@ import useEventAsync from '@renderer/hooks/papi-hooks/use-event-async.hook'; import useDataProvider from '@renderer/hooks/papi-hooks/use-data-provider.hook'; import useData from '@renderer/hooks/papi-hooks/use-data.hook'; import useSetting from '@renderer/hooks/papi-hooks/use-setting.hook'; +import useProjectDataProvider from './use-project-data-provider.hook'; // Declare an interface for the object we're exporting so that JSDoc comments propagate export interface PapiHooks { usePromise: typeof usePromise; useEvent: typeof useEvent; useEventAsync: typeof useEventAsync; + useProjectDataProvider: typeof useProjectDataProvider; useDataProvider: typeof useDataProvider; /** JSDOC DESTINATION UseDataHook */ useData: typeof useData; @@ -23,6 +25,7 @@ const papiHooks: PapiHooks = { usePromise, useEvent, useEventAsync, + useProjectDataProvider, useDataProvider, useData, useSetting, diff --git a/src/renderer/hooks/papi-hooks/use-data-provider.hook.ts b/src/renderer/hooks/papi-hooks/use-data-provider.hook.ts index 952a27177d..8286607553 100644 --- a/src/renderer/hooks/papi-hooks/use-data-provider.hook.ts +++ b/src/renderer/hooks/papi-hooks/use-data-provider.hook.ts @@ -1,14 +1,11 @@ import dataProviderService from '@shared/services/data-provider.service'; import IDataProvider from '@shared/models/data-provider.interface'; -import { useCallback, useMemo, useState } from 'react'; -import useEvent from '@renderer/hooks/papi-hooks/use-event.hook'; -import usePromise from '@renderer/hooks/papi-hooks/use-promise.hook'; -import { isString } from '@shared/utils/util'; +import createUseNetworkObjectHook from '@renderer/hooks/hook-generators/create-use-network-object-hook.util'; /** * Gets a data provider with specified provider name - * @param dataProviderSource string name of the data provider to get OR dataProvider (result of useDataProvider if you - * want this hook to just return the data provider again) + * @param dataProviderSource string name of the data provider to get OR dataProvider (result of + * useDataProvider, if you want this hook to just return the data provider again) * @returns undefined if the data provider has not been retrieved, * data provider if it has been retrieved and is not disposed, * and undefined again if the data provider is disposed @@ -16,43 +13,13 @@ import { isString } from '@shared/utils/util'; * @type `T` - the type of data provider to return. Use `IDataProvider`, * specifying your own types, or provide a custom data provider type */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function useDataProvider>( - dataProviderSource: string | T | undefined, -): T | undefined { - // Check to see if they passed in the results of a useDataProvider hook or undefined - const didReceiveDataProvider = !isString(dataProviderSource); - - // Get the data provider for this data provider name - // Note: do nothing if we received a data provider, but still run this hook. We must make sure to run the same number of hooks in all code paths) - const [dataProvider] = usePromise( - useMemo(() => { - return didReceiveDataProvider - ? // We already have a data provider or undefined, so we don't need to run this promise - undefined - : async () => - // We have the data provider's type, so we need to get the provider - dataProviderSource - ? // Type assert here - the user of this hook must make sure to provide the correct type - (dataProviderService.get(dataProviderSource) as Promise) - : undefined; - }, [didReceiveDataProvider, dataProviderSource]), - undefined, - ); - - // Disable this hook when the data provider is disposed - // Note: do nothing if we received a data provider, but still run this hook. We must make sure to run the same number of hooks in all code paths) - const [isDisposed, setIsDisposed] = useState(false); - useEvent( - !didReceiveDataProvider && dataProvider && !isDisposed ? dataProvider.onDidDispose : undefined, - useCallback(() => setIsDisposed(true), []), - ); - // If we received a data provider or undefined, return it - if (didReceiveDataProvider) return dataProviderSource; - - // If we had to get a data provider, return it if it is not disposed - return dataProvider && !isDisposed ? dataProvider : undefined; -} +const useDataProvider = createUseNetworkObjectHook(dataProviderService.get) as < + // We don't know what type the data provider serves + // eslint-disable-next-line @typescript-eslint/no-explicit-any + T extends IDataProvider, +>( + dataProviderSource: string | T | undefined, +) => T | undefined; export default useDataProvider; diff --git a/src/renderer/hooks/papi-hooks/use-project-data-provider.hook.ts b/src/renderer/hooks/papi-hooks/use-project-data-provider.hook.ts new file mode 100644 index 0000000000..1508b18894 --- /dev/null +++ b/src/renderer/hooks/papi-hooks/use-project-data-provider.hook.ts @@ -0,0 +1,24 @@ +import { papiFrontendProjectDataProviderService } from '@shared/services/project-data-provider.service'; +import { ProjectTypes, ProjectDataTypes } from 'papi-shared-types'; +import createUseNetworkObjectHook from '@renderer/hooks/hook-generators/create-use-network-object-hook.util'; +import IDataProvider from '@shared/models/data-provider.interface'; + +/** + * Gets a project data provider with specified provider name + * @param projectDataProviderSource string name of the id of the project to get OR projectDataProvider (result + * of useProjectDataProvider, if you want this hook to just return the data provider again) + * @returns undefined if the project data provider has not been retrieved, the requested project + * data provider if it has been retrieved and is not disposed, and undefined again if the project + * data provider is disposed + * + * @ProjectType `T` - the project type for the project to use. The returned project data provider + * will have the project data provider type associated with this project type. + */ + +const useProjectDataProvider = createUseNetworkObjectHook( + papiFrontendProjectDataProviderService.getProjectDataProvider, +) as ( + projectDataProviderSource: string | IDataProvider | undefined, +) => IDataProvider | undefined; + +export default useProjectDataProvider; 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;