From b439b05b9965c04895eac09e044119d90c918481 Mon Sep 17 00:00:00 2001 From: tjcouch-sil Date: Fri, 26 May 2023 08:12:56 -0500 Subject: [PATCH 01/16] Changed addWebView command to request --- src/shared/services/web-view.service.ts | 32 ++++++++++++------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/shared/services/web-view.service.ts b/src/shared/services/web-view.service.ts index e7623d7afa..f100d5a408 100644 --- a/src/shared/services/web-view.service.ts +++ b/src/shared/services/web-view.service.ts @@ -7,11 +7,9 @@ import cloneDeep from 'lodash/cloneDeep'; import { isRenderer } from '@shared/utils/internal-util'; import { aggregateUnsubscriberAsyncs, - CommandHandler, getModuleSimilarApiMessage, serializeRequestType, } from '@shared/utils/papi-util'; -import * as commandService from '@shared/services/command.service'; import { getErrorMessage, newNonce, wait } from '@shared/utils/util'; // We need the papi here to pass it into WebViews. Don't use it anywhere else in this file // eslint-disable-next-line import/no-cycle @@ -28,11 +26,13 @@ import { WebViewContentType, WebViewProps, } from '@shared/data/web-view.model'; +import * as networkService from '@shared/services/network.service'; /** Prefix on requests that indicates that the request is related to webView operations */ const CATEGORY_WEB_VIEW = 'webView'; const DEFAULT_FLOAT_SIZE = { width: 300, height: 150 }; const DEFAULT_PANEL_DIRECTION: PanelDirection = 'right'; +const ADD_WEB_VIEW_REQUEST = 'addWebView'; /** Whether this service has finished setting up */ let isInitialized = false; @@ -107,14 +107,14 @@ export const addWebView = async ( // HACK: Quick fix for https://github.com/paranext/paranext-core/issues/52 // Try to run addWebView several times until the renderer is up // Once we implement a way to track dependencies across processes, this can go away - // Note that commands turn into requests, and requests are retried, so there is another loop + // Note that requests are retried, so there is another loop // within this loop deeper down. for (let attemptsRemaining = 5; attemptsRemaining > 0; attemptsRemaining--) { let success = true; try { // eslint-disable-next-line no-await-in-loop - await commandService.sendCommand<[WebViewContents, Layout], void>( - 'addWebView', + await networkService.request<[WebViewContents, Layout], void>( + serializeRequestType(CATEGORY_WEB_VIEW, ADD_WEB_VIEW_REQUEST), webView, layout, ); @@ -126,8 +126,8 @@ export const addWebView = async ( attemptsRemaining === 1 || getErrorMessage(error) !== `No handler was found to process the request of type ${serializeRequestType( - 'command', - 'addWebView', + CATEGORY_WEB_VIEW, + ADD_WEB_VIEW_REQUEST, )}` ) throw error; @@ -279,8 +279,8 @@ export const addWebView = async ( }; /** Commands that this process will handle if it is the renderer. Registered automatically at initialization */ -const rendererCommandFunctions: { [commandName: string]: CommandHandler } = { - addWebView, +const rendererRequestHandlers = { + [serializeRequestType(CATEGORY_WEB_VIEW, ADD_WEB_VIEW_REQUEST)]: addWebView, }; /** Sets up the WebViewService. Runs only once */ @@ -292,20 +292,20 @@ export const initialize = () => { // Set up subscriptions that the service needs to work - // Register built-in commands + // Register built-in requests if (isRenderer()) { // TODO: make a registerRequestHandlers function that we use here and in NetworkService.initialize? - const unsubPromises = Object.entries(rendererCommandFunctions).map(([commandName, handler]) => - commandService.registerCommand(commandName, handler), + const unsubPromises = Object.entries(rendererRequestHandlers).map(([requestType, handler]) => + networkService.registerRequestHandler(requestType, handler), ); - // Wait to successfully register all commands - const unsubscribeCommands = aggregateUnsubscriberAsyncs(await Promise.all(unsubPromises)); + // Wait to successfully register all requests + const unsubscribeRequests = aggregateUnsubscriberAsyncs(await Promise.all(unsubPromises)); - // On closing, try to remove command listeners + // On closing, try to remove request listeners // TODO: should do this on the server when the connection closes or when the server exits as well window.addEventListener('beforeunload', async () => { - await unsubscribeCommands(); + await unsubscribeRequests(); }); } From a3448b2950f0e6697520213b9a48b9e455ea35f1 Mon Sep 17 00:00:00 2001 From: tjcouch-sil Date: Fri, 26 May 2023 14:40:28 -0500 Subject: [PATCH 02/16] Started working on web view provider service --- src/shared/data/web-view.model.ts | 2 +- src/shared/services/data-provider.service.ts | 7 ++- .../services/web-view-provider.service.ts | 61 +++++++++++++++++++ 3 files changed, 66 insertions(+), 4 deletions(-) create mode 100644 src/shared/services/web-view-provider.service.ts diff --git a/src/shared/data/web-view.model.ts b/src/shared/data/web-view.model.ts index ab3d69e5d0..8ce25b4b8c 100644 --- a/src/shared/data/web-view.model.ts +++ b/src/shared/data/web-view.model.ts @@ -1,6 +1,6 @@ import { ReactNode } from 'react'; -export type WebViewProps = Omit; +export type WebViewProps = WebViewContents; /** * Information used to recreate a tab diff --git a/src/shared/services/data-provider.service.ts b/src/shared/services/data-provider.service.ts index f3865d15f0..9cdcde32f2 100644 --- a/src/shared/services/data-provider.service.ts +++ b/src/shared/services/data-provider.service.ts @@ -56,9 +56,10 @@ const initialize = () => { return initializePromise; }; -/** Indicate if we are aware of an existing data provider with the given name. If a data provider - * with the given name is someone else on the network, this function won't tell you about it - * unless something else in the existing process is subscribed to it. +/** + * Indicate if we are aware of an existing data provider with the given name. If a data provider + * with the given name is somewhere else on the network, this function won't tell you about it + * unless something else in the existing process is subscribed to it. */ function hasKnown(providerName: string): boolean { return networkObjectService.hasKnown(getDataProviderObjectId(providerName)); diff --git a/src/shared/services/web-view-provider.service.ts b/src/shared/services/web-view-provider.service.ts new file mode 100644 index 0000000000..0ff4fb44d9 --- /dev/null +++ b/src/shared/services/web-view-provider.service.ts @@ -0,0 +1,61 @@ +/** + * Handles registering web view providers and serving web views around the papi. + * Exposed on the papi. + */ + +import { WebViewContents } from '@shared/data/web-view.model'; +import networkObjectService from '@shared/services/network-object.service'; +import * as networkService from '@shared/services/network.service'; + +/** Suffix on network objects that indicates that the network object is a data provider */ +const WEB_VIEW_PROVIDER_LABEL = 'webView'; + +/** Gets the id for the web view network object with the given name */ +const getWebViewProviderObjectId = (providerName: string) => + `${providerName}-${WEB_VIEW_PROVIDER_LABEL}`; + +/** Whether this service has finished setting up */ +let isInitialized = false; + +/** Promise that resolves when this service is finished initializing */ +let initializePromise: Promise | undefined; + +/** Sets up the service. Only runs once and always returns the same promise after that */ +const initialize = () => { + if (initializePromise) return initializePromise; + + initializePromise = (async (): Promise => { + if (isInitialized) return; + + // TODO: Might be best to make a singleton or something + await networkService.initialize(); + + isInitialized = true; + })(); + + return initializePromise; +}; + +/** + * Indicate if we are aware of an existing web view provider with the given name. If a web view + * provider with the given name is somewhere else on the network, this function won't tell you about + * it unless something else in the existing process is subscribed to it. + */ +function hasKnown(providerName: string): boolean { + return networkObjectService.hasKnown(getWebViewProviderObjectId(providerName)); +} + +export type WebViewProvider = { + deserialize(serializedWebView: Omit): Promise; +}; + +async function register(providerName: string, webViewProvider: WebViewProvider) { + await initialize(); +} + +const webViewProviderService = { + initialize, + hasKnown, +}; + +export default webViewProviderService; From dcd861a8bef499e0642c7b17394289f0bdd8c4c5 Mon Sep 17 00:00:00 2001 From: tjcouch-sil Date: Fri, 2 Jun 2023 15:03:30 -0500 Subject: [PATCH 03/16] Finished first pass on web view provider service, other cleanup --- lib/papi-dts/papi.d.ts | 11 ++-- .../paranext-dock-layout.component.tsx | 5 +- .../components/web-view.component.tsx | 6 +- src/shared/data/web-view.model.ts | 3 + src/shared/models/web-view-provider.model.ts | 23 +++++++ src/shared/services/data-provider.service.ts | 7 +-- .../services/web-view-provider.service.ts | 60 ++++++++++++++++--- 7 files changed, 91 insertions(+), 24 deletions(-) create mode 100644 src/shared/models/web-view-provider.model.ts diff --git a/lib/papi-dts/papi.d.ts b/lib/papi-dts/papi.d.ts index 72d016e645..b1712d0d8f 100644 --- a/lib/papi-dts/papi.d.ts +++ b/lib/papi-dts/papi.d.ts @@ -1100,7 +1100,7 @@ declare module 'shared/services/command.service' { } declare module 'shared/data/web-view.model' { import { ReactNode } from 'react'; - export type WebViewProps = Omit; + export type WebViewProps = WebViewContents; /** * Information used to recreate a tab */ @@ -1160,6 +1160,8 @@ declare module 'shared/data/web-view.model' { }; /** WebView definition created by extensions to show web content */ export type WebViewContents = WebViewContentsReact | WebViewContentsHtml; + /** Serialized WebView information that does not contain the content of the WebView */ + export type WebViewContentsSerialized = Omit; export const TYPE_WEBVIEW = 'webView'; interface TabLayout { type: 'tab'; @@ -1568,9 +1570,10 @@ declare module 'shared/services/data-provider.service' { */ import IDataProvider, { IDisposableDataProvider } from 'shared/models/data-provider.interface'; import IDataProviderEngine from 'shared/models/data-provider-engine.model'; - /** Indicate if we are aware of an existing data provider with the given name. If a data provider - * with the given name is someone else on the network, this function won't tell you about it - * unless something else in the existing process is subscribed to it. + /** + * Indicate if we are aware of an existing data provider with the given name. If a data provider + * with the given name is somewhere else on the network, this function won't tell you about it + * unless something else in the existing process is subscribed to it. */ function hasKnown(providerName: string): boolean; /** diff --git a/src/renderer/components/docking/paranext-dock-layout.component.tsx b/src/renderer/components/docking/paranext-dock-layout.component.tsx index 9e1e484a19..a757a2e8a7 100644 --- a/src/renderer/components/docking/paranext-dock-layout.component.tsx +++ b/src/renderer/components/docking/paranext-dock-layout.component.tsx @@ -30,8 +30,8 @@ import { TabInfo, } from '@shared/data/web-view.model'; import LogError from '@shared/log-error.model'; -import papi from '@shared/services/papi.service'; import { serializeTabId, deserializeTabId } from '@shared/utils/papi-util'; +import { onDidAddWebView } from '@shared/services/web-view.service'; type TabType = string; @@ -52,7 +52,6 @@ const groups: { [key: string]: TabGroup } = { }; const savedLayout: LayoutData = getStorageValue(DOCK_LAYOUT_KEY, testLayout as LayoutData); -// TODO: Build this mapping from extensions so extensions can create their own panels const tabTypeCreationMap = new Map([ ['about', createAboutPanel], ['buttons', createButtonsPanel], @@ -221,7 +220,7 @@ export default function ParanextDockLayout() { const dockLayoutRef = useRef(null!); useEvent( - papi.webViews.onDidAddWebView, + onDidAddWebView, useCallback((event: AddWebViewEvent) => { const dockLayout = dockLayoutRef.current; addWebViewToDock(event, dockLayout); diff --git a/src/renderer/components/web-view.component.tsx b/src/renderer/components/web-view.component.tsx index 8ecae23c3b..8e845977ee 100644 --- a/src/renderer/components/web-view.component.tsx +++ b/src/renderer/components/web-view.component.tsx @@ -39,9 +39,9 @@ export function WebView({ id, content, title, contentType }: WebViewProps) { // allow-scripts so the iframe can actually do things // allow-pointer-lock so the iframe can lock the pointer as desired // Note: Mozilla's iframe page 'allow-same-origin' and 'allow-scripts' warns that listing both of these - // allows the child scripts to remove this sandbox attribute from the iframe. However, it seems that this - // is done by accessing window.parent or window.top, which is removed from the iframe with the injected - // scripts in WebViewService. We will probably want to stay vigilant on security in this area. + // allows the child scripts to remove this sandbox attribute from the iframe. This means the + // sandboxing will do nothing for a determined hacker. We must distrust the whole renderer due + // to this issue. We will probably want to stay vigilant on security in this area. sandbox="allow-same-origin allow-scripts allow-pointer-lock" srcDoc={content} /> diff --git a/src/shared/data/web-view.model.ts b/src/shared/data/web-view.model.ts index 8ce25b4b8c..dd54bb48fd 100644 --- a/src/shared/data/web-view.model.ts +++ b/src/shared/data/web-view.model.ts @@ -65,6 +65,9 @@ export type WebViewContentsHtml = WebViewContentsBase & { /** WebView definition created by extensions to show web content */ export type WebViewContents = WebViewContentsReact | WebViewContentsHtml; +/** Serialized WebView information that does not contain the content of the WebView */ +export type WebViewContentsSerialized = Omit; + export const TYPE_WEBVIEW = 'webView'; interface TabLayout { diff --git a/src/shared/models/web-view-provider.model.ts b/src/shared/models/web-view-provider.model.ts new file mode 100644 index 0000000000..77ff91db6c --- /dev/null +++ b/src/shared/models/web-view-provider.model.ts @@ -0,0 +1,23 @@ +import { WebViewContents, WebViewContentsSerialized } from '@shared/data/web-view.model'; +import { + DisposableNetworkObject, + NetworkObject, + NetworkableObject, +} from '@shared/models/network-object.model'; +import { CanHaveOnDidDispose } from '@shared/models/disposal.model'; + +// What the developer registers +export interface IWebViewProvider extends NetworkableObject { + deserialize(serializedWebView: WebViewContentsSerialized): Promise; +} + +// What the papi gives on get. Basically a layer over NetworkObject +export interface WebViewProvider + extends NetworkObject, + CanHaveOnDidDispose {} + +// What the papi returns on register. Basically a layer over DisposableNetworkObject +export interface DisposableWebViewProvider + extends DisposableNetworkObject, + // Need to omit dispose here because it is optional on WebViewProvider but is required on DisposableNetworkObject + Omit {} diff --git a/src/shared/services/data-provider.service.ts b/src/shared/services/data-provider.service.ts index 9cdcde32f2..bde88b2df5 100644 --- a/src/shared/services/data-provider.service.ts +++ b/src/shared/services/data-provider.service.ts @@ -16,7 +16,7 @@ import { deepEqual, serializeRequestType } from '@shared/utils/papi-util'; import AsyncVariable from '@shared/utils/async-variable'; import { NetworkObject } from '@shared/models/network-object.model'; import networkObjectService from '@shared/services/network-object.service'; -import logger from './logger.service'; +import logger from '@shared/services/logger.service'; /** Suffix on network objects that indicates that the network object is a data provider */ const DATA_PROVIDER_LABEL = 'data'; @@ -251,11 +251,8 @@ async function registerEngine( ): Promise> { await initialize(); - // There is a potential networking sync issue here. We check for a data provider, then we create a network event, then we create a network object. - // If someone else registers an engine with the same data provider name at the same time, the two registrations could get intermixed and mess stuff up - // TODO: fix this split network request issue. Just try to register the network object. If it succeeds, continue. If it fails, give up. if (hasKnown(providerName)) - throw new Error(`Data provider with type ${providerName} is already registered`); + throw new Error(`Data provider with name ${providerName} is already registered`); // Validate that the data provider engine has what it needs if (!dataProviderEngine.get || typeof dataProviderEngine.get !== 'function') diff --git a/src/shared/services/web-view-provider.service.ts b/src/shared/services/web-view-provider.service.ts index 0ff4fb44d9..055bb525ce 100644 --- a/src/shared/services/web-view-provider.service.ts +++ b/src/shared/services/web-view-provider.service.ts @@ -3,16 +3,21 @@ * Exposed on the papi. */ -import { WebViewContents } from '@shared/data/web-view.model'; +import { + DisposableWebViewProvider, + IWebViewProvider, + WebViewProvider, +} from '@shared/models/web-view-provider.model'; import networkObjectService from '@shared/services/network-object.service'; import * as networkService from '@shared/services/network.service'; +import logger from '@shared/services/logger.service'; /** Suffix on network objects that indicates that the network object is a data provider */ const WEB_VIEW_PROVIDER_LABEL = 'webView'; /** Gets the id for the web view network object with the given name */ -const getWebViewProviderObjectId = (providerName: string) => - `${providerName}-${WEB_VIEW_PROVIDER_LABEL}`; +const getWebViewProviderObjectId = (webViewType: string) => + `${webViewType}-${WEB_VIEW_PROVIDER_LABEL}`; /** Whether this service has finished setting up */ let isInitialized = false; @@ -41,21 +46,58 @@ const initialize = () => { * provider with the given name is somewhere else on the network, this function won't tell you about * it unless something else in the existing process is subscribed to it. */ -function hasKnown(providerName: string): boolean { - return networkObjectService.hasKnown(getWebViewProviderObjectId(providerName)); +function hasKnown(webViewType: string): boolean { + return networkObjectService.hasKnown(getWebViewProviderObjectId(webViewType)); } -export type WebViewProvider = { - deserialize(serializedWebView: Omit): Promise; -}; +async function register( + webViewType: string, + webViewProvider: IWebViewProvider, +): Promise { + await initialize(); + + if (hasKnown(webViewType)) + throw new Error(`WebView provider for WebView type ${webViewType} is already registered`); + + // Validate that the WebView provider has what it needs + if (!webViewProvider.deserialize || typeof webViewProvider.deserialize !== 'function') + throw new Error(`WebView provider does not have a deserialize function`); + + // We are good to go! Create the WebView provider + + // Get the object id for this web view provider name + const webViewProviderObjectId = getWebViewProviderObjectId(webViewType); + + // Set up the WebView provider to be a network object so other processes can use it + const disposableWebViewProvider = (await networkObjectService.set( + webViewProviderObjectId, + webViewProvider, + )) as DisposableWebViewProvider; -async function register(providerName: string, webViewProvider: WebViewProvider) { + return disposableWebViewProvider; +} + +async function get(webViewType: string): Promise { await initialize(); + + // Get the object id for this web view provider name + const webViewProviderObjectId = getWebViewProviderObjectId(webViewType); + + const webViewProvider = await networkObjectService.get(webViewProviderObjectId); + + if (!webViewProvider) { + logger.info(`No WebView provider found for WebView type ${webViewType}`); + return undefined; + } + + return webViewProvider; } const webViewProviderService = { initialize, hasKnown, + register, + get, }; export default webViewProviderService; From 02ae9c753890c5b57c5429b665a2b849b0a3fcf3 Mon Sep 17 00:00:00 2001 From: tjcouch-sil Date: Fri, 2 Jun 2023 18:41:00 -0500 Subject: [PATCH 04/16] Refactored docking layout types to support save/load concepts --- lib/papi-dts/papi.d.ts | 72 ++++++++------- .../paranext-dock-layout.component.test.ts | 16 ++-- .../paranext-dock-layout.component.tsx | 90 +++++++++++-------- .../components/web-view.component.tsx | 44 ++++++--- .../testing/about-panel.component.tsx | 7 +- .../testing/test-buttons-panel.component.tsx | 7 +- src/renderer/testing/test-layout.data.ts | 26 +++--- src/renderer/testing/test-panel.component.tsx | 13 +-- ...est-quick-verse-heresy-panel.component.tsx | 7 +- src/shared/data/web-view.model.ts | 45 +++++++--- src/shared/services/web-view.service.ts | 16 +++- src/shared/utils/papi-util.ts | 28 ------ 12 files changed, 216 insertions(+), 155 deletions(-) diff --git a/lib/papi-dts/papi.d.ts b/lib/papi-dts/papi.d.ts index b1712d0d8f..8c9c45b0c4 100644 --- a/lib/papi-dts/papi.d.ts +++ b/lib/papi-dts/papi.d.ts @@ -181,26 +181,6 @@ declare module 'shared/utils/papi-util' { export function serializeRequestType(category: string, directive: string): string; /** Split a request message requestType string into its parts */ export function deserializeRequestType(requestType: string): RequestType; - /** Parts of a Dock Tab ID */ - export interface TabIdParts { - /** Type of the tab */ - type: string; - /** ID of the particular tab type */ - typeId: string; - } - /** - * Create a tab ID. - * @param type Type of the tab. - * @param typeId ID of the particular tab type. - * @returns a tab ID - */ - export function serializeTabId(type: string, typeId: string): string; - /** - * Split the tab ID into its parts. - * @param id Tab ID. - * @returns The two parts of the tab ID - */ - export function deserializeTabId(id: string): TabIdParts; /** * HTML Encodes the provided string. * Thanks to ChatGPT @@ -1100,30 +1080,39 @@ declare module 'shared/services/command.service' { } declare module 'shared/data/web-view.model' { import { ReactNode } from 'react'; - export type WebViewProps = WebViewContents; + /** Props that are passed to the web view component */ + export type WebViewProps = WebViewContents & Pick; /** - * Information used to recreate a tab + * Serialized information used to recreate a tab. + * + * {@link TabLoader} deserializes this into {@link TabInfo} */ export type SavedTabInfo = { /** - * Tab ID - must be unique + * Tab ID - a unique identifier that identifies this tab */ - id?: string; + id: string; /** - * Data needed to recreate the tab during load + * Type of tab - indicates what kind of built-in tab this info represents + */ + tabType: string; + /** + * Data needed to deserialize the tab during load */ data?: unknown; }; /** - * Information needed to create a tab inside of Paranext + * Information that Paranext uses to create a tab in the dock layout. + * + * {@link TabLoader} deserialize {@link SavedTabInfo} into this */ - export type TabInfo = { + export type TabInfo = SavedTabInfo & { /** * Text to show on the title bar of the tab */ title: string; /** - * Content to show inside the tab + * Content to show inside the tab. */ content: ReactNode; /** @@ -1136,16 +1125,25 @@ declare module 'shared/data/web-view.model' { minHeight?: number; }; /** + * Function that takes a serialized tab and creates a Paranext tab out of it. Each type of tab must + * provide a TabLoader. + * * For now all tab creators must do their own data type verification */ - export type TabCreator = (tabData: SavedTabInfo) => TabInfo; + export type TabLoader = (savedTabInfo: SavedTabInfo) => TabInfo; + /** + * Function that takes a Paranext tab and creates a serialized tab out of it. Each type of tab can + * provide a TabSaver. If they do not provide one, the `SavedTabInfo` properties are stripped from + * TabInfo before saving. + */ + export type TabSaver = (tabInfo: TabInfo) => SavedTabInfo; export enum WebViewContentType { React = 'react', HTML = 'html', } /** Base WebView properties that all WebViews share */ type WebViewContentsBase = { - id: string; + webViewType: string; content: string; title?: string; }; @@ -1161,8 +1159,9 @@ declare module 'shared/data/web-view.model' { /** WebView definition created by extensions to show web content */ export type WebViewContents = WebViewContentsReact | WebViewContentsHtml; /** Serialized WebView information that does not contain the content of the WebView */ - export type WebViewContentsSerialized = Omit; - export const TYPE_WEBVIEW = 'webView'; + export type WebViewContentsSerialized = + | Omit + | Omit; interface TabLayout { type: 'tab'; } @@ -1198,9 +1197,16 @@ declare module 'shared/data/web-view.model' { }; } declare module 'shared/services/web-view.service' { - import { AddWebViewEvent, Layout, WebViewContents } from 'shared/data/web-view.model'; + import { + AddWebViewEvent, + Layout, + SavedTabInfo, + TabInfo, + WebViewContents, + } from 'shared/data/web-view.model'; /** Event that emits with webView info when a webView is added */ export const onDidAddWebView: import('shared/models/papi-event.model').PapiEvent; + export function saveTabInfoBase(tabInfo: TabInfo): SavedTabInfo; /** * Adds a WebView and runs all event handlers who are listening to this event * @param webView full html document to set as the webview iframe contents. Can be shortened to just a string diff --git a/src/renderer/components/docking/paranext-dock-layout.component.test.ts b/src/renderer/components/docking/paranext-dock-layout.component.test.ts index f0f2e37683..063b94a4f1 100644 --- a/src/renderer/components/docking/paranext-dock-layout.component.test.ts +++ b/src/renderer/components/docking/paranext-dock-layout.component.test.ts @@ -68,10 +68,14 @@ describe('Dock Layout Component', () => { }); describe('loadTab()', () => { - it('should throw when no id', () => { - const savedTabInfo: SavedTabInfo = {}; - - expect(() => loadTab(savedTabInfo)).toThrow(); + it('should throw when no id or tabType', () => { + const savedTabInfoNone = {} as SavedTabInfo; + const savedTabInfoNoId = {} as SavedTabInfo; + const savedTabInfoNoTabType = {} as SavedTabInfo; + + expect(() => loadTab(savedTabInfoNone)).toThrow(); + expect(() => loadTab(savedTabInfoNoId)).toThrow(); + expect(() => loadTab(savedTabInfoNoTabType)).toThrow(); }); }); @@ -88,7 +92,7 @@ describe('Dock Layout Component', () => { when(mockDockLayout.find(anything())).thenReturn(undefined); const dockLayout = instance(mockDockLayout); const event: AddWebViewEvent = { - webView: { id: 'myId', content: '' }, + webView: { id: 'myId', webViewType: 'test', content: '' }, layout: { type: 'wacked' } as unknown as FloatLayout, }; @@ -104,7 +108,7 @@ describe('Dock Layout Component', () => { when(mockDockLayout.find(anything())).thenReturn(undefined); const dockLayout = instance(mockDockLayout); const event: AddWebViewEvent = { - webView: { id: 'myId', content: '' }, + webView: { id: 'myId', webViewType: 'test', content: '' }, layout: { type: 'panel', direction: 'top', targetTabId: 'unknownTabId' }, }; diff --git a/src/renderer/components/docking/paranext-dock-layout.component.tsx b/src/renderer/components/docking/paranext-dock-layout.component.tsx index a757a2e8a7..47e514b82d 100644 --- a/src/renderer/components/docking/paranext-dock-layout.component.tsx +++ b/src/renderer/components/docking/paranext-dock-layout.component.tsx @@ -14,24 +14,29 @@ import DockLayout, { import createErrorTab from '@renderer/components/docking/error-tab.component'; import ParanextPanel from '@renderer/components/docking/paranext-panel.component'; import ParanextTabTitle from '@renderer/components/docking/paranext-tab-title.component'; -import createWebViewPanel from '@renderer/components/web-view.component'; +import loadWebViewPanel, { + TAB_TYPE_WEBVIEW, + saveWebViewPanel, +} from '@renderer/components/web-view.component'; import useEvent from '@renderer/hooks/papi-hooks/use-event.hook'; -import createAboutPanel from '@renderer/testing/about-panel.component'; -import createButtonsPanel from '@renderer/testing/test-buttons-panel.component'; +import loadAboutPanel, { TAB_TYPE_ABOUT } from '@renderer/testing/about-panel.component'; +import loadButtonsPanel, { TAB_TYPE_BUTTONS } from '@renderer/testing/test-buttons-panel.component'; import testLayout, { FIRST_TAB_ID } from '@renderer/testing/test-layout.data'; -import createTabPanel from '@renderer/testing/test-panel.component'; -import createQuickVerseHeresyPanel from '@renderer/testing/test-quick-verse-heresy-panel.component'; +import loadTestPanel, { TAB_TYPE_TEST } from '@renderer/testing/test-panel.component'; +import loadQuickVerseHeresyPanel, { + TAB_TYPE_QUICK_VERSE_HERESY, +} from '@renderer/testing/test-quick-verse-heresy-panel.component'; import { AddWebViewEvent, FloatLayout, SavedTabInfo, - TYPE_WEBVIEW, - TabCreator, + TabLoader, TabInfo, + TabSaver, } from '@shared/data/web-view.model'; import LogError from '@shared/log-error.model'; -import { serializeTabId, deserializeTabId } from '@shared/utils/papi-util'; -import { onDidAddWebView } from '@shared/services/web-view.service'; +import { onDidAddWebView, saveTabInfoBase } from '@shared/services/web-view.service'; +import { getErrorMessage } from '@shared/utils/util'; type TabType = string; @@ -52,59 +57,69 @@ const groups: { [key: string]: TabGroup } = { }; const savedLayout: LayoutData = getStorageValue(DOCK_LAYOUT_KEY, testLayout as LayoutData); -const tabTypeCreationMap = new Map([ - ['about', createAboutPanel], - ['buttons', createButtonsPanel], - ['quick-verse-heresy', createQuickVerseHeresyPanel], - ['tab', createTabPanel], - [TYPE_WEBVIEW, createWebViewPanel], +const tabLoaderMap = new Map([ + [TAB_TYPE_ABOUT, loadAboutPanel], + [TAB_TYPE_BUTTONS, loadButtonsPanel], + [TAB_TYPE_QUICK_VERSE_HERESY, loadQuickVerseHeresyPanel], + [TAB_TYPE_TEST, loadTestPanel], + [TAB_TYPE_WEBVIEW, loadWebViewPanel], ]); +const tabSaverMap = new Map([[TAB_TYPE_WEBVIEW, saveWebViewPanel]]); + let previousTabId: string = FIRST_TAB_ID; let floatPosition: FloatPosition = { left: 0, top: 0, width: 0, height: 0 }; -function getTabDataFromSavedInfo(tabInfo: SavedTabInfo): TabInfo { - let tabCreator: TabCreator | undefined; - if (tabInfo.id) { - const { type } = deserializeTabId(tabInfo.id); - tabCreator = tabTypeCreationMap.get(type); - } - if (!tabCreator) return createErrorTab(`No handler for the tab type '${tabInfo.id}'`); +function loadSavedTabInfo(tabInfo: SavedTabInfo): TabInfo { + const tabLoader = tabLoaderMap.get(tabInfo.tabType); + if (!tabLoader) return createErrorTab(`No tab loader for tabType '${tabInfo.tabType}'`); // Call the creation method to let the extension method create the tab try { - return tabCreator(tabInfo); + return tabLoader(tabInfo); } catch (e) { // If the tab couldn't be created, replace it with an error tab - if (e instanceof Error) return createErrorTab(e.message); - return createErrorTab(String(e)); + return createErrorTab(getErrorMessage(e)); } } +type RCDockTabInfo = TabData & Omit & { tabInfoTitle: string }; + /** * Creates tab data from the specified saved tab information by calling back to the * extension that registered the creation of the tab type * @param savedTabInfo Data that is to be used to create the new tab (comes from rc-dock) */ -export function loadTab(savedTabInfo: SavedTabInfo): TabData & SavedTabInfo { +export function loadTab(savedTabInfo: SavedTabInfo): RCDockTabInfo { if (!savedTabInfo.id) throw new LogError('loadTab: "id" is missing.'); - const { id } = savedTabInfo; - const newTabData = getTabDataFromSavedInfo(savedTabInfo); + // Load the tab from the saved tab info + const tabInfo = loadSavedTabInfo(savedTabInfo); - // Translate the data from the extension to be in the form needed by rc-dock + // Translate the data from the loaded tab to be in the form needed by rc-dock return { - id, - data: savedTabInfo.data, - title: , - content: {newTabData.content}, - minWidth: newTabData.minWidth, - minHeight: newTabData.minHeight, + ...tabInfo, + tabInfoTitle: tabInfo.title, + title: , + content: {tabInfo.content}, group: TAB_GROUP, closable: true, }; } +function saveTab(dockTabInfo: RCDockTabInfo): SavedTabInfo { + // Remove the rc-dock properties that are not also in SavedTabInfo + // We don't need to use the other properties, but we need to remove them + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { parent, group, closable, title, tabInfoTitle, ...strippedTabInfo } = dockTabInfo; + // Put back the tabInfoTitle we saved off in `loadTab` + const tabInfo: TabInfo = { ...strippedTabInfo, title: tabInfoTitle }; + + const tabSaver = tabSaverMap.get(tabInfo.tabType); + + return tabSaver ? tabSaver(tabInfo) : saveTabInfoBase(tabInfo); +} + /** * When rc-dock detects a changed layout, save it. * @@ -170,8 +185,8 @@ export function getFloatPosition( } export function addWebViewToDock({ webView, layout }: AddWebViewEvent, dockLayout: DockLayout) { - const tabId = serializeTabId(TYPE_WEBVIEW, webView.id); - const tab = loadTab({ id: tabId, data: webView }); + const tabId = webView.id; + const tab = loadTab({ id: tabId, tabType: TAB_TYPE_WEBVIEW, data: webView }); let targetTab = dockLayout.find(tabId); // Update existing WebView @@ -234,6 +249,7 @@ export default function ParanextDockLayout() { defaultLayout={savedLayout} dropMode="edge" loadTab={loadTab} + saveTab={saveTab} onLayoutChange={onLayoutChange} /> ); diff --git a/src/renderer/components/web-view.component.tsx b/src/renderer/components/web-view.component.tsx index 8e845977ee..44d21ec16c 100644 --- a/src/renderer/components/web-view.component.tsx +++ b/src/renderer/components/web-view.component.tsx @@ -3,16 +3,19 @@ import { SavedTabInfo, TabInfo, WebViewContentType, + WebViewContents, + WebViewContentsSerialized, WebViewProps, } from '@shared/data/web-view.model'; -import { deserializeTabId } from '@shared/utils/papi-util'; +import { saveTabInfoBase } from '@shared/services/web-view.service'; -export function getTitle({ id, title, contentType }: WebViewProps): string { - const defaultTitle = id ? `${id} ${contentType}` : `${contentType} Web View`; - return title || defaultTitle; +export const TAB_TYPE_WEBVIEW = 'webView'; + +export function getTitle({ webViewType, title, contentType }: Partial): string { + return title || `${webViewType || contentType} Web View`; } -export function WebView({ id, content, title, contentType }: WebViewProps) { +export function WebView({ webViewType, content, title, contentType }: WebViewProps) { // This ref will always be defined // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const iframeRef = useRef(null!); @@ -26,7 +29,7 @@ export function WebView({ id, content, title, contentType }: WebViewProps) { return (